archctx-contracts 0.6.3 → 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.
- package/fixtures/invalid/recommendation-v3-structural-before-evidence.json +59 -0
- package/fixtures/valid/archctx-capabilities.json +3 -3
- package/fixtures/valid/product-version-manifest.json +6 -6
- package/fixtures/valid/projection-manifest.json +175 -0
- package/fixtures/valid/projection-result.json +5 -5
- package/fixtures/valid/recommendation-v3.json +69 -0
- package/fixtures/valid/runtime-recommendation-show.json +312 -0
- package/fixtures/valid/runtime-refactor-scan.json +542 -0
- package/package.json +1 -1
- package/schemas/runtime/archctx-capabilities.schema.json +7 -2
- package/schemas/runtime/architecture-refresh-signal.schema.json +27 -0
- package/schemas/runtime/changeset.schema.json +42 -16
- package/schemas/runtime/projection-apply-readback.schema.json +351 -10
- package/schemas/runtime/projection-apply-recovery.schema.json +24 -3
- package/schemas/runtime/projection-manifest.schema.json +491 -0
- package/schemas/runtime/projection-request.schema.json +11 -2
- package/schemas/runtime/projection-result.schema.json +108 -8
- package/schemas/runtime/recommendation-v3.schema.json +1166 -0
- package/schemas/runtime/runtime-recommendation-show.schema.json +2449 -0
- package/schemas/runtime/runtime-refactor-scan.schema.json +2489 -0
- package/schemas/runtime/structural-observation-payload.schema.json +578 -0
- package/src/index.ts +1 -0
- package/src/ledger.ts +26 -10
- package/src/product-version.ts +2 -2
- package/src/projection-manifest.ts +346 -0
- package/src/projection.ts +338 -6
- package/src/refactor.ts +268 -1
- package/src/schema.ts +20 -4
- 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/
|
|
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-
|
|
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
|
-
/**
|
|
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
|
|
475
|
-
|
|
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
|
|