agentlinkops 0.6.10 → 0.6.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (213) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/.mcp.json +0 -0
  4. package/CHANGELOG.md +19 -0
  5. package/LICENSE +0 -0
  6. package/NOTICE +0 -0
  7. package/README.md +4 -4
  8. package/bin/agentlinkops.mjs +0 -0
  9. package/bin/linktrail.mjs +0 -0
  10. package/cli/adapters/csv.js +0 -0
  11. package/cli/adapters/sqlite-crm.js +0 -0
  12. package/cli/adapters/suppliers.js +0 -0
  13. package/cli/admission.js +0 -0
  14. package/cli/agent-setup.js +0 -0
  15. package/cli/agentlinkops.mjs +0 -0
  16. package/cli/args.js +0 -0
  17. package/cli/catalog.js +4435 -175
  18. package/cli/check.js +0 -0
  19. package/cli/chronology.js +39 -0
  20. package/cli/citation.js +0 -0
  21. package/cli/client.js +1 -1
  22. package/cli/cloud-error.js +0 -0
  23. package/cli/commands.js +0 -0
  24. package/cli/config.js +0 -0
  25. package/cli/connection.js +0 -0
  26. package/cli/context-mcp.js +0 -0
  27. package/cli/context.js +0 -0
  28. package/cli/delivery.js +0 -0
  29. package/cli/disavow.js +0 -0
  30. package/cli/doctor.js +0 -0
  31. package/cli/env.js +0 -0
  32. package/cli/fleet.js +30 -19
  33. package/cli/gsc-browser.js +0 -0
  34. package/cli/import.js +4 -1
  35. package/cli/index-observations.js +0 -0
  36. package/cli/index-submissions.js +0 -0
  37. package/cli/ledger.js +0 -0
  38. package/cli/lifecycle.js +0 -0
  39. package/cli/linktrail.mjs +0 -0
  40. package/cli/local-result.js +0 -0
  41. package/cli/main.js +42 -15
  42. package/cli/mcp-client.js +2 -2
  43. package/cli/mirror.js +0 -0
  44. package/cli/mix.js +0 -0
  45. package/cli/native-lock.js +0 -0
  46. package/cli/oauth-credentials.js +0 -0
  47. package/cli/oauth-flow.js +0 -0
  48. package/cli/placement-projection.js +106 -0
  49. package/cli/platforms.js +0 -0
  50. package/cli/receipt-performance.js +0 -0
  51. package/cli/receipts.js +0 -0
  52. package/cli/report.js +51 -19
  53. package/cli/setup-plan.js +0 -0
  54. package/cli/skill.js +0 -0
  55. package/cli/state.js +62 -29
  56. package/cli/status.js +14 -18
  57. package/cli/sync-lock.js +0 -0
  58. package/cli/sync.js +22 -3
  59. package/package.json +1 -1
  60. package/references/citation-panels.md +0 -0
  61. package/references/cli.md +0 -0
  62. package/references/disavow.md +0 -0
  63. package/references/discovery-handoff.md +0 -0
  64. package/references/local-crm-draft-handoff.md +0 -0
  65. package/references/portable-ledger-handoff.md +0 -0
  66. package/references/recipes/catalog.json +0 -0
  67. package/references/recipes/command-notes.md +0 -0
  68. package/references/recipes/placement-reconciliation.md +0 -0
  69. package/references/recipes/qualified-campaign-handoff.md +0 -0
  70. package/references/recipes/site-context-brief.md +0 -0
  71. package/references/recipes/sourced-linkable-asset.md +0 -0
  72. package/references/reports.md +0 -0
  73. package/scripts/agentlinkops.py +0 -0
  74. package/scripts/disavow.py +0 -0
  75. package/scripts/linktrail.py +0 -0
  76. package/scripts/migrations/001_crm.sql +0 -0
  77. package/scripts/migrations/002_cloud_events.sql +0 -0
  78. package/scripts/migrations/003_disavow.sql +0 -0
  79. package/scripts/migrations/004_placement_costs.sql +0 -0
  80. package/scripts/outreach-crm-draft.py +0 -0
  81. package/scripts/portable-handoff.py +0 -0
  82. package/scripts/report.py +0 -0
  83. package/shared/admission-contract.js +0 -0
  84. package/shared/admission-report-contract.js +0 -0
  85. package/shared/agent-clients.js +0 -0
  86. package/shared/citation-evidence-contract.js +0 -0
  87. package/shared/disavow-contract.js +0 -0
  88. package/shared/exec-grammar.js +0 -0
  89. package/shared/index-observation-contract.js +2 -2
  90. package/shared/lifecycle-contract.js +0 -0
  91. package/shared/lifecycle-money.js +0 -0
  92. package/shared/monitoring-chronology-export.js +57 -0
  93. package/shared/monitoring-read-contract.js +41 -0
  94. package/shared/observation-event.js +0 -0
  95. package/shared/opportunity-record.js +0 -0
  96. package/shared/response-contract.js +3 -0
  97. package/shared/search-connection-contract.js +0 -0
  98. package/skills/agentlinkops-assets/SKILL.md +0 -0
  99. package/skills/agentlinkops-assets/agents/openai.yaml +0 -0
  100. package/skills/agentlinkops-campaigns/SKILL.md +0 -0
  101. package/skills/agentlinkops-campaigns/agents/openai.yaml +0 -0
  102. package/skills/agentlinkops-connect/SKILL.md +0 -0
  103. package/skills/agentlinkops-connect/agents/openai.yaml +0 -0
  104. package/skills/agentlinkops-connect/references/agentlinkops.md +4 -3
  105. package/skills/agentlinkops-crm/SKILL.md +1 -1
  106. package/skills/agentlinkops-crm/agents/openai.yaml +0 -0
  107. package/skills/agentlinkops-discovery/SKILL.md +0 -0
  108. package/skills/content-defingerprinting/SKILL.md +0 -0
  109. package/skills/content-defingerprinting/scripts/watermarks/README.md +0 -0
  110. package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +0 -0
  111. package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +0 -0
  112. package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +0 -0
  113. package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +0 -0
  114. package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +0 -0
  115. package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +0 -0
  116. package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +0 -0
  117. package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +0 -0
  118. package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +0 -0
  119. package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +0 -0
  120. package/skills/internal-linking-optimizer/SKILL.md +0 -0
  121. package/skills/internal-linking-optimizer/references/source-prompt.md +0 -0
  122. package/skills/pre-publish-review/SKILL.md +0 -0
  123. package/skills/source-cited-content-builder/SKILL.md +0 -0
  124. package/skills/source-cited-content-builder/references/source-prompt.md +0 -0
  125. package/skills/source-cited-humanizer/SKILL.md +0 -0
  126. package/src/citations/adapter.js +0 -0
  127. package/src/citations/alert.js +0 -0
  128. package/src/citations/browser/chatgpt-citations.js +0 -0
  129. package/src/citations/browser/engine.js +0 -0
  130. package/src/citations/browser/gen/core/providers/_shared/rawSourcesDom.js +0 -0
  131. package/src/citations/browser/gen/core/providers/_shared/resetProviderPage.js +0 -0
  132. package/src/citations/browser/gen/core/providers/_shared/sourceUtils.js +0 -0
  133. package/src/citations/browser/gen/core/providers/ai-overview/index.js +0 -0
  134. package/src/citations/browser/gen/core/providers/ai-overview/lib/expand.js +0 -0
  135. package/src/citations/browser/gen/core/providers/ai-overview/lib/extractSources.js +0 -0
  136. package/src/citations/browser/gen/core/providers/ai-overview/lib/pageLifecycle.js +0 -0
  137. package/src/citations/browser/gen/core/providers/ai-overview/lib/session.js +0 -0
  138. package/src/citations/browser/gen/core/providers/chatgpt/index.js +0 -0
  139. package/src/citations/browser/gen/core/providers/chatgpt/lib/dismissAuthModal.js +0 -0
  140. package/src/citations/browser/gen/core/providers/chatgpt/lib/extractSources.js +0 -0
  141. package/src/citations/browser/gen/core/providers/chatgpt/lib/pageLifecycle.js +0 -0
  142. package/src/citations/browser/gen/core/providers/claude/index.js +0 -0
  143. package/src/citations/browser/gen/core/providers/claude/lib/extractSources.js +0 -0
  144. package/src/citations/browser/gen/core/providers/claude/lib/pageLifecycle.js +0 -0
  145. package/src/citations/browser/gen/core/providers/gemini/index.js +0 -0
  146. package/src/citations/browser/gen/core/providers/gemini/lib/extractSources.js +0 -0
  147. package/src/citations/browser/gen/core/providers/gemini/lib/pageLifecycle.js +0 -0
  148. package/src/citations/browser/gen/core/providers/gemini/lib/session.js +0 -0
  149. package/src/citations/browser/gen/core/providers/index.js +0 -0
  150. package/src/citations/browser/gen/core/providers/perplexity/index.js +0 -0
  151. package/src/citations/browser/gen/core/providers/perplexity/lib/extractSources.js +0 -0
  152. package/src/citations/browser/gen/core/providers/perplexity/lib/navigation.js +0 -0
  153. package/src/citations/browser/gen/lib/browser/camoufox.js +0 -0
  154. package/src/citations/browser/gen/lib/browser/display.js +0 -0
  155. package/src/citations/browser/gen/lib/browser/domOps.js +0 -0
  156. package/src/citations/browser/gen/lib/browser/humanBehavior.js +0 -0
  157. package/src/citations/browser/gen/lib/browser/navigate.js +0 -0
  158. package/src/citations/browser/gen/lib/extraction/sourceUtils.js +0 -0
  159. package/src/citations/browser/gen/lib/input/editor/clearInput.js +0 -0
  160. package/src/citations/browser/gen/lib/input/editor/findEditor.js +0 -0
  161. package/src/citations/browser/gen/lib/input/editor/promptInput.js +0 -0
  162. package/src/citations/browser/gen/lib/input/markdown/converter.js +0 -0
  163. package/src/citations/browser/gen/lib/input/markdown/toMarkdown.js +0 -0
  164. package/src/citations/browser/gen/lib/input/response/detectBotPage.js +0 -0
  165. package/src/citations/browser/gen/lib/input/response/isGenerating.js +0 -0
  166. package/src/citations/browser/gen/lib/input/response/waitForFinish.js +0 -0
  167. package/src/citations/browser/gen/lib/input/sources/findButton.js +0 -0
  168. package/src/citations/browser/gen/lib/input/sources/openPanel.js +0 -0
  169. package/src/citations/browser/gen/utils/agent-constants.js +0 -0
  170. package/src/citations/browser/providers.js +0 -0
  171. package/src/citations/browser/proxy-admission.js +0 -0
  172. package/src/citations/browser/shims/errors.js +0 -0
  173. package/src/citations/browser/shims/types.js +0 -0
  174. package/src/citations/browser/shims/utils.js +0 -0
  175. package/src/citations/contract.js +0 -0
  176. package/src/citations/egress-receipts.js +0 -0
  177. package/src/citations/evidence-integrity.js +0 -0
  178. package/src/citations/inventory.js +0 -0
  179. package/src/citations/login.js +0 -0
  180. package/src/citations/match.js +0 -0
  181. package/src/citations/panel-builder.js +0 -0
  182. package/src/citations/report.js +0 -0
  183. package/src/citations/runner.js +0 -0
  184. package/src/citations/stats.js +0 -0
  185. package/src/citations/sync.js +0 -0
  186. package/src/citations/watch.js +0 -0
  187. package/src/competitors/contract.js +0 -0
  188. package/src/competitors/domain-mix.js +0 -0
  189. package/src/context/ga4.js +0 -0
  190. package/src/context/gsc.js +0 -0
  191. package/src/context/site-profile.js +0 -0
  192. package/src/context/util.js +0 -0
  193. package/src/context-tool-registry.js +0 -0
  194. package/src/corpus/acquire.js +0 -0
  195. package/src/corpus/seeds.js +0 -0
  196. package/src/discovery/contract.js +0 -0
  197. package/src/discovery/import.js +0 -0
  198. package/src/evidence-reference.js +0 -0
  199. package/src/index-observation.js +0 -0
  200. package/src/locator.js +0 -0
  201. package/src/response-shaping.js +0 -0
  202. package/src/tool-views.js +5 -4
  203. package/src/verifier/fetch.js +0 -0
  204. package/src/verifier/index.js +0 -0
  205. package/src/verifier/observation-kind.js +0 -0
  206. package/src/verifier/parser.js +0 -0
  207. package/src/verifier/readiness.js +0 -0
  208. package/src/verifier/render-evidence.js +0 -0
  209. package/src/verifier/robots.js +0 -0
  210. package/src/verifier/transition.js +0 -0
  211. package/src/verifier/url.js +0 -0
  212. package/templates/SITE.template.md +0 -0
  213. package/templates/campaigns.v1.json +0 -0
