archctx-contracts 0.6.3 → 0.7.1

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 (38) 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/capability-dependencies.json +35 -0
  4. package/fixtures/valid/docs-anchors.json +105 -0
  5. package/fixtures/valid/flow-selector-check.json +50 -0
  6. package/fixtures/valid/product-version-manifest.json +6 -6
  7. package/fixtures/valid/projection-manifest.json +175 -0
  8. package/fixtures/valid/projection-result.json +5 -5
  9. package/fixtures/valid/recommendation-v3.json +69 -0
  10. package/fixtures/valid/runtime-recommendation-show.json +312 -0
  11. package/fixtures/valid/runtime-refactor-scan.json +543 -0
  12. package/package.json +1 -1
  13. package/schemas/runtime/archctx-capabilities.schema.json +7 -2
  14. package/schemas/runtime/architecture-refresh-signal.schema.json +27 -0
  15. package/schemas/runtime/capability-dependencies.schema.json +116 -0
  16. package/schemas/runtime/changeset.schema.json +42 -16
  17. package/schemas/runtime/docs-anchors.schema.json +143 -0
  18. package/schemas/runtime/flow-selector-check.schema.json +109 -0
  19. package/schemas/runtime/projection-apply-readback.schema.json +351 -10
  20. package/schemas/runtime/projection-apply-recovery.schema.json +24 -3
  21. package/schemas/runtime/projection-manifest.schema.json +491 -0
  22. package/schemas/runtime/projection-request.schema.json +11 -2
  23. package/schemas/runtime/projection-result.schema.json +108 -8
  24. package/schemas/runtime/recommendation-v3.schema.json +1167 -0
  25. package/schemas/runtime/runtime-recommendation-show.schema.json +2463 -0
  26. package/schemas/runtime/runtime-refactor-scan.schema.json +2508 -0
  27. package/schemas/runtime/structural-observation-payload.schema.json +579 -0
  28. package/src/architecture.ts +6 -3
  29. package/src/docs-anchors.ts +126 -0
  30. package/src/index.ts +3 -0
  31. package/src/ledger.ts +26 -10
  32. package/src/ownership-dependencies.ts +129 -0
  33. package/src/product-version.ts +2 -2
  34. package/src/projection-manifest.ts +346 -0
  35. package/src/projection.ts +406 -6
  36. package/src/refactor.ts +305 -1
  37. package/src/schema.ts +20 -4
  38. 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,141 @@ 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
+ "capability-dependencies-v1",
66
+ "changeset-entity-operations-v1",
67
+ "changeset-ownership-delta-v1",
68
+ "code-facts-unavailable-error-v1",
69
+ "docs-anchors-v1",
70
+ "flow-selector-check-v1",
63
71
  "module-statistics-v1",
64
72
  "projection-apply-readback-v1",
65
73
  "projection-apply-receipt-v1",
66
74
  "projection-apply-recovery-v1",
75
+ "projection-apply-replay-v1",
76
+ "projection-check-freshness-v1",
77
+ "projection-manifest-contract-v2",
78
+ "projection-observed-major-change-acceptance-v1",
79
+ "projection-orphan-review-v1",
80
+ "projection-preview-v1",
67
81
  "projection-prior-committed-applies-v1",
68
82
  "projection-protocol-v2",
83
+ "recommendation-scan-decision-v1",
69
84
  "recommendation-v3",
70
85
  "refactor-assessment-v1",
