archctx-contracts 0.6.2 → 0.7.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 (29) hide show
  1. package/fixtures/invalid/recommendation-v3-structural-before-evidence.json +59 -0
  2. package/fixtures/valid/archctx-capabilities.json +3 -3
  3. package/fixtures/valid/product-version-manifest.json +6 -6
  4. package/fixtures/valid/projection-manifest.json +175 -0
  5. package/fixtures/valid/projection-result.json +5 -5
  6. package/fixtures/valid/recommendation-v3.json +69 -0
  7. package/fixtures/valid/runtime-recommendation-show.json +312 -0
  8. package/fixtures/valid/runtime-refactor-scan.json +542 -0
  9. package/package.json +1 -1
  10. package/schemas/runtime/archctx-capabilities.schema.json +7 -2
  11. package/schemas/runtime/architecture-refresh-signal.schema.json +27 -0
  12. package/schemas/runtime/changeset.schema.json +42 -16
  13. package/schemas/runtime/projection-apply-readback.schema.json +351 -10
  14. package/schemas/runtime/projection-apply-recovery.schema.json +24 -3
  15. package/schemas/runtime/projection-manifest.schema.json +491 -0
  16. package/schemas/runtime/projection-request.schema.json +11 -2
  17. package/schemas/runtime/projection-result.schema.json +108 -8
  18. package/schemas/runtime/recommendation-v3.schema.json +1166 -0
  19. package/schemas/runtime/runtime-recommendation-show.schema.json +2449 -0
  20. package/schemas/runtime/runtime-refactor-scan.schema.json +2489 -0
  21. package/schemas/runtime/structural-observation-payload.schema.json +578 -0
  22. package/src/index.ts +1 -0
  23. package/src/ledger.ts +26 -10
  24. package/src/product-version.ts +2 -2
  25. package/src/projection-manifest.ts +346 -0
  26. package/src/projection.ts +338 -6
  27. package/src/refactor.ts +268 -1
  28. package/src/schema.ts +20 -4
  29. package/src/validator.ts +87 -15
package/src/projection.ts CHANGED
@@ -11,7 +11,7 @@ export const PROJECTION_APPLY_RECOVERY_RESULT_SCHEMA_VERSION = "archcontext.proj
11
11
  export const PROJECTION_APPLY_READBACK_RESULT_SCHEMA_VERSION = "archcontext.projection-apply-readback-result/v1" as const;
12
12
  export const ARCHITECTURE_REFRESH_SIGNAL_SCHEMA_VERSION = "archcontext.architecture-refresh-signal/v1" as const;
13
13
  export const ARCHCTX_CAPABILITIES_SCHEMA_VERSION = "archcontext.capabilities/v1" as const;
14
- export const ARCHITECTURE_DOCS_RENDERER_VERSION = "archcontext.docs-renderer/v4" as const;
14
+ export const ARCHITECTURE_DOCS_RENDERER_VERSION = "archcontext.docs-renderer/v5" as const;
15
15
  export const AGENT_CONTEXT_RENDERER_VERSION = "archcontext.agent-context-renderer/v1" as const;
16
16
 
17
17
  export const PROJECTION_MODES = ["check", "plan", "apply", "adopt"] as const;
