archctx-contracts 0.7.1 → 0.8.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.
Files changed (56) hide show
  1. package/fixtures/invalid/hook-debt-checkpoint-failed-read.json +54 -0
  2. package/fixtures/invalid/native-context-invalid-model-summary.json +635 -0
  3. package/fixtures/invalid/native-context-missing-contract-bytes.json +636 -0
  4. package/fixtures/invalid/native-context-missing-input-bytes.json +636 -0
  5. package/fixtures/invalid/native-context-mixed-snapshot.json +637 -0
  6. package/fixtures/invalid/projection-result-applied-incomplete-coverage.json +78 -0
  7. package/fixtures/invalid/refactor-decision-open-status.json +38 -0
  8. package/fixtures/valid/archctx-capabilities.json +3 -3
  9. package/fixtures/valid/capability-dependencies.json +1 -1
  10. package/fixtures/valid/docs-anchors.json +2 -1
  11. package/fixtures/valid/flow-selector-check.json +1 -1
  12. package/fixtures/valid/hook-debt-checkpoint.json +54 -0
  13. package/fixtures/valid/native-context-export.json +636 -0
  14. package/fixtures/valid/product-version-manifest.json +8 -8
  15. package/fixtures/valid/projection-manifest.json +47 -169
  16. package/fixtures/valid/projection-result.json +5 -5
  17. package/fixtures/valid/refactor-decision.json +38 -0
  18. package/fixtures/valid/runtime-recommendation-show.json +1 -0
  19. package/fixtures/valid/runtime-refactor-scan.json +6 -0
  20. package/package.json +1 -1
  21. package/schemas/repo/refactor-decision.schema.json +218 -0
  22. package/schemas/runtime/archctx-capabilities.schema.json +2 -2
  23. package/schemas/runtime/architecture-debt.schema.json +222 -0
  24. package/schemas/runtime/capability-dependencies.schema.json +10 -6
  25. package/schemas/runtime/changeset.schema.json +24 -1
  26. package/schemas/runtime/docs-anchors.schema.json +4 -3
  27. package/schemas/runtime/hook-debt-checkpoint.schema.json +69 -0
  28. package/schemas/runtime/native-context-export.schema.json +823 -0
  29. package/schemas/runtime/practice-checkpoint.schema.json +1 -0
  30. package/schemas/runtime/product-version-manifest.schema.json +2 -2
  31. package/schemas/runtime/projection-apply-readback.schema.json +470 -7
  32. package/schemas/runtime/projection-apply-recovery.schema.json +2 -2
  33. package/schemas/runtime/projection-manifest.schema.json +81 -186
  34. package/schemas/runtime/projection-result.schema.json +132 -5
  35. package/schemas/runtime/projection-target.schema.json +4 -1
  36. package/schemas/runtime/recommendation-v3.schema.json +106 -0
  37. package/schemas/runtime/review-result.schema.json +1 -0
  38. package/schemas/runtime/runtime-recommendation-show.schema.json +234 -13
  39. package/schemas/runtime/runtime-refactor-scan.schema.json +171 -0
  40. package/schemas/runtime/structural-observation-payload.schema.json +106 -0
  41. package/src/architecture-debt.ts +115 -0
  42. package/src/changeset-recovery.ts +49 -0
  43. package/src/docs-anchors.ts +2 -1
  44. package/src/failure.ts +776 -0
  45. package/src/index.ts +6 -0
  46. package/src/ledger.ts +14 -3
  47. package/src/native-context.ts +277 -0
  48. package/src/ownership-dependencies.ts +39 -1
  49. package/src/practices.ts +2 -0
  50. package/src/product-version.ts +37 -4
  51. package/src/projection-manifest.ts +372 -130
  52. package/src/projection-recovery.ts +111 -0
  53. package/src/projection.ts +443 -11
  54. package/src/refactor.ts +525 -2
  55. package/src/schema.ts +6 -0
  56. package/src/validator.ts +8 -3
@@ -1,6 +1,7 @@
1
1
  import type { ProjectionTargetFormat, ProjectionTargetOwnership, ProjectionTargetScopeKind, ProjectionTargetType } from "./ledger";