package/cli/report.js CHANGED
@@ -18,13 +18,15 @@
18
18
  // evidence reference that resolves to the observation itself. One dataset feeds both exports:
19
19
  // this HTML view for a human client, and `report --json` for an agent, so the two can never
20
20
  // disagree about what was observed.
21
+ // v3 freezes activity separately from retained receipts. Older v2 datasets still render with
22
+ // their original columns and wording; activity never changes an old observation's date/hash.
21
23
  import {indexRowsForUrl} from './index-observations.js';
22
24
  import {reconcileIndexObservations} from '../src/index-observation.js';
23
25
  import { createHash } from 'node:crypto';
24
- import { latestByEntry } from './mirror.js';
26
+ import { projectPlacements } from './placement-projection.js';
25
27
  import { mirrorEvidenceReference } from '../src/evidence-reference.js';
26
28
 
27
- export const REPORT_VERSION = 2;
29
+ export const REPORT_VERSION = 3;
28
30
 
29
31
  /** HTML-escape. Every value below is publisher or customer content and none of it is trusted. */
30
32
  export const escape = value => String(value ?? '')
@@ -35,6 +37,10 @@ const STATE_LABEL = Object.freeze({
35
37
  present: 'Present', absent: 'Not found', unknown: 'Could not check',
36
38
  source_unavailable: 'Page unavailable', unchecked: 'Never checked',
37
39
  });
40
+ const CURRENT_STATE_LABEL = Object.freeze({
41
+ ...STATE_LABEL,
42
+ suspected_missing: 'Suspected missing', confirmed_missing: 'Confirmed missing',
43
+ });
38
44
 
39
45
  /**
40
46
  * The frozen dataset a report renders.
@@ -42,12 +48,10 @@ const STATE_LABEL = Object.freeze({
42
48
  * Selection happens here and nowhere else, so a caller can hold this object, assert it, and know
43
49
  * the rendered document is a pure function of it.
44
50
  */
