@agentskit/doc-bridge 1.10.0 → 1.11.0

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.
@@ -343,6 +343,7 @@ type GatesConfig = {
343
343
 
344
344
  type GateId =
345
345
  | 'index-freshness'
346
+ | 'index-reproducible' // opt-in; in no preset
346
347
  | 'human-guide-links'
347
348
  | 'link-rot' // reserved; emits a diagnostic and is not executed
348
349
  | 'okf-type'
@@ -358,7 +359,7 @@ type GateId =
358
359
  | `standard` | + `human-guide-links` in v1 |
359
360
  | `strict` | + `okf-type` in v1 |
360
361
 
361
- Implemented gates include `index-freshness`, `human-guide-links`, `okf-type`, `docs-style`, and the opt-in `documentation-standard-v1`. For v1 compatibility, `link-rot`, `routing-currency`, and `bootstrap-size` remain accepted as reserved IDs; including one emits `AK_DOCS_RESERVED_GATE` and does not claim that the gate ran. Unknown IDs are rejected.
362
+ Implemented gates include `index-freshness`, `human-guide-links`, `okf-type`, `docs-style`, and the opt-in `index-reproducible` and `documentation-standard-v1`. For v1 compatibility, `link-rot`, `routing-currency`, and `bootstrap-size` remain accepted as reserved IDs; including one emits `AK_DOCS_RESERVED_GATE` and does not claim that the gate ran. Unknown IDs are rejected.
362
363
 
363
364
  ### Structural vs style validation
364
365
 
@@ -367,10 +368,31 @@ These gates are deterministic lint checks, not editorial grading:
367
368
  | Gate | Kind | What it proves |
368
369
  |------|------|----------------|
369
370
  | `index-freshness` | structural | Generated index matches current docs/config |
371
+ | `index-reproducible` | structural | Every indexed path is committed, so a clean checkout rebuilds the same index |
370
372
  | `human-guide-links` | structural | Local `humanDoc` links resolve through configured human-doc adapters |
371
373
  | `okf-type` | OKF lint | Agent docs have required `type:` frontmatter when strict/required |
372
374
  | `docs-style` | style lint | Opt-in deterministic profile checks for title, purpose, audience, examples, owner/source, task orientation, and stale wording |
373
375
 
376
+ ### Why `index-reproducible` is opt-in
377
+
378
+ A scan walks what is on disk and has no reason to consult `.gitignore`, so a generated module or
379
+ document joins the corpus on a machine that has built and leaves it on one that has not. That is
380
+ harmless while the index is regenerated on every run, and a defect the moment the index is
381
+ committed for a gate to verify: two checkouts of the same commit produce different artifacts,
382
+ `index-freshness` reports staleness that nothing caused, and regenerating cannot fix it because the
383
+ next machine disagrees in the other direction.
384
+
385
+ `index-reproducible` asks Git which of the indexed paths it ignores, and names each one with the
386
+ rule that matched (`apps/docs-next/.gitignore:13:lib/ask-context.ts`) so the fix is one lookup
387
+ away: add the path to `safety.exclude`. It reports success, not failure, when the index is not
388
+ committed or the project is not a Git checkout — there is nothing to reproduce in either case. A
389
+ file that is both tracked and matched by an ignore rule is not flagged; being committed is the
390
+ point.
391
+
392
+ It is in no preset, because enabling it for every consumer would fail gates that pass for good
393
+ reasons. `ak-docs index` and `ak-docs doctor` report the same finding unconditionally — as a
394
+ diagnostic and a `warn` issue — so a repository learns about it before deciding to enforce it.
395
+
374
396
  `docs-style` supports `google-dev-docs`, `playbook-okf`, and `custom` profiles. It is not part of the default path and does not grade prose quality; it checks for explicit structural signals. LLM critique remains planned optional behavior.
375
397
 
376
398
  ---
@@ -2,7 +2,7 @@
2
2
  "manifest_version": "0.3",
3
3
  "name": "doc-bridge",
4
4
  "display_name": "Doc Bridge",
5
- "version": "1.10.0",
5
+ "version": "1.11.0",
6
6
  "description": "Deterministic repository handoffs for coding agents, running locally without an LLM or API key.",
7
7
  "long_description": "Doc Bridge turns a repository's own documentation and ownership metadata into deterministic handoffs: where an agent should start, which paths it may edit, which checks it must run, and when a human must take over. The local connector exposes the same read-only contract available through Doc Bridge CLI and CI.",
8
8
  "author": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentskit/doc-bridge",
3
- "version": "1.10.0",
3
+ "version": "1.11.0",
4
4
  "mcpName": "io.github.AgentsKit-io/doc-bridge",
5
5
  "description": "Human↔agent documentation bridge — deterministic handoffs, doc-site links, memory→docs, optional AgentsKit RAG/chat.",
6
6
  "type": "module",
@@ -127,7 +127,7 @@
127
127
  "@agentskit/ink": "0.10.9",
128
128
  "@agentskit/react": "0.8.3",
129
129
  "@anthropic-ai/mcpb": "2.1.2",
130
- "@changesets/cli": "^3.0.1",
130
+ "@changesets/cli": "^3.0.3",
131
131
  "@lhci/cli": "^0.15.1",
132
132
  "@playwright/test": "1.63.0",
133
133
  "@tailwindcss/postcss": "4.3.3",
@@ -3,7 +3,7 @@
3
3
  import { spawnSync } from 'node:child_process'
4
4
  import { isAbsolute } from 'node:path'
5
5
 
6
- const VERSION = '1.10.0'
6
+ const VERSION = '1.11.0'
7
7
  const kinds = new Set(['package', 'ownership'])
8
8
  const args = process.argv.slice(2)
9
9
  const id = args[0]
@@ -99,6 +99,7 @@ import { independentlyAdjudicateStudyLedger, persistIndependentlyAdjudicatedLedg
99
99
  import { formatStudyProviderCliText, parseStudyProviderCliConfig } from '../study/provider-cli.js'
100
100
  import { calculateStudyMetrics, formatStudyMetricsText } from '../study/metrics.js'
101
101
  import { formatStudyVerificationText, parseStudyVerificationBinding } from '../study/verification.js'
102
+ import { checkIndexReproducibility } from '../discovery/reproducibility.js'
102
103
 
103
104
  type Command =
104
105
  | 'help'
@@ -469,7 +470,11 @@ const loadProject = (configPath?: string) => {
469
470
  return { config, configPath: path, root }
470
471
  }
471
472
 
472
- const indexDiagnostics = (config: DocBridgeConfigV1, result: ReturnType<typeof buildDocBridgeIndex>): string[] => {
473
+ const indexDiagnostics = (
474
+ root: string,
475
+ config: DocBridgeConfigV1,
476
+ result: ReturnType<typeof buildDocBridgeIndex>,
477
+ ): string[] => {
473
478
  const diagnostics: string[] = []
474
479
  const onlyDoc = result.index.knowledge.length === 1 ? result.index.knowledge[0] : undefined
475
480
  if (onlyDoc?.path === config.corpus.agent.index) {
@@ -484,6 +489,22 @@ const indexDiagnostics = (config: DocBridgeConfigV1, result: ReturnType<typeof b
484
489
  'No ownership handoffs yet. Add routing.options.ownership, package frontmatter (package + editRoot), or a monorepo plugin.',
485
490
  )
486
491
  }
492
+ /*
493
+ * Said at write time, because this is the moment the unreproducible artifact is created and the
494
+ * only moment the operator can see what their machine had that another will not.
495
+ */
496
+ const reproducibility = checkIndexReproducibility(
497
+ root,
498
+ config.index?.outFile ?? '.doc-bridge/index.json',
499
+ result.index.knowledge.map((entry) => entry.path),
500
+ )
501
+ if (reproducibility.ignored.length > 0) {
502
+ const sample = reproducibility.ignored.slice(0, 5).map((entry) => `${entry.path} (${entry.rule})`)
503
+ diagnostics.push(
504
+ `${reproducibility.ignored.length} indexed path(s) are ignored by Git: ${sample.join(', ')}${reproducibility.ignored.length > sample.length ? `, and ${reproducibility.ignored.length - sample.length} more` : ''}.`,
505
+ 'A clean checkout will not have them, so it builds a different index. Add them to safety.exclude in doc-bridge.config.json.',
506
+ )
507
+ }
487
508
  return diagnostics
488
509
  }
489
510
 
@@ -1749,7 +1770,7 @@ export const runCli = (argv: readonly string[]): number | undefined | Promise<nu
1749
1770
  })
1750
1771
  }