71
- "refactor-resolution-v1"
86
+ "refactor-observation-evidence-v1",
87
+ "refactor-resolution-v1",
88
+ "refactor-scan-limits-v1",
89
+ "refactor-scan-untracked-count-v1"
90
+ ] as const;
91
+
92
+ /**
93
+ * The closed set of semantic facets a capability's semantic state digests. Each facet maps to the
94
+ * major-change reason code it raises when its digest moves.
95
+ */
96
+ export const ARCHITECTURE_SEMANTIC_FACETS = [
97
+ "constraints",
98
+ "entrypoints",
99
+ "interfaces",
100
+ "lifecycle",
101
+ "names",
102
+ "ownership",
103
+ "placement",
104
+ "relations",
105
+ "responsibilities",
106
+ "riskBoundaries"
107
+ ] as const;
108
+ export const ARCHITECTURE_PROOF_P1_STATUSES = ["proven", "unprovable"] as const;
109
+ export const ARCHITECTURE_PROOF_P2_STATUSES = ["not-applicable", "proven", "unprovable"] as const;
110
+
111
+ export const ARCHITECTURE_FLOW_SELECTOR_CHECK_SCHEMA_VERSION = "archcontext.flow-selector-check/v1" as const;
112
+
113
+ /**
114
+ * The proof status of one declared entrypoint→sink selector, as the P2 flow proof reads its
115
+ * evidence. Only `proven` can bind a flow step. `no-direct-call`: no `calls` edge from the source
116
+ * to the sink. `only-references`: no proving call, but the source references the sink by value.
117
+ * `false-builtin-call`: the only calls carry a runtime built-in name and the source file does not
118
+ * import the sink file. `ambiguous-symbol`: the source or the sink matches more than one indexed
119
+ * symbol. `evidence-missing`: no evidence was measured (for example, no CodeGraph index).
120
+ * `evidence-truncated`: the evidence producer could not enumerate every edge.
121
+ */
122
+ export const ARCHITECTURE_FLOW_SELECTOR_PROOF_STATUSES = [
123
+ "ambiguous-symbol",
124
+ "evidence-missing",
125
+ "evidence-truncated",
126
+ "false-builtin-call",
127
+ "no-direct-call",
128
+ "only-references",
129
+ "proven"
130
+ ] as const;
131
+ export type ArchitectureFlowSelectorProofStatus = typeof ARCHITECTURE_FLOW_SELECTOR_PROOF_STATUSES[number];
132
+
133
+ export interface ArchitectureFlowSelectorSiteV1 {
134
+ path: string;
135
+ line?: number;
136
+ }
137
+
138
+ export interface ArchitectureFlowSelectorCheckEntryV1 {
139
+ nodeId: string;
140
+ entrypointId: string;
141
+ sourcePath: string;
142
+ sourceSymbol: string;
143
+ sinkId: string;
144
+ sinkPath: string;
145
+ sinkSymbol: string;
146
+ status: ArchitectureFlowSelectorProofStatus;
147
+ callSites: ArchitectureFlowSelectorSiteV1[];
148
+ referenceSites: ArchitectureFlowSelectorSiteV1[];
149
+ rejectedBuiltinCallSites: ArchitectureFlowSelectorSiteV1[];
150
+ /** The required-flow steps that name this selector as evidence. Empty for an unused selector. */
151
+ flowSteps: Array<{ flowId: string; stepId: string }>;
152
+ }
153
+
154
+ export interface ArchitectureFlowSelectorCheckCapabilityV1 {
155
+ capabilityId: string;
156
+ /** The same P2 status a projection records for this capability. */
157
+ p2Status: typeof ARCHITECTURE_PROOF_P2_STATUSES[number];
158
+ diagnostics: Array<{ code: string; flowId?: string; stepId?: string; detail: string }>;
159
+ }
160
+
161
+ /**
162
+ * `archctx flows check`: the P2 flow proof without rendering. Read-only. `ok` is true when no
163
+ * capability that declares a flow has an unprovable P2 proof.
164
+ */
165
+ export interface ArchitectureFlowSelectorCheckV1 {
166
+ schemaVersion: typeof ARCHITECTURE_FLOW_SELECTOR_CHECK_SCHEMA_VERSION;
167
+ ok: boolean;
168
+ codeGraph: { availability: "ready" | "unavailable"; version: string };
169
+ summary: { selectors: number; proven: number; unproven: number; usedUnproven: number };
170
+ selectors: ArchitectureFlowSelectorCheckEntryV1[];
171
+ capabilities: ArchitectureFlowSelectorCheckCapabilityV1[];
172
+ }
173
+
174
+ /** `files[].preview.format`: a `create` carries the rendered body, an `update` or `delete` a unified diff. */
175
+ export const PROJECTION_FILE_PREVIEW_FORMATS = ["body", "unified-diff"] as const;
176
+ /** Upper bound, in UTF-8 bytes, on one `files[].preview.content`. */
177
+ export const PROJECTION_FILE_PREVIEW_MAX_BYTES = 65_536;
178
+ /** Upper bound, in UTF-8 bytes, on the sum of every `files[].preview.content` of one result. */
179
+ export const PROJECTION_PREVIEW_TOTAL_MAX_BYTES = 1_048_576;
180
+
181
+ /**
182
+ * Why a projection is not fresh. Shared by the core freshness evaluator and the `check` result's
183
+ * `freshness` field so the two can never disagree.
184
+ */
185
+ export const PROJECTION_FRESHNESS_REASON_CODES = [
186
+ "projection-manifest-missing",
187
+ "projection-manifest-unreadable",
188
+ "projection-snapshot-provenance-missing",
189
+ "projection-source-changed-since-stamp",
190
+ "projection-source-stamp-invalid",
191
+ "projection-source-stamp-missing",
192
+ "projection-source-tree-digest-mismatch"
72
193
  ] as const;
73
194
 
74
195
  export type ProjectionMode = (typeof PROJECTION_MODES)[number];
196
+ export type ProjectionFreshnessReasonCode = (typeof PROJECTION_FRESHNESS_REASON_CODES)[number];
75
197
  export type ProjectionTarget = (typeof PROJECTION_TARGETS)[number];