45
- export function freezeDataset(entries, observations, { asOf, title = 'Backlink report', includeNotes = false, includeRetired = false, indexObservations = [] } = {}) {
46
- const latest = latestByEntry(observations);
47
- const rows = entries
48
- .filter(entry => includeRetired || entry.intent !== 'retired')
49
- .map(entry => {
50
- const row = latest.get(entry.id) ?? null;
51
+ export function freezeDataset(entries, observations, { asOf, title = 'Backlink report', includeNotes = false, includeRetired = false, indexObservations = [], activityState = null } = {}) {
52
+ const rows = projectPlacements(entries.filter(entry => includeRetired || entry.intent !== 'retired'), observations, { state: activityState })
53
+ .map(projection => {
54
+ const { entry, row } = projection;
51
55
  const indexRows=indexRowsForUrl(indexObservations,entry.source);
52
56
  const origin = row?.source === 'cloud' ? 'cloud' : (row?.source ?? null);
53
57
  return {
@@ -57,6 +61,16 @@ export function freezeDataset(entries, observations, { asOf, title = 'Backlink r
57
61
  reason: row?.reason ?? null,
58
62
  occurrences: row?.occurrences ?? 0,
59
63
  checked_at: row?.checked_at ?? null,
64
+ current_state: projection.current_state,
65
+ uncertain: projection.uncertain,
66
+ activity: {
67
+ available: projection.activity_available,
68
+ identity_status: projection.identity_status,
69
+ latest_attempt: projection.latest_attempt,
70
+ last_successful_observation: projection.last_successful_observation,
71
+ last_link_verification: projection.last_link_verification,
72
+ first_present: projection.first_present,
73
+ },
60
74
  checked_by: row?.source ?? null,
61
75
  anchor: row?.result?.occurrences?.[0]?.anchor ?? null,
62
76
  rel: row?.result?.occurrences?.[0]?.rel ?? [],
@@ -105,10 +119,16 @@ export function freezeDataset(entries, observations, { asOf, title = 'Backlink r
105
119
  // Stated rather than implied. A report over a ledger nobody has checked is not a report about
106
120
  // links, and the reader should see that before the table.
107
121
  coverage: {
108
- checked: rows.filter(row => row.checked_at).length,
109
- never_checked: rows.filter(row => !row.checked_at).length,
122
+ checked: rows.filter(row => row.activity.latest_attempt).length,
123
+ never_checked: rows.filter(row => !row.activity.latest_attempt).length,
124
+ retained_receipt_entries: rows.filter(row => row.checked_at).length,
125
+ missing_receipt_entries: rows.filter(row => !row.checked_at).length,
110
126
  observed_from: dates[0] ?? null,
111
127
  observed_to: dates.at(-1) ?? null,
128
+ activity_entries: rows.filter(row => row.activity.available).length,
129
+ identity_unmatched_entries: rows.filter(row => row.activity.identity_status === 'not_matched').length,
130
+ denominator: includeRetired ? 'all_ledger_entries' : 'non_retired_ledger_entries',
131
+ known_present_after_unknown: rows.filter(row => row.current_state === 'present' && row.uncertain).length,
112
132
  },
113
133
  };
114
134
  }
@@ -149,6 +169,18 @@ export function renderReport(dataset, { brand = null } = {}) {
149
169
  const digest = createHash('sha256').update(JSON.stringify(dataset)).digest('hex');
150
170
  const card = (label, value) => `<div class="card"><b>${escape(value)}</b><span>${escape(label)}</span></div>`;
151
171
  const coverage = dataset.coverage;
172
+ const hasActivity = dataset.v >= 3;
173
+ const activityCell = row => {
174
+ const activity = row.activity ?? {};
175
+ const describe = (label, attempt) => `<div>${escape(label)}: ${attempt ? `${escape(attempt.state)} · ${escape(attempt.checked_at)}` : 'not retained'}</div>`
176
+ + (attempt ? `<div class="note">${escape(attempt.reason ?? 'reason not recorded')} · ${escape(attempt.method ?? 'method not recorded')}</div>`
177
+ + `<div class="ref">${escape(attempt.evidence_reference ?? 'no retained receipt reference')}</div>` : '');
178
+ return `<div>${escape(CURRENT_STATE_LABEL[row.current_state] ?? row.current_state ?? 'Unknown')}${row.uncertain ? ' · latest attempt inconclusive' : ''}</div>`
179
+ + describe('Latest attempt', activity.latest_attempt)
180
+ + describe('Last conclusive check', activity.last_successful_observation)
181
+ + describe('Last link verification', activity.last_link_verification)
182
+ + `<div class="note">Activity ${activity.available ? 'recorded' : 'not supplied or not retained'}; identity ${escape(activity.identity_status ?? 'not retained')}</div>`;
183
+ };
152
184
  // The receipt column. "hash not recorded" is stated rather than papered over: a local check
153
185
  // that never completed a fetch has no hash, and a cloud-synced row may carry its snapshot key
154
186
  // without a hash in an older event. Neither is a claim about the link.
@@ -164,10 +196,10 @@ export function renderReport(dataset, { brand = null } = {}) {
164
196
  const rows = dataset.rows.map(row => `<tr>
165
197
  <td class="url"><a href="${escape(row.source)}">${escape(row.source)}</a></td>
166
198
  <td class="url">${escape(row.target)}</td>
167
- <td><span class="state ${escape(row.state)}">${escape(STATE_LABEL[row.state] ?? row.state)}</span></td>
199
+ <td><span class="state ${escape(row.state)}">${escape(hasActivity && !row.evidence && row.activity?.latest_attempt ? 'No retained observation' : (hasActivity ? CURRENT_STATE_LABEL : STATE_LABEL)[row.state] ?? row.state)}</span></td>
168
200
  <td>${row.anchor ? escape(row.anchor) : '<span class="note">&mdash;</span>'}${(row.rel ?? []).length ? `<div class="note">rel: ${escape(row.rel.join(' '))}</div>` : ''}</td>
169
201
  <td>${row.checked_at ? escape(row.checked_at.slice(0, 10)) : '<span class="note">never</span>'}</td>
170
- <td>${evidenceCell(row)}</td>
202
+ <td>${evidenceCell(row)}</td>${hasActivity ? `\n <td>${activityCell(row)}</td>` : ''}
171
203
  ${hasIndex?`<td>${(row.index_observations??[]).map(o=>`${escape(o.tier)} · ${escape(o.reason)}<div>${escape(o.checked_at)} · ${escape(o.backend)}</div><div class="ref">${escape(o.locator)}</div>`).join('<hr>')||'Not checked'}</td>`:''}
172
204
  ${dataset.include_notes ? `<td class="note">${escape(row.ref ?? '')}${row.note ? `<div>${escape(row.note)}</div>` : ''}</td>` : ''}
173
205
  </tr>`).join('\n');
@@ -188,21 +220,21 @@ export function renderReport(dataset, { brand = null } = {}) {
188
220
  <p class="sub">${brand ? `${escape(brand)} &middot; ` : ''}Prepared ${escape(dataset.as_of)}</p>
189
221
  <div class="cards">
190
222
  ${card('links in report', dataset.totals.entries)}
191
- ${card('present', dataset.totals.present ?? 0)}
192
- ${card('not found', dataset.totals.absent ?? 0)}
193
- ${card('could not check', (dataset.totals.unknown ?? 0) + (dataset.totals.source_unavailable ?? 0))}
194
- ${card('never checked', dataset.totals.unchecked ?? 0)}
223
+ ${card(hasActivity ? 'retained present' : 'present', dataset.totals.present ?? 0)}
224
+ ${card(hasActivity ? 'retained not found' : 'not found', dataset.totals.absent ?? 0)}
225
+ ${card(hasActivity ? 'retained could not check' : 'could not check', (dataset.totals.unknown ?? 0) + (dataset.totals.source_unavailable ?? 0))}
226
+ ${card('never checked', hasActivity ? coverage.never_checked : dataset.totals.unchecked ?? 0)}
195
227
  </div>
196
228
  <p class="caveat"><strong>What these dates mean.</strong> Every row shows the date that link was
197
229
  last observed, between ${escape(coverage.observed_from ?? 'n/a')} and ${escape(coverage.observed_to ?? 'n/a')}.
198
230
  The date at the top is when this document was prepared and does not make any observation newer
199
- than it is. ${coverage.never_checked ? `${coverage.never_checked} of ${dataset.totals.entries} links have never been checked and are listed as such.` : ''}</p>
231
+ than it is. ${coverage.never_checked ? `${coverage.never_checked} of ${dataset.totals.entries} links have never been checked and are listed as such.` : ''}</p>${hasActivity ? '\n<p class="caveat"><strong>Activity and verification.</strong> The result and receipt date describe the retained observation. Activity lists later attempts and complete checks separately; an unchanged recheck can be newer than that receipt. An inconclusive attempt preserves the earlier verified state, and does not prove loss. A complete unavailable source is a conclusive source check, not a link verification. Missing activity is not inferred from a report preparation date.</p>' : ''}
200
232
  <p class="caveat"><strong>What a result means.</strong> &ldquo;Present&rdquo; means the link was in
201
233
  the observed HTML. &ldquo;Could not check&rdquo; is not the same as
202
234
  &ldquo;not found&rdquo;: it means the page could not be read, and no conclusion was drawn.
203
235
  Each receipt identifies the check method when it was recorded. A snapshot alone does not prove the link was visible on screen.</p>
204
236
  <table><thead><tr>
205
- <th>Source page</th><th>Target</th><th>Result</th><th>Anchor</th><th>Observed</th><th>Evidence</th>${hasIndex?'<th>Index evidence</th>':''}${dataset.include_notes ? '<th>Notes</th>' : ''}
237
+ <th>Source page</th><th>Target</th><th>Result</th><th>Anchor</th><th>Observed</th><th>Evidence</th>${hasActivity ? '<th>Activity and verified state</th>' : ''}${hasIndex?'<th>Index evidence</th>':''}${dataset.include_notes ? '<th>Notes</th>' : ''}
206
238
  </tr></thead><tbody>
207
239
  ${rows}
208
240
  </tbody></table>
@@ -219,7 +251,7 @@ ${receiptRows}
219
251
  </tbody></table>` : ''}
220
252
  <footer>
221
253
  AgentLinkOps report v${dataset.v}. Dataset digest <code>${escape(digest)}</code> &mdash; the same
222
- ledger and observations always produce this digest. The same dataset exports as JSON
254
+ ledger and observations${hasActivity ? ' and frozen activity' : ''} always produce this digest. The same dataset exports as JSON
223
255
  (<code>agentlinkops report --json</code>) and carries the same digest.
224
256
  ${dataset.include_notes ? 'Private notes are included in this document.' : 'Private notes and references are excluded.'}
225
257
  </footer>
package/cli/setup-plan.js CHANGED
File without changes
package/cli/skill.js CHANGED
File without changes
package/cli/state.js CHANGED
@@ -10,8 +10,9 @@
10
10
  // was this last confirmed" — is exactly what a small, rewritten map answers better.
11
11
  import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
12
12
  import { dirname, join } from 'node:path';
13
+ import { placementIdentity, attemptSummary, isConclusive, isLinkVerification, validTime } from './placement-projection.js';
13
14
 
14
- export const STATE_VERSION = 2;
15
+ export const STATE_VERSION = 3;
15
16
 
16
17
  /**
17
18
  * Brings a state file forward without losing anything it already held.
@@ -58,49 +59,81 @@ export async function writeState(path, state) {
58
59
  }
59
60
 
60
61
  /** When each entry was last checked, whatever the answer was. */
61
- export function lastCheckedMap(state) {
62
- return new Map(Object.entries(state.entries ?? {}).map(([id, record]) => [id, record.last_checked]));
62
+ export function lastCheckedMap(state, entries = null) {
63
+ const identities = entries ? new Map(entries.map(entry => [entry.id, placementIdentity(entry)])) : null;
64
+ return new Map(Object.entries(state.entries ?? {}).filter(([id, record]) => !identities
65
+ || identities.get(id) && record.last_placement === identities.get(id)).map(([id, record]) => [id, record.last_checked]));
66
+ }
67
+
68
+ const methodOf = row => row.result?.evidence?.method ?? null;
69
+ const eligible = (row, previous) => validTime(row.checked_at)
70
+ && (!validTime(previous?.last_checked) || Date.parse(row.checked_at) > Date.parse(previous.last_checked));
71
+ const repeats = (row, previous) => previous
72
+ && previous.last_placement === placementIdentity(row)
73
+ && previous.last_state === row.state
74
+ && (previous.last_reason ?? null) === (row.reason ?? null)
75
+ && (previous.last_signature ?? null) === (row.result?.linkSignature ?? null)
76
+ && previous.last_complete === (row.complete === true)
77
+ && (previous.last_method ?? null) === methodOf(row)
78
+ && (previous.last_checker_version ?? null) === (row.checker_version ?? row.result?.evidence?.checkerVersion ?? null);
79
+
80
+ function applyRow(previous, row) {
81
+ const identity = placementIdentity(row);
82
+ const samePlacement = previous?.last_placement === identity;
83
+ const attempt = attemptSummary(row, { retained: !repeats(row, previous) });
84
+ const knownPresent = isConclusive(attempt) && attempt.state === 'present';
85
+ const priorFirst = samePlacement && previous?.first_present_observation?.placement === identity
86
+ && isConclusive(previous.first_present_observation) && previous.first_present_observation.state === 'present'
87
+ ? previous.first_present_observation : null;
88
+ const firstPresent = priorFirst ?? (knownPresent ? attempt : null);
89
+ return {
90
+ ...previous,
91
+ last_checked: row.checked_at,
92
+ last_complete: row.complete === true,
93
+ last_placement: identity,
94
+ last_state: row.state,
95
+ last_reason: row.reason ?? null,
96
+ last_signature: row.result?.linkSignature ?? null,
97
+ last_method: methodOf(row),
98
+ last_checker_version: row.checker_version ?? row.result?.evidence?.checkerVersion ?? null,
99
+ checks: (previous?.checks ?? 0) + 1,
100
+ last_attempt: attempt,
101
+ last_successful_observation: isConclusive(attempt) ? attempt : samePlacement ? previous?.last_successful_observation ?? null : null,
102
+ last_link_verification: isLinkVerification(attempt) ? attempt : samePlacement ? previous?.last_link_verification ?? null : null,
103
+ first_present: firstPresent?.checked_at ?? null,
104
+ first_present_placement: firstPresent ? identity : null,
105
+ first_present_observation: firstPresent,
106
+ ...(samePlacement && previous?.first_present && !previous.first_present_observation
107
+ ? { legacy_first_present: previous.first_present } : {}),
108
+ };
63
109
  }
64
110
 
65
111
  /**
66
112
  * Splits a run's rows into the ones worth recording and the ones that repeat.
67
113
  *
68
- * A row is worth recording when it is the first for its entry, or when the state, the reason or
69
- * the link signature moved. The signature is in there deliberately: a link that is still present
70
- * but whose anchor or rel changed is a real event, and comparing only the state would miss it.
114
+ * A row is worth recording when it is the first for its placement, or when the state, reason,
115
+ * link signature, completeness or method moved. A link that is still present but whose anchor
116
+ * or rel changed is a real event, and comparing only the state would miss it.
71
117
  */
72
118
  export function selectChanged(rows, state) {
73
- const changed = [], repeated = [];
119
+ const changed = [], repeated = [], ignored = [];
120
+ const entries = { ...(state.entries ?? {}) };
74
121
  for (const row of rows) {
75
- const previous = state.entries?.[row.id];
76
- const signature = row.result?.linkSignature ?? null;
77
- const same = previous
78
- && previous.last_state === row.state
79
- && (previous.last_reason ?? null) === (row.reason ?? null)
80
- && (previous.last_signature ?? null) === signature;
81
- (same ? repeated : changed).push(row);
122
+ const previous = entries[row.id];
123
+ if (!eligible(row, previous)) { ignored.push(row); continue; }
124
+ (repeats(row, previous) ? repeated : changed).push(row);
125
+ entries[row.id] = applyRow(previous, row);
82
126
  }
83
- return { changed, repeated };
127
+ return { changed, repeated, ignored };
84
128
  }
85
129
 
86
130
  /** Applies a run to the activity record. Every check counts, recorded or not. */
87
131
  export function applyRun(state, rows) {
88
132
  const entries = { ...(state.entries ?? {}) };
89
133
  for (const row of rows) {
90
- const previous = entries[row.id] ?? { checks: 0 };
91
- entries[row.id] = {
92
- ...previous,
93
- last_checked: row.checked_at,
94
- last_complete: row.complete === true,
95
- last_placement: row.result?.sourceUrl && row.result?.targetUrl ? JSON.stringify([row.result.sourceUrl, row.result.targetUrl, row.result.targetScope ?? 'exact']) : null,
96
- last_state: row.state,
97
- last_reason: row.reason ?? null,
98
- last_signature: row.result?.linkSignature ?? null,
99
- checks: (previous.checks ?? 0) + 1,
100
- // The date a placement was first seen is worth keeping where nothing can compact it away.
101
- first_present: previous.first_present ?? (row.state === 'present' ? row.checked_at : null),
102
- first_present_placement: previous.first_present ? previous.first_present_placement ?? null : row.state === 'present' && row.result?.sourceUrl && row.result?.targetUrl ? JSON.stringify([row.result.sourceUrl, row.result.targetUrl, row.result.targetScope ?? 'exact']) : null,
103
- };
134
+ const previous = entries[row.id];
135
+ if (!eligible(row, previous)) continue;
136
+ entries[row.id] = applyRow(previous, row);
104
137
  }
105
138
  return { ...state, v: STATE_VERSION, entries };
106
139
  }
package/cli/status.js CHANGED
@@ -4,32 +4,28 @@
4
4
  // expectation, closes the laptop, and three weeks later an agent opens the repository and reads
5
5
  // this. So it answers in the order a person cares about, not the order the data is stored in:
6
6
  // what appeared, what was lost, what we cannot say, and what needs a decision.
7
- import { latestByEntry } from './mirror.js';
7
+ import { projectPlacements } from './placement-projection.js';
8
8
 
9
9
  /** A ledger entry whose observations disagree with its declared intent. */
10
- export function disagreements(entries, observations) {
11
- const latest = latestByEntry(observations);
10
+ export function disagreements(entries, observations, { state = null } = {}) {
12
11
  const out = [];
13
- for (const entry of entries) {
14
- const row = latest.get(entry.id);
15
- if (!row) { out.push({ entry, kind: 'never_checked' }); continue; }
12
+ for (const projection of projectPlacements(entries, observations, { state })) {
13
+ const { entry, row, latest_attempt: attempt } = projection;
14
+ const activity = { current_state: projection.current_state, uncertain: projection.uncertain,
15
+ latest_attempt: attempt, last_successful_observation: projection.last_successful_observation,
16
+ last_link_verification: projection.last_link_verification,
17
+ evidence_observed_at: projection.evidence_observed_at, identity_status: projection.identity_status };
18
+ if (!attempt) { out.push({ entry, kind: 'never_checked', activity }); continue; }
16
19
  if (entry.intent === 'expected') {
17
- const hostedState = row.source === 'cloud' ? row.result?.watchState : null;
18
- const confirmed = row.state === 'confirmed_missing'
19
- || (row.state === 'absent' && hostedState === 'confirmed_missing');
20
- const suspected = row.state === 'suspected_missing' || row.state === 'absent'
21
- || (row.state === 'unknown' && hostedState === 'suspected_missing');
22
- const missing = ['absent', 'suspected_missing', 'confirmed_missing'].includes(row.state);
23
- // The change mirror omits repeated checks, so raw absence cannot prove confirmation.
24
- if ((confirmed || suspected) && row.complete === true) {
25
- out.push({ entry, row, kind: confirmed ? 'lost' : 'suspected_missing' });
26
- } else if (missing || ['unknown', 'source_unavailable'].includes(row.state)) {
27
- out.push({ entry, row, kind: 'cannot_say' });
20
+ if (projection.uncertain || attempt.state === 'source_unavailable') {
21
+ out.push({ entry, row, kind: 'cannot_say', activity });
22
+ } else if (['suspected_missing', 'confirmed_missing'].includes(projection.current_state)) {
23
+ out.push({ entry, row, kind: projection.current_state === 'confirmed_missing' ? 'lost' : 'suspected_missing', activity });
28
24
  }
29
25
  }
30
26
  // Nothing is promoted automatically: whether a link that appeared is one we now EXPECT is
31
27
  // a judgement about a relationship, not about HTML. So this proposes the edit and stops.
32
- if (entry.intent === 'wanted' && row.state === 'present') out.push({ entry, row, kind: 'appeared', suggest: { intent: 'expected' } });
28
+ if (entry.intent === 'wanted' && !projection.uncertain && projection.current_state === 'present') out.push({ entry, row, kind: 'appeared', activity, suggest: { intent: 'expected' } });
33
29
  }
34
30
  return out;
35
31
  }
package/cli/sync-lock.js CHANGED
File without changes
package/cli/sync.js CHANGED
@@ -12,6 +12,24 @@ import { CloudError } from './client.js';
12
12
 
13
13
  const PUSH_BATCH = 100;
14
14
 
15
+ function completeImportRows(result, expected) {
16
+ const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
17
+ const text = value => typeof value === 'string' && value.trim().length > 0;
18
+ const refuse = () => { throw new CloudError('INVALID_IMPORT_RESPONSE', 502, null,
19
+ 'The cloud returned an incomplete or invalid watch import result. Some watches may already have been stored. Inspect accessible watches and their local references before retrying sync; this request was not automatically replayed.'); };
20
+ if (!record(result) || !Array.isArray(result.rows) || result.rows.length !== expected) refuse();
21
+ const seen = new Set();
22
+ for (const row of result.rows) {
23
+ if (!record(row) || !Number.isInteger(row.index) || row.index < 0 || row.index >= expected || seen.has(row.index)) refuse();
24
+ seen.add(row.index);
25
+ const watch = Object.hasOwn(row, 'watch'), error = Object.hasOwn(row, 'error');
26
+ if (watch === error) refuse();
27
+ if (watch && (!record(row.watch) || !text(row.watch.id) || typeof row.watch.created !== 'boolean')) refuse();
28
+ if (error && (!record(row.error) || !text(row.error.code) || !text(row.error.message))) refuse();
29
+ }
30
+ return result.rows;
31
+ }
32
+
15
33
  export function syncPlan(entries, state, { includeWanted = false, ledgerIds = null } = {}) {
16
34
  if (ledgerIds !== null && (!Array.isArray(ledgerIds) || ledgerIds.some(id => typeof id !== 'string') || ledgerIds.some(id => !entries.some(entry => entry.id === id))))
17
35
  throw new CloudError('INVALID_SYNC_SELECTION', 0);
@@ -38,12 +56,13 @@ export async function pushExpectations(client, entries, state, { projectId, onPr
38
56
  for (let index = 0; index < watched.length; index += PUSH_BATCH) {
39
57
  const batch = watched.slice(index, index + PUSH_BATCH);
40
58
  const result = await client.importWatches(projectId, batch.map(toWatchInput));
41
- for (const row of result.rows ?? []) {
59
+ // Validate the complete acknowledgement before adopting any of this batch.
60
+ // Earlier POSTs may have committed; a malformed response never authorizes a replay.
61
+ for (const row of completeImportRows(result, batch.length)) {
42
62
  const entry = batch[row.index];
43
- if (!entry) continue;
44
63
  if (row.error) { failed.push({ id: entry.id, code: row.error.code, message: row.error.message }); continue; }
45
64
  mapping[entry.id] = row.watch.id;
46
- if (row.watch.created !== false) created.push(entry.id);
65
+ if (row.watch.created) created.push(entry.id);
47
66
  }
48
67
  onProgress?.(Math.min(index + PUSH_BATCH, watched.length), watched.length);
49
68
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentlinkops",
3
- "version": "0.6.10",
3
+ "version": "0.6.13",
4
4
  "description": "Backlink checks with dated evidence and portable results, optional repository ledgers, supplier-export imports and sync to AgentLinkOps.",
5
5
  "author": "AgentLinkOps (https://agentlinkops.com)",
6
6
  "license": "Apache-2.0",
File without changes
package/references/cli.md CHANGED
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
package/scripts/report.py CHANGED
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
@@ -4,7 +4,7 @@ export const indexConfigureInput=z.object({projectId:id,enabled:z.boolean(),dail
4
4
  export const indexInspectInput=z.object({connectionId:id,watchId:id,idempotencyKey:z.string().regex(/^[\x21-\x7e]{1,128}$/)}).strict();
5
5
  export const indexHistoryInput=z.object({projectId:id,watchId:id,limit:z.number().int().min(1).max(100).optional(),beforeId:digest.optional()}).strict();
6
6
  export const indexReceiptInput=z.object({projectId:id,watchId:id,receiptId:digest}).strict();
7
- export const indexEventsInput=z.object({projectId:id,watchId:id,limit:z.number().int().min(1).max(100).optional()}).strict();
7
+ export const indexEventsInput=z.object({projectId:id,watchId:id,limit:z.number().int().min(1).max(100).optional(),beforeId:digest.optional()}).strict();
8
8
  export const indexSettingsInput=z.object({projectId:id}).strict();
9
9
  export const indexSettingsResponse=z.object({projectId:id,enabled:z.boolean(),dailyLimit:z.number().int().positive(),retentionDays:z.number().int().positive(),cadenceSeconds:z.number().int().positive(),schedulingAvailable:z.boolean()}).passthrough();
10
10
  export const indexObservation=z.looseObject({id:digest,observationId:id,checkedAt:time,recordedAt:time,expiresAt:time,evidenceSha256:digest,eventId:digest.nullable(),ignored:z.string().nullable(),version:z.literal(1),url:z.url(),lane:z.enum(['gsc','licensed_site']),backend:z.string(),source_key:z.string(),tier:z.enum(['indexed','not_indexed','likely_indexed','not_found_in_site_query','unknown']),confidence:z.enum(['google_index_snapshot','search_result_sample','unknown']),reason:z.string(),live_page_test:z.literal(false),submission_requested:z.literal(false)});
@@ -13,4 +13,4 @@ export const indexInspectResponse=indexReceiptResponse.extend({replayed:z.boolea
13
13
  export const indexAttemptSummary=z.object({observedAt:time,latestAttempt:z.object({id:digest,admittedAt:time,status:z.enum(['in_progress','unsettled','completed']),receiptId:digest.nullable(),leaseExpiresAt:time.nullable()}).nullable(),unsettledCount:z.number().int().nonnegative()});
14
14
  export const indexWebsiteQuota=z.object({windowStart:time,observedAt:time,admitted:z.number().int().nonnegative(),limit:z.number().int().positive(),remaining:z.number().int().nonnegative()});
15
15
  export const indexHistoryResponse=z.object({observations:z.array(indexObservation),nextCursor:digest.nullable(),attemptSummary:indexAttemptSummary.optional(),websiteQuota:indexWebsiteQuota.optional()}).passthrough();
16
- export const indexEventsResponse=z.object({events:z.array(z.looseObject({id:digest,receiptId:digest,type:z.enum(['index.deindexed','index.reindexed']),createdAt:time,url:z.url(),source_key:z.string(),before:z.enum(['indexed','not_indexed']),after:z.enum(['indexed','not_indexed']),observation_ids:z.array(id)}))}).passthrough();
16
+ export const indexEventsResponse=z.object({events:z.array(z.looseObject({id:digest,receiptId:digest,type:z.enum(['index.deindexed','index.reindexed']),createdAt:time,url:z.url(),source_key:z.string(),before:z.enum(['indexed','not_indexed']),after:z.enum(['indexed','not_indexed']),observation_ids:z.array(id)})),nextCursor:digest.nullable().optional(),hasMore:z.boolean().optional()}).passthrough();
File without changes
File without changes
@@ -0,0 +1,57 @@
1
+ // Offline frozen chronology export. No hosted store, authentication, network or
2
+ // collection dependency belongs in the customer's CLI export closure.
3
+ const fail=(status,code,message)=>{throw Object.assign(new Error(message),{name:'ChronologyExportError',status,code});};
4
+ const iso=value=>typeof value==='string'&&Number.isFinite(Date.parse(value))?new Date(value).toISOString():null;
5
+ const validIdentity=value=>{
6
+ if(!value||typeof value!=='object'||Array.isArray(value))return false;
7
+ const id=v=>typeof v==='string'&&v.length>0&&v.length<=200&&!/[\s\x00-\x1f\x7f]/u.test(v);
8
+ const url=v=>{if(typeof v!=='string'||!v||v.length>4096)return false;try{const u=new URL(v);return ['http:','https:'].includes(u.protocol)&&!u.username&&!u.password;}catch{return false;}};
9
+ return ['workspaceId','projectId','watchId'].every(k=>id(value[k]))&&url(value.sourceUrl)&&url(value.targetUrl)
10
+ &&['exact','domain','subdomain','path'].includes(value.targetScope)&&(value.targetId===null||id(value.targetId))
11
+ &&(value.localReference===null||typeof value.localReference==='string'&&value.localReference.length<=2048)
12
+ &&value.matchBasis==='configured_target_url';
13
+ };
14
+ const compare=(a,b)=>a.sortAt>b.sortAt?-1:a.sortAt<b.sortAt?1:a.id>b.id?-1:a.id<b.id?1:0;
15
+ const sha256=async value=>Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(value))),b=>b.toString(16).padStart(2,'0')).join('');
16
+ const baseItem=(stream,row,identity,observedAt,recordedAt,kind)=>({id:`${stream}:${row.id}`,rawId:row.id,stream,kind,identity,observedAt:iso(observedAt),recordedAt:iso(recordedAt),sortAt:iso(observedAt)??iso(recordedAt),state:null,reason:null,sourceKey:null,method:null,checkerVersion:null,evidence:{sha256:null,reference:null,expiresAt:null,availability:'not_checked'},data:{}});
17
+
18
+ // Export only a supplied frozen dataset. This function never reads current DB
19
+ // state or fills a missing origin with today's projection.
20
+ export async function freezeChronology(pages,{localItems=[]}={}){
21
+ if(!Array.isArray(pages)||!pages.length||pages.length>10000||!Array.isArray(localItems)||localItems.length>10000)fail(400,'INVALID_CHRONOLOGY_EXPORT','Provide bounded chronology pages.');
22
+ const first=pages[0];if(!validIdentity(first?.identity)||iso(first?.asOf)!==first.asOf||!/^[a-f0-9]{64}$/.test(first?.snapshotId??''))fail(400,'INVALID_CHRONOLOGY_EXPORT','A frozen page requires an exact placement identity, watermark and snapshot digest.');
23
+ const byId=new Map();let continuous=first.pageCursor===null;
24
+ for(let i=0;i<pages.length;i++){
25
+ const page=pages[i];if(!page||page.schemaVersion!==1||page.snapshotId!==first.snapshotId||page.asOf!==first.asOf||JSON.stringify(page.identity)!==JSON.stringify(first.identity)||!Array.isArray(page.items))fail(400,'CHRONOLOGY_EXPORT_SCOPE_MISMATCH','Export pages must belong to one frozen placement read.');
26
+ if(typeof page.hasMore!=='boolean'||(page.hasMore?typeof page.nextCursor!=='string'||!page.nextCursor||page.nextCursor.length>12000:page.nextCursor!==null))fail(400,'INVALID_CHRONOLOGY_EXPORT','Each frozen page must declare whether another page exists with a consistent continuation cursor.');
27
+ if(i&&pages[i-1].nextCursor!==page.pageCursor)continuous=false;
28
+ for(const row of page.items){if(!row||JSON.stringify(row.identity)!==JSON.stringify(first.identity))fail(400,'CHRONOLOGY_EXPORT_SCOPE_MISMATCH','Every retained row must belong to this exact frozen placement.');const previous=byId.get(row.id);if(previous&&JSON.stringify(previous)!==JSON.stringify(row))fail(400,'CHRONOLOGY_EXPORT_CONFLICT','A frozen row changed between pages.');byId.set(row.id,row);}
29
+ }
30
+ for(const row of localItems){if(row.stream!=='local_submissions'||JSON.stringify(row.identity)!==JSON.stringify(first.identity)||!iso(row.sortAt)||row.sortAt>first.asOf)fail(400,'CHRONOLOGY_EXPORT_SCOPE_MISMATCH','Local submission context must match this placement and cutoff.');const previous=byId.get(row.id);if(previous&&JSON.stringify(previous)!==JSON.stringify(row))fail(400,'CHRONOLOGY_EXPORT_CONFLICT','A local receipt identity changed.');byId.set(row.id,row);}
31
+ const complete=continuous&&pages.at(-1).hasMore===false,coverage=JSON.parse(JSON.stringify(pages.at(-1).coverage));
32
+ if(localItems.length)coverage.local_submissions={status:'available',reason:'validated_local_reported_receipts',hasMore:false,providerAuthenticity:'not_established'};
33
+ const items=[...byId.values()].sort(compare),gaps=[];
34
+ for(const [stream,origin] of Object.entries(coverage)){
35
+ if(origin.status==='available'){const loaded=items.filter(row=>row.stream===stream);origin.loadedRange={newestAt:loaded[0]?.sortAt??null,oldestAt:loaded.at(-1)?.sortAt??null,itemCount:loaded.length};}
36
+ if(origin.status!=='available'||origin.compactedBefore||origin.retentionFloor>0)gaps.push({stream,reason:origin.reason,...(origin.compactedBefore?{compactedBefore:origin.compactedBefore}:{}),...(origin.retentionFloor>0?{retentionFloor:origin.retentionFloor}:{})});
37
+ }
38
+ if(!complete)gaps.push({stream:'pagination',reason:'partial_pagination'});
39
+ const data={schemaVersion:1,identity:first.identity,asOf:first.asOf,snapshotId:first.snapshotId,items,coverage,gaps,pagination:{complete,pageCount:pages.length,reason:complete?'all_loaded_pages':'partial_pagination',nextCursor:pages.at(-1).nextCursor??null},historyMeaning:first.historyMeaning};
40
+ const frozen=JSON.parse(JSON.stringify(data));return {...frozen,contentHash:await sha256(JSON.stringify(frozen))};
41
+ }
42
+
43
+ // This is an offline normalization boundary, not server ingestion, collection or
44
+ // proof of provider authenticity. The hash uses the existing CLI receipt format.
45
+ export async function normalizeLocalSubmissions(rows,identity){
46
+ if(!validIdentity(identity))fail(400,'INVALID_SUBMISSION_RECEIPT','An exact placement identity is required.');
47
+ if(!Array.isArray(rows)||rows.length>10000)fail(400,'INVALID_SUBMISSION_RECEIPT','Provide bounded local submission receipts.');
48
+ const fields=['v','id','project_id','url','channel','endpoint','submitted_at','http_status','evidence_sha256'],items=[],byId=new Map();
49
+ for(const row of rows){
50
+ if(!row||Object.keys(row).some(k=>!fields.includes(k))||fields.some(k=>!Object.hasOwn(row,k))||row.v!==1||!/^[\w-]{1,128}$/.test(row.id??'')||!/^[\w-]{1,128}$/.test(row.project_id??'')||row.channel!=='indexnow'||row.endpoint!=='https://api.indexnow.org/indexnow'||iso(row.submitted_at)!==row.submitted_at||row.http_status!==null&&(!Number.isInteger(row.http_status)||row.http_status<100||row.http_status>599))fail(400,'INVALID_SUBMISSION_RECEIPT','Use the existing validated local submission format.');
51
+ let url;try{url=new URL(row.url);}catch{fail(400,'INVALID_SUBMISSION_RECEIPT','The submission URL is invalid.');}if(url.href!==row.url||!['http:','https:'].includes(url.protocol)||url.username||url.password||url.hash)fail(400,'INVALID_SUBMISSION_RECEIPT','The submission URL must be exact HTTP(S).');
52
+ const body=Object.fromEntries(fields.filter(k=>k!=='evidence_sha256').map(k=>[k,row[k]]));if(await sha256(JSON.stringify(body))!==row.evidence_sha256)fail(400,'INVALID_SUBMISSION_RECEIPT','The local receipt hash does not match.');
53
+ const key=`${row.project_id}:${row.id}`,previous=byId.get(key);if(previous&&JSON.stringify(previous)!==JSON.stringify(row))fail(409,'SUBMISSION_IDENTITY_CONFLICT','A local submission ID has conflicting contents.');byId.set(key,row);if(previous||row.project_id!==identity.projectId||![identity.sourceUrl,identity.targetUrl].includes(row.url))continue;
54
+ const item=baseItem('local_submissions',{id:key},identity,null,null,'reported_submission');items.push({...item,sortAt:row.submitted_at,reason:row.http_status===200?'received':row.http_status===202?'key_validation_pending':row.http_status===null?'unknown':'failed',method:'reported_indexnow_http_response',evidence:{...item.evidence,sha256:row.evidence_sha256,availability:'local_receipt'},data:{...row,subject:row.url===identity.sourceUrl?'source':'target',reportedSubmissionAt:row.submitted_at,sortTimeMeaning:'reported_submission_time',retrievedAt:null,providerAuthenticity:'not_established',participantIndexingVerified:false,causationEstablished:false}});
55
+ }
56
+ return items.sort(compare);
57
+ }
@@ -0,0 +1,41 @@
1
+ import {z} from 'zod';
2
+ const id=z.string().min(1).max(200),time=z.string().datetime(),nullableTime=time.nullable(),count=z.number().int().nonnegative();
3
+ export const chronologyStreams=['context','link_observations','target_observations','link_events','target_events','index_observations','index_events','local_submissions'];
4
+ export const chronologyInput=z.object({watchId:id,limit:z.number().int().min(1).max(100).optional(),cursor:z.string().max(12000).optional(),streams:z.array(z.enum(chronologyStreams)).min(1).max(8).refine(values=>new Set(values).size===values.length).optional()}).strict();
5
+ const identity=z.object({workspaceId:id,projectId:id,watchId:id,sourceUrl:z.url(),targetUrl:z.url(),targetScope:z.enum(['exact','domain','subdomain','path']),targetId:id.nullable(),localReference:z.string().nullable(),matchBasis:z.literal('configured_target_url')});
6
+ export const chronologyResponse=z.object({schemaVersion:z.literal(1),identity,asOf:time,snapshotId:z.string().regex(/^[a-f0-9]{64}$/),items:z.array(z.looseObject({id:z.string(),rawId:z.string(),stream:z.enum(chronologyStreams),kind:z.string(),identity,observedAt:nullableTime,recordedAt:nullableTime,sortAt:time,state:z.string().nullable(),reason:z.string().nullable(),sourceKey:z.string().nullable(),method:z.string().nullable(),checkerVersion:z.string().nullable(),evidence:z.looseObject({sha256:z.string().nullable(),reference:z.string().nullable(),expiresAt:nullableTime,availability:z.string()}),data:z.record(z.string(),z.unknown())})),pageCursor:z.string().nullable(),nextCursor:z.string().nullable(),hasMore:z.boolean(),coverage:z.partialRecord(z.enum(chronologyStreams),z.looseObject({status:z.enum(['available','denied','unavailable']),reason:z.string()})),historyMeaning:z.string()}).passthrough();
7
+ // Additive retained-data projection; absence preserves older backend responses.
8
+ const nullableSeconds=z.number().nonnegative().nullable();
9
+ const coverageProjection=z.object({
10
+ supplied:count,active:count,paused:count,recordedHeld:count,schedulingCandidates:count,busy:count,dueCandidates:count,
11
+ eligibility:z.object({status:z.literal('unavailable'),eligible:z.null(),reason:z.literal('admission_not_rechecked')}),
12
+ denominatorMeaning:z.string(),freshnessWindow:z.string(),cadenceSeconds:z.object({minimum:count.nullable(),maximum:count.nullable()}),
13
+ oldestOverdueAt:nullableTime,oldestOverdueAgeSeconds:nullableSeconds,nextScheduledAt:nullableTime,nextEligibleAt:z.null(),nextEligibleReason:z.literal('admission_not_rechecked'),
14
+ neverAttempted:count,neverConclusive:count,currentUnknown:count,unknownReasons:z.record(z.string(),count),freshConclusive:count,staleConclusive:count,
15
+ shares:z.object({currentUnknown:z.number().min(0).max(1).nullable(),neverAttempted:z.number().min(0).max(1).nullable(),freshConclusive:z.number().min(0).max(1).nullable()}),shareMeaning:z.string(),
16
+ freshRetainedLinkVerification:count.optional(),linkVerificationMeaning:z.string().optional(),reasonCounts:z.object({due:z.record(z.string(),count),deferred:z.record(z.string(),count),unavailable:z.record(z.string(),count).optional()})
17
+ });
18
+ const projectedJobs=z.object({
19
+ retained:count,queued:count,running:count,succeeded:count,failed:count,cancelled:count,everClaimed:count,claimAttempts:count,retryClaims:count,retryExhausted:count,expiredRunningLeases:count,
20
+ oldestQueuedAt:nullableTime,oldestQueuedAgeSeconds:nullableSeconds,oldestRunningAdmissionAt:nullableTime,oldestRunningAdmissionAgeSeconds:nullableSeconds,latestAdmissionAt:nullableTime,latestCompletionAt:nullableTime,
21
+ scopedRetainedDeadLetters:count,deadLetterMeaning:z.string(),admissionToTerminal:z.object({samples:count,minimumSeconds:nullableSeconds,maximumSeconds:nullableSeconds,meaning:z.string()}),
22
+ startedAt:z.null(),queueWaitSeconds:z.null(),providerLatencySeconds:z.null(),timingReason:z.literal('first_start_and_provider_timing_not_retained'),
23
+ dispatch:z.object({outstanding:count,deferred:count,expiredSendingLeases:count,nextDispatchAt:nullableTime,meaning:z.string()}),historyMeaning:z.string()
24
+ });
25
+ const projectedPlane=z.object({coverage:coverageProjection,jobs:projectedJobs});
26
+ const indexProjection=z.discriminatedUnion('status',[
27
+ z.object({status:z.literal('denied'),reason:z.string()}).strict(),z.object({status:z.literal('unavailable'),reason:z.string()}).strict(),
28
+ z.object({status:z.literal('available'),reason:z.string(),suppliedSourceWatches:count,activeSourceWatches:count,
29
+ lanes:z.array(z.object({lane:z.enum(['gsc','licensed_site']),retainedStates:count,observedActiveWatches:count,neverObservedActiveWatches:count,currentUnknownStates:count,latestObservationAt:nullableTime,oldestObservationAt:nullableTime,oldestObservationAgeSeconds:nullableSeconds})).length(2),
30
+ schedule:z.discriminatedUnion('status',[z.object({status:z.literal('unavailable'),reason:z.literal('schema_unavailable')}),z.object({status:z.literal('available'),projectOptedIn:z.boolean(),inspectionEnabled:z.boolean(),schedulerEnabled:z.boolean(),cadenceSeconds:count.nullable(),dailyLimit:count.nullable(),nextProjectScanAt:nullableTime,propertyEligibleWatches:z.null(),propertyEligibilityReason:z.literal('current_source_property_grant_not_assessed'),eligibleWatches:z.null(),eligibilityReason:z.literal('admission_not_rechecked')})]),meaning:z.string()})
31
+ ]);
32
+ export const monitoringAssuranceProjection=z.object({schemaVersion:z.literal(1),source:projectedPlane,target:projectedPlane,index:indexProjection,
33
+ serviceTickWindow:z.object({expectedTicks:z.null(),completedTicks:z.null(),availabilityShare:z.null(),reason:z.literal('tick_history_not_retained')}),
34
+ capacity:z.object({dueOpportunities:z.null(),missedOpportunities:z.null(),checksPerEligibleWatch:z.null(),reason:z.literal('bounded_roster_opportunity_history_not_retained')}),meaning:z.string()});
35
+ export const assuranceInput=z.object({projectId:id}).strict();
36
+ const metric=z.object({supplied:count,active:count,paused:count,neverAttempted:count,neverConclusive:count,due:count,overdue:count,currentUnknown:count,latestAttemptAt:nullableTime,oldestAttemptAt:nullableTime,oldestAttemptAgeSeconds:z.number().nonnegative().nullable(),latestConclusiveAt:nullableTime,oldestConclusiveAt:nullableTime,oldestConclusiveAgeSeconds:z.number().nonnegative().nullable()});
37
+ export const assuranceResponse=z.object({schemaVersion:z.literal(1),workspaceId:id,projectId:id,asOf:time,source:metric,target:metric,monitoringProjection:monitoringAssuranceProjection.optional(),sourceLinkVerification:z.object({latestRetainedAt:nullableTime,oldestRetainedAt:nullableTime,observedWatches:count,meaning:z.string()}),sourceJobs:z.object({queued:count,running:count}),overdueReasons:z.record(z.string(),count),index:z.discriminatedUnion('status',[z.object({status:z.literal('available'),reason:z.string(),observedWatches:count,latestObservationAt:nullableTime,oldestObservationAt:nullableTime,oldestObservationAgeSeconds:z.number().nonnegative().nullable(),denominator:z.string()}),z.object({status:z.literal('denied'),reason:z.string()}),z.object({status:z.literal('unavailable'),reason:z.string()})]),serviceTicks:z.object({status:z.enum(['available','unavailable']),meaning:z.string(),items:z.array(z.object({plan:z.string(),latestAttemptAt:time,latestCompletionAt:nullableTime,latestSuccessAt:nullableTime,latestOutcome:z.enum(['running','succeeded','failed']),ageSeconds:z.number().nonnegative()}))}),assurance:z.object({elapsedSoak:z.literal('not_established'),backupRestore:z.literal('not_established'),externalDelivery:z.literal('not_established')}),denominator:z.string()}).passthrough();
38
+ export const monitoringReadResponses={get_placement_chronology:chronologyResponse,get_monitoring_service_assurance:assuranceResponse};
39
+ const at='2026-10-07T00:00:00.000Z',projectId='pr_example',workspaceId='ws_example';
40
+ const emptyMetric={supplied:0,active:0,paused:0,neverAttempted:0,neverConclusive:0,due:0,overdue:0,currentUnknown:0,latestAttemptAt:null,oldestAttemptAt:null,oldestAttemptAgeSeconds:null,latestConclusiveAt:null,oldestConclusiveAt:null,oldestConclusiveAgeSeconds:null};
41
+ export const monitoringReadExamples={get_placement_chronology:{schemaVersion:1,identity:{workspaceId,projectId,watchId:'watch_example',sourceUrl:'https://publisher.example.org/resource',targetUrl:'https://example.org/guide',targetScope:'exact',targetId:null,localReference:null,matchBasis:'configured_target_url'},asOf:at,snapshotId:'a'.repeat(64),items:[],pageCursor:null,nextCursor:null,hasMore:false,coverage:{context:{status:'available',reason:'recorded_declarations_only'},local_submissions:{status:'unavailable',reason:'local_only_not_hosted'}},historyMeaning:'Retained evidence only; gaps do not establish inactivity.'},get_monitoring_service_assurance:{schemaVersion:1,workspaceId,projectId,asOf:at,source:emptyMetric,target:emptyMetric,sourceLinkVerification:{latestRetainedAt:null,oldestRetainedAt:null,observedWatches:0,meaning:'Retained complete link receipts.'},sourceJobs:{queued:0,running:0},overdueReasons:{},index:{status:'unavailable',reason:'not_configured'},serviceTicks:{status:'unavailable',meaning:'Scheduler metadata, not customer coverage.',items:[]},assurance:{elapsedSoak:'not_established',backupRestore:'not_established',externalDelivery:'not_established'},denominator:'Supplied project records; zero is not an inventory of all backlinks.'}};
File without changes
File without changes