@@ -31,6 +31,7 @@ export const PROJECTION_RESULT_STATUSES = [
31
31
  export const PROJECTION_HUMAN_ACTION_REASON_CODES = [
32
32
  "adoption-required",
33
33
  "manual-region-conflict",
34
+ "orphaned-document-review",
34
35
  "target-collision",
35
36
  "unprovable-required-flow",
36
37
  "unresolved-major-change"
@@ -58,20 +59,73 @@ export const ARCHITECTURE_REFRESH_TARGETS = [
58
59
  "capability-index"
59
60
  ] as const;
60
61
  export const ARCHCTX_FEATURES = [
62
+ "architecture-change-capabilities-v1",
61
63
  "architecture-docs-renderer-v2",
62
64
  "architecture-refresh-signal-v1",
65
+ "changeset-entity-operations-v1",
66
+ "code-facts-unavailable-error-v1",
63
67
  "module-statistics-v1",
64
68
  "projection-apply-readback-v1",
65
69
  "projection-apply-receipt-v1",
66
70
  "projection-apply-recovery-v1",
71
+ "projection-apply-replay-v1",
72
+ "projection-check-freshness-v1",
73
+ "projection-manifest-contract-v2",
74
+ "projection-observed-major-change-acceptance-v1",
75
+ "projection-orphan-review-v1",
76
+ "projection-preview-v1",
67
77
  "projection-prior-committed-applies-v1",
68
78
  "projection-protocol-v2",
79
+ "recommendation-scan-decision-v1",
69
80
  "recommendation-v3",
70
81
  "refactor-assessment-v1",
71
- "refactor-resolution-v1"
82
+ "refactor-observation-evidence-v1",
83
+ "refactor-resolution-v1",
84
+ "refactor-scan-limits-v1"
85
+ ] as const;
86
+
87
+ /**
88
+ * The closed set of semantic facets a capability's semantic state digests. Each facet maps to the
89
+ * major-change reason code it raises when its digest moves.
90
+ */
91
+ export const ARCHITECTURE_SEMANTIC_FACETS = [
92
+ "constraints",
93
+ "entrypoints",
94
+ "interfaces",
95
+ "lifecycle",
96
+ "names",
97
+ "ownership",
98
+ "placement",
99
+ "relations",
100
+ "responsibilities",
101
+ "riskBoundaries"
102
+ ] as const;
103
+ export const ARCHITECTURE_PROOF_P1_STATUSES = ["proven", "unprovable"] as const;
104
+ export const ARCHITECTURE_PROOF_P2_STATUSES = ["not-applicable", "proven", "unprovable"] as const;
105
+
106
+ /** `files[].preview.format`: a `create` carries the rendered body, an `update` or `delete` a unified diff. */
107
+ export const PROJECTION_FILE_PREVIEW_FORMATS = ["body", "unified-diff"] as const;
108
+ /** Upper bound, in UTF-8 bytes, on one `files[].preview.content`. */
109
+ export const PROJECTION_FILE_PREVIEW_MAX_BYTES = 65_536;
110
+ /** Upper bound, in UTF-8 bytes, on the sum of every `files[].preview.content` of one result. */
111
+ export const PROJECTION_PREVIEW_TOTAL_MAX_BYTES = 1_048_576;
112
+
113
+ /**
114
+ * Why a projection is not fresh. Shared by the core freshness evaluator and the `check` result's
115
+ * `freshness` field so the two can never disagree.
116
+ */
117
+ export const PROJECTION_FRESHNESS_REASON_CODES = [
118
+ "projection-manifest-missing",
119
+ "projection-manifest-unreadable",
120
+ "projection-snapshot-provenance-missing",
121
+ "projection-source-changed-since-stamp",
122
+ "projection-source-stamp-invalid",
123
+ "projection-source-stamp-missing",
124
+ "projection-source-tree-digest-mismatch"
72
125
  ] as const;
73
126
 
74
127
  export type ProjectionMode = (typeof PROJECTION_MODES)[number];
128
+ export type ProjectionFreshnessReasonCode = (typeof PROJECTION_FRESHNESS_REASON_CODES)[number];
75
129
  export type ProjectionTarget = (typeof PROJECTION_TARGETS)[number];
76
130
  export type ProjectionResultStatus = (typeof PROJECTION_RESULT_STATUSES)[number];
77
131
  export type ProjectionHumanActionReasonCode = (typeof PROJECTION_HUMAN_ACTION_REASON_CODES)[number];
@@ -79,8 +133,47 @@ export type ProjectionPriorCommittedApplyOperation = (typeof PROJECTION_PRIOR_CO
79
133
  export type ArchitectureMajorChangeReasonCode = (typeof ARCHITECTURE_MAJOR_CHANGE_REASON_CODES)[number];
80
134
  export type ArchitectureRefreshTarget = (typeof ARCHITECTURE_REFRESH_TARGETS)[number];
81
135
  export type ArchctxFeature = (typeof ARCHCTX_FEATURES)[number];
136
+ export type ArchitectureSemanticFacet = (typeof ARCHITECTURE_SEMANTIC_FACETS)[number];
137
+ export type ProjectionFilePreviewFormat = (typeof PROJECTION_FILE_PREVIEW_FORMATS)[number];
82
138
  export type Sha256Digest = `sha256:${string}`;
83
139
 
140
+ /** One capability's P1/P2 proof status, as its semantic state records it. */
141
+ export interface ArchitectureCapabilityProofStatusV1 {
142
+ p1: (typeof ARCHITECTURE_PROOF_P1_STATUSES)[number];
143
+ p2: (typeof ARCHITECTURE_PROOF_P2_STATUSES)[number];
144
+ }
145
+
146
+ /**
147
+ * Why one capability is part of a major change: the reason codes and semantic facets that moved for
148
+ * it between the committed semantic baseline and this run. `proofStatusBefore` is null when the
149
+ * baseline has no such capability (or no baseline exists); `proofStatusAfter` is null when the
150
+ * capability was removed. A capability present on one side only lists no changed facets. A
151
+ * capability listed only because its current proof is unprovable, while another capability carries
152
+ * the observed change, lists no reason codes.
153
+ */
154
+ export interface ArchitectureCapabilityChangeV1 {
155
+ capabilityId: string;
156
+ reasonCodes: ArchitectureMajorChangeReasonCode[];
157
+ changedFacets: ArchitectureSemanticFacet[];
158
+ proofStatusBefore: ArchitectureCapabilityProofStatusV1 | null;
159
+ proofStatusAfter: ArchitectureCapabilityProofStatusV1 | null;
160
+ }
161
+
162
+ /**
163
+ * What a `plan` would write to one file, returned in the response only and never persisted: the
164
+ * rendered body of a `create`, or a unified diff (current bytes to rendered bytes) of an `update` or
165
+ * `delete`. `content` is cut at a line boundary to at most PROJECTION_FILE_PREVIEW_MAX_BYTES UTF-8
166
+ * bytes, and to whatever remains of PROJECTION_PREVIEW_TOTAL_MAX_BYTES across the result, in path
167
+ * order; `byteLength` is the UTF-8 length of the complete content and `truncated` says whether
168
+ * `content` is shorter.
169
+ */
170
+ export interface ProjectionFilePreviewV1 {
171
+ format: ProjectionFilePreviewFormat;
172
+ content: string;
173
+ byteLength: number;
174
+ truncated: boolean;
175
+ }
176
+
84
177
  export interface ProjectionExpectedSnapshotV1 {
85
178
  repositoryId: string;
86
179
  workspaceId: string;
@@ -98,6 +191,14 @@ export interface ProjectionRequestV1 {
98
191
  expected: ProjectionExpectedSnapshotV1;
99
192
  adoptionPlanId?: string;
100
193
  acceptedChange?: AcceptedArchitectureChangeReferenceV1;
194
+ /**
195
+ * `apply` and `adopt` only, never together with `acceptedChange`: accept the major change this
196
+ * run observes at `expected`. The provider classifies, derives the accepted change and applies
197
+ * it against the same expected snapshot, which the daemon re-checks under its writer lock before
198
+ * writing. The apply receipt records the observed change with provider-generated `changeSetId`
199
+ * and `eventId`. Absent, a major change still stops at `human-action-required` (#261).
200
+ */
201
+ acceptObservedMajorChange?: true;
101
202
  }
102
203
 
103
204
  export interface ProjectionSnapshotV1 extends ProjectionExpectedSnapshotV1 {
@@ -112,7 +213,6 @@ export interface ProjectionSnapshotV1 extends ProjectionExpectedSnapshotV1 {
112
213
  generatedFrom: {
113
214
  codeGraphPackage: "@colbymchenry/codegraph";
114
215
  codeGraphVersion: "1.6.1";
115
- codeGraphBinaryDigest: Sha256Digest;
116
216
  codeGraphStatus: "ready" | "unavailable";
117
217
  };
118
218
  }
@@ -122,11 +222,23 @@ export interface ProjectionFileResultV1 {
122
222
  action: "create" | "delete" | "unchanged" | "update";
123
223
  preimageDigest: Sha256Digest | null;
124
224
  outputDigest: Sha256Digest | null;
225
+ /**
226
+ * `plan` results only (#264): what this entry would write. Absent on every other mode, on a
227
+ * committed receipt, and on an entry whose path the projection rejected for adoption or ownership
228
+ * repair, because no apply writes that rendered body. Consumers gate on `projection-preview-v1`.
229
+ */
230
+ preview?: ProjectionFilePreviewV1;
125
231
  }
126
232
 
127
233
  export interface ProjectionHumanActionV1 {
128
234
  reasonCode: ProjectionHumanActionReasonCode;
129
235
  affectedNodeIds: string[];
236
+ /**
237
+ * Present exactly on `orphaned-document-review`: the managed document whose target the model no
238
+ * longer renders and which may hold human text. `apply` never deletes it; a human must review,
239
+ * move or remove it. A generated-only orphan is a `files[]` delete instead.
240
+ */
241
+ path?: string;
130
242
  requestPayloadDigest: Sha256Digest;
131
243
  }
132
244
 
@@ -170,6 +282,13 @@ export interface ArchitectureRefreshSignalV1 {
170
282
  acceptedChange?: AcceptedArchitectureChangeReferenceV1;
171
283
  reasonCodes: ArchitectureMajorChangeReasonCode[];
172
284
  affectedNodeIds: string[];
285
+ /**
286
+ * The observed per-capability breakdown of the semantic delta this signal classifies, sorted by
287
+ * capabilityId (#264). Every signal this provider produces carries it; it is part of the signal
288
+ * identity; signals committed before it existed lack it. Consumers gate on
289
+ * `architecture-change-capabilities-v1`, not on field presence.
290
+ */
291
+ capabilities?: ArchitectureCapabilityChangeV1[];
173
292
  refreshTargets: ArchitectureRefreshTarget[];
174
293
  baseDigests: ArchitectureDigestSetV1;
175
294
  resultingDigests: ArchitectureDigestSetV1;
@@ -203,6 +322,21 @@ export interface ProjectionPriorCommittedApplyV1 {
203
322
  files: ProjectionPriorCommittedApplyFileV1[];
204
323
  }
205
324
 
325
+ export interface ProjectionFreshnessStaleNodeV1 {
326
+ nodeId: string;
327
+ /** Source-footprint digest the node's projected document is stamped with. */
328
+ stampedDigest: Sha256Digest;
329
+ /** Source-footprint digest measured by this run. */
330
+ currentDigest: Sha256Digest;
331
+ }
332
+
333
+ /** Read-only per-node freshness of the committed projection, as `check` measured it. */
334
+ export interface ProjectionFreshnessV1 {
335
+ ok: boolean;
336
+ reasonCodes: ProjectionFreshnessReasonCode[];
337
+ staleNodes: ProjectionFreshnessStaleNodeV1[];
338
+ }
339
+
206
340
  export interface ProjectionResultV2 {
207
341
  schemaVersion: typeof PROJECTION_RESULT_SCHEMA_VERSION;
208
342
  requestId: string;
@@ -214,8 +348,31 @@ export interface ProjectionResultV2 {
214
348
  humanActions: ProjectionHumanActionV1[];
215
349
  refreshSignals: ArchitectureRefreshSignalV1[];
216
350
  applyReceipt?: ProjectionApplyIdentityV1;
217
- /** Omitted, never `[]`, when no earlier attempt of this requestId committed. */
351
+ /**
352
+ * Omitted, never `[]`, when no earlier attempt of this requestId committed. Never present on
353
+ * `check` results: a check runs in-process without the daemon journal, so read committed applies
354
+ * through `apply` or `readback`.
355
+ */
218
356
  priorCommittedApplies?: ProjectionPriorCommittedApplyV1[];
357
+ /**
358
+ * Present on `check` results only: which nodes changed since their documents were verified.
359
+ * Consumers gate on the `projection-check-freshness-v1` capability, not on field presence.
360
+ */
361
+ freshness?: ProjectionFreshnessV1;
362
+ /**
363
+ * Present, as `true`, only when this response returns an apply this requestId already committed
364
+ * for the same request digest, without applying again (#265). It is excluded from
365
+ * `receiptDigest`, so a replay carries the committed receipt digest unchanged.
366
+ */
367
+ replayed?: true;
368
+ /**
369
+ * Present only on a `human-action-required` result whose request sent `acceptObservedMajorChange`
370
+ * and the provider declined it: `declined-unprovable-proof` means a capability's P1 or P2 proof is
371
+ * unprovable, so no observed change can be accepted. The result then carries an
372
+ * `unprovable-required-flow` action naming those capabilities, never `unresolved-major-change`,
373
+ * so a caller can tell a declined flag from an absent one (#275).
374
+ */
375
+ majorChangeAcceptance?: "declined-unprovable-proof";
219
376
  receiptDigest: Sha256Digest;
220
377
  }
221
378
 
@@ -243,6 +400,12 @@ export interface ProjectionApplyRecoveryBindingV1 {
243
400
  generatedFrom: ProjectionSnapshotV1["generatedFrom"];
244
401
  ownedOutputDigest: Sha256Digest;
245
402
  receiptDigest: Sha256Digest;
403
+ /**
404
+ * `digestJson` of the caller's ProjectionRequestV1 exactly as received. With the result's
405
+ * requestId it is the replay key of #265; it never includes provider-generated ids. Receipts
406
+ * committed before it existed lack it and are never replayed.
407
+ */
408
+ requestDigest?: Sha256Digest;
246
409
  }
247
410
 
248
411
  /**
@@ -365,6 +528,11 @@ export function projectionRequestInvariantIssues(input: ProjectionRequestV1): st
365
528
  if (!/^[a-zA-Z0-9_.:-]+$/.test(input.requestId)) issues.push("requestId must use the stable identifier character set");
366
529
  if (input.mode === "adopt" && !input.adoptionPlanId) issues.push("adoptionPlanId is required when mode=adopt");
367
530
  if (input.mode !== "adopt" && input.adoptionPlanId !== undefined) issues.push("adoptionPlanId is only allowed when mode=adopt");
531
+ if (input.acceptObservedMajorChange !== undefined) {
532
+ if (input.acceptObservedMajorChange !== true) issues.push("acceptObservedMajorChange must be true when present");
533
+ if (input.mode !== "apply" && input.mode !== "adopt") issues.push("acceptObservedMajorChange is only allowed when mode=apply or mode=adopt");
534
+ if (input.acceptedChange !== undefined) issues.push("acceptObservedMajorChange and acceptedChange are mutually exclusive");
535
+ }
368
536
  if (input.acceptedChange) {
369
537
  if (input.acceptedChange.changeSetId.trim() === "") issues.push("acceptedChange.changeSetId must not be empty");
370
538
  if (input.acceptedChange.eventId.trim() === "") issues.push("acceptedChange.eventId must not be empty");
@@ -381,6 +549,17 @@ export function projectionRequestInvariantIssues(input: ProjectionRequestV1): st
381
549
  return issues;
382
550
  }
383
551
 
552
+ /**
553
+ * Invariants that bind a result to the request mode that produced it, which the result itself does
554
+ * not carry: only a `plan` result previews what it would write (#264); `check`, `apply` and
555
+ * `adopt` results never carry `files[].preview`, whatever their status.
556
+ */
557
+ export function projectionResultRequestModeIssues(input: ProjectionResultV2, mode: ProjectionMode): string[] {
558
+ return mode !== "plan" && input.files.some((file) => file.preview !== undefined)
559
+ ? [`files[].preview is only allowed on a plan result, never when mode=${mode}`]
560
+ : [];
561
+ }
562
+
384
563
  export function projectionResultInvariantIssues(input: ProjectionResultV2): string[] {
385
564
  const issues = [
386
565
  ...sortedUniqueIssues("affectedNodeIds", input.affectedNodeIds),
@@ -400,6 +579,19 @@ export function projectionResultInvariantIssues(input: ProjectionResultV2): stri
400
579
  if (!input.applyReceipt) issues.push("applied-reconcile-required status requires applyReceipt");
401
580
  if (input.refreshSignals.length > 0) issues.push("applied-reconcile-required status cannot deliver refreshSignals");
402
581
  }
582
+ for (const [index, action] of input.humanActions.entries()) {
583
+ const prefix = `humanActions[${index}]`;
584
+ if (!(PROJECTION_HUMAN_ACTION_REASON_CODES as readonly string[]).includes(action.reasonCode)) issues.push(`${prefix}.reasonCode is unsupported`);
585
+ if ((action.reasonCode === "orphaned-document-review") !== (action.path !== undefined)) {
586
+ issues.push(`${prefix}.path must be present exactly when reasonCode=orphaned-document-review`);
587
+ }
588
+ if (action.path !== undefined && !isRepoRelativePosixPath(action.path)) issues.push(`${prefix}.path must be a repository-relative POSIX path`);
589
+ }
590
+ const orphanReviewPaths = input.humanActions.flatMap((action) => action.reasonCode === "orphaned-document-review" && action.path !== undefined ? [action.path] : []);
591
+ issues.push(...sortedUniqueIssues("humanActions[orphaned-document-review].path", orphanReviewPaths));
592
+ if (orphanReviewPaths.some((path) => input.files.some((file) => file.path === path))) {
593
+ issues.push("an orphaned-document-review path must not also be a files[] entry");
594
+ }
403
595
  for (const [index, file] of input.files.entries()) {
404
596
  const prefix = `files[${index}]`;
405
597
  if (file.action === "create" && (file.preimageDigest !== null || file.outputDigest === null)) {
@@ -414,10 +606,36 @@ export function projectionResultInvariantIssues(input: ProjectionResultV2): stri
414
606
  if (file.action === "unchanged" && (file.preimageDigest === null || file.outputDigest === null || file.preimageDigest !== file.outputDigest)) {
415
607
  issues.push(`${prefix} unchanged requires equal non-null digests`);
416
608
  }
609
+ if (file.preview !== undefined) issues.push(...projectionFilePreviewIssues(file, prefix));
610
+ }
611
+ const previewBytes = input.files.reduce((total, file) => total + (file.preview ? utf8ByteLength(file.preview.content) : 0), 0);
612
+ if (previewBytes > PROJECTION_PREVIEW_TOTAL_MAX_BYTES) {
613
+ issues.push(`files[].preview.content must not exceed ${PROJECTION_PREVIEW_TOTAL_MAX_BYTES} UTF-8 bytes in total`);
614
+ }
615
+ // The result does not carry its request mode; projectionResultRequestModeIssues binds a preview
616
+ // to mode=plan. On the result alone, a committed apply (and so a persisted receipt) never carries one.
617
+ if (input.files.some((file) => file.preview !== undefined)
618
+ && (input.applyReceipt !== undefined || input.status === "applied" || input.status === "applied-reconcile-required")) {
619
+ issues.push("files[].preview is only allowed on a plan result, never on a committed apply");
417
620
  }
418
621
  if (input.priorCommittedApplies) {
419
622
  issues.push(...projectionPriorCommittedAppliesIssues(input.priorCommittedApplies, input.requestId));
420
623
  }
624
+ if (input.freshness) issues.push(...projectionFreshnessIssues(input.freshness));
625
+ if (input.majorChangeAcceptance !== undefined) {
626
+ if (input.majorChangeAcceptance !== "declined-unprovable-proof") issues.push("majorChangeAcceptance is unsupported");
627
+ if (input.status !== "human-action-required"
628
+ || !input.humanActions.some((action) => action.reasonCode === "unprovable-required-flow")
629
+ || input.humanActions.some((action) => action.reasonCode === "unresolved-major-change")) {
630
+ issues.push("majorChangeAcceptance requires human-action-required with unprovable-required-flow in place of unresolved-major-change");
631
+ }
632
+ }
633
+ if (input.replayed !== undefined) {
634
+ if (input.replayed !== true) issues.push("replayed must be true when present");
635
+ if (!input.applyReceipt || (input.status !== "applied" && input.status !== "applied-reconcile-required")) {
636
+ issues.push("replayed is only allowed on a committed apply result with applyReceipt");
637
+ }
638
+ }
421
639
  const { receiptDigest, ...receiptPayload } = input;
422
640
  if (projectionResultReceiptDigest(receiptPayload) !== receiptDigest) issues.push("receiptDigest must match the canonical projection result payload");
423
641
  for (const [index, signal] of input.refreshSignals.entries()) {
@@ -431,6 +649,53 @@ export function projectionResultInvariantIssues(input: ProjectionResultV2): stri
431
649
  return issues;
432
650
  }
433
651
 
652
+ function projectionFilePreviewIssues(file: ProjectionFileResultV1, prefix: string): string[] {
653
+ const preview = file.preview!;
654
+ const issues: string[] = [];
655
+ const expectedFormat: ProjectionFilePreviewFormat | undefined = file.action === "create"
656
+ ? "body"
657
+ : file.action === "update" || file.action === "delete" ? "unified-diff" : undefined;
658
+ if (expectedFormat === undefined) issues.push(`${prefix}.preview is not allowed when action=${file.action}`);
659
+ else if (preview.format !== expectedFormat) issues.push(`${prefix}.preview.format must be ${expectedFormat} when action=${file.action}`);
660
+ if (typeof preview.content !== "string" || typeof preview.truncated !== "boolean"
661
+ || !Number.isSafeInteger(preview.byteLength) || preview.byteLength < 0) {
662
+ issues.push(`${prefix}.preview must carry string content, a boolean truncated flag and a non-negative integer byteLength`);
663
+ return issues;
664
+ }
665
+ const contentBytes = utf8ByteLength(preview.content);
666
+ if (contentBytes > PROJECTION_FILE_PREVIEW_MAX_BYTES) issues.push(`${prefix}.preview.content must not exceed ${PROJECTION_FILE_PREVIEW_MAX_BYTES} UTF-8 bytes`);
667
+ if (preview.truncated ? contentBytes >= preview.byteLength : contentBytes !== preview.byteLength) {
668
+ issues.push(`${prefix}.preview.truncated must be true exactly when content is shorter than byteLength`);
669
+ }
670
+ return issues;
671
+ }
672
+
673
+ const UTF8 = new TextEncoder();
674
+
675
+ function utf8ByteLength(value: string): number {
676
+ return UTF8.encode(value).length;
677
+ }
678
+
679
+ export function projectionFreshnessIssues(input: ProjectionFreshnessV1): string[] {
680
+ const issues = [
681
+ ...sortedUniqueIssues("freshness.reasonCodes", input.reasonCodes),
682
+ ...sortedUniqueIssues("freshness.staleNodes.nodeId", input.staleNodes.map((node) => node.nodeId))
683
+ ];
684
+ const unsupported = input.reasonCodes.find((code) => !(PROJECTION_FRESHNESS_REASON_CODES as readonly string[]).includes(code));
685
+ if (unsupported) issues.push(`freshness.reasonCodes contains an unsupported code: ${unsupported}`);
686
+ if (input.ok !== (input.reasonCodes.length === 0)) issues.push("freshness.ok must be true exactly when reasonCodes is empty");
687
+ if ((input.staleNodes.length > 0) !== input.reasonCodes.includes("projection-source-changed-since-stamp")) {
688
+ issues.push("freshness.staleNodes must be non-empty exactly when projection-source-changed-since-stamp is reported");
689
+ }
690
+ for (const [index, node] of input.staleNodes.entries()) {
691
+ const prefix = `freshness.staleNodes[${index}]`;
692
+ if (node.nodeId.trim() === "") issues.push(`${prefix}.nodeId must not be empty`);
693
+ if (!SHA256_DIGEST.test(node.stampedDigest) || !SHA256_DIGEST.test(node.currentDigest)) issues.push(`${prefix} digests must be SHA-256 digests`);
694
+ else if (node.stampedDigest === node.currentDigest) issues.push(`${prefix} must name two different digests`);
695
+ }
696
+ return issues;
697
+ }
698
+
434
699
  export function projectionPriorCommittedAppliesIssues(
435
700
  input: readonly ProjectionPriorCommittedApplyV1[],
436
701
  requestId: string
@@ -471,8 +736,9 @@ export function projectionPriorCommittedAppliesIssues(
471
736
  * projectionReceiptDigest, which projectionResultInvariantIssues verifies.
472
737
  */
473
738
  export function projectionResultReceiptDigest(input: Omit<ProjectionResultV2, "receiptDigest">): Sha256Digest {
474
- const refreshSignals = input.refreshSignals.map(({ projectionReceiptDigest: _projectionReceiptDigest, ...signal }) => signal);
475
- return digestJson({ ...input, refreshSignals } as unknown as Json) as Sha256Digest;
739
+ const { replayed: _replayed, ...payload } = input;
740
+ const refreshSignals = payload.refreshSignals.map(({ projectionReceiptDigest: _projectionReceiptDigest, ...signal }) => signal);
741
+ return digestJson({ ...payload, refreshSignals } as unknown as Json) as Sha256Digest;
476
742
  }
477
743
 
478
744
  export function projectionApplyLookupKey(input: {
@@ -551,6 +817,7 @@ export function projectionApplyRecoveryBindingInvariantIssues(
551
817
  issues.push("recovery schemaVersion is invalid");
552
818
  }
553
819
  if (binding.targets.length === 0) issues.push("recovery targets must contain at least one projection target");
820
+ if (binding.requestDigest !== undefined && !SHA256_DIGEST.test(binding.requestDigest)) issues.push("recovery requestDigest must be a SHA-256 digest");
554
821
  if (receipt) {
555
822
  if (binding.receiptDigest !== receipt.result.receiptDigest) {
556
823
  issues.push("recovery receiptDigest must match the committed result receiptDigest");
@@ -596,6 +863,33 @@ export function projectionApplyReadbackRequestInvariantIssues(request: Projectio
596
863
  return issues;
597
864
  }
598
865
 
866
+ /**
867
+ * The readback request for a committed apply receipt, built only from what the receipt recorded:
868
+ * its requestId, the recovery binding's targets, changedPaths and original expected snapshot, and
869
+ * the identity's accepted change. Readback always takes `mode: "apply"`, also for an adopt receipt,
870
+ * because the binding never records the mode. A caller that held only `acceptObservedMajorChange`
871
+ * never had the accepted change, so `AC_PROJECTION_APPLY_COMMITTED` hands it this request (#278).
872
+ * Undefined for a receipt without a recovery binding, which readback cannot prove.
873
+ */
874
+ export function projectionApplyReadbackRequestFromReceipt(receipt: ProjectionApplyReceiptV1): ProjectionRequestV1 | undefined {
875
+ const binding = receipt.recovery;
876
+ if (!binding) return undefined;
877
+ return {
878
+ schemaVersion: PROJECTION_REQUEST_SCHEMA_VERSION,
879
+ requestId: receipt.result.requestId,
880
+ profile: "repo-harness/v1",
881
+ mode: "apply",
882
+ targets: [...binding.targets],
883
+ changedPaths: [...binding.changedPaths],
884
+ expected: { ...binding.originalExpectedSnapshot },
885
+ acceptedChange: {
886
+ ...receipt.identity.acceptedChange,
887
+ reasonCodes: [...receipt.identity.acceptedChange.reasonCodes],
888
+ affectedNodeIds: [...receipt.identity.acceptedChange.affectedNodeIds]
889
+ }
890
+ };
891
+ }
892
+
599
893
  export function projectionApplyAbsenceInvariantIssues(input: ProjectionApplyAbsenceV1, request: ProjectionRequestV1): string[] {
600
894
  const issues = projectionApplyReadbackRequestInvariantIssues(request);
601
895
  if (input.schemaVersion !== "archcontext.projection-apply-absence/v1") issues.push("absence schemaVersion is invalid");
@@ -773,6 +1067,7 @@ export function architectureRefreshSignalInvariantIssues(input: ArchitectureRefr
773
1067
  issues.push(`${prefix}.acceptedChange.affectedNodeIds must match signal affectedNodeIds`);
774
1068
  }
775
1069
  }
1070
+ if (input.capabilities !== undefined) issues.push(...architectureCapabilityChangesIssues(input.capabilities, `${prefix}.capabilities`));
776
1071
  if (input.reasonCodes.length === 0) issues.push(`${prefix}.reasonCodes must contain at least one reason`);
777
1072
  if (input.affectedNodeIds.length === 0) issues.push(`${prefix}.affectedNodeIds must contain at least one node`);
778
1073
  if (input.refreshTargets.length === 0) issues.push(`${prefix}.refreshTargets must contain at least one target`);
@@ -791,6 +1086,43 @@ export function architectureRefreshSignalInvariantIssues(input: ArchitectureRefr
791
1086
  return issues;
792
1087
  }
793
1088
 
1089
+ /** Shape and canonical order of a per-capability major-change breakdown (#264). */
1090
+ export function architectureCapabilityChangesIssues(input: readonly ArchitectureCapabilityChangeV1[], prefix = "capabilities"): string[] {
1091
+ const issues = sortedUniqueIssues(`${prefix}.capabilityId`, input.map((entry) => entry.capabilityId));
1092
+ if (input.length === 0) issues.push(`${prefix} must name at least one capability`);
1093
+ const reasons = new Set<string>(ARCHITECTURE_MAJOR_CHANGE_REASON_CODES);
1094
+ const facets = new Set<string>(ARCHITECTURE_SEMANTIC_FACETS);
1095
+ for (const [index, entry] of input.entries()) {
1096
+ const entryPrefix = `${prefix}[${index}]`;
1097
+ if (typeof entry.capabilityId !== "string" || entry.capabilityId.trim() === "") issues.push(`${entryPrefix}.capabilityId must not be empty`);
1098
+ issues.push(
1099
+ ...sortedUniqueIssues(`${entryPrefix}.reasonCodes`, entry.reasonCodes),
1100
+ ...sortedUniqueIssues(`${entryPrefix}.changedFacets`, entry.changedFacets)
1101
+ );
1102
+ const reason = entry.reasonCodes.find((code) => !reasons.has(code));
1103
+ if (reason !== undefined) issues.push(`${entryPrefix}.reasonCodes contains unsupported reason: ${reason}`);
1104
+ const facet = entry.changedFacets.find((name) => !facets.has(name));
1105
+ if (facet !== undefined) issues.push(`${entryPrefix}.changedFacets contains unsupported facet: ${facet}`);
1106
+ for (const field of ["proofStatusBefore", "proofStatusAfter"] as const) {
1107
+ const status = entry[field];
1108
+ if (status !== null && !isArchitectureCapabilityProofStatus(status)) issues.push(`${entryPrefix}.${field} must be null or a P1/P2 proof status`);
1109
+ }
1110
+ if (entry.proofStatusBefore === null && entry.proofStatusAfter === null) issues.push(`${entryPrefix} must exist before or after the change`);
1111
+ if ((entry.proofStatusBefore === null || entry.proofStatusAfter === null) && entry.changedFacets.length > 0) {
1112
+ issues.push(`${entryPrefix}.changedFacets must be empty for a capability present on one side only`);
1113
+ }
1114
+ }
1115
+ return issues;
1116
+ }
1117
+
1118
+ export function isArchitectureCapabilityProofStatus(value: unknown): value is ArchitectureCapabilityProofStatusV1 {
1119
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1120
+ const record = value as Record<string, unknown>;
1121
+ return Object.keys(record).length === 2
1122
+ && (ARCHITECTURE_PROOF_P1_STATUSES as readonly unknown[]).includes(record.p1)
1123
+ && (ARCHITECTURE_PROOF_P2_STATUSES as readonly unknown[]).includes(record.p2);
1124
+ }
1125
+
794
1126
  const SHA256_DIGEST = /^sha256:[a-f0-9]{64}$/;
795
1127
  const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
796
1128