2
2
  import {
3
3
  ARCHITECTURE_DOCS_RENDERER_VERSION,
4
+ PROJECTION_REQUEST_SCHEMA_VERSION,
4
5
  ARCHITECTURE_SEMANTIC_FACETS,
5
6
  isArchitectureCapabilityProofStatus,
6
7
  type ArchitectureCapabilityProofStatusV1,
@@ -8,20 +9,37 @@ import {
8
9
  type ArchitectureSemanticFacet,
9
10
  type Sha256Digest
10
11
  } from "./projection";
11
- import { isRepoRelativePosixPath } from "./schema";
12
+ import { digestJson, isRepoRelativePosixPath, type Json } from "./schema";
12
13
 
13
14
  /**
14
15
  * The committed projection manifest, `docs/architecture/.projection-manifest.json` (#264). The
15
- * projection engine writes it and reads its semantic baseline and provenance back; this module is
16
- * the only definition of its shape.
16
+ * projection engine writes it and reads its semantic baseline and per-target rows back; this module
17
+ * is the only definition of its shape, of its byte layout and of every value derived from it.
17
18
  *
18
- * `schemaVersion` is `v2` from 0.7.0: content-stamp targets (`sourceFootprintDigest`, `scale`) and
19
- * provenance v3. A `v1` manifest (renderer v4) is not valid here, but the projection still reads its
20
- * semantic baseline and plans a rewrite of the manifest and every generated document.
19
+ * `schemaVersion` is `v3` (#327). The file holds no global digest of all owned sources: header
20
+ * lines change only on a protocol upgrade, the one Git-visible anchor is
21
+ * `semanticBaseline.modelDigest`, and every other line belongs to one capability or one target.
22
+ * Two pull requests that touch different capabilities therefore rewrite disjoint lines. The global
23
+ * digests a run still needs (source tree, flow proof, projection) are computed from the rows when
24
+ * the manifest is read (`architectureDocsProjectionManifestDigests`).
25
+ *
26
+ * A `v1` or `v2` manifest is read only for its `schemaVersion` and refused with
27
+ * `projection-manifest-upgrade-required`; nothing else in it is read.
21
28
  */
22
- export const ARCHITECTURE_DOCS_PROJECTION_MANIFEST_PATH = "docs/architecture/.projection-manifest.json" as const;
23
- export const ARCHITECTURE_DOCS_PROJECTION_MANIFEST_SCHEMA_VERSION = "archcontext.architecture-docs-projection-manifest/v2" as const;
29
+ export const ARCHITECTURE_DOCS_PROJECTION_MANIFEST_SCHEMA_VERSION = "archcontext.architecture-docs-projection-manifest/v3" as const;
30
+ /** Manifest versions earlier releases wrote. Only their `schemaVersion` is read, to refuse them. */
31
+ export const SUPERSEDED_ARCHITECTURE_DOCS_PROJECTION_MANIFEST_SCHEMA_VERSIONS = [
32
+ "archcontext.architecture-docs-projection-manifest/v1",
33
+ "archcontext.architecture-docs-projection-manifest/v2"
34
+ ] as const;
35
+ /** The reason code of a refused superseded manifest; the operator re-baselines once with `archctx docs apply --approved`. */
36
+ export const PROJECTION_MANIFEST_UPGRADE_REQUIRED = "projection-manifest-upgrade-required" as const;
37
+ /** The reason code of a current-format manifest that fails its contract; restore it from Git. */
38
+ export const PROJECTION_MANIFEST_INVALID = "projection-manifest-invalid" as const;
39
+ /** The reason code of a declared source footprint that no entity-summary target carries a stamp for. */
40
+ export const PROJECTION_FOOTPRINT_UNCOVERED = "projection-footprint-uncovered" as const;
24
41
  export const ARCHITECTURE_DOCS_PROJECTION_PROVENANCE_SCHEMA_VERSION = "archcontext.architecture-docs-projection-provenance/v3" as const;
42
+ export const ARCHITECTURE_DOCS_LAYOUT_VERSION_V1 = "archcontext.docs-layout/v1" as const;
25
43
  export const ARCHITECTURE_SEMANTIC_STATE_SCHEMA_VERSION = "archcontext.architecture-semantic-state/v1" as const;
26
44
  export const ARCHITECTURE_DOCS_PROJECTION_PROFILES = ["default", "repo-harness/v1"] as const;
27
45
 
@@ -43,33 +61,26 @@ export interface ArchitectureSemanticStateV1 {
43
61
  flowProofFingerprint: string;
44
62
  }
45
63
 
46
- export interface ArchitectureProofEvidenceDigestsV1 {
47
- /** Declared source footprint identity, the same value the committed provenance records. */
48
- sourceTreeDigest: string;
49
- /** Digest of the selector-evidence facts the P1/P2 compilation reads. */
50
- selectorEvidenceDigest: string;
51
- /** The renderer version that compiled the proofs, so a compiler change is never credited to journals. */
64
+ /**
65
+ * The inputs besides the model that the flow proofs of one projection were compiled from, per
66
+ * capability. Committed acceptance compares the manifest's value with a fresh measurement: a
67
+ * capability whose proof moved while its semantic fingerprint held is explained by the journaled
68
+ * model only when its member stamps, its selector-evidence digest and the renderer are unchanged.
69
+ */
70
+ export interface ArchitectureProjectionProofEvidenceV1 {
52
71
  rendererVersion: string;
53
- }
54
-
55
- export interface ArchitectureProjectionSemanticBaselineV1 {
56
- semanticState: ArchitectureSemanticStateV1;
57
- digests: ArchitectureDigestSetV1;
58
- /**
59
- * The non-model proof inputs this baseline was rendered from, recorded fresh on every render.
60
- * Absent in manifests written before it existed.
61
- */
62
- evidence?: ArchitectureProofEvidenceDigestsV1;
72
+ /** Per capability: digest of the proof-relevant selector evidence its flows cite. */
73
+ selectorEvidenceDigests: Record<string, string>;
74
+ /** Per node that declares `source.include`: the content digest of its footprint. */
75
+ sourceStamps: Record<string, string>;
63
76
  }
64
77
 
65
78
  /**
66
- * Committed projection provenance. It records machine-independent values only: content digests of
67
- * the model and the declared sources, plus renderer, layout and CodeGraph versions, so two machines
68
- * projecting the same commit write the same bytes (#277). HEAD, the full-worktree digest, the
69
- * CodeGraph evidence digest, the indexed-worktree digest and the CodeGraph status depend on the
70
- * checkout path, the index build time, the platform or whether `codegraph init` ran; they are runtime
71
- * facts (the projection runtime snapshot) and never reach the committed manifest. A provenance of
72
- * any other schemaVersion is not read as this one; the next projection rewrites it.
79
+ * Projection provenance of one run. Runtime only: receipts, recovery bindings and envelopes carry
80
+ * it, the committed manifest does not (#327). It records machine-independent values only: content
81
+ * digests of the model and the declared sources, plus renderer, layout and CodeGraph versions.
82
+ * HEAD, the full-worktree digest, the CodeGraph evidence digest, the indexed-worktree digest and
83
+ * the CodeGraph status are runtime snapshot facts (#277).
73
84
  */
74
85
  export interface ArchitectureDocumentationProjectionProvenanceV3 {
75
86
  schemaVersion: typeof ARCHITECTURE_DOCS_PROJECTION_PROVENANCE_SCHEMA_VERSION;
@@ -77,14 +88,16 @@ export interface ArchitectureDocumentationProjectionProvenanceV3 {
77
88
  modelDigest: string;
78
89
  projectionInputDigest: string;
79
90
  rendererVersion: typeof ARCHITECTURE_DOCS_RENDERER_VERSION;
80
- layoutVersion: "archcontext.docs-layout/v1";
91
+ layoutVersion: typeof ARCHITECTURE_DOCS_LAYOUT_VERSION_V1;
81
92
  /** Reproducible CodeGraph identity only: package name and version (#266, #277). */
82
- generatedFrom: {
83
- codeGraphPackage: string;
84
- codeGraphVersion: string;
85
- };
93
+ generatedFrom: ArchitectureDocsProjectionGeneratedFromV1;
86
94
  }
87
95
 
96
+ export type ArchitectureDocsProjectionGeneratedFromV1 = {
97
+ codeGraphPackage: string;
98
+ codeGraphVersion: string;
99
+ };
100
+
88
101
  /**
89
102
  * One 1–2–5 magnitude bucket: the half-open range `[lower, upper)` that contains a measured count.
90
103
  * `{ lower: 0, upper: 1 }` holds only zero; otherwise `lower` is `1`, `2` or `5` times a power of ten
@@ -104,37 +117,54 @@ export interface ArchitectureCapabilityScaleV1 {
104
117
  lineCountBucket: ArchitectureScaleBucketV1;
105
118
  }
106
119
 
107
- export interface ArchitectureDocsProjectionManifestTargetV2 {
108
- targetId: string;
120
+ /**
121
+ * One target row of the committed manifest, keyed by `targetId`. The targetId and the renderer
122
+ * version are not repeated in the row: the key and the manifest header carry them.
123
+ */
124
+ export interface ArchitectureDocsProjectionManifestTargetV3 {
109
125
  type: ProjectionTargetType;
110
126
  scope: { kind: ProjectionTargetScopeKind; id?: string; entityKind?: string };
111
127
  path: string;
112
128
  ownership: ProjectionTargetOwnership;
113
- rendererVersion: typeof ARCHITECTURE_DOCS_RENDERER_VERSION;
114
129
  format: ProjectionTargetFormat;
115
- sourceDigest: string;
130
+ /** `digestJson({ targetId, body })` of the generated body: the region of a `mixed` target, the whole file of a `generated` one. */
116
131
  outputDigest: string;
117
132
  /**
118
- * `entity-summary` targets of a node that declares `source.include` only: the content digest of
119
- * that footprint the document was verified against. Present exactly when `scale` is.
133
+ * `entity-summary` targets only: the content digest of every declared source footprint this
134
+ * target documents, by node id. A footprint belongs to its node's nearest ancestor-or-self that
135
+ * has an entity-summary target, so every declared footprint is stamped on exactly one row.
120
136
  */
121
- sourceFootprintDigest?: string;
122
- /** `entity-summary` targets of a node that declares `source.include` only (#264). */
137
+ sourceStamps?: Record<string, string>;
138
+ /** Present exactly when `sourceStamps` stamps the target's own node: the scale buckets its document prints (#264). */
123
139
  scale?: ArchitectureCapabilityScaleV1;
124
140
  }
125
141
 
126
- export interface ArchitectureDocsProjectionManifestV2 {
142
+ /** One capability of the committed semantic baseline. */
143
+ export interface ArchitectureDocsProjectionManifestCapabilityV3 extends ArchitectureCapabilitySemanticStateV1 {
144
+ /** Digest of the proof-relevant selector evidence for the selectors this capability's flows cite. */
145
+ selectorEvidenceDigest: string;
146
+ }
147
+
148
+ export interface ArchitectureDocsProjectionManifestV3 {
127
149
  schemaVersion: typeof ARCHITECTURE_DOCS_PROJECTION_MANIFEST_SCHEMA_VERSION;
128
- rendererVersion: typeof ARCHITECTURE_DOCS_RENDERER_VERSION;
150
+ /**
151
+ * The renderer that wrote this manifest. Any renderer is readable: a renderer bump re-renders
152
+ * the owned outputs (drift reports them stale) and the per-capability proof check compares it.
153
+ */
154
+ rendererVersion: string;
155
+ /** Optional repository-declared engine floors; preserved by every render. */
156
+ minimumRendererVersion?: string;
157
+ minimumProtocolVersion?: string;
129
158
  profile: ArchitectureDocsProjectionProfile;
130
- sourceDigest: string;
131
- provenance: ArchitectureDocumentationProjectionProvenanceV3;
132
- projectionDigest: string;
133
- semanticBaseline: ArchitectureProjectionSemanticBaselineV1;
134
- receiptDigest: string;
135
- targetCount: number;
136
- fileCount: number;
137
- targets: ArchitectureDocsProjectionManifestTargetV2[];
159
+ layoutVersion: typeof ARCHITECTURE_DOCS_LAYOUT_VERSION_V1;
160
+ generatedFrom: ArchitectureDocsProjectionGeneratedFromV1;
161
+ semanticBaseline: {
162
+ /** The one Git-visible anchor: the digest of the model this baseline was compiled from. */
163
+ modelDigest: string;
164
+ /** Sorted and unique by capabilityId. */
165
+ capabilities: ArchitectureDocsProjectionManifestCapabilityV3[];
166
+ };
167
+ targets: Record<string, ArchitectureDocsProjectionManifestTargetV3>;
138
168
  }
139
169
 
140
170
  const SHA256 = /^sha256:[a-f0-9]{64}$/;
@@ -158,28 +188,191 @@ export function architectureDocsProjectionManifestIssues(value: unknown): string
158
188
  const issues: string[] = [];
159
189
  const manifest = record(value);
160
190
  if (!manifest) return ["manifest must be an object"];
161
- issues.push(...exactKeys(manifest, ["schemaVersion", "rendererVersion", "profile", "sourceDigest", "provenance", "projectionDigest", "semanticBaseline", "receiptDigest", "targetCount", "fileCount", "targets"], [], "manifest"));
191
+ issues.push(...exactKeys(manifest, ["schemaVersion", "rendererVersion", "profile", "layoutVersion", "generatedFrom", "semanticBaseline", "targets"], ["minimumRendererVersion", "minimumProtocolVersion"], "manifest"));
162
192
  if (manifest.schemaVersion !== ARCHITECTURE_DOCS_PROJECTION_MANIFEST_SCHEMA_VERSION) issues.push("manifest.schemaVersion is unsupported");
163
- if (manifest.rendererVersion !== ARCHITECTURE_DOCS_RENDERER_VERSION) issues.push(`manifest.rendererVersion must be ${ARCHITECTURE_DOCS_RENDERER_VERSION}`);
193
+ if (!isNonEmptyString(manifest.rendererVersion)) issues.push("manifest.rendererVersion must be a non-empty string");
194
+ for (const [key, family] of [["minimumRendererVersion", "archcontext.docs-renderer/v"], ["minimumProtocolVersion", "archcontext.projection-request/v"]] as const) {
195
+ if (Object.hasOwn(manifest, key) && versionNumber(manifest[key], family) === undefined) issues.push(`manifest.${key} must name a supported version family`);
196
+ }
164
197
  if (!(ARCHITECTURE_DOCS_PROJECTION_PROFILES as readonly unknown[]).includes(manifest.profile)) issues.push("manifest.profile is unsupported");
165
- for (const field of ["sourceDigest", "projectionDigest", "receiptDigest"] as const) {
166
- if (!isDigest(manifest[field])) issues.push(`manifest.${field} must be a SHA-256 digest`);
198
+ if (manifest.layoutVersion !== ARCHITECTURE_DOCS_LAYOUT_VERSION_V1) issues.push("manifest.layoutVersion is unsupported");
199
+ issues.push(...generatedFromIssues(manifest.generatedFrom, "manifest.generatedFrom"));
200
+ const baseline = record(manifest.semanticBaseline);
201
+ if (!baseline) issues.push("manifest.semanticBaseline must be an object");
202
+ else {
203
+ issues.push(...exactKeys(baseline, ["modelDigest", "capabilities"], [], "manifest.semanticBaseline"));
204
+ if (!isDigest(baseline.modelDigest)) issues.push("manifest.semanticBaseline.modelDigest must be a SHA-256 digest");
205
+ issues.push(...capabilityEntriesIssues(baseline.capabilities, "manifest.semanticBaseline.capabilities", true));
167
206
  }
168
- issues.push(...architectureDocsProjectionProvenanceIssues(manifest.provenance, "manifest.provenance"));
169
- issues.push(...architectureProjectionSemanticBaselineIssues(manifest.semanticBaseline, "manifest.semanticBaseline"));
170
- const targets = manifest.targets;
171
- if (!Array.isArray(targets)) {
172
- issues.push("manifest.targets must be an array");
207
+ const targets = record(manifest.targets);
208
+ if (!targets) {
209
+ issues.push("manifest.targets must be an object keyed by targetId");
173
210
  return issues;
174
211
  }
175
- if (manifest.targetCount !== targets.length) issues.push("manifest.targetCount must equal the number of targets");
176
- if (manifest.fileCount !== targets.length) issues.push("manifest.fileCount must equal the number of targets");
177
- const targetIds = targets.map((target) => record(target)?.targetId);
178
- if (new Set(targetIds).size !== targetIds.length) issues.push("manifest.targets.targetId must be unique");
179
- targets.forEach((target, index) => issues.push(...manifestTargetIssues(target, `manifest.targets[${index}]`)));
212
+ const paths = new Set<string>();
213
+ const stampOwners = new Map<string, string>();
214
+ for (const [targetId, row] of Object.entries(targets)) {
215
+ const prefix = `manifest.targets[${JSON.stringify(targetId)}]`;
216
+ if (!isNonEmptyString(targetId)) issues.push("manifest.targets keys must be non-empty targetIds");
217
+ issues.push(...manifestTargetIssues(row, prefix));
218
+ const path = record(row)?.path;
219
+ if (typeof path === "string") {
220
+ if (paths.has(path)) issues.push(`${prefix}.path is recorded by another target`);
221
+ paths.add(path);
222
+ }
223
+ for (const nodeId of Object.keys(record(record(row)?.sourceStamps) ?? {})) {
224
+ const owner = stampOwners.get(nodeId);
225
+ if (owner !== undefined) issues.push(`${prefix}.sourceStamps.${nodeId} is already stamped on ${owner}`);
226
+ else stampOwners.set(nodeId, targetId);
227
+ }
228
+ }
180
229
  return issues;
181
230
  }
182
231
 
232
+ /**
233
+ * Reads only the `schemaVersion` of a manifest body: the one field this release reads from a
234
+ * manifest it does not write. Undefined when the body is not a JSON object or carries no string
235
+ * `schemaVersion`.
236
+ */
237
+ export function architectureDocsProjectionManifestSchemaVersion(body: string): string | undefined {
238
+ let parsed: unknown;
239
+ try {
240
+ parsed = JSON.parse(body);
241
+ } catch {
242
+ return undefined;
243
+ }
244
+ const schemaVersion = record(parsed)?.schemaVersion;
245
+ return typeof schemaVersion === "string" ? schemaVersion : undefined;
246
+ }
247
+
248
+ /** True for a manifest version an earlier release wrote; such a manifest is refused, never read. */
249
+ export function isSupersededArchitectureDocsProjectionManifestSchemaVersion(schemaVersion: string | undefined): boolean {
250
+ return (SUPERSEDED_ARCHITECTURE_DOCS_PROJECTION_MANIFEST_SCHEMA_VERSIONS as readonly (string | undefined)[]).includes(schemaVersion);
251
+ }
252
+
253
+ /**
254
+ * The committed byte layout. JSON, with one rule beyond `JSON.stringify`: every value that one
255
+ * capability or one target owns sits on lines no other capability or target shares, and an
256
+ * unchanged line separates the lines of any two owners. Git merges two branches that touch
257
+ * different owners without a conflict, because a merge conflicts on adjacent changed lines too.
258
+ *
259
+ * - Header lines (`schemaVersion`, `rendererVersion`, `profile`, `layoutVersion`, `generatedFrom`)
260
+ * change only on a protocol upgrade.
261
+ * - `semanticBaseline.modelDigest` is the one shared line a model change rewrites; two concurrent
262
+ * model changes conflict there, as they should.
263
+ * - Each capability entry is indented JSON and starts with its own `capabilityId` line.
264
+ * - Each target row is one line, after a line holding its `targetId` key, in canonical key order.
265
+ */
266
+ export function serializeArchitectureDocsProjectionManifest(manifest: ArchitectureDocsProjectionManifestV3): string {
267
+ const capabilities = [...manifest.semanticBaseline.capabilities]
268
+ .sort((left, right) => compareStrings(left.capabilityId, right.capabilityId))
269
+ .map(canonicalCapability);
270
+ const capabilityBlock = JSON.stringify(capabilities, null, 2).split("\n").map((line, index) => index === 0 ? line : ` ${line}`).join("\n");
271
+ const targetIds = Object.keys(manifest.targets).sort(compareStrings);
272
+ const targetLines = targetIds.length === 0
273
+ ? [" \"targets\": {}"]
274
+ : [
275
+ " \"targets\": {",
276
+ ...targetIds.flatMap((targetId, index) => [
277
+ ` ${JSON.stringify(targetId)}:`,
278
+ ` ${JSON.stringify(canonicalTargetRow(manifest.targets[targetId]!))}${index === targetIds.length - 1 ? "" : ","}`
279
+ ]),
280
+ " }"
281
+ ];
282
+ return [
283
+ "{",
284
+ ` "schemaVersion": ${JSON.stringify(manifest.schemaVersion)},`,
285
+ ` "rendererVersion": ${JSON.stringify(manifest.rendererVersion)},`,
286
+ ...(manifest.minimumRendererVersion === undefined ? [] : [` "minimumRendererVersion": ${JSON.stringify(manifest.minimumRendererVersion)},`]),
287
+ ...(manifest.minimumProtocolVersion === undefined ? [] : [` "minimumProtocolVersion": ${JSON.stringify(manifest.minimumProtocolVersion)},`]),
288
+ ` "profile": ${JSON.stringify(manifest.profile)},`,
289
+ ` "layoutVersion": ${JSON.stringify(manifest.layoutVersion)},`,
290
+ ` "generatedFrom": ${JSON.stringify({ codeGraphPackage: manifest.generatedFrom.codeGraphPackage, codeGraphVersion: manifest.generatedFrom.codeGraphVersion })},`,
291
+ " \"semanticBaseline\": {",
292
+ ` "modelDigest": ${JSON.stringify(manifest.semanticBaseline.modelDigest)},`,
293
+ ` "capabilities": ${capabilityBlock}`,
294
+ " },",
295
+ ...targetLines,
296
+ "}",
297
+ ""
298
+ ].join("\n");
299
+ }
300
+
301
+ /** The runtime semantic state the baseline records: capability entries without their evidence, plus the two aggregates. */
302
+ export function architectureDocsProjectionManifestSemanticState(manifest: ArchitectureDocsProjectionManifestV3): ArchitectureSemanticStateV1 {
303
+ return architectureSemanticStateFromCapabilities(manifest.semanticBaseline.capabilities.map(({ selectorEvidenceDigest: _selectorEvidenceDigest, ...capability }) => capability));
304
+ }
305
+
306
+ /**
307
+ * Assembles a semantic state from its capability entries. The two aggregates are a function of
308
+ * the entries, so the committed manifest records only the entries and every reader recomputes them.
309
+ */
310
+ export function architectureSemanticStateFromCapabilities(capabilities: readonly ArchitectureCapabilitySemanticStateV1[]): ArchitectureSemanticStateV1 {
311
+ const sorted = [...capabilities].sort((left, right) => compareStrings(left.capabilityId, right.capabilityId));
312
+ return {
313
+ schemaVersion: ARCHITECTURE_SEMANTIC_STATE_SCHEMA_VERSION,
314
+ capabilities: sorted,
315
+ semanticFingerprint: digestJson(sorted.map(({ capabilityId, semanticFingerprint }) => ({ capabilityId, semanticFingerprint })) as unknown as Json),
316
+ flowProofFingerprint: digestJson(sorted.map(({ capabilityId, flowProofFingerprint }) => ({ capabilityId, flowProofFingerprint })) as unknown as Json)
317
+ };
318
+ }
319
+
320
+ /** Every stamp the rows record, by node id. A valid manifest stamps each node on one row only. */
321
+ export function architectureDocsProjectionManifestSourceStamps(manifest: ArchitectureDocsProjectionManifestV3): Record<string, string> {
322
+ const stamps: Record<string, string> = {};
323
+ for (const row of Object.values(manifest.targets)) Object.assign(stamps, row.sourceStamps ?? {});
324
+ return stamps;
325
+ }
326
+
327
+ /** The proof evidence the baseline was compiled from, as `ArchitectureProjectionProofEvidenceV1`. */
328
+ export function architectureDocsProjectionManifestProofEvidence(manifest: ArchitectureDocsProjectionManifestV3): ArchitectureProjectionProofEvidenceV1 {
329
+ return {
330
+ rendererVersion: manifest.rendererVersion,
331
+ selectorEvidenceDigests: Object.fromEntries(manifest.semanticBaseline.capabilities.map((capability) => [capability.capabilityId, capability.selectorEvidenceDigest])),
332
+ sourceStamps: architectureDocsProjectionManifestSourceStamps(manifest)
333
+ };
334
+ }
335
+
336
+ /**
337
+ * The declared source tree digest: the digest of every footprint stamp, by node id. Runtime
338
+ * provenance and the read-time value of a committed manifest use this one formula, so they agree
339
+ * exactly when every stamp agrees.
340
+ */
341
+ export function architectureSourceStampsDigest(stamps: Readonly<Record<string, string>>): Sha256Digest {
342
+ return digestJson({
343
+ schemaVersion: "archcontext.architecture-source-stamps/v1",
344
+ stamps: Object.keys(stamps).sort(compareStrings).map((nodeId) => ({ nodeId, digest: stamps[nodeId]! }))
345
+ } as unknown as Json) as Sha256Digest;
346
+ }
347
+
348
+ /** The projection digest: renderer, profile and every target's output digest. */
349
+ export function architectureProjectionOutputsDigest(input: {
350
+ rendererVersion: string;
351
+ profile: string;
352
+ outputDigests: Readonly<Record<string, string>>;
353
+ }): Sha256Digest {
354
+ return digestJson({
355
+ schemaVersion: "archcontext.architecture-projection-outputs/v1",
356
+ rendererVersion: input.rendererVersion,
357
+ profile: input.profile,
358
+ outputs: Object.keys(input.outputDigests).sort(compareStrings).map((targetId) => ({ targetId, outputDigest: input.outputDigests[targetId]! }))
359
+ } as unknown as Json) as Sha256Digest;
360
+ }
361
+
362
+ /** The global digests of a committed manifest, computed from its rows at read time. */
363
+ export function architectureDocsProjectionManifestDigests(manifest: ArchitectureDocsProjectionManifestV3): ArchitectureDigestSetV1 {
364
+ return {
365
+ modelDigest: manifest.semanticBaseline.modelDigest as Sha256Digest,
366
+ sourceTreeDigest: architectureSourceStampsDigest(architectureDocsProjectionManifestSourceStamps(manifest)),
367
+ flowProofDigest: architectureDocsProjectionManifestSemanticState(manifest).flowProofFingerprint as Sha256Digest,
368
+ projectionDigest: architectureProjectionOutputsDigest({
369
+ rendererVersion: manifest.rendererVersion,
370
+ profile: manifest.profile,
371
+ outputDigests: Object.fromEntries(Object.entries(manifest.targets).map(([targetId, row]) => [targetId, row.outputDigest]))
372
+ })
373
+ };
374
+ }
375
+
183
376
  export function architectureDocsProjectionProvenanceIssues(value: unknown, prefix = "provenance"): string[] {
184
377
  const provenance = record(value);
185
378
  if (!provenance) return [`${prefix} must be an object`];
@@ -189,72 +382,30 @@ export function architectureDocsProjectionProvenanceIssues(value: unknown, prefi
189
382
  if (!isDigest(provenance[field])) issues.push(`${prefix}.${field} must be a SHA-256 digest`);
190
383
  }
191
384
  if (provenance.rendererVersion !== ARCHITECTURE_DOCS_RENDERER_VERSION) issues.push(`${prefix}.rendererVersion must be ${ARCHITECTURE_DOCS_RENDERER_VERSION}`);
192
- if (provenance.layoutVersion !== "archcontext.docs-layout/v1") issues.push(`${prefix}.layoutVersion is unsupported`);
193
- const generatedFrom = record(provenance.generatedFrom);
194
- if (!generatedFrom) {
195
- issues.push(`${prefix}.generatedFrom must be an object`);
196
- return issues;
197
- }
198
- issues.push(...exactKeys(generatedFrom, ["codeGraphPackage", "codeGraphVersion"], [], `${prefix}.generatedFrom`));
199
- for (const field of ["codeGraphPackage", "codeGraphVersion"] as const) {
200
- if (!isNonEmptyString(generatedFrom[field])) issues.push(`${prefix}.generatedFrom.${field} must be a non-empty string`);
201
- }
202
- return issues;
203
- }
204
-
205
- export function architectureProjectionSemanticBaselineIssues(value: unknown, prefix = "semanticBaseline"): string[] {
206
- const baseline = record(value);
207
- if (!baseline) return [`${prefix} must be an object`];
208
- const issues = exactKeys(baseline, ["semanticState", "digests"], ["evidence"], prefix);
209
- issues.push(...architectureSemanticStateIssues(baseline.semanticState, `${prefix}.semanticState`));
210
- issues.push(...architectureDigestSetIssues(baseline.digests, `${prefix}.digests`));
211
- if (baseline.evidence !== undefined) {
212
- const evidence = record(baseline.evidence);
213
- if (!evidence) issues.push(`${prefix}.evidence must be an object`);
214
- else {
215
- issues.push(...exactKeys(evidence, ["sourceTreeDigest", "selectorEvidenceDigest", "rendererVersion"], [], `${prefix}.evidence`));
216
- if (!isDigest(evidence.sourceTreeDigest)) issues.push(`${prefix}.evidence.sourceTreeDigest must be a SHA-256 digest`);
217
- if (!isDigest(evidence.selectorEvidenceDigest)) issues.push(`${prefix}.evidence.selectorEvidenceDigest must be a SHA-256 digest`);
218
- if (!isNonEmptyString(evidence.rendererVersion)) issues.push(`${prefix}.evidence.rendererVersion must be a non-empty string`);
219
- }
220
- }
385
+ if (provenance.layoutVersion !== ARCHITECTURE_DOCS_LAYOUT_VERSION_V1) issues.push(`${prefix}.layoutVersion is unsupported`);
386
+ issues.push(...generatedFromIssues(provenance.generatedFrom, `${prefix}.generatedFrom`));
221
387
  return issues;
222
388
  }
223
389
 
224
- export function architectureDigestSetIssues(value: unknown, prefix = "digests"): string[] {
225
- const digests = record(value);
226
- if (!digests) return [`${prefix} must be an object`];
227
- const fields = ["modelDigest", "sourceTreeDigest", "flowProofDigest", "projectionDigest"] as const;
228
- const issues = exactKeys(digests, fields, [], prefix);
229
- for (const field of fields) if (!isDigest(digests[field])) issues.push(`${prefix}.${field} must be a SHA-256 digest`);
230
- return issues;
231
- }
232
-
233
- export function architectureSemanticStateIssues(value: unknown, prefix = "semanticState"): string[] {
234
- const state = record(value);
235
- if (!state) return [`${prefix} must be an object`];
236
- const issues = exactKeys(state, ["schemaVersion", "capabilities", "semanticFingerprint", "flowProofFingerprint"], [], prefix);
237
- if (state.schemaVersion !== ARCHITECTURE_SEMANTIC_STATE_SCHEMA_VERSION) issues.push(`${prefix}.schemaVersion is unsupported`);
238
- if (!isDigest(state.semanticFingerprint)) issues.push(`${prefix}.semanticFingerprint must be a SHA-256 digest`);
239
- if (!isDigest(state.flowProofFingerprint)) issues.push(`${prefix}.flowProofFingerprint must be a SHA-256 digest`);
240
- if (!Array.isArray(state.capabilities)) {
241
- issues.push(`${prefix}.capabilities must be an array`);
242
- return issues;
243
- }
244
- const ids = state.capabilities.map((entry) => record(entry)?.capabilityId);
245
- if (!isSortedUniqueStrings(ids)) issues.push(`${prefix}.capabilities must be sorted and unique by capabilityId`);
246
- state.capabilities.forEach((entry, index) => {
247
- const entryPrefix = `${prefix}.capabilities[${index}]`;
390
+ function capabilityEntriesIssues(value: unknown, prefix: string, withSelectorEvidence: boolean): string[] {
391
+ if (!Array.isArray(value)) return [`${prefix} must be an array`];
392
+ const issues: string[] = [];
393
+ const ids = value.map((entry) => record(entry)?.capabilityId);
394
+ if (!isSortedUniqueStrings(ids)) issues.push(`${prefix} must be sorted and unique by capabilityId`);
395
+ value.forEach((entry, index) => {
396
+ const entryPrefix = `${prefix}[${index}]`;
248
397
  const capability = record(entry);
249
398
  if (!capability) {
250
399
  issues.push(`${entryPrefix} must be an object`);
251
400
  return;
252
401
  }
253
- issues.push(...exactKeys(capability, ["capabilityId", "memberNodeIds", "semanticFingerprint", "flowProofFingerprint", "proofStatus", "facets"], [], entryPrefix));
402
+ const required = ["capabilityId", "memberNodeIds", "semanticFingerprint", "flowProofFingerprint", "proofStatus", "facets", ...(withSelectorEvidence ? ["selectorEvidenceDigest"] : [])];
403
+ issues.push(...exactKeys(capability, required, [], entryPrefix));
254
404
  if (!isNonEmptyString(capability.capabilityId)) issues.push(`${entryPrefix}.capabilityId must be a non-empty string`);
255
405
  if (!Array.isArray(capability.memberNodeIds) || !isSortedUniqueStrings(capability.memberNodeIds)) issues.push(`${entryPrefix}.memberNodeIds must be sorted and unique`);
256
406
  if (!isDigest(capability.semanticFingerprint)) issues.push(`${entryPrefix}.semanticFingerprint must be a SHA-256 digest`);
257
407
  if (!isDigest(capability.flowProofFingerprint)) issues.push(`${entryPrefix}.flowProofFingerprint must be a SHA-256 digest`);
408
+ if (withSelectorEvidence && !isDigest(capability.selectorEvidenceDigest)) issues.push(`${entryPrefix}.selectorEvidenceDigest must be a SHA-256 digest`);
258
409
  if (!isArchitectureCapabilityProofStatus(capability.proofStatus)) issues.push(`${entryPrefix}.proofStatus must be a P1/P2 proof status`);
259
410
  const facets = record(capability.facets);
260
411
  if (!facets) issues.push(`${entryPrefix}.facets must be an object`);
@@ -268,6 +419,54 @@ export function architectureSemanticStateIssues(value: unknown, prefix = "semant
268
419
  return issues;
269
420
  }
270
421
 
422
+ function generatedFromIssues(value: unknown, prefix: string): string[] {
423
+ const generatedFrom = record(value);
424
+ if (!generatedFrom) return [`${prefix} must be an object`];
425
+ const issues = exactKeys(generatedFrom, ["codeGraphPackage", "codeGraphVersion"], [], prefix);
426
+ for (const field of ["codeGraphPackage", "codeGraphVersion"] as const) {
427
+ if (!isNonEmptyString(generatedFrom[field])) issues.push(`${prefix}.${field} must be a non-empty string`);
428
+ }
429
+ return issues;
430
+ }
431
+
432
+ function canonicalCapability(capability: ArchitectureDocsProjectionManifestCapabilityV3): ArchitectureDocsProjectionManifestCapabilityV3 {
433
+ return {
434
+ capabilityId: capability.capabilityId,
435
+ memberNodeIds: [...capability.memberNodeIds],
436
+ semanticFingerprint: capability.semanticFingerprint,
437
+ flowProofFingerprint: capability.flowProofFingerprint,
438
+ proofStatus: { p1: capability.proofStatus.p1, p2: capability.proofStatus.p2 },
439
+ facets: Object.fromEntries(Object.keys(capability.facets).sort(compareStrings).map((facet) => [facet, capability.facets[facet as ArchitectureSemanticFacet]])) as Record<ArchitectureSemanticFacet, string>,
440
+ selectorEvidenceDigest: capability.selectorEvidenceDigest
441
+ };
442
+ }
443
+
444
+ function canonicalTargetRow(row: ArchitectureDocsProjectionManifestTargetV3): ArchitectureDocsProjectionManifestTargetV3 {
445
+ return {
446
+ type: row.type,
447
+ scope: {
448
+ kind: row.scope.kind,
449
+ ...(row.scope.id !== undefined ? { id: row.scope.id } : {}),
450
+ ...(row.scope.entityKind !== undefined ? { entityKind: row.scope.entityKind } : {})
451
+ },
452
+ path: row.path,
453
+ ownership: row.ownership,
454
+ format: row.format,
455
+ outputDigest: row.outputDigest,
456
+ ...(row.sourceStamps !== undefined
457
+ ? { sourceStamps: Object.fromEntries(Object.keys(row.sourceStamps).sort(compareStrings).map((nodeId) => [nodeId, row.sourceStamps![nodeId]!])) }
458
+ : {}),
459
+ ...(row.scale !== undefined
460
+ ? {
461
+ scale: {
462
+ fileCountBucket: { lower: row.scale.fileCountBucket.lower, upper: row.scale.fileCountBucket.upper },
463
+ lineCountBucket: { lower: row.scale.lineCountBucket.lower, upper: row.scale.lineCountBucket.upper }
464
+ }
465
+ }
466
+ : {})
467
+ };
468
+ }
469
+
271
470
  /** True when the value is a valid 1–2–5 magnitude bucket (see ArchitectureScaleBucketV1). */
272
471
  export function isArchitectureScaleBucket(value: unknown): value is ArchitectureScaleBucketV1 {
273
472
  const bucket = record(value);
@@ -287,8 +486,7 @@ export function isArchitectureScaleBucket(value: unknown): value is Architecture
287
486
  function manifestTargetIssues(value: unknown, prefix: string): string[] {
288
487
  const target = record(value);
289
488
  if (!target) return [`${prefix} must be an object`];
290
- const issues = exactKeys(target, ["targetId", "type", "scope", "path", "ownership", "rendererVersion", "format", "sourceDigest", "outputDigest"], ["sourceFootprintDigest", "scale"], prefix);
291
- if (!isNonEmptyString(target.targetId)) issues.push(`${prefix}.targetId must be a non-empty string`);
489
+ const issues = exactKeys(target, ["type", "scope", "path", "ownership", "format", "outputDigest"], ["sourceStamps", "scale"], prefix);
292
490
  if (typeof target.type !== "string" || !Object.hasOwn(TARGET_TYPES, target.type)) issues.push(`${prefix}.type is unsupported`);
293
491
  const scope = record(target.scope);
294
492
  if (!scope || typeof scope.kind !== "string" || !Object.hasOwn(SCOPE_KINDS, scope.kind)) issues.push(`${prefix}.scope.kind is unsupported`);
@@ -300,15 +498,24 @@ function manifestTargetIssues(value: unknown, prefix: string): string[] {
300
498
  }
301
499
  if (typeof target.path !== "string" || !isRepoRelativePosixPath(target.path)) issues.push(`${prefix}.path must be a repository-relative POSIX path`);
302
500
  if (typeof target.ownership !== "string" || !Object.hasOwn(OWNERSHIPS, target.ownership)) issues.push(`${prefix}.ownership is unsupported`);
303
- if (target.rendererVersion !== ARCHITECTURE_DOCS_RENDERER_VERSION) issues.push(`${prefix}.rendererVersion must be ${ARCHITECTURE_DOCS_RENDERER_VERSION}`);
304
501
  if (typeof target.format !== "string" || !Object.hasOwn(FORMATS, target.format)) issues.push(`${prefix}.format is unsupported`);
305
- if (!isDigest(target.sourceDigest)) issues.push(`${prefix}.sourceDigest must be a SHA-256 digest`);
306
502
  if (!isDigest(target.outputDigest)) issues.push(`${prefix}.outputDigest must be a SHA-256 digest`);
307
- if (target.sourceFootprintDigest !== undefined && !isDigest(target.sourceFootprintDigest)) issues.push(`${prefix}.sourceFootprintDigest must be a SHA-256 digest`);
308
- if ((target.sourceFootprintDigest !== undefined || target.scale !== undefined) && target.type !== "entity-summary") {
309
- issues.push(`${prefix}.sourceFootprintDigest and scale are only allowed on entity-summary targets`);
503
+ if ((target.sourceStamps !== undefined || target.scale !== undefined) && target.type !== "entity-summary") {
504
+ issues.push(`${prefix}.sourceStamps and scale are only allowed on entity-summary targets`);
310
505
  }
311
- if ((target.sourceFootprintDigest === undefined) !== (target.scale === undefined)) issues.push(`${prefix}.sourceFootprintDigest and scale must be present together`);
506
+ if (target.sourceStamps !== undefined) {
507
+ const stamps = record(target.sourceStamps);
508
+ if (!stamps || Object.keys(stamps).length === 0) issues.push(`${prefix}.sourceStamps must be a non-empty object keyed by node id`);
509
+ else {
510
+ for (const [nodeId, digest] of Object.entries(stamps)) {
511
+ if (!isNonEmptyString(nodeId)) issues.push(`${prefix}.sourceStamps keys must be non-empty node ids`);
512
+ if (!isDigest(digest)) issues.push(`${prefix}.sourceStamps.${nodeId} must be a SHA-256 digest`);
513
+ }
514
+ }
515
+ }
516
+ const ownNodeId = scope?.id;
517
+ const stampsOwnNode = typeof ownNodeId === "string" && record(target.sourceStamps) !== undefined && Object.hasOwn(record(target.sourceStamps)!, ownNodeId);
518
+ if (stampsOwnNode !== (target.scale !== undefined)) issues.push(`${prefix}.scale must be present exactly when sourceStamps stamps the target's own node`);
312
519
  if (target.scale !== undefined) {
313
520
  const scale = record(target.scale);
314
521
  if (!scale) issues.push(`${prefix}.scale must be an object`);
@@ -344,3 +551,38 @@ function isNonEmptyString(value: unknown): value is string {
344
551
  function isSortedUniqueStrings(values: readonly unknown[]): boolean {
345
552
  return values.every((value, index) => typeof value === "string" && (index === 0 || (values[index - 1] as string) < value));
346
553
  }
554
+
555
+ function compareStrings(left: string, right: string): number {
556
+ return left < right ? -1 : left > right ? 1 : 0;
557
+ }
558
+
559
+ function versionNumber(value: unknown, family: string): number | undefined {
560
+ if (typeof value !== "string" || !value.startsWith(family)) return undefined;
561
+ const suffix = value.slice(family.length);
562
+ if (!/^[1-9][0-9]*$/.test(suffix)) return undefined;
563
+ const version = Number(suffix);
564
+ return Number.isSafeInteger(version) ? version : undefined;
565
+ }
566
+
567
+ /** Pure engine compatibility; absent floors retain the v3 behavior. Future formats never rebaseline. */
568
+ export function architectureDocsProjectionCompatibilityIssues(value: unknown, engine: { rendererVersion: string; protocolVersion: string } = {
569
+ rendererVersion: ARCHITECTURE_DOCS_RENDERER_VERSION, protocolVersion: PROJECTION_REQUEST_SCHEMA_VERSION
570
+ }): string[] {
571
+ const manifest = record(value);
572
+ if (!manifest) return [];
573
+ const issues: string[] = [];
574
+ if (["minimumRendererVersion", "minimumProtocolVersion"].some(key => Object.hasOwn(manifest, key))
575
+ && manifest.schemaVersion !== ARCHITECTURE_DOCS_PROJECTION_MANIFEST_SCHEMA_VERSION) issues.push("projection manifest floors require the current schema");
576
+ if (typeof manifest.schemaVersion === "string" && manifest.schemaVersion !== ARCHITECTURE_DOCS_PROJECTION_MANIFEST_SCHEMA_VERSION
577
+ && !isSupersededArchitectureDocsProjectionManifestSchemaVersion(manifest.schemaVersion)) issues.push("projection manifest schema is unsupported by this engine");
578
+ for (const [key, family, current] of [
579
+ ["minimumRendererVersion", "archcontext.docs-renderer/v", engine.rendererVersion],
580
+ ["minimumProtocolVersion", "archcontext.projection-request/v", engine.protocolVersion]
581
+ ] as const) {
582
+ if (!Object.hasOwn(manifest, key)) continue;
583
+ const required = versionNumber(manifest[key], family);
584
+ const supported = versionNumber(current, family);
585
+ if (required === undefined || supported === undefined || supported < required) issues.push(`projection manifest ${key} is unsupported by this engine`);
586
+ }
587
+ return issues;
588
+ }