agentlinkops 0.6.1 → 0.6.3

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 (201) 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 +17 -0
  5. package/LICENSE +0 -0
  6. package/NOTICE +0 -0
  7. package/README.md +0 -0
  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 +130 -0
  14. package/cli/agent-setup.js +1014 -101
  15. package/cli/agentlinkops.mjs +0 -0
  16. package/cli/args.js +0 -0
  17. package/cli/catalog.js +12837 -4694
  18. package/cli/check.js +0 -0
  19. package/cli/citation.js +181 -32
  20. package/cli/client.js +1 -1
  21. package/cli/commands.js +3 -3
  22. package/cli/config.js +2 -1
  23. package/cli/connection.js +0 -0
  24. package/cli/context-mcp.js +0 -0
  25. package/cli/context.js +0 -0
  26. package/cli/delivery.js +0 -0
  27. package/cli/disavow.js +54 -0
  28. package/cli/doctor.js +7 -8
  29. package/cli/env.js +0 -0
  30. package/cli/fleet.js +0 -0
  31. package/cli/gsc-browser.js +0 -0
  32. package/cli/import.js +0 -0
  33. package/cli/index-observations.js +89 -0
  34. package/cli/ledger.js +7 -1
  35. package/cli/lifecycle.js +40 -0
  36. package/cli/linktrail.mjs +0 -0
  37. package/cli/local-result.js +0 -0
  38. package/cli/main.js +99 -7
  39. package/cli/mirror.js +0 -0
  40. package/cli/mix.js +0 -0
  41. package/cli/platforms.js +0 -0
  42. package/cli/receipt-performance.js +0 -0
  43. package/cli/receipts.js +0 -0
  44. package/cli/report.js +79 -6
  45. package/cli/setup-plan.js +0 -0
  46. package/cli/skill.js +41 -4
  47. package/cli/state.js +0 -0
  48. package/cli/status.js +0 -0
  49. package/cli/sync.js +23 -1
  50. package/package.json +3 -2
  51. package/references/citation-panels.md +26 -0
  52. package/references/cli.md +0 -0
  53. package/references/disavow.md +0 -0
  54. package/references/discovery-handoff.md +32 -0
  55. package/references/recipes/catalog.json +148 -0
  56. package/references/recipes/command-notes.md +9 -0
  57. package/references/recipes/placement-reconciliation.md +41 -0
  58. package/references/recipes/qualified-campaign-handoff.md +56 -0
  59. package/references/recipes/site-context-brief.md +25 -0
  60. package/references/recipes/sourced-linkable-asset.md +25 -0
  61. package/references/reports.md +0 -0
  62. package/scripts/agentlinkops.py +0 -0
  63. package/scripts/disavow.py +0 -0
  64. package/scripts/linktrail.py +0 -0
  65. package/scripts/migrations/001_crm.sql +0 -0
  66. package/scripts/migrations/002_cloud_events.sql +0 -0
  67. package/scripts/migrations/003_disavow.sql +0 -0
  68. package/scripts/migrations/004_placement_costs.sql +0 -0
  69. package/scripts/report.py +0 -0
  70. package/shared/admission-contract.js +16 -0
  71. package/shared/admission-report-contract.js +8 -0
  72. package/shared/agent-clients.js +0 -0
  73. package/shared/citation-evidence-contract.js +27 -0
  74. package/shared/disavow-contract.js +18 -0
  75. package/shared/index-observation-contract.js +14 -0
  76. package/shared/lifecycle-contract.js +22 -0
  77. package/shared/lifecycle-money.js +24 -0
  78. package/shared/observation-event.js +12 -1
  79. package/shared/opportunity-record.js +259 -0
  80. package/shared/response-contract.js +2 -0
  81. package/shared/search-connection-contract.js +0 -0
  82. package/skills/agentlinkops-assets/SKILL.md +2 -0
  83. package/skills/agentlinkops-assets/agents/openai.yaml +0 -0
  84. package/skills/agentlinkops-campaigns/SKILL.md +5 -1
  85. package/skills/agentlinkops-campaigns/agents/openai.yaml +0 -0
  86. package/skills/agentlinkops-connect/SKILL.md +8 -0
  87. package/skills/agentlinkops-connect/agents/openai.yaml +0 -0
  88. package/skills/agentlinkops-connect/references/agentlinkops.md +9 -5
  89. package/skills/agentlinkops-crm/SKILL.md +2 -0
  90. package/skills/agentlinkops-crm/agents/openai.yaml +0 -0
  91. package/skills/agentlinkops-discovery/SKILL.md +17 -0
  92. package/skills/content-defingerprinting/SKILL.md +0 -0
  93. package/skills/content-defingerprinting/scripts/watermarks/README.md +0 -0
  94. package/skills/content-defingerprinting/scripts/watermarks/UPSTREAM.json +0 -0
  95. package/skills/content-defingerprinting/scripts/watermarks/revendor.sh +0 -0
  96. package/skills/content-defingerprinting/scripts/watermarks/vendor/LICENSE.upstream +0 -0
  97. package/skills/content-defingerprinting/scripts/watermarks/vendor/clean_text.py +0 -0
  98. package/skills/content-defingerprinting/scripts/watermarks/vendor/common.py +0 -0
  99. package/skills/content-defingerprinting/scripts/watermarks/vendor/inspect_text.py +0 -0
  100. package/skills/content-defingerprinting/scripts/watermarks/vendor/score_stylometry.py +0 -0
  101. package/skills/content-defingerprinting/scripts/watermarks/vendor/text_unicode.py +0 -0
  102. package/skills/content-defingerprinting/scripts/watermarks/verify-vendor.py +0 -0
  103. package/skills/content-defingerprinting/scripts/watermarks/wm-scan.py +0 -0
  104. package/skills/internal-linking-optimizer/SKILL.md +0 -0
  105. package/skills/internal-linking-optimizer/references/source-prompt.md +0 -0
  106. package/skills/pre-publish-review/SKILL.md +0 -0
  107. package/skills/source-cited-content-builder/SKILL.md +0 -0
  108. package/skills/source-cited-content-builder/references/source-prompt.md +0 -0
  109. package/skills/source-cited-humanizer/SKILL.md +0 -0
  110. package/src/citations/adapter.js +102 -21
  111. package/src/citations/alert.js +38 -13
  112. package/src/citations/browser/engine.js +145 -50
  113. package/src/citations/browser/gen/core/providers/_shared/rawSourcesDom.js +0 -0
  114. package/src/citations/browser/gen/core/providers/_shared/resetProviderPage.js +0 -0
  115. package/src/citations/browser/gen/core/providers/_shared/sourceUtils.js +0 -0
  116. package/src/citations/browser/gen/core/providers/ai-overview/index.js +0 -0
  117. package/src/citations/browser/gen/core/providers/ai-overview/lib/expand.js +0 -0
  118. package/src/citations/browser/gen/core/providers/ai-overview/lib/extractSources.js +0 -0
  119. package/src/citations/browser/gen/core/providers/ai-overview/lib/pageLifecycle.js +0 -0
  120. package/src/citations/browser/gen/core/providers/ai-overview/lib/session.js +0 -0
  121. package/src/citations/browser/gen/core/providers/chatgpt/index.js +0 -0
  122. package/src/citations/browser/gen/core/providers/chatgpt/lib/dismissAuthModal.js +0 -0
  123. package/src/citations/browser/gen/core/providers/chatgpt/lib/extractSources.js +0 -0
  124. package/src/citations/browser/gen/core/providers/chatgpt/lib/pageLifecycle.js +0 -0
  125. package/src/citations/browser/gen/core/providers/claude/index.js +0 -0
  126. package/src/citations/browser/gen/core/providers/claude/lib/extractSources.js +0 -0
  127. package/src/citations/browser/gen/core/providers/claude/lib/pageLifecycle.js +0 -0
  128. package/src/citations/browser/gen/core/providers/gemini/index.js +0 -0
  129. package/src/citations/browser/gen/core/providers/gemini/lib/extractSources.js +0 -0
  130. package/src/citations/browser/gen/core/providers/gemini/lib/pageLifecycle.js +0 -0
  131. package/src/citations/browser/gen/core/providers/gemini/lib/session.js +0 -0
  132. package/src/citations/browser/gen/core/providers/index.js +0 -0
  133. package/src/citations/browser/gen/core/providers/perplexity/index.js +0 -0
  134. package/src/citations/browser/gen/core/providers/perplexity/lib/extractSources.js +0 -0
  135. package/src/citations/browser/gen/core/providers/perplexity/lib/navigation.js +0 -0
  136. package/src/citations/browser/gen/lib/browser/camoufox.js +0 -0
  137. package/src/citations/browser/gen/lib/browser/display.js +0 -0
  138. package/src/citations/browser/gen/lib/browser/domOps.js +0 -0
  139. package/src/citations/browser/gen/lib/browser/humanBehavior.js +0 -0
  140. package/src/citations/browser/gen/lib/browser/navigate.js +0 -0
  141. package/src/citations/browser/gen/lib/extraction/sourceUtils.js +0 -0
  142. package/src/citations/browser/gen/lib/input/editor/clearInput.js +0 -0
  143. package/src/citations/browser/gen/lib/input/editor/findEditor.js +0 -0
  144. package/src/citations/browser/gen/lib/input/editor/promptInput.js +0 -0
  145. package/src/citations/browser/gen/lib/input/markdown/converter.js +0 -0
  146. package/src/citations/browser/gen/lib/input/markdown/toMarkdown.js +0 -0
  147. package/src/citations/browser/gen/lib/input/response/detectBotPage.js +0 -0
  148. package/src/citations/browser/gen/lib/input/response/isGenerating.js +0 -0
  149. package/src/citations/browser/gen/lib/input/response/waitForFinish.js +0 -0
  150. package/src/citations/browser/gen/lib/input/sources/findButton.js +0 -0
  151. package/src/citations/browser/gen/lib/input/sources/openPanel.js +0 -0
  152. package/src/citations/browser/gen/utils/agent-constants.js +0 -0
  153. package/src/citations/browser/providers.js +3 -1
  154. package/src/citations/browser/shims/errors.js +0 -0
  155. package/src/citations/browser/shims/types.js +0 -0
  156. package/src/citations/browser/shims/utils.js +1 -0
  157. package/src/citations/contract.js +55 -9
  158. package/src/citations/inventory.js +106 -0
  159. package/src/citations/login.js +122 -0
  160. package/src/citations/match.js +12 -0
  161. package/src/citations/panel-builder.js +44 -39
  162. package/src/citations/report.js +88 -21
  163. package/src/citations/runner.js +59 -31
  164. package/src/citations/stats.js +133 -61
  165. package/src/citations/sync.js +131 -0
  166. package/src/citations/watch.js +57 -77
  167. package/src/competitors/contract.js +0 -0
  168. package/src/competitors/domain-mix.js +0 -0
  169. package/src/context/ga4.js +0 -0
  170. package/src/context/gsc.js +0 -0
  171. package/src/context/site-profile.js +0 -0
  172. package/src/context/util.js +0 -0
  173. package/src/context-tool-registry.js +0 -0
  174. package/src/corpus/acquire.js +47 -8
  175. package/src/corpus/seeds.js +132 -19
  176. package/src/discovery/contract.js +0 -0
  177. package/src/discovery/import.js +0 -0
  178. package/src/evidence-reference.js +112 -0
  179. package/src/index-observation.js +161 -0
  180. package/src/locator.js +112 -0
  181. package/src/tool-views.js +6 -2
  182. package/src/verifier/fetch.js +42 -6
  183. package/src/verifier/index.js +0 -0
  184. package/src/verifier/observation-kind.js +0 -0
  185. package/src/verifier/parser.js +0 -0
  186. package/src/verifier/readiness.js +0 -0
  187. package/src/verifier/render-evidence.js +0 -0
  188. package/src/verifier/robots.js +0 -0
  189. package/src/verifier/transition.js +0 -0
  190. package/src/verifier/url.js +0 -0
  191. package/templates/SITE.template.md +0 -0
  192. package/templates/campaigns.v1.json +0 -0
  193. package/src/citations/browser/gen/core/providers/_shared/google.js +0 -4
  194. package/src/citations/browser/gen/core/providers/types.js +0 -0
  195. package/src/citations/browser/gen/lib/browser/playwrightCompat.js +0 -321
  196. package/src/citations/browser/gen/lib/browser/proxy/forwarder.js +0 -251
  197. package/src/citations/browser/gen/lib/browser/runtimeTypes.js +0 -0
  198. package/src/citations/browser/gen/lib/input/editor/assertNotBlocked.js +0 -54
  199. package/src/citations/browser/gen/lib/input/editor/findSendButton.js +0 -21
  200. package/src/citations/browser/gen/lib/input/response/getText.js +0 -10
  201. package/src/citations/fixtures/dataforseo-aio.json +0 -82