1751
1772
  const result = buildDocBridgeIndex({ root, config })
1752
- const diagnostics = indexDiagnostics(config, result)
1773
+ const diagnostics = indexDiagnostics(root, config, result)
1753
1774
  const handoffCount = Object.keys(result.index.handoffs ?? {}).length
1754
1775
  writeJson({
1755
1776
  ok: true,
@@ -122,6 +122,7 @@ export const GatesConfigSchema = z
122
122
  .array(
123
123
  z.enum([
124
124
  'index-freshness',
125
+ 'index-reproducible',
125
126
  'human-guide-links',
126
127
  'link-rot',
127
128
  'okf-type',
@@ -137,6 +138,7 @@ export const GatesConfigSchema = z
137
138
  .array(
138
139
  z.enum([
139
140
  'index-freshness',
141
+ 'index-reproducible',
140
142
  'human-guide-links',
141
143
  'link-rot',
142
144
  'okf-type',
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Whether a committed index could have been produced by a clean checkout.
3
+ *
4
+ * The recommended setup commits `.doc-bridge/index.json` so a gate can verify it, and that only
5
+ * works while the index is a function of committed content. A scan has no reason to know about
6
+ * `.gitignore` — it walks what is on disk — so a generated module or document silently joins the
7
+ * corpus on any machine that has built, and silently leaves it on one that has not. The index then
8
+ * disagrees with itself between two checkouts of the same commit, `index-freshness` reports an
9
+ * artifact that nothing invalidated, and regenerating cannot fix it because the next machine
10
+ * disagrees in the other direction.
11
+ *
12
+ * Dogfooding found this the expensive way: a 25-package monorepo whose gate passed locally and
13
+ * failed in CI because `pnpm lint` had generated one `.ts` file into the corpus before the index
14
+ * was written. The scan cannot guess which files are generated, but Git already knows, so the
15
+ * check is: of the paths this index carries, which ones does Git ignore?
16
+ *
17
+ * It only applies when the index is itself tracked. A consumer who regenerates on every run has
18
+ * nothing to reproduce, and reporting generated files to them would be noise.
19
+ */
20
+
21
+ import { execFileSync } from 'node:child_process'
22
+
23
+ export type IgnoredIndexEntry = {
24
+ readonly path: string
25
+ /** The ignore rule that matched, as `.gitignore:12:pattern`, so the fix is one lookup away. */
26
+ readonly rule: string
27
+ }
28
+
29
+ export type IndexReproducibility = {
30
+ /**
31
+ * Whether the question was answerable. False outside a Git checkout, when Git is unavailable, or
32
+ * when the index is not tracked — in all three cases `ignored` is empty and means nothing.
33
+ */
34
+ readonly checked: boolean
35
+ /** Why the check did not run, for a caller that wants to say so. */
36
+ readonly skipped?: 'no-git' | 'index-untracked'
37
+ /** Indexed paths that Git ignores, in path order. */
38
+ readonly ignored: readonly IgnoredIndexEntry[]
39
+ }
40
+
41
+ const NOT_CHECKED = (skipped: 'no-git' | 'index-untracked'): IndexReproducibility => ({
42
+ checked: false,
43
+ skipped,
44
+ ignored: [],
45
+ })
46
+
47
+ /**
48
+ * `undefined` means the command did not answer.
49
+ *
50
+ * `allowExit1` is for `check-ignore` alone, which exits 1 when nothing matched — the common answer
51
+ * rather than a failure, and it still writes whatever did match to stdout. The probes must not
52
+ * share that tolerance: `ls-files --error-unmatch` exits 1 precisely to say "not tracked", and
53
+ * reading that as success made the untracked case look checked.
54
+ */
55
+ const git = (
56
+ root: string,
57
+ args: readonly string[],
58
+ options: { readonly input?: string; readonly allowExit1?: boolean } = {},
59
+ ): string | undefined => {
60
+ try {
61
+ return execFileSync('git', args, {
62
+ cwd: root,
63
+ encoding: 'utf8',
64
+ stdio: ['pipe', 'pipe', 'ignore'],
65
+ maxBuffer: 64 * 1024 * 1024,
66
+ ...(options.input === undefined ? {} : { input: options.input }),
67
+ })
68
+ } catch (error) {
69
+ const status = (error as { status?: number }).status
70
+ if (options.allowExit1 && status === 1) return (error as { stdout?: string }).stdout ?? ''
71
+ return undefined
72
+ }
73
+ }
74
+
75
+ /**
76
+ * The paths a committed index carries that Git ignores.
77
+ *
78
+ * `git check-ignore` consults the index by default, so a tracked file that also matches an ignore
79
+ * pattern is correctly reported as not ignored — being committed is the whole point. A test pins
80
+ * that behaviour rather than trusting it.
81
+ */
82
+ export const checkIndexReproducibility = (
83
+ root: string,
84
+ indexPath: string,
85
+ paths: readonly string[],
86
+ ): IndexReproducibility => {
87
+ if (git(root, ['rev-parse', '--is-inside-work-tree']) === undefined) return NOT_CHECKED('no-git')
88
+ if (git(root, ['ls-files', '--error-unmatch', '--', indexPath]) === undefined) {
89
+ return NOT_CHECKED('index-untracked')
90
+ }
91
+ if (paths.length === 0) return { checked: true, ignored: [] }
92
+
93
+ const unique = [...new Set(paths)].sort()
94
+ const output = git(root, ['check-ignore', '--verbose', '-z', '--stdin'], {
95
+ input: `${unique.join('\0')}\0`,
96
+ allowExit1: true,
97
+ })
98
+ if (output === undefined) return NOT_CHECKED('no-git')
99
+
100
+ /* `-z --verbose` emits four NUL-terminated fields per match: source, line, pattern, path. */
101
+ const fields = output.split('\0')
102
+ const ignored: IgnoredIndexEntry[] = []
103
+ for (let index = 0; index + 3 < fields.length; index += 4) {
104
+ const [source, line, pattern, path] = [fields[index], fields[index + 1], fields[index + 2], fields[index + 3]]
105
+ if (!path) continue
106
+ ignored.push({ path, rule: `${source ?? '?'}:${line ?? '?'}:${pattern ?? '?'}` })
107
+ }
108
+ return { checked: true, ignored: ignored.sort((left, right) => left.path.localeCompare(right.path)) }
109
+ }
@@ -8,6 +8,7 @@ import { buildDocBridgeIndex } from '../index-builder/build-index.js'
8
8
  import { scanAgentCorpus } from '../index-builder/scan-corpus.js'
9
9
  import { runGates, type GateRunResult } from '../gates/run-gates.js'
10
10
  import { IndexNotFoundError, loadDocBridgeIndex } from '../query/load-index.js'
11
+ import { checkIndexReproducibility, type IndexReproducibility } from '../discovery/reproducibility.js'
11
12
  import type { DocBridgeIndexV1 } from '../schemas/doc-bridge-index.js'
12
13
  import type { DiscoverySnapshotV1 } from '../schemas/knowledge.js'
13
14
  import { doctorBadgeMetrics, type DoctorBadgeMetrics } from './badge.js'
@@ -104,6 +105,8 @@ export type DoctorCoverage = {
104
105
  readonly hasIndex: boolean
105
106
  }
106
107
  readonly gates: GateRunResult
108
+ /** Whether a clean checkout would rebuild this index, or whether generated files leak into it. */
109
+ readonly reproducibility: IndexReproducibility
107
110
  }
108
111
 
109
112
  export type DoctorReport = {
@@ -265,6 +268,22 @@ const buildIssues = (coverage: DoctorCoverage): DoctorIssue[] => {
265
268
  })
266
269
  }
267
270
 
271
+ /*
272
+ * A warning, not an error: the index is not wrong, it is unreproducible, and every reader of it
273
+ * on another machine is the one who finds out. The fix belongs to the repository's
274
+ * `safety.exclude`, so the message names the rule that matched and lets the operator decide.
275
+ */
276
+ const { ignored } = coverage.reproducibility
277
+ if (ignored.length > 0) {
278
+ const sample = ignored.slice(0, 5).map((entry) => `${entry.path} (${entry.rule})`)
279
+ issues.push({
280
+ severity: 'warn',
281
+ code: 'index-not-reproducible',
282
+ message: `${ignored.length} indexed path(s) are ignored by Git, so a clean checkout builds a different index: ${sample.join(', ')}${ignored.length > sample.length ? `, and ${ignored.length - sample.length} more` : ''}.`,
283
+ action: 'edit doc-bridge.config.json # add the generated paths to safety.exclude',
284
+ })
285
+ }
286
+
268
287
  for (const id of coverage.packages.missingAgentDoc) {
269
288
  issues.push({
270
289
  severity: 'warn',
@@ -423,6 +442,11 @@ export const runDoctor = (root: string, config: DocBridgeConfigV1): DoctorReport
423
442
  hasIndex,
424
443
  },
425
444
  gates,
445
+ reproducibility: checkIndexReproducibility(
446
+ root,
447
+ config.index?.outFile ?? '.doc-bridge/index.json',
448
+ index.knowledge.map((entry) => entry.path),
449
+ ),
426
450
  }
427
451
 
428
452
  const issues = buildIssues(coverage)
@@ -9,9 +9,11 @@ import { buildDocBridgeIndex } from '../index-builder/build-index.js'
9
9
  import { scanAgentCorpus } from '../index-builder/scan-corpus.js'
10
10
  import { scanHumanDocRecords } from '../index-builder/human-adapters/index.js'
11
11
  import { IndexNotFoundError, loadDocBridgeIndex } from '../query/load-index.js'
12
+ import { checkIndexReproducibility } from '../discovery/reproducibility.js'
12
13
 
13
14
  export type GateId =
14
15
  | 'index-freshness'
16
+ | 'index-reproducible'
15
17
  | 'human-guide-links'
16
18
  | 'okf-type'
17
19
  | 'docs-style'
@@ -54,6 +56,48 @@ export const runGate = (
54
56
  if (id === 'human-guide-links') return runHumanGuideLinksGate(root, config)
55
57
  if (id === 'okf-type') return runOkfTypeGate(root, config)
56
58
  if (id === 'docs-style') return runDocsStyleGate(root, config)
59
+ if (id === 'index-reproducible') {
60
+ /*
61
+ * Opt-in, and in no preset: a repository that regenerates the index on every run has nothing
62
+ * to enforce, and turning this on for everyone would fail gates that were passing for good
63
+ * reasons. `gates.include: ['index-reproducible']` is how a repository that commits the index
64
+ * says it wants the guarantee enforced rather than merely reported.
65
+ */
66
+ let index
67
+ try {
68
+ index = loadDocBridgeIndex(root, config)
69
+ } catch (error) {
70
+ if (error instanceof IndexNotFoundError) return { id, ok: false, message: error.message }
71
+ throw error
72
+ }
73
+ const result = checkIndexReproducibility(
74
+ root,
75
+ config.index?.outFile ?? '.doc-bridge/index.json',
76
+ index.knowledge.map((entry) => entry.path),
77
+ )
78
+ if (!result.checked) {
79
+ return {
80
+ id,
81
+ ok: true,
82
+ message:
83
+ result.skipped === 'index-untracked'
84
+ ? 'Index is not committed, so nothing has to reproduce it'
85
+ : 'Not a Git checkout; reproducibility was not checked',
86
+ }
87
+ }
88
+ if (result.ignored.length === 0) {
89
+ return { id, ok: true, message: 'Every indexed path is committed' }
90
+ }
91
+ const sample = result.ignored.slice(0, 5).map((entry) => `${entry.path} (${entry.rule})`)
92
+ return {
93
+ id,
94
+ ok: false,
95
+ message: `${result.ignored.length} indexed path(s) are ignored by Git, so a clean checkout builds a different index. Add them to safety.exclude.`,
96
+ expected: 'every indexed path is committed',
97
+ actual: sample.join(', '),
98
+ }
99
+ }
100
+
57
101
  if (id !== 'index-freshness') throw new Error(`Unsupported gate "${id}"`)
58
102
 
59
103
  let current: string
package/src/index.ts CHANGED
@@ -98,12 +98,18 @@ export {
98
98
  repositoryInputs,
99
99
  type RepositoryInputsV1,
100
100
  } from './index-builder/project-corpus.js'
101
+ export {
102
+ checkIndexReproducibility,
103
+ type IgnoredIndexEntry,
104
+ type IndexReproducibility,
105
+ } from './discovery/reproducibility.js'
101
106
  export {
102
107
  DOCUMENT_BODY_LIMIT,
103
108
  EMPTY_OVERLAY_HASH,
104
109
  RETRIEVAL_PROJECTION_VERSION,
105
110
  projectRetrievalIndex,
106
111
  relationConfidence,
112
+ snapshotObservationHash,
107
113
  toKnowledgeEntry,
108
114
  weakerConfidence,
109
115
  type CuratedDocument,
@@ -37,7 +37,7 @@ import { resolveSearchParams, resolveSearchWeights } from './weights.js'
37
37
  * from what the snapshot says about it rather than from what is on disk now.
38
38
  */
39
39
 
40
- export const RETRIEVAL_PROJECTION_VERSION = 1 as const
40
+ export const RETRIEVAL_PROJECTION_VERSION = 2 as const
41
41
 
42
42
  /** Documentation body kept for search. Long enough to answer a question, short enough to ship. */
43
43
  export const DOCUMENT_BODY_LIMIT = 4_000
@@ -51,6 +51,31 @@ const MAX_SUMMARY = 400
51
51
  /** The overlay hash when there is no overlay: the hash of an empty accepted set. */
52
52
  export const EMPTY_OVERLAY_HASH = sha256NormalizedV1({ accepted: [] })
53
53
 
54
+ /**
55
+ * What the snapshot observed, without the revision it observed it at.
56
+ *
57
+ * `snapshot.contentHash` seals the whole artifact, `sourceRevision` included — the commit SHA when
58
+ * the working tree is clean, a digest of the scanned files when it is not. That is right for an
59
+ * artifact whose job is to say what one revision looked like, and wrong as a projection input: the
60
+ * projection is a function of what was found, not of where it was found. Sealing the revision into
61
+ * it made an index that any commit invalidates without one thing it describes having changed — so
62
+ * an index committed to a repository was stale the moment it landed, because landing it is a
63
+ * commit, and a freshness gate could never pass twice.
64
+ *
65
+ * Entities and relations are the projection's whole input; the analyzer identity comes with them,
66
+ * because two analyzer versions that observe the same entities and relations have nothing left to
67
+ * disagree about, and one that observes different ones is caught by the entities.
68
+ */
69
+ export const snapshotObservationHash = (
70
+ snapshot: Pick<DiscoverySnapshotV1, 'entities' | 'relations' | 'pipelineVersion' | 'analyzerVersions'>,
71
+ ): string =>
72
+ sha256NormalizedV1({
73
+ pipelineVersion: snapshot.pipelineVersion,
74
+ analyzerVersions: snapshot.analyzerVersions,
75
+ entities: snapshot.entities,
76
+ relations: snapshot.relations,
77
+ })
78
+
54
79
  const CONFIDENCE_RANK: Readonly<Record<Confidence, number>> = { observed: 0, declared: 1, fuzzy: 2, proposed: 3 }
55
80
 
56
81
  /** The weaker of two confidences: a chain is as trustworthy as its least trustworthy link. */
@@ -115,7 +140,7 @@ export type RetrievalOverlayInput = {
115
140
  }
116
141
 
117
142
  export type ProjectRetrievalOptions = {
118
- readonly snapshot: Pick<DiscoverySnapshotV1, 'contentHash' | 'entities' | 'relations'>
143
+ readonly snapshot: Pick<DiscoverySnapshotV1, 'contentHash' | 'entities' | 'relations' | 'pipelineVersion' | 'analyzerVersions'>
119
144
  readonly config: DocBridgeConfigV1 | undefined
120
145
  readonly routes?: RetrievalRoutes
121
146
  readonly curated?: readonly CuratedDocument[]
@@ -459,13 +484,15 @@ export const projectRetrievalIndex = (options: ProjectRetrievalOptions): Retriev
459
484
  entries,
460
485
  }
461
486
  /*
462
- * The hash is over the inputs, not the output: the projection is a function, so three equal
463
- * input hashes mean an equal artifact, and a reader checking freshness compares three hashes
464
- * instead of re-projecting.
487
+ * The hash is over the inputs, not the output: the projection is a function, so equal input
488
+ * hashes mean an equal artifact, and a reader checking freshness compares hashes instead of
489
+ * re-projecting. `snapshotHash` stays on the artifact as provenance — which snapshot this came
490
+ * from — but the seal uses the observation, so the same repository projects to the same hash
491
+ * whatever revision it was scanned at.
465
492
  */
466
493
  const contentHash = sha256NormalizedV1({
467
494
  projectionVersion: RETRIEVAL_PROJECTION_VERSION,
468
- snapshotHash: base.snapshotHash,
495
+ observationHash: snapshotObservationHash(snapshot),
469
496
  overlayHash: base.overlayHash,
470
497
  configurationHash: base.configurationHash,
471
498
  lexiconVersion: base.lexiconVersion,
@@ -136,8 +136,13 @@ export const RetrievalIndexV1Schema = z
136
136
  schemaVersion: z.literal(RETRIEVAL_INDEX_SCHEMA_VERSION),
137
137
  contentHash: hash,
138
138
  contentHashAlgo: z.literal('sha256-normalized-v1'),
139
- /** The three inputs the projection is a function of. Same three hashes, same projection. */
139
+ /**
140
+ * Which snapshot this was projected from. Provenance, not a seal input: it carries the
141
+ * snapshot's `sourceRevision`, and the projection is a function of what the snapshot observed
142
+ * rather than of the revision it was observed at.
143
+ */
140
144
  snapshotHash: hash,
145
+ /** The inputs the projection is a function of. Same hashes, same projection. */
141
146
  overlayHash: hash,
142
147
  configurationHash: hash,
143
148
  lexiconVersion: z.number().int().nonnegative().max(1_000),
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const PACKAGE_VERSION = '1.10.0'
1
+ export const PACKAGE_VERSION = '1.11.0'