76
198
  export type ProjectionResultStatus = (typeof PROJECTION_RESULT_STATUSES)[number];
77
199
  export type ProjectionHumanActionReasonCode = (typeof PROJECTION_HUMAN_ACTION_REASON_CODES)[number];
@@ -79,8 +201,47 @@ export type ProjectionPriorCommittedApplyOperation = (typeof PROJECTION_PRIOR_CO
79
201
  export type ArchitectureMajorChangeReasonCode = (typeof ARCHITECTURE_MAJOR_CHANGE_REASON_CODES)[number];
80
202
  export type ArchitectureRefreshTarget = (typeof ARCHITECTURE_REFRESH_TARGETS)[number];
81
203
  export type ArchctxFeature = (typeof ARCHCTX_FEATURES)[number];
204
+ export type ArchitectureSemanticFacet = (typeof ARCHITECTURE_SEMANTIC_FACETS)[number];
205
+ export type ProjectionFilePreviewFormat = (typeof PROJECTION_FILE_PREVIEW_FORMATS)[number];
82
206
  export type Sha256Digest = `sha256:${string}`;
83
207
 
208
+ /** One capability's P1/P2 proof status, as its semantic state records it. */
209
+ export interface ArchitectureCapabilityProofStatusV1 {
210
+ p1: (typeof ARCHITECTURE_PROOF_P1_STATUSES)[number];
211
+ p2: (typeof ARCHITECTURE_PROOF_P2_STATUSES)[number];
212
+ }
213
+
214
+ /**
215
+ * Why one capability is part of a major change: the reason codes and semantic facets that moved for
216
+ * it between the committed semantic baseline and this run. `proofStatusBefore` is null when the
217
+ * baseline has no such capability (or no baseline exists); `proofStatusAfter` is null when the
218
+ * capability was removed. A capability present on one side only lists no changed facets. A
219
+ * capability listed only because its current proof is unprovable, while another capability carries
220
+ * the observed change, lists no reason codes.
221
+ */
222
+ export interface ArchitectureCapabilityChangeV1 {
223
+ capabilityId: string;
224
+ reasonCodes: ArchitectureMajorChangeReasonCode[];
225
+ changedFacets: ArchitectureSemanticFacet[];
226
+ proofStatusBefore: ArchitectureCapabilityProofStatusV1 | null;
227
+ proofStatusAfter: ArchitectureCapabilityProofStatusV1 | null;
228
+ }
229
+
230
+ /**
231
+ * What a `plan` would write to one file, returned in the response only and never persisted: the
232
+ * rendered body of a `create`, or a unified diff (current bytes to rendered bytes) of an `update` or
233
+ * `delete`. `content` is cut at a line boundary to at most PROJECTION_FILE_PREVIEW_MAX_BYTES UTF-8
234
+ * bytes, and to whatever remains of PROJECTION_PREVIEW_TOTAL_MAX_BYTES across the result, in path
235
+ * order; `byteLength` is the UTF-8 length of the complete content and `truncated` says whether
236
+ * `content` is shorter.
237
+ */
238
+ export interface ProjectionFilePreviewV1 {
239
+ format: ProjectionFilePreviewFormat;
240
+ content: string;
241
+ byteLength: number;
242
+ truncated: boolean;
243
+ }
244
+
84
245
  export interface ProjectionExpectedSnapshotV1 {
85
246
  repositoryId: string;
86
247
  workspaceId: string;
@@ -98,6 +259,14 @@ export interface ProjectionRequestV1 {
98
259
  expected: ProjectionExpectedSnapshotV1;
99
260
  adoptionPlanId?: string;
100
261
  acceptedChange?: AcceptedArchitectureChangeReferenceV1;
262
+ /**
263
+ * `apply` and `adopt` only, never together with `acceptedChange`: accept the major change this
264
+ * run observes at `expected`. The provider classifies, derives the accepted change and applies
265
+ * it against the same expected snapshot, which the daemon re-checks under its writer lock before
266
+ * writing. The apply receipt records the observed change with provider-generated `changeSetId`
267
+ * and `eventId`. Absent, a major change still stops at `human-action-required` (#261).
268
+ */
269
+ acceptObservedMajorChange?: true;
101
270
  }
102
271
 
103
272
  export interface ProjectionSnapshotV1 extends ProjectionExpectedSnapshotV1 {
@@ -112,7 +281,6 @@ export interface ProjectionSnapshotV1 extends ProjectionExpectedSnapshotV1 {
112
281
  generatedFrom: {
113
282
  codeGraphPackage: "@colbymchenry/codegraph";
114
283
  codeGraphVersion: "1.6.1";
115
- codeGraphBinaryDigest: Sha256Digest;
116
284
  codeGraphStatus: "ready" | "unavailable";
117
285
  };
118
286
  }
@@ -122,11 +290,23 @@ export interface ProjectionFileResultV1 {
122
290
  action: "create" | "delete" | "unchanged" | "update";
123
291
  preimageDigest: Sha256Digest | null;
124
292
  outputDigest: Sha256Digest | null;
293
+ /**
294
+ * `plan` results only (#264): what this entry would write. Absent on every other mode, on a
295
+ * committed receipt, and on an entry whose path the projection rejected for adoption or ownership
296
+ * repair, because no apply writes that rendered body. Consumers gate on `projection-preview-v1`.
297
+ */
298
+ preview?: ProjectionFilePreviewV1;
125
299
  }
126
300
 
127
301
  export interface ProjectionHumanActionV1 {
128
302
  reasonCode: ProjectionHumanActionReasonCode;
129
303
  affectedNodeIds: string[];
304
+ /**
305
+ * Present exactly on `orphaned-document-review`: the managed document whose target the model no
306
+ * longer renders and which may hold human text. `apply` never deletes it; a human must review,
307
+ * move or remove it. A generated-only orphan is a `files[]` delete instead.
308
+ */
309
+ path?: string;
130
310
  requestPayloadDigest: Sha256Digest;
131
311
  }
132
312
 
@@ -170,6 +350,13 @@ export interface ArchitectureRefreshSignalV1 {
170
350
  acceptedChange?: AcceptedArchitectureChangeReferenceV1;
171
351
  reasonCodes: ArchitectureMajorChangeReasonCode[];
172
352
  affectedNodeIds: string[];
353
+ /**
354
+ * The observed per-capability breakdown of the semantic delta this signal classifies, sorted by
355
+ * capabilityId (#264). Every signal this provider produces carries it; it is part of the signal
356
+ * identity; signals committed before it existed lack it. Consumers gate on
357
+ * `architecture-change-capabilities-v1`, not on field presence.
358
+ */
359
+ capabilities?: ArchitectureCapabilityChangeV1[];
173
360
  refreshTargets: ArchitectureRefreshTarget[];
174
361
  baseDigests: ArchitectureDigestSetV1;
175
362
  resultingDigests: ArchitectureDigestSetV1;
@@ -203,6 +390,21 @@ export interface ProjectionPriorCommittedApplyV1 {
203
390
  files: ProjectionPriorCommittedApplyFileV1[];
204
391
  }
205
392
 
393
+ export interface ProjectionFreshnessStaleNodeV1 {
394
+ nodeId: string;
395
+ /** Source-footprint digest the node's projected document is stamped with. */
396
+ stampedDigest: Sha256Digest;
397
+ /** Source-footprint digest measured by this run. */
398
+ currentDigest: Sha256Digest;
399
+ }
400
+
401
+ /** Read-only per-node freshness of the committed projection, as `check` measured it. */
402
+ export interface ProjectionFreshnessV1 {
403
+ ok: boolean;
404
+ reasonCodes: ProjectionFreshnessReasonCode[];
405
+ staleNodes: ProjectionFreshnessStaleNodeV1[];
406
+ }
407
+
206
408
  export interface ProjectionResultV2 {
207
409
  schemaVersion: typeof PROJECTION_RESULT_SCHEMA_VERSION;
208
410
  requestId: string;
@@ -214,8 +416,31 @@ export interface ProjectionResultV2 {
214
416
  humanActions: ProjectionHumanActionV1[];
215
417
  refreshSignals: ArchitectureRefreshSignalV1[];
216
418
  applyReceipt?: ProjectionApplyIdentityV1;
217
- /** Omitted, never `[]`, when no earlier attempt of this requestId committed. */
419
+ /**
420
+ * Omitted, never `[]`, when no earlier attempt of this requestId committed. Never present on
421
+ * `check` results: a check runs in-process without the daemon journal, so read committed applies
422
+ * through `apply` or `readback`.
423
+ */
218
424
  priorCommittedApplies?: ProjectionPriorCommittedApplyV1[];
425
+ /**
426
+ * Present on `check` results only: which nodes changed since their documents were verified.
427
+ * Consumers gate on the `projection-check-freshness-v1` capability, not on field presence.
428
+ */
429
+ freshness?: ProjectionFreshnessV1;
430
+ /**
431
+ * Present, as `true`, only when this response returns an apply this requestId already committed
432
+ * for the same request digest, without applying again (#265). It is excluded from
433
+ * `receiptDigest`, so a replay carries the committed receipt digest unchanged.
434
+ */
435
+ replayed?: true;
436
+ /**
437
+ * Present only on a `human-action-required` result whose request sent `acceptObservedMajorChange`
438
+ * and the provider declined it: `declined-unprovable-proof` means a capability's P1 or P2 proof is
439
+ * unprovable, so no observed change can be accepted. The result then carries an
440
+ * `unprovable-required-flow` action naming those capabilities, never `unresolved-major-change`,
441
+ * so a caller can tell a declined flag from an absent one (#275).
442
+ */
443
+ majorChangeAcceptance?: "declined-unprovable-proof";
219
444
  receiptDigest: Sha256Digest;
220
445
  }
221
446
 
@@ -243,6 +468,12 @@ export interface ProjectionApplyRecoveryBindingV1 {
243
468
  generatedFrom: ProjectionSnapshotV1["generatedFrom"];
244
469
  ownedOutputDigest: Sha256Digest;
245
470
  receiptDigest: Sha256Digest;
471
+ /**
472
+ * `digestJson` of the caller's ProjectionRequestV1 exactly as received. With the result's
473
+ * requestId it is the replay key of #265; it never includes provider-generated ids. Receipts
474
+ * committed before it existed lack it and are never replayed.
475
+ */
476
+ requestDigest?: Sha256Digest;
246
477
  }
247
478
 
248
479
  /**
@@ -365,6 +596,11 @@ export function projectionRequestInvariantIssues(input: ProjectionRequestV1): st
365
596
  if (!/^[a-zA-Z0-9_.:-]+$/.test(input.requestId)) issues.push("requestId must use the stable identifier character set");
366
597
  if (input.mode === "adopt" && !input.adoptionPlanId) issues.push("adoptionPlanId is required when mode=adopt");
367
598
  if (input.mode !== "adopt" && input.adoptionPlanId !== undefined) issues.push("adoptionPlanId is only allowed when mode=adopt");
599
+ if (input.acceptObservedMajorChange !== undefined) {
600
+ if (input.acceptObservedMajorChange !== true) issues.push("acceptObservedMajorChange must be true when present");
601
+ if (input.mode !== "apply" && input.mode !== "adopt") issues.push("acceptObservedMajorChange is only allowed when mode=apply or mode=adopt");
602
+ if (input.acceptedChange !== undefined) issues.push("acceptObservedMajorChange and acceptedChange are mutually exclusive");
603
+ }
368
604
  if (input.acceptedChange) {
369
605
  if (input.acceptedChange.changeSetId.trim() === "") issues.push("acceptedChange.changeSetId must not be empty");
370
606
  if (input.acceptedChange.eventId.trim() === "") issues.push("acceptedChange.eventId must not be empty");
@@ -381,6 +617,17 @@ export function projectionRequestInvariantIssues(input: ProjectionRequestV1): st
381
617
  return issues;
382
618
  }
383
619
 
620
+ /**
621
+ * Invariants that bind a result to the request mode that produced it, which the result itself does
622
+ * not carry: only a `plan` result previews what it would write (#264); `check`, `apply` and
623
+ * `adopt` results never carry `files[].preview`, whatever their status.
624
+ */
625
+ export function projectionResultRequestModeIssues(input: ProjectionResultV2, mode: ProjectionMode): string[] {
626
+ return mode !== "plan" && input.files.some((file) => file.preview !== undefined)
627
+ ? [`files[].preview is only allowed on a plan result, never when mode=${mode}`]
628
+ : [];
629
+ }
630
+
384
631
  export function projectionResultInvariantIssues(input: ProjectionResultV2): string[] {
385
632
  const issues = [
386
633
  ...sortedUniqueIssues("affectedNodeIds", input.affectedNodeIds),
@@ -400,6 +647,19 @@ export function projectionResultInvariantIssues(input: ProjectionResultV2): stri
400
647
  if (!input.applyReceipt) issues.push("applied-reconcile-required status requires applyReceipt");
401
648
  if (input.refreshSignals.length > 0) issues.push("applied-reconcile-required status cannot deliver refreshSignals");
402
649
  }
650
+ for (const [index, action] of input.humanActions.entries()) {
651
+ const prefix = `humanActions[${index}]`;
652
+ if (!(PROJECTION_HUMAN_ACTION_REASON_CODES as readonly string[]).includes(action.reasonCode)) issues.push(`${prefix}.reasonCode is unsupported`);
653
+ if ((action.reasonCode === "orphaned-document-review") !== (action.path !== undefined)) {
654
+ issues.push(`${prefix}.path must be present exactly when reasonCode=orphaned-document-review`);
655
+ }
656
+ if (action.path !== undefined && !isRepoRelativePosixPath(action.path)) issues.push(`${prefix}.path must be a repository-relative POSIX path`);
657
+ }
658
+ const orphanReviewPaths = input.humanActions.flatMap((action) => action.reasonCode === "orphaned-document-review" && action.path !== undefined ? [action.path] : []);
659
+ issues.push(...sortedUniqueIssues("humanActions[orphaned-document-review].path", orphanReviewPaths));
660
+ if (orphanReviewPaths.some((path) => input.files.some((file) => file.path === path))) {
661
+ issues.push("an orphaned-document-review path must not also be a files[] entry");
662
+ }
403
663
  for (const [index, file] of input.files.entries()) {
404
664
  const prefix = `files[${index}]`;
405
665
  if (file.action === "create" && (file.preimageDigest !== null || file.outputDigest === null)) {
@@ -414,10 +674,36 @@ export function projectionResultInvariantIssues(input: ProjectionResultV2): stri
414
674
  if (file.action === "unchanged" && (file.preimageDigest === null || file.outputDigest === null || file.preimageDigest !== file.outputDigest)) {
415
675
  issues.push(`${prefix} unchanged requires equal non-null digests`);
416
676
  }
677
+ if (file.preview !== undefined) issues.push(...projectionFilePreviewIssues(file, prefix));
678
+ }
679
+ const previewBytes = input.files.reduce((total, file) => total + (file.preview ? utf8ByteLength(file.preview.content) : 0), 0);
680
+ if (previewBytes > PROJECTION_PREVIEW_TOTAL_MAX_BYTES) {
681
+ issues.push(`files[].preview.content must not exceed ${PROJECTION_PREVIEW_TOTAL_MAX_BYTES} UTF-8 bytes in total`);
682
+ }
683
+ // The result does not carry its request mode; projectionResultRequestModeIssues binds a preview
684
+ // to mode=plan. On the result alone, a committed apply (and so a persisted receipt) never carries one.
685
+ if (input.files.some((file) => file.preview !== undefined)
686
+ && (input.applyReceipt !== undefined || input.status === "applied" || input.status === "applied-reconcile-required")) {
687
+ issues.push("files[].preview is only allowed on a plan result, never on a committed apply");
417
688
  }
418
689
  if (input.priorCommittedApplies) {
419
690
  issues.push(...projectionPriorCommittedAppliesIssues(input.priorCommittedApplies, input.requestId));
420
691
  }
692
+ if (input.freshness) issues.push(...projectionFreshnessIssues(input.freshness));
693
+ if (input.majorChangeAcceptance !== undefined) {
694
+ if (input.majorChangeAcceptance !== "declined-unprovable-proof") issues.push("majorChangeAcceptance is unsupported");
695
+ if (input.status !== "human-action-required"
696
+ || !input.humanActions.some((action) => action.reasonCode === "unprovable-required-flow")
697
+ || input.humanActions.some((action) => action.reasonCode === "unresolved-major-change")) {
698
+ issues.push("majorChangeAcceptance requires human-action-required with unprovable-required-flow in place of unresolved-major-change");
699
+ }
700
+ }
701
+ if (input.replayed !== undefined) {
702
+ if (input.replayed !== true) issues.push("replayed must be true when present");
703
+ if (!input.applyReceipt || (input.status !== "applied" && input.status !== "applied-reconcile-required")) {
704
+ issues.push("replayed is only allowed on a committed apply result with applyReceipt");
705
+ }
706
+ }
421
707
  const { receiptDigest, ...receiptPayload } = input;
422
708
  if (projectionResultReceiptDigest(receiptPayload) !== receiptDigest) issues.push("receiptDigest must match the canonical projection result payload");
423
709
  for (const [index, signal] of input.refreshSignals.entries()) {
@@ -431,6 +717,53 @@ export function projectionResultInvariantIssues(input: ProjectionResultV2): stri
431
717
  return issues;
432
718
  }
433
719
 
720
+ function projectionFilePreviewIssues(file: ProjectionFileResultV1, prefix: string): string[] {
721
+ const preview = file.preview!;
722
+ const issues: string[] = [];
723
+ const expectedFormat: ProjectionFilePreviewFormat | undefined = file.action === "create"
724
+ ? "body"
725
+ : file.action === "update" || file.action === "delete" ? "unified-diff" : undefined;
726
+ if (expectedFormat === undefined) issues.push(`${prefix}.preview is not allowed when action=${file.action}`);
727
+ else if (preview.format !== expectedFormat) issues.push(`${prefix}.preview.format must be ${expectedFormat} when action=${file.action}`);
728
+ if (typeof preview.content !== "string" || typeof preview.truncated !== "boolean"
729
+ || !Number.isSafeInteger(preview.byteLength) || preview.byteLength < 0) {
730
+ issues.push(`${prefix}.preview must carry string content, a boolean truncated flag and a non-negative integer byteLength`);
731
+ return issues;
732
+ }
733
+ const contentBytes = utf8ByteLength(preview.content);
734
+ if (contentBytes > PROJECTION_FILE_PREVIEW_MAX_BYTES) issues.push(`${prefix}.preview.content must not exceed ${PROJECTION_FILE_PREVIEW_MAX_BYTES} UTF-8 bytes`);
735
+ if (preview.truncated ? contentBytes >= preview.byteLength : contentBytes !== preview.byteLength) {
736
+ issues.push(`${prefix}.preview.truncated must be true exactly when content is shorter than byteLength`);
737
+ }
738
+ return issues;
739
+ }
740
+
741
+ const UTF8 = new TextEncoder();
742
+
743
+ function utf8ByteLength(value: string): number {
744
+ return UTF8.encode(value).length;
745
+ }
746
+
747
+ export function projectionFreshnessIssues(input: ProjectionFreshnessV1): string[] {
748
+ const issues = [
749
+ ...sortedUniqueIssues("freshness.reasonCodes", input.reasonCodes),
750
+ ...sortedUniqueIssues("freshness.staleNodes.nodeId", input.staleNodes.map((node) => node.nodeId))
751
+ ];
752
+ const unsupported = input.reasonCodes.find((code) => !(PROJECTION_FRESHNESS_REASON_CODES as readonly string[]).includes(code));
753
+ if (unsupported) issues.push(`freshness.reasonCodes contains an unsupported code: ${unsupported}`);
754
+ if (input.ok !== (input.reasonCodes.length === 0)) issues.push("freshness.ok must be true exactly when reasonCodes is empty");
755
+ if ((input.staleNodes.length > 0) !== input.reasonCodes.includes("projection-source-changed-since-stamp")) {
756
+ issues.push("freshness.staleNodes must be non-empty exactly when projection-source-changed-since-stamp is reported");
757
+ }
758
+ for (const [index, node] of input.staleNodes.entries()) {
759
+ const prefix = `freshness.staleNodes[${index}]`;
760
+ if (node.nodeId.trim() === "") issues.push(`${prefix}.nodeId must not be empty`);
761
+ if (!SHA256_DIGEST.test(node.stampedDigest) || !SHA256_DIGEST.test(node.currentDigest)) issues.push(`${prefix} digests must be SHA-256 digests`);
762
+ else if (node.stampedDigest === node.currentDigest) issues.push(`${prefix} must name two different digests`);
763
+ }
764
+ return issues;
765
+ }
766
+
434
767
  export function projectionPriorCommittedAppliesIssues(
435
768
  input: readonly ProjectionPriorCommittedApplyV1[],
436
769
  requestId: string
@@ -471,8 +804,9 @@ export function projectionPriorCommittedAppliesIssues(
471
804
  * projectionReceiptDigest, which projectionResultInvariantIssues verifies.
472
805
  */
473
806
  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;
807
+ const { replayed: _replayed, ...payload } = input;
808
+ const refreshSignals = payload.refreshSignals.map(({ projectionReceiptDigest: _projectionReceiptDigest, ...signal }) => signal);
809
+ return digestJson({ ...payload, refreshSignals } as unknown as Json) as Sha256Digest;
476
810
  }
477
811
 
478
812
  export function projectionApplyLookupKey(input: {
@@ -551,6 +885,7 @@ export function projectionApplyRecoveryBindingInvariantIssues(
551
885
  issues.push("recovery schemaVersion is invalid");
552
886
  }
553
887
  if (binding.targets.length === 0) issues.push("recovery targets must contain at least one projection target");
888
+ if (binding.requestDigest !== undefined && !SHA256_DIGEST.test(binding.requestDigest)) issues.push("recovery requestDigest must be a SHA-256 digest");
554
889
  if (receipt) {
555
890
  if (binding.receiptDigest !== receipt.result.receiptDigest) {
556
891
  issues.push("recovery receiptDigest must match the committed result receiptDigest");
@@ -596,6 +931,33 @@ export function projectionApplyReadbackRequestInvariantIssues(request: Projectio
596
931
  return issues;
597
932
  }
598
933
 
934
+ /**
935
+ * The readback request for a committed apply receipt, built only from what the receipt recorded:
936
+ * its requestId, the recovery binding's targets, changedPaths and original expected snapshot, and
937
+ * the identity's accepted change. Readback always takes `mode: "apply"`, also for an adopt receipt,
938
+ * because the binding never records the mode. A caller that held only `acceptObservedMajorChange`
939
+ * never had the accepted change, so `AC_PROJECTION_APPLY_COMMITTED` hands it this request (#278).
940
+ * Undefined for a receipt without a recovery binding, which readback cannot prove.
941
+ */
942
+ export function projectionApplyReadbackRequestFromReceipt(receipt: ProjectionApplyReceiptV1): ProjectionRequestV1 | undefined {
943
+ const binding = receipt.recovery;
944
+ if (!binding) return undefined;
945
+ return {
946
+ schemaVersion: PROJECTION_REQUEST_SCHEMA_VERSION,
947
+ requestId: receipt.result.requestId,
948
+ profile: "repo-harness/v1",
949
+ mode: "apply",
950
+ targets: [...binding.targets],
951
+ changedPaths: [...binding.changedPaths],
952
+ expected: { ...binding.originalExpectedSnapshot },
953
+ acceptedChange: {
954
+ ...receipt.identity.acceptedChange,
955
+ reasonCodes: [...receipt.identity.acceptedChange.reasonCodes],
956
+ affectedNodeIds: [...receipt.identity.acceptedChange.affectedNodeIds]
957
+ }
958
+ };
959
+ }
960
+
599
961
  export function projectionApplyAbsenceInvariantIssues(input: ProjectionApplyAbsenceV1, request: ProjectionRequestV1): string[] {
600
962
  const issues = projectionApplyReadbackRequestInvariantIssues(request);
601
963
  if (input.schemaVersion !== "archcontext.projection-apply-absence/v1") issues.push("absence schemaVersion is invalid");
@@ -773,6 +1135,7 @@ export function architectureRefreshSignalInvariantIssues(input: ArchitectureRefr
773
1135
  issues.push(`${prefix}.acceptedChange.affectedNodeIds must match signal affectedNodeIds`);
774
1136
  }
775
1137
  }
1138
+ if (input.capabilities !== undefined) issues.push(...architectureCapabilityChangesIssues(input.capabilities, `${prefix}.capabilities`));
776
1139
  if (input.reasonCodes.length === 0) issues.push(`${prefix}.reasonCodes must contain at least one reason`);
777
1140
  if (input.affectedNodeIds.length === 0) issues.push(`${prefix}.affectedNodeIds must contain at least one node`);
778
1141
  if (input.refreshTargets.length === 0) issues.push(`${prefix}.refreshTargets must contain at least one target`);
@@ -791,6 +1154,43 @@ export function architectureRefreshSignalInvariantIssues(input: ArchitectureRefr
791
1154
  return issues;
792
1155
  }
793
1156
 
1157
+ /** Shape and canonical order of a per-capability major-change breakdown (#264). */
1158
+ export function architectureCapabilityChangesIssues(input: readonly ArchitectureCapabilityChangeV1[], prefix = "capabilities"): string[] {
1159
+ const issues = sortedUniqueIssues(`${prefix}.capabilityId`, input.map((entry) => entry.capabilityId));
1160
+ if (input.length === 0) issues.push(`${prefix} must name at least one capability`);
1161
+ const reasons = new Set<string>(ARCHITECTURE_MAJOR_CHANGE_REASON_CODES);
1162
+ const facets = new Set<string>(ARCHITECTURE_SEMANTIC_FACETS);
1163
+ for (const [index, entry] of input.entries()) {
1164
+ const entryPrefix = `${prefix}[${index}]`;
1165
+ if (typeof entry.capabilityId !== "string" || entry.capabilityId.trim() === "") issues.push(`${entryPrefix}.capabilityId must not be empty`);
1166
+ issues.push(
1167
+ ...sortedUniqueIssues(`${entryPrefix}.reasonCodes`, entry.reasonCodes),
1168
+ ...sortedUniqueIssues(`${entryPrefix}.changedFacets`, entry.changedFacets)
1169
+ );
1170
+ const reason = entry.reasonCodes.find((code) => !reasons.has(code));
1171
+ if (reason !== undefined) issues.push(`${entryPrefix}.reasonCodes contains unsupported reason: ${reason}`);
1172
+ const facet = entry.changedFacets.find((name) => !facets.has(name));
1173
+ if (facet !== undefined) issues.push(`${entryPrefix}.changedFacets contains unsupported facet: ${facet}`);
1174
+ for (const field of ["proofStatusBefore", "proofStatusAfter"] as const) {
1175
+ const status = entry[field];
1176
+ if (status !== null && !isArchitectureCapabilityProofStatus(status)) issues.push(`${entryPrefix}.${field} must be null or a P1/P2 proof status`);
1177
+ }
1178
+ if (entry.proofStatusBefore === null && entry.proofStatusAfter === null) issues.push(`${entryPrefix} must exist before or after the change`);
1179
+ if ((entry.proofStatusBefore === null || entry.proofStatusAfter === null) && entry.changedFacets.length > 0) {
1180
+ issues.push(`${entryPrefix}.changedFacets must be empty for a capability present on one side only`);
1181
+ }
1182
+ }
1183
+ return issues;
1184
+ }
1185
+
1186
+ export function isArchitectureCapabilityProofStatus(value: unknown): value is ArchitectureCapabilityProofStatusV1 {
1187
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1188
+ const record = value as Record<string, unknown>;
1189
+ return Object.keys(record).length === 2
1190
+ && (ARCHITECTURE_PROOF_P1_STATUSES as readonly unknown[]).includes(record.p1)
1191
+ && (ARCHITECTURE_PROOF_P2_STATUSES as readonly unknown[]).includes(record.p2);
1192
+ }
1193
+
794
1194
  const SHA256_DIGEST = /^sha256:[a-f0-9]{64}$/;
795
1195
  const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/;
796
1196