@@ -13,8 +13,11 @@ export const FETCH_KINDS = Object.freeze(['direct', 'rendered', 'proxied']);
13
13
  export const ACQUISITION_LIMITS = Object.freeze({ maxBytes: 2 * 1024 * 1024, timeoutMs: 20_000, maxRedirects: 5 });
14
14
 
15
15
  // Every way a page attempt can end. The frontier records the outcome; none of them is ever
16
- // allowed to reach the corpus as "this page has no links".
17
- export const OUTCOMES = Object.freeze(['fetched', 'not_found', 'blocked', 'unavailable', 'not_configured', 'deferred']);
16
+ // allowed to reach the corpus as "this page has no links". `unchanged` is the conditional-GET
17
+ // end: a 304 against validators we sent. It is a real observation with provenance, and it is
18
+ // recorded in the refresh ledger (corpus_refresh_checks), never as a corpus_pages row — a page
19
+ // we did not re-read is not a page we fetched, and the pages table has no vocabulary for it.
20
+ export const OUTCOMES = Object.freeze(['fetched', 'not_found', 'blocked', 'unavailable', 'not_configured', 'deferred', 'unchanged']);
18
21
 
19
22
  const BLOCKED_REASONS = /^(robots_disallowed|robots_|access_challenge|source_http_(401|403|429))/;
20
23
  // A slot we could not take is not the publisher's fault and not a defect: retry it later.
@@ -48,7 +51,7 @@ export function createAcquisition(options = {}) {
48
51
  const runtimes = options.runtimes ?? {};
49
52
  const clock = options.now ?? (() => new Date().toISOString());
50
53
 
51
- async function direct(url, beforeFetch) {
54
+ async function direct(url, beforeFetch, { validators = null, document = false } = {}) {
52
55
  // The public boundary is a property of the transport, not of this module. Refuse rather
53
56
  // than fetch on an unasserted one; a lexically public hostname can still resolve inside.
54
57
  if (options.publicFetchSafe !== true) throw new AcquisitionError('public_fetch_boundary_not_asserted');
@@ -57,9 +60,12 @@ export function createAcquisition(options = {}) {
57
60
  const trace = { finalUrl: url, redirects: [], robots: null, httpStatus: null };
58
61
  try {
59
62
  const fetcher = publicFetcher({ ...options, beforeFetch: beforeFetch ?? options.beforeFetch, maxBytes, maxRedirects: ACQUISITION_LIMITS.maxRedirects }, controller.signal);
60
- const { response, body } = await fetcher.source(url, trace);
63
+ const { response, body, notModified, validators: earned } = await fetcher.source(url, trace, { validators, document });
64
+ if (notModified) return { outcome: 'unchanged', trace, html: null, validators: earned };
61
65
  if (NOT_FOUND.has(response.status)) return { outcome: 'not_found', trace, html: null };
62
- return { outcome: 'fetched', trace, html: body?.text ?? '', bytes: body?.bytes ?? 0 };
66
+ return { outcome: 'fetched', trace, html: body?.text ?? '', bytes: body?.bytes ?? 0,
67
+ linkHeader: response.headers.get('link'),
68
+ validators: { etag: response.headers.get('etag'), lastModified: response.headers.get('last-modified') } };
63
69
  } finally {
64
70
  clearTimeout(timer);
65
71
  }
@@ -93,20 +99,26 @@ export function createAcquisition(options = {}) {
93
99
  /**
94
100
  * Acquires one page. Always resolves: an attempt that failed is a recorded outcome, not
95
101
  * a thrown error, because the corpus must keep attempted outcomes and not only successes.
102
+ *
103
+ * `{ validators }` makes the direct path a conditional GET: pass the ETag/Last-Modified a
104
+ * previous fetch of this URL earned, and an unchanged page comes back as outcome
105
+ * `unchanged` with `http_status` 304 and no body — a dated observation, not an absence.
96
106
  */
97
- async fetchPage(rawUrl, { kind = 'direct', beforeFetch = null } = {}) {
107
+ async fetchPage(rawUrl, { kind = 'direct', beforeFetch = null, validators = null } = {}) {
98
108
  const at = clock();
99
109
  const base = { url: rawUrl, final_url: null, kind, http_status: null, robots: null,
100
- html: null, body_truncated: false, fetched_at: at, reason: null };
110
+ html: null, body_truncated: false, fetched_at: at, reason: null, etag: null, last_modified: null };
101
111
  if (!FETCH_KINDS.includes(kind)) return { ...base, outcome: 'unavailable', reason: 'unsupported_fetch_kind' };
102
112
  const checked = validatePublicUrl(rawUrl);
103
113
  if (!checked.valid) return { ...base, outcome: 'unavailable', reason: `unsafe_url:${checked.reason}` };
104
114
 
105
115
  try {
106
116
  if (kind === 'direct') {
107
- const { outcome, trace, html } = await direct(checked.url, beforeFetch);
117
+ const { outcome, trace, html, validators: earned, linkHeader } = await direct(checked.url, beforeFetch, { validators });
108
118
  return { ...base, outcome, final_url: trace.finalUrl, http_status: trace.httpStatus,
109
119
  robots: trace.robots, redirects: trace.redirects, html,
120
+ etag: earned?.etag ?? null, last_modified: earned?.lastModified ?? null,
121
+ link_header: linkHeader ?? null,
110
122
  // The verifier's reader refuses an oversized body outright, so a returned body was
111
123
  // read to the end. Truncation reaches extraction from runtimes with their own caps.
112
124
  body_truncated: false, render_ms: 0, proxy_bytes: 0 };
@@ -131,5 +143,32 @@ export function createAcquisition(options = {}) {
131
143
  return { ...base, outcome, reason };
132
144
  }
133
145
  },
146
+
147
+ /**
148
+ * Acquires one non-page document: a sitemap or an RSS/Atom feed. Same transport, same
149
+ * robots-per-hop, same public boundary and same outcome vocabulary as `fetchPage`; only
150
+ * the Accept header and the content-type gate differ, because the XML family is the
151
+ * expected representation here. Conditional GET works the same way — feeds revalidate too.
152
+ *
153
+ * The crawl loop never calls this; it exists for the freshness lanes (sitemap diffing,
154
+ * feed polling) that read publisher declarations rather than pages.
155
+ */
156
+ async fetchDocument(rawUrl, { beforeFetch = null, validators = null } = {}) {
157
+ const at = clock();
158
+ const base = { url: rawUrl, final_url: null, kind: 'direct', http_status: null,
159
+ body: null, fetched_at: at, reason: null, etag: null, last_modified: null };
160
+ const checked = validatePublicUrl(rawUrl);
161
+ if (!checked.valid) return { ...base, outcome: 'unavailable', reason: `unsafe_url:${checked.reason}` };
162
+ try {
163
+ const { outcome, trace, html, validators: earned, linkHeader } = await direct(checked.url, beforeFetch, { validators, document: true });
164
+ return { ...base, outcome, final_url: trace.finalUrl, http_status: trace.httpStatus,
165
+ body: outcome === 'fetched' ? html : null,
166
+ etag: earned?.etag ?? null, last_modified: earned?.lastModified ?? null,
167
+ link_header: linkHeader ?? null };
168
+ } catch (error) {
169
+ const reason = typeof error?.reason === 'string' ? error.reason : 'acquisition_error';
170
+ return { ...base, outcome: classifyFailure(reason), reason };
171
+ }
172
+ },
134
173
  };
135
174
  }
@@ -18,7 +18,81 @@ export class SeedError extends Error {
18
18
  constructor(reason) { super(reason); this.name = 'SeedError'; this.reason = reason; }
19
19
  }
20
20
 
21
- function decodeXml(value) {
21
+ // The seed-composition cap (DP-0049-T01 Fix 2). The birding corpus took 32.3% of its pages
22
+ // from six .edu library-guide seed hosts and the citation graph followed the seeds: 59 of 100
23
+ // shortlist rows were library infrastructure. The cap keeps one seed CLASS from dominating a
24
+ // corpus no matter how large its sitemaps are, without touching the per-host cap that keeps
25
+ // one large publisher from doing the same.
26
+ export const SEED_COMPOSITION = Object.freeze({ maxShare: 1 / 3 });
27
+
28
+ /**
29
+ * Parses a seeds file into ordered `{ host, class }` records. Every comment line opens a
30
+ * class for the host lines that follow it, so the class headers the earlier seed files
31
+ * already carried de facto ("--- surfaced by Q1 ... ---") became the explicit unit the
32
+ * composition cap enforces. A bare `#` with nothing after it resets to `unclassified`.
33
+ */
34
+ export function parseSeedHosts(text) {
35
+ if (typeof text !== 'string') throw new SeedError('seeds_not_text');
36
+ const hosts = [];
37
+ let klass = 'unclassified';
38
+ for (const line of text.split(/\r?\n/u)) {
39
+ const trimmed = line.trim();
40
+ if (!trimmed) continue;
41
+ if (trimmed.startsWith('#')) {
42
+ const label = trimmed.replace(/^#+\s*/u, '').trim();
43
+ klass = label || 'unclassified';
44
+ continue;
45
+ }
46
+ hosts.push({ host: trimmed.toLowerCase(), class: klass });
47
+ }
48
+ return hosts;
49
+ }
50
+
51
+ /**
52
+ * Caps each class's seed-URL contribution. `classHosts` is one record per class in file
53
+ * order: `{ class, hosts: [[url, ...], ...] }` — the per-host URL lists AFTER the per-host
54
+ * cap, in admission order. A class over the cap is down-sampled round-robin across its
55
+ * hosts so the cut lands on every host evenly instead of beheading the last few; the share
56
+ * is computed against the PRE-CAP total and nothing is redistributed to other classes, so
57
+ * the rule is deterministic and auditable. A single-class run is never cut: one class cannot
58
+ * dominate itself.
59
+ *
60
+ * @returns The kept URLs in class order, and the per-class accounting the run report carries.
61
+ */
62
+ export function capSeedComposition(classHosts, { maxShare = SEED_COMPOSITION.maxShare } = {}) {
63
+ if (!Array.isArray(classHosts) || classHosts.some(entry => !entry || !Array.isArray(entry.hosts))) {
64
+ throw new SeedError('seed_classes_malformed');
65
+ }
66
+ const total = classHosts.reduce((sum, entry) => sum + entry.hosts.reduce((n, urls) => n + urls.length, 0), 0);
67
+ const cap = Math.max(1, Math.floor(maxShare * total));
68
+ const multiClass = classHosts.filter(entry => entry.hosts.some(urls => urls.length)).length > 1;
69
+ const kept = [], perClass = [];
70
+ for (const entry of classHosts) {
71
+ const offered = entry.hosts.reduce((n, urls) => n + urls.length, 0);
72
+ let keptUrls = [];
73
+ if (multiClass && offered > cap) {
74
+ // Round-robin one URL per host per turn, hosts in file order, until the cap is hit.
75
+ const cursors = entry.hosts.map(() => 0);
76
+ while (keptUrls.length < cap) {
77
+ let advanced = false;
78
+ for (let h = 0; h < entry.hosts.length && keptUrls.length < cap; h++) {
79
+ if (cursors[h] < entry.hosts[h].length) {
80
+ keptUrls.push(entry.hosts[h][cursors[h]++]);
81
+ advanced = true;
82
+ }
83
+ }
84
+ if (!advanced) break;
85
+ }
86
+ } else {
87
+ keptUrls = entry.hosts.flat();
88
+ }
89
+ kept.push(...keptUrls);
90
+ perClass.push({ class: entry.class, offered, kept: keptUrls.length, cut: offered - keptUrls.length });
91
+ }
92
+ return { kept, cap, applied: multiClass && perClass.some(entry => entry.cut > 0), perClass };
93
+ }
94
+
95
+ export function decodeXml(value) {
22
96
  return value.replace(/&(?:#(\d{1,7})|#x([0-9a-f]{1,6})|([a-z]+));/giu, (whole, dec, hex, name) => {
23
97
  if (dec || hex) {
24
98
  const code = Number.parseInt(dec ?? hex, dec ? 10 : 16);
@@ -84,7 +158,7 @@ export function withinSitemapScope(candidate, sitemapUrl, scope = 'path') {
84
158
  *
85
159
  * @throws SeedError when the document is too large or is not a sitemap at all.
86
160
  */
87
- export function parseSitemap(input, { baseUrl, enforceScope = true, scope = 'path' } = {}) {
161
+ export function parseSitemapEntries(input, { baseUrl, enforceScope = true, scope = 'path' } = {}) {
88
162
  if (typeof input !== 'string') throw new SeedError('sitemap_not_text');
89
163
  if (input.length > SEED_LIMITS.documentBytes) throw new SeedError('sitemap_too_large');
90
164
  // A byte-order mark before the root element defeats every subsequent match.
@@ -98,12 +172,24 @@ export function parseSitemap(input, { baseUrl, enforceScope = true, scope = 'pat
98
172
  if (indexAt < 0 && setAt < 0) return parseTextSitemap(xml, { baseUrl, enforceScope, scope });
99
173
  const kind = indexAt >= 0 && (setAt < 0 || indexAt < setAt) ? 'index' : 'urlset';
100
174
 
101
- const urls = [];
175
+ const entries = [];
176
+ const seen = new Set();
102
177
  const skipped = {};
103
178
  let truncated = false;
179
+ // Collected rather than iterated lazily because each entry's lastmod is searched in the
180
+ // region up to the NEXT `<loc>`, which needs lookahead. Collection stops one past the cap:
181
+ // a document with more `<loc>` blocks than the cap is knowably truncated without reading
182
+ // all of them, which is the same signal the lazy scan's early break used to produce.
104
183
  const pattern = /<\s*loc\s*>([\s\S]*?)<\s*\/\s*loc\s*>/giu;
184
+ const matches = [];
105
185
  for (const match of xml.matchAll(pattern)) {
106
- if (urls.length >= SEED_LIMITS.urlsPerDocument) { truncated = true; break; }
186
+ matches.push(match);
187
+ if (matches.length > SEED_LIMITS.urlsPerDocument) break;
188
+ }
189
+ const lastmodPattern = /<\s*lastmod\s*>([\s\S]*?)<\s*\/\s*lastmod\s*>/iu;
190
+ for (let index = 0; index < matches.length; index++) {
191
+ const match = matches[index];
192
+ if (entries.length >= SEED_LIMITS.urlsPerDocument) { truncated = true; break; }
107
193
  let raw = decodeXml(match[1].replace(/<!\[CDATA\[([\s\S]*?)\]\]>/gu, '$1')).trim();
108
194
  if (!raw) continue;
109
195
  // The protocol requires an absolute URL here. Resolving a relative value against the
@@ -114,9 +200,27 @@ export function parseSitemap(input, { baseUrl, enforceScope = true, scope = 'pat
114
200
  skipped.out_of_sitemap_scope = (skipped.out_of_sitemap_scope ?? 0) + 1;
115
201
  continue;
116
202
  }
117
- if (!urls.includes(checked.url)) urls.push(checked.url);
203
+ if (seen.has(checked.url)) continue;
204
+ seen.add(checked.url);
205
+ // A lastmod belongs to the `<url>` (or `<sitemap>`) block its `<loc>` opens; the region
206
+ // ends where the next entry begins. Captured RAW: this is the publisher's declared change
207
+ // time, parsed where it is spent (`parseTimestamp` in freshness.js), and an unparsable
208
+ // claim is counted there rather than silently dropped here.
209
+ const region = xml.slice(match.index + match[0].length, matches[index + 1]?.index ?? xml.length);
210
+ const lastmodRaw = lastmodPattern.exec(region)?.[1]?.trim() ?? null;
211
+ entries.push({ loc: checked.url, lastmod: lastmodRaw });
118
212
  }
119
- return { kind, urls, truncated, skipped };
213
+ // The loop can fill the cap on the LAST collected match, with nothing left to iterate and
214
+ // trip the in-loop break. Collection stopping at cap+1 is itself the proof that more
215
+ // `<loc>` blocks existed, which is exactly what truncated means.
216
+ if (!truncated && entries.length >= SEED_LIMITS.urlsPerDocument && matches.length > SEED_LIMITS.urlsPerDocument) truncated = true;
217
+ return { kind, entries, truncated, skipped };
218
+ }
219
+
220
+ /** The URL-only view of a sitemap, for callers that do not diff by lastmod. */
221
+ export function parseSitemap(input, options = {}) {
222
+ const { kind, entries, truncated, skipped } = parseSitemapEntries(input, options);
223
+ return { kind, urls: entries.map(entry => entry.loc), truncated, skipped };
120
224
  }
121
225
 
122
226
  /** A sitemap entry must stand on its own as a public absolute URL. */
@@ -125,37 +229,42 @@ function absoluteEntry(value) {
125
229
  return validatePublicUrl(value);
126
230
  }
127
231
 
128
- /** The protocol's plain-text format: one absolute URL per line, no markup. */
232
+ /** The protocol's plain-text format: one absolute URL per line, no markup. No lastmod. */
129
233
  function parseTextSitemap(text, { baseUrl, enforceScope, scope = 'path' }) {
130
- const urls = [], skipped = {};
234
+ const entries = [], seen = new Set(), skipped = {};
131
235
  let truncated = false;
132
236
  for (const line of text.split(/\r?\n/u)) {
133
237
  const value = line.trim();
134
238
  if (!value || value.startsWith('#')) continue;
135
239
  // Anything with markup is not a text sitemap; do not half-read an unknown document.
136
240
  if (value.startsWith('<')) throw new SeedError('not_a_sitemap');
137
- if (urls.length >= SEED_LIMITS.urlsPerDocument) { truncated = true; break; }
241
+ if (entries.length >= SEED_LIMITS.urlsPerDocument) { truncated = true; break; }
138
242
  const checked = absoluteEntry(value);
139
243
  if (!checked.valid) { skipped[checked.reason] = (skipped[checked.reason] ?? 0) + 1; continue; }
140
244
  if (enforceScope && baseUrl && !withinSitemapScope(checked.url, baseUrl, scope)) {
141
245
  skipped.out_of_sitemap_scope = (skipped.out_of_sitemap_scope ?? 0) + 1;
142
246
  continue;
143
247
  }
144
- if (!urls.includes(checked.url)) urls.push(checked.url);
248
+ if (seen.has(checked.url)) continue;
249
+ seen.add(checked.url);
250
+ entries.push({ loc: checked.url, lastmod: null });
145
251
  }
146
- if (!urls.length && !Object.keys(skipped).length) throw new SeedError('not_a_sitemap');
147
- return { kind: 'urlset', urls, truncated, skipped };
252
+ if (!entries.length && !Object.keys(skipped).length) throw new SeedError('not_a_sitemap');
253
+ return { kind: 'urlset', entries, truncated, skipped };
148
254
  }
149
255
 
150
256
  /**
151
- * Walks a sitemap index to its leaf URLs, using a caller-supplied fetch so this module still
152
- * performs no I/O. `fetchDocument(url)` returns the body text or null when it could not be read.
257
+ * Walks a sitemap index to its leaf entries, using a caller-supplied fetch so this module
258
+ * still performs no I/O. `fetchDocument(url)` returns the body text or null when it could not
259
+ * be read. Leaf entries carry their declared lastmod; an index child carries the index's own
260
+ * lastmod, which describes the FILE rather than the URLs inside it, and so is not attached to
261
+ * anything downstream.
153
262
  *
154
263
  * A document we could not read is counted, never treated as a sitemap with no URLs — the same
155
264
  * rule extraction follows, for the same reason.
156
265
  */
157
266
  export async function expandSitemaps(roots, fetchDocument, { maxDepth = SEED_LIMITS.indexDepth, maxDocuments = SEED_LIMITS.sitemapDirectives, scope = 'path' } = {}) {
158
- const urls = [], visited = new Set(), failures = {};
267
+ const entries = [], seenLocs = new Set(), visited = new Set(), failures = {};
159
268
  let documents = 0, truncated = false;
160
269
  const queue = roots.map(url => ({ url, depth: 0 }));
161
270
  while (queue.length) {
@@ -167,16 +276,20 @@ export async function expandSitemaps(roots, fetchDocument, { maxDepth = SEED_LIM
167
276
  const body = await fetchDocument(url);
168
277
  if (body === null || body === undefined) { failures.unreadable = (failures.unreadable ?? 0) + 1; continue; }
169
278
  let parsed;
170
- try { parsed = parseSitemap(body, { baseUrl: url, scope }); }
279
+ try { parsed = parseSitemapEntries(body, { baseUrl: url, scope }); }
171
280
  catch (error) { failures[error.reason] = (failures[error.reason] ?? 0) + 1; continue; }
172
281
  if (parsed.truncated) truncated = true;
173
282
  if (parsed.kind === 'index') {
174
283
  // An index deeper than the limit is a loop or a mistake; stop rather than follow it.
175
284
  if (depth >= maxDepth) { failures.index_too_deep = (failures.index_too_deep ?? 0) + 1; continue; }
176
- for (const child of parsed.urls) queue.push({ url: child, depth: depth + 1 });
285
+ for (const child of parsed.entries) queue.push({ url: child.loc, depth: depth + 1 });
177
286
  continue;
178
287
  }
179
- for (const found of parsed.urls) if (!urls.includes(found)) urls.push(found);
288
+ for (const found of parsed.entries) {
289
+ if (seenLocs.has(found.loc)) continue;
290
+ seenLocs.add(found.loc);
291
+ entries.push(found);
292
+ }
180
293
  }
181
- return { urls, documents, truncated, failures };
294
+ return { urls: entries.map(entry => entry.loc), entries, documents, truncated, failures };
182
295
  }
File without changes
File without changes
@@ -0,0 +1,112 @@
1
+ // The evidence reference (DP-0004-T21): one stable, linkable name for one observation.
2
+ //
3
+ // A report row says a link was present on a date. A reference is the part that lets a reader
4
+ // point at the observation BEHIND that sentence — after the document was forwarded, after the
5
+ // session that produced it is gone, and, for the repository mirror, after the hosted account
6
+ // is cancelled. The buyer-lane demand this answers: an agency has to show a client WHY it
7
+ // believes a placement state, not just that it does.
8
+ //
9
+ // Two storages, because that is where observations actually live:
10
+ //
11
+ // hosted — the workspace's immutable observation row. Resolves through `locate_link`,
12
+ // `get_evidence` or `GET /v1/observations/{id}/evidence` while the workspace
13
+ // exists; the raw snapshot answers 410 EVIDENCE_EXPIRED once its lifecycle
14
+ // removes the bytes, and the reference still names the observation.
15
+ // mirror — the customer's repository observations file, keyed the way `observationKey`
16
+ // keys a row: which entry, when it was checked, and who ran the check. Resolves
17
+ // offline with `agentlinkops locate --ref …` from the repository alone.
18
+ //
19
+ // A reference is DERIVED, never minted. Every component comes from immutable observation
20
+ // data, so the same observation produces the same reference on any machine on any day, and
21
+ // nothing in a reference is live session state — which is what makes an export that carries
22
+ // them survivable past cancelation. Anchoring (DP-0042-T07) will later make the content hash
23
+ // independently verifiable; the reference is the handle that hash attaches to.
24
+ export const EVIDENCE_REFERENCE_VERSION = 1;
25
+ const PREFIX = `agentlinkops:evidence/${EVIDENCE_REFERENCE_VERSION}`;
26
+ const ID = /^[\w-]{1,128}$/u;
27
+ const ORIGINS = Object.freeze(['local', 'cloud']);
28
+
29
+ const validTimestamp = value => typeof value === 'string' && value.length <= 64 && Number.isFinite(Date.parse(value));
30
+
31
+ /** A reference to a hosted observation row. Null rather than a reference that cannot resolve. */
32
+ export function hostedEvidenceReference(observationId) {
33
+ if (typeof observationId !== 'string' || !ID.test(observationId)) return null;
34
+ return `${PREFIX}/link/hosted/${observationId}`;
35
+ }
36
+
37
+ /**
38
+ * A reference to an observation in the repository mirror.
39
+ *
40
+ * `checkedAt` is the check's own timestamp (the cloud's, for cloud rows) rather than a local
41
+ * clock, so a replayed event produces the same reference. `origin` says who ran the check,
42
+ * because a local check and a cloud check of the same entry at the same instant are two real
43
+ * observations, not a duplicate.
44
+ */
45
+ export function mirrorEvidenceReference(input = {}) {
46
+ const { entryId, checkedAt, origin } = input ?? {};
47
+ if (typeof entryId !== 'string' || !ID.test(entryId)) return null;
48
+ if (!ORIGINS.includes(origin)) return null;
49
+ if (!validTimestamp(checkedAt)) return null;
50
+ return `${PREFIX}/link/mirror/${entryId}/${origin}/${encodeURIComponent(checkedAt)}`;
51
+ }
52
+
53
+ /**
54
+ * Parses a reference back into what it names. Strict on purpose: a malformed reference is a
55
+ * reader's typo or a forged document, and resolving it by similarity would point at the wrong
56
+ * observation while appearing to verify the right one.
57
+ *
58
+ * Target-kind references are not part of this version; a future version adds them rather than
59
+ * this parser accepting a shape nothing resolves.
60
+ */
61
+ export function parseEvidenceReference(reference) {
62
+ if (typeof reference !== 'string' || reference.length > 512) return null;
63
+ const parts = reference.split('/');
64
+ if (parts[0] !== 'agentlinkops:evidence' || parts[1] !== String(EVIDENCE_REFERENCE_VERSION)) return null;
65
+ if (parts[2] === 'index') {
66
+ if(parts[3]==='mirror' && parts.length===5 && /^[a-f0-9]{64}$/.test(parts[4]))return {v:1,subject:'index',storage:'mirror',receiptId:parts[4]};
67
+ if(parts[3]==='hosted' && parts.length===7 && ID.test(parts[4]) && ID.test(parts[5]) && /^[a-f0-9]{64}$/.test(parts[6]))return {v:1,subject:'index',storage:'hosted',projectId:parts[4],watchId:parts[5],receiptId:parts[6]};
68
+ return null;
69
+ }
70
+ if (parts[2] !== 'link') return null;
71
+ const [storage, ...rest] = parts.slice(3);
72
+ if (storage === 'hosted') {
73
+ const [observationId] = rest;
74
+ if (rest.length !== 1 || !ID.test(observationId ?? '')) return null;
75
+ return { v: EVIDENCE_REFERENCE_VERSION, subject: 'link', storage: 'hosted', observationId };
76
+ }
77
+ if (storage === 'mirror') {
78
+ const [entryId, origin, encodedAt] = rest;
79
+ if (rest.length !== 3 || !ID.test(entryId ?? '') || !ORIGINS.includes(origin ?? '')) return null;
80
+ let checkedAt;
81
+ try { checkedAt = decodeURIComponent(encodedAt ?? ''); } catch { return null; }
82
+ if (!validTimestamp(checkedAt)) return null;
83
+ return { v: EVIDENCE_REFERENCE_VERSION, subject: 'link', storage: 'mirror', entryId, checkedAt, origin };
84
+ }
85
+ return null;
86
+ }
87
+
88
+ /** How a holder resolves a reference, stated beside the reference rather than guessed at. */
89
+ export function describeEvidenceReference(reference) {
90
+ const parsed = parseEvidenceReference(reference);
91
+ if (!parsed) return null;
92
+ if(parsed.subject==='index')return {reference,...parsed,resolves:parsed.storage==='mirror'?['agentlinkops locate --ref <reference> (offline exported index receipt)']:[`agentlinkops call get_index_observation --set projectId=${parsed.projectId} --set watchId=${parsed.watchId} --set receiptId=${parsed.receiptId}`],survives_cancelation:parsed.storage==='mirror'};
93
+ return {
94
+ reference,
95
+ ...parsed,
96
+ resolves: parsed.storage === 'hosted'
97
+ ? [
98
+ 'agentlinkops call locate_link --set observationId=<id> (positions and live-page handoff)',
99
+ 'agentlinkops call get_evidence --set subject=link --set observationId=<id> (retained snapshot; 410 once expired)',
100
+ 'GET /v1/observations/<id>/evidence (HTTP, workspace credentials)',
101
+ ]
102
+ : ['agentlinkops locate --ref <reference> (offline, from the repository ledger and observations)'],
103
+ survives_cancelation: parsed.storage === 'mirror',
104
+ };
105
+ }
106
+
107
+ export function indexEvidenceReference({receiptId,projectId,watchId,storage='mirror'}={}){
108
+ if(!/^[a-f0-9]{64}$/.test(receiptId??''))return null;
109
+ if(storage==='mirror')return `${PREFIX}/index/mirror/${receiptId}`;
110
+ if(storage==='hosted'&&ID.test(projectId??'')&&ID.test(watchId??''))return `${PREFIX}/index/hosted/${projectId}/${watchId}/${receiptId}`;
111
+ return null;
112
+ }
@@ -0,0 +1,161 @@
1
+ // Pure index-observation semantics. No fetch, OAuth, scraping, quota mutation,
2
+ // persistence or hosted feature activation occurs in this module.
3
+ export const INDEX_OBSERVATION_VERSION = 1;
4
+ export const INDEX_CONFIRMATION = Object.freeze({ probes: 2, minProbeIntervalMs: 30 * 60_000, graceMs: 24 * 60 * 60_000 });
5
+ export const URL_INSPECTION_LIMITS = Object.freeze({ propertyPerDay: 2000, propertyPerMinute: 600, projectPerDay: 10_000_000, projectPerMinute: 15_000 });
6
+ const positiveCoverage = new Set(['Submitted and indexed', 'Indexed, not submitted in sitemap', 'Indexed']);
7
+ const fail = code => { throw Object.assign(new Error(code), { code }); };
8
+ const text = value => typeof value === 'string' && value.length ? value : null;
9
+ function timestamp(value) {
10
+ if (typeof value !== 'string' || !/^\d{4}-\d\d-\d\dT.*(?:Z|[+-]\d\d:\d\d)$/u.test(value) || !Number.isFinite(Date.parse(value))) fail('INVALID_INDEX_TIMESTAMP');
11
+ return new Date(value).toISOString();
12
+ }
13
+ function url(value) {
14
+ let parsed; try { parsed = new URL(value); } catch { fail('INVALID_INDEX_URL'); }
15
+ if (!['https:', 'http:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.hash) fail('INVALID_INDEX_URL');
16
+ return parsed.href;
17
+ }
18
+ export function validateInspectionProperty(inspectionUrl, siteUrl, grantedProperties) {
19
+ const inspected = new URL(url(inspectionUrl));
20
+ if (!Array.isArray(grantedProperties) || !grantedProperties.includes(siteUrl)) fail('INDEX_PROPERTY_NOT_GRANTED');
21
+ if (typeof siteUrl !== 'string') fail('INVALID_INDEX_PROPERTY');
22
+ if (siteUrl.startsWith('sc-domain:')) {
23
+ const domain = siteUrl.slice(10);
24
+ if (!/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u.test(domain) || domain.includes('..')) fail('INVALID_INDEX_PROPERTY');
25
+ if (inspected.hostname !== domain && !inspected.hostname.endsWith(`.${domain}`)) fail('INDEX_URL_OUTSIDE_PROPERTY');
26
+ } else {
27
+ const property = new URL(url(siteUrl));
28
+ if (!siteUrl.endsWith('/') || property.search) fail('INVALID_INDEX_PROPERTY');
29
+ if (inspected.origin !== property.origin || !inspected.pathname.startsWith(property.pathname)) fail('INDEX_URL_OUTSIDE_PROPERTY');
30
+ }
31
+ // Keep the exact granted property string; do not silently fix a missing slash.
32
+ return siteUrl;
33
+ }
34
+ function envelope({ observationId, inspectionUrl, checkedAt, payload, lane, backend, sourceKey }) {
35
+ if (!text(observationId) || observationId.length > 128) fail('INVALID_INDEX_OBSERVATION_ID');
36
+ let raw; try { raw = payload === undefined ? null : JSON.parse(JSON.stringify(payload)); } catch { fail('INVALID_INDEX_RESPONSE'); }
37
+ if (new TextEncoder().encode(JSON.stringify(raw)).byteLength > 262144) fail('INDEX_RESPONSE_EXCEEDS_BOUND');
38
+ return { version: INDEX_OBSERVATION_VERSION, observation_id: observationId, url: url(inspectionUrl), checked_at: timestamp(checkedAt),
39
+ lane, backend, source_key: sourceKey, tier: 'unknown', confidence: 'unknown', reason: 'invalid_response',
40
+ raw_response: raw, last_crawl_at: null, live_page_test: false, submission_requested: false };
41
+ }
42
+ export function gscIndexObservation(input) {
43
+ const siteUrl = validateInspectionProperty(input.inspectionUrl, input.siteUrl, input.grantedProperties);
44
+ const observation = envelope({ ...input, lane: 'gsc', backend: 'google_url_inspection', sourceKey: `gsc:${siteUrl}` });
45
+ observation.property = siteUrl;
46
+ const response = observation.raw_response;
47
+ const result = response?.inspectionResult?.indexStatusResult;
48
+ if (response?.error) return { ...observation, reason: 'inspection_request_failed' };
49
+ if (!result || typeof result !== 'object' || Array.isArray(result)) return observation;
50
+ const tuple = Object.fromEntries(['verdict', 'coverageState', 'indexingState', 'robotsTxtState', 'pageFetchState', 'googleCanonical', 'userCanonical', 'lastCrawlTime', 'crawledAs'].map(key => [key, text(result[key])]));
51
+ observation.inspection = tuple;
52
+ observation.reason = tuple.coverageState ?? 'coverage_reason_missing';
53
+ observation.last_crawl_at = tuple.lastCrawlTime; // Provider time is not our retrieval time.
54
+ observation.inspection_result_link = text(response.inspectionResult.inspectionResultLink);
55
+ if (!tuple.coverageState) return observation;
56
+ if (tuple.verdict === 'PASS') {
57
+ // coverageState is human-readable and can be localized. v1 admits only these
58
+ // reviewed en-US positive labels; unknown/new labels fail closed.
59
+ let canonicalMatches = false;
60
+ try { canonicalMatches = url(tuple.googleCanonical) === observation.url; } catch { /* missing or invalid is unknown */ }
61
+ if (positiveCoverage.has(tuple.coverageState) && tuple.indexingState === 'INDEXING_ALLOWED' && tuple.pageFetchState === 'SUCCESSFUL' && canonicalMatches) {
62
+ return { ...observation, tier: 'indexed', confidence: 'google_index_snapshot' };
63
+ }
64
+ return { ...observation, reason: 'inconsistent_or_unsupported_index_tuple' };
65
+ }
66
+ if (['NEUTRAL', 'FAIL'].includes(tuple.verdict) && !positiveCoverage.has(tuple.coverageState)) {
67
+ return { ...observation, tier: 'not_indexed', confidence: 'google_index_snapshot' };
68
+ }
69
+ return { ...observation, reason: 'unspecified_or_inconsistent_index_verdict' };
70
+ }
71
+ export function licensedSiteIndexObservation(input) {
72
+ if (!text(input.provider) || input.provider.length > 128) fail('INVALID_INDEX_PROVIDER');
73
+ const inspected = url(input.inspectionUrl);
74
+ if (input.query !== `site:${inspected}`) fail('INDEX_QUERY_SCOPE_MISMATCH');
75
+ if (!text(input.country) || !text(input.language)) fail('INDEX_LOCALE_REQUIRED');
76
+ const sourceKey = JSON.stringify(['licensed_site', input.provider, input.query, input.country, input.language]);
77
+ const observation = envelope({ ...input, lane: 'licensed_site', backend: input.provider, sourceKey });
78
+ observation.query = input.query; observation.country = input.country; observation.language = input.language;
79
+ const result = observation.raw_response;
80
+ if (result?.success !== true || result.complete !== true || !Array.isArray(result.results)) return observation;
81
+ const urls = [];
82
+ for (const row of result.results) {
83
+ try { urls.push(url(row?.url)); } catch { return { ...observation, reason: 'invalid_result_url' }; }
84
+ }
85
+ return { ...observation, tier: urls.includes(inspected) ? 'likely_indexed' : 'not_found_in_site_query',
86
+ confidence: 'search_result_sample', reason: urls.includes(inspected) ? 'exact_url_returned' : 'exact_url_not_returned' };
87
+ }
88
+
89
+ export function transitionIndexObservation(previous, observation, policy = INDEX_CONFIRMATION) {
90
+ if (observation?.version !== INDEX_OBSERVATION_VERSION || !['gsc', 'licensed_site'].includes(observation.lane)
91
+ || !['indexed', 'not_indexed', 'likely_indexed', 'not_found_in_site_query', 'unknown'].includes(observation.tier)
92
+ || !text(observation.observation_id) || !text(observation.source_key)) fail('INVALID_INDEX_OBSERVATION');
93
+ if (observation.lane !== 'gsc' && ['indexed', 'not_indexed'].includes(observation.tier)) fail('INVALID_INDEX_CONFIDENCE');
94
+ const at = timestamp(observation.checked_at);
95
+ if (!Number.isInteger(policy.probes) || policy.probes < 2 || policy.probes > 10 || !Number.isFinite(policy.minProbeIntervalMs)
96
+ || policy.minProbeIntervalMs < 1 || !Number.isFinite(policy.graceMs) || policy.graceMs < policy.minProbeIntervalMs) fail('INVALID_INDEX_CONFIRMATION_POLICY');
97
+ if (previous && (previous.url !== observation.url || previous.source_key !== observation.source_key)) fail('INDEX_STATE_SCOPE_MISMATCH');
98
+ const state = previous ? structuredClone(previous) : { version: 1, url: observation.url, source_key: observation.source_key,
99
+ confirmed: null, pending_negative: null, latest: null };
100
+ if (state.latest && (state.latest.observation_id === observation.observation_id || state.confirmed?.observation_id === observation.observation_id
101
+ || state.pending_negative?.observation_ids.includes(observation.observation_id) || Date.parse(at) <= Date.parse(state.latest.checked_at))) {
102
+ return { state, event: null, ignored: 'duplicate_or_out_of_order' };
103
+ }
104
+ state.latest = structuredClone(observation);
105
+ if (observation.tier === 'unknown' || observation.lane !== 'gsc') return { state, event: null };
106
+ if (observation.tier === 'indexed') {
107
+ const before = state.confirmed;
108
+ state.confirmed = { tier: 'indexed', observed_at: at, observation_id: observation.observation_id, reason: observation.reason };
109
+ state.pending_negative = null;
110
+ return { state, event: before?.tier === 'not_indexed' ? { type: 'index.reindexed',
111
+ id: `index.reindexed:${before.observation_id}:${observation.observation_id}`, url: observation.url,
112
+ source_key: observation.source_key, before: before.tier, after: 'indexed', observation_ids: [before.observation_id, observation.observation_id] } : null };
113
+ }
114
+ if (observation.tier !== 'not_indexed' || state.confirmed?.tier === 'not_indexed') return { state, event: null };
115
+ const pending = state.pending_negative ?? { first_at: at, last_counted_at: at, observation_ids: [observation.observation_id] };
116
+ if (state.pending_negative && Date.parse(at) - Date.parse(pending.last_counted_at) >= policy.minProbeIntervalMs) {
117
+ if (pending.observation_ids.length < policy.probes) pending.observation_ids.push(observation.observation_id);
118
+ pending.last_counted_at = at;
119
+ }
120
+ state.pending_negative = pending;
121
+ if (pending.observation_ids.length < policy.probes || Date.parse(at) - Date.parse(pending.first_at) < policy.graceMs) return { state, event: null };
122
+ const before = state.confirmed;
123
+ state.confirmed = { tier: 'not_indexed', observed_at: at, observation_id: observation.observation_id, reason: observation.reason };
124
+ state.pending_negative = null;
125
+ return { state, event: before?.tier === 'indexed' ? { type: 'index.deindexed',
126
+ id: `index.deindexed:${pending.observation_ids[0]}:${observation.observation_id}`, url: observation.url,
127
+ source_key: observation.source_key, before: before.tier, after: 'not_indexed',
128
+ observation_ids: [...new Set([...pending.observation_ids, observation.observation_id])], first_negative_at: pending.first_at } : null };
129
+ }
130
+
131
+ // Advisory arithmetic only: a future transactional scheduler must reserve all
132
+ // four budgets atomically. This function cannot admit a network call.
133
+ export function inspectionQuotaHeadroom(usage, limits = URL_INSPECTION_LIMITS) {
134
+ return Math.min(...Object.keys(URL_INSPECTION_LIMITS).map(key => {
135
+ const row = usage?.[key];
136
+ if (!row || !Number.isSafeInteger(row.attempted) || row.attempted < 0 || !Number.isSafeInteger(row.reserved) || row.reserved < 0
137
+ || !Number.isSafeInteger(limits[key]) || limits[key] < 0) fail('INVALID_INDEX_QUOTA_USAGE');
138
+ return Math.max(0, limits[key] - row.attempted - row.reserved);
139
+ }));
140
+ }
141
+
142
+ // Compare dated evidence; never overwrite either source or select a winner.
143
+ export function reconcileIndexObservations(observations,{maxSkewMs=86400000}={}){
144
+ if(!Array.isArray(observations)||observations.length>100||!Number.isFinite(maxSkewMs)||maxSkewMs<0)fail('INVALID_INDEX_RECONCILIATION');
145
+ const bySource=new Map();
146
+ for(const observation of observations){
147
+ if(!observation?.url||!observation.source_key)fail('INVALID_INDEX_OBSERVATION');timestamp(observation.checked_at);
148
+ const before=bySource.get(observation.source_key);if(!before||observation.checked_at>before.checked_at||(observation.checked_at===before.checked_at&&observation.observation_id>before.observation_id))bySource.set(observation.source_key,observation);
149
+ }
150
+ const rows=[...bySource.values()].sort((a,b)=>a.source_key.localeCompare(b.source_key));
151
+ if(new Set(rows.map(r=>r.url)).size>1)fail('INDEX_STATE_SCOPE_MISMATCH');
152
+ const comparisons=[];
153
+ for(let i=0;i<rows.length;i++)for(let j=i+1;j<rows.length;j++){
154
+ const a=rows[i],b=rows[j];if(a.lane===b.lane)continue;
155
+ const signs={indexed:1,likely_indexed:1,not_indexed:-1,not_found_in_site_query:-1};
156
+ const skew=Math.abs(Date.parse(a.checked_at)-Date.parse(b.checked_at));
157
+ const finding=skew>maxSkewMs?'not_comparable':!signs[a.tier]||!signs[b.tier]?'inconclusive':signs[a.tier]===signs[b.tier]?'agreement':'disagreement';
158
+ comparisons.push({finding,resolution:'unresolved',observation_ids:[a.observation_id,b.observation_id],source_keys:[a.source_key,b.source_key],checked_at:[a.checked_at,b.checked_at],tiers:[a.tier,b.tier],time_skew_ms:skew,negative_search_result_is_not_proof:true});
159
+ }
160
+ return {version:1,url:rows[0]?.url??null,comparisons,combined_verdict:null};
161
+ }