archctx-contracts 0.4.8 → 0.5.2

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": "archcontext.capabilities/v1",
3
- "package": { "name": "archctx", "version": "0.4.8" },
3
+ "package": { "name": "archctx", "version": "0.5.2" },
4
4
  "protocols": {
5
5
  "projectionRequest": "archcontext.projection-request/v1",
6
6
  "projectionResult": "archcontext.projection-result/v2",
@@ -10,5 +10,5 @@
10
10
  "architectureDocs": "archcontext.docs-renderer/v4",
11
11
  "agentContext": "archcontext.agent-context-renderer/v1"
12
12
  },
13
- "features": ["architecture-docs-renderer-v2", "architecture-refresh-signal-v1", "projection-apply-receipt-v1", "projection-apply-recovery-v1", "projection-protocol-v2"]
13
+ "features": ["architecture-docs-renderer-v2", "architecture-refresh-signal-v1", "module-statistics-v1", "projection-apply-receipt-v1", "projection-apply-recovery-v1", "projection-protocol-v2", "recommendation-v3", "refactor-assessment-v1", "refactor-resolution-v1"]
14
14
  }
@@ -2,7 +2,7 @@
2
2
  "schemaVersion": "archcontext.product-version-manifest/v1",
3
3
  "product": {
4
4
  "name": "archctx",
5
- "version": "0.4.8",
5
+ "version": "0.5.2",
6
6
  "distribution": "one-package"
7
7
  },
8
8
  "packageManager": "bun@1.4.0",
@@ -12,25 +12,25 @@
12
12
  "surfaces": {
13
13
  "cli": {
14
14
  "packageName": "@archcontext/surfaces",
15
- "version": "0.4.8",
15
+ "version": "0.5.2",
16
16
  "entrypoint": "archctx"
17
17
  },
18
18
  "daemon": {
19
19
  "packageName": "@archcontext/local-runtime",
20
- "version": "0.4.8",
20
+ "version": "0.5.2",
21
21
  "entrypoint": "archctx daemon start",
22
22
  "rpcSchemaVersion": "archcontext.runtime-rpc/v1"
23
23
  },
24
24
  "mcp": {
25
25
  "packageName": "@archcontext/surfaces",
26
- "version": "0.4.8",
26
+ "version": "0.5.2",
27
27
  "entrypoint": "archctx mcp",
28
28
  "transport": "stdio"
29
29
  }
30
30
  },
31
31
  "schemas": {
32
32
  "schemaSetVersion": "2026-06-25.al0-ledger",
33
- "contractsPackageVersion": "0.4.8",
33
+ "contractsPackageVersion": "0.5.2",
34
34
  "reviewChallenge": "archcontext.review-challenge/v2",
35
35
  "attestation": "archcontext.attestation/v2",
36
36
  "deviceIdentity": "archcontext.device-identity/v1",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "archctx-contracts",
3
- "version": "0.4.8",
3
+ "version": "0.5.2",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -39,7 +39,7 @@
39
39
  "type": "array",
40
40
  "minItems": 3,
41
41
  "uniqueItems": true,
42
- "items": { "enum": ["architecture-docs-renderer-v2", "architecture-refresh-signal-v1", "projection-apply-receipt-v1", "projection-apply-recovery-v1", "projection-protocol-v2"] }
42
+ "items": { "enum": ["architecture-docs-renderer-v2", "architecture-refresh-signal-v1", "module-statistics-v1", "projection-apply-receipt-v1", "projection-apply-recovery-v1", "projection-protocol-v2", "recommendation-v3", "refactor-assessment-v1", "refactor-resolution-v1"] }
43
43
  }
44
44
  }
45
45
  }
package/src/index.ts CHANGED
@@ -7,5 +7,6 @@ export * from "./ports";
7
7
  export * from "./practices";
8
8
  export * from "./projection";
9
9
  export * from "./product-version";
10
+ export * from "./refactor";
10
11
  export * from "./schema";
11
12
  export * from "./validator";
package/src/ledger.ts CHANGED
@@ -1,3 +1,11 @@
1
+ import type { ArchitectureMajorChangeReasonCode } from "./projection";
2
+ import type {
3
+ ArchitectureTargetDeltaV1,
4
+ RefactorKillListEntryV1,
5
+ RefactorObservationKind,
6
+ RefactorScale,
7
+ RefactorTargetOutcomeV1
8
+ } from "./refactor";
1
9
  import { digestJson, type Json } from "./schema";
2
10
 
3
11
  export const ARCHITECTURE_EVENT_SCHEMA_VERSION = "archcontext.architecture-event/v1" as const;
@@ -6,6 +14,7 @@ export const EVIDENCE_ITEM_SCHEMA_VERSION = "archcontext.evidence-item/v2" as co
6
14
  export const EVIDENCE_BINDING_SCHEMA_VERSION = "archcontext.evidence-binding/v1" as const;
7
15
  export const RECOMMENDATION_RUN_SCHEMA_VERSION = "archcontext.recommendation-run/v1" as const;
8
16
  export const RECOMMENDATION_SCHEMA_VERSION = "archcontext.recommendation/v2" as const;
17
+ export const RECOMMENDATION_V3_SCHEMA_VERSION = "archcontext.recommendation/v3" as const;
9
18
  export const RECOMMENDATION_FEEDBACK_SCHEMA_VERSION = "archcontext.recommendation-feedback/v1" as const;
10
19
  export const AGENT_JOB_SCHEMA_VERSION = "archcontext.agent-job/v1" as const;
11
20
  export const INVESTIGATION_REPORT_SCHEMA_VERSION = "archcontext.investigation-report/v1" as const;
@@ -14,9 +23,23 @@ export const ARCHITECTURE_CANDIDATE_DELTA_SCHEMA_VERSION = "archcontext.architec
14
23
  export const ARCHITECTURE_CANDIDATE_DELTA_POLICY_SCHEMA_VERSION = "archcontext.architecture-candidate-delta-policy/v1" as const;
15
24
  export const PROJECTION_TARGET_SCHEMA_VERSION = "archcontext.projection-target/v1" as const;
16
25
 
26
+ export const RECOMMENDATION_CATEGORIES = ["practice", "refactor_proposal", "structural_observation"] as const;
27
+
17
28
  export type ArchitectureFactAuthority = "declared" | "observed" | "verified" | "proposed" | "projected";
18
29
  export type ArchitectureLedgerMode = "yaml" | "dual" | "dual-compare" | "ledger-shadow" | "ledger" | "ledger-authoritative";
19
30
  export type ArchitectureActorKind = "developer" | "daemon" | "hook" | "cli" | "mcp" | "subagent" | "migration" | "system";
31
+ export type ArchitectureActorSource = "cli" | "mcp" | "manual" | "daemon" | "system" | "subagent";
32
+ export type RecommendationCategory = (typeof RECOMMENDATION_CATEGORIES)[number];
33
+ export type RecommendationStatus =
34
+ | "open"
35
+ | "acknowledged"
36
+ | "accepted"
37
+ | "rejected"
38
+ | "deferred"
39
+ | "waived"
40
+ | "resolved"
41
+ | "superseded"
42
+ | "expired";
20
43
  export type ArchitectureEventSource =
21
44
  | "prepare_task"
22
45
  | "checkpoint"
@@ -28,7 +51,8 @@ export type ArchitectureEventSource =
28
51
  | "projection_reconcile"
29
52
  | "migration"
30
53
  | "manual"
31
- | "agent_audit";
54
+ | "agent_audit"
55
+ | "refactor_scan";
32
56
 
33
57
  export type EvidenceStrengthV2 = "heuristic" | "declared" | "observed" | "verified";
34
58
  export type EvidencePolarityV2 = "positive" | "absence" | "declaration";
@@ -621,7 +645,7 @@ export interface RecommendationV2 {
621
645
  fingerprint: string;
622
646
  subject: string;
623
647
  practiceId?: string;
624
- status: "open" | "acknowledged" | "accepted" | "rejected" | "deferred" | "waived" | "resolved" | "superseded" | "expired";
648
+ status: RecommendationStatus;
625
649
  confidence: "low" | "medium" | "high";
626
650
  enforcement: "advisory" | "checkpoint" | "complete";
627
651
  risk: "low" | "medium" | "high";
@@ -639,12 +663,12 @@ export interface RecommendationFeedbackV1 {
639
663
  recommendationId: string;
640
664
  runId: string;
641
665
  action: "acknowledge" | "accept" | "reject" | "defer" | "waive" | "resolve";
642
- previousStatus: RecommendationV2["status"];
643
- nextStatus: RecommendationV2["status"];
666
+ previousStatus: RecommendationStatus;
667
+ nextStatus: RecommendationStatus;
644
668
  actor: {
645
669
  kind: ArchitectureActorKind;
646
670
  id: string;
647
- source: "cli" | "mcp" | "manual" | "daemon" | "system" | "subagent";
671
+ source: ArchitectureActorSource;
648
672
  };
649
673
  reason: string;
650
674
  explicit: true;
@@ -655,6 +679,78 @@ export interface RecommendationFeedbackV1 {
655
679
  extensions?: Record<string, Json>;
656
680
  }
657
681
 
682
+ export interface RecommendationAuthorV1 {
683
+ kind: ArchitectureActorKind;
684
+ id: string;
685
+ source: ArchitectureActorSource;
686
+ }
687
+
688
+ export interface RecommendationRelationsV1 {
689
+ supersedes?: string;
690
+ regressesFrom?: string;
691
+ }
692
+
693
+ export interface PracticeRecommendationPayloadV1 {
694
+ practiceId: string;
695
+ baselineDigest: string | null;
696
+ }
697
+
698
+ export interface StructuralObservationPayloadV1 {
699
+ assessmentDigest: string;
700
+ kind: RefactorObservationKind;
701
+ affectedNodeIds: string[];
702
+ baselineSnapshotDigest: string;
703
+ derivedOutcomes: RefactorTargetOutcomeV1[];
704
+ }
705
+
706
+ export interface RefactorProposalPayloadV1 {
707
+ assessmentDigest: string;
708
+ proposalDigest: string;
709
+ scale: RefactorScale;
710
+ affectedNodeIds: string[];
711
+ majorChangeReasons: ArchitectureMajorChangeReasonCode[];
712
+ baselineSnapshotDigest: string;
713
+ targetDelta?: ArchitectureTargetDeltaV1;
714
+ targetOutcomes: RefactorTargetOutcomeV1[];
715
+ killList: RefactorKillListEntryV1[];
716
+ }
717
+
718
+ export type RecommendationPayloadV1 =
719
+ | PracticeRecommendationPayloadV1
720
+ | StructuralObservationPayloadV1
721
+ | RefactorProposalPayloadV1;
722
+
723
+ /** Strict superset of RecommendationV2: every v2 field is kept verbatim. */
724
+ export interface RecommendationV3Base {
725
+ schemaVersion: typeof RECOMMENDATION_V3_SCHEMA_VERSION;
726
+ recommendationId: string;
727
+ runId: string;
728
+ fingerprint: string;
729
+ subject: string;
730
+ practiceId?: string;
731
+ status: RecommendationStatus;
732
+ confidence: "low" | "medium" | "high";
733
+ enforcement: "advisory" | "checkpoint" | "complete";
734
+ risk: "low" | "medium" | "high";
735
+ uncertainty: "low" | "medium" | "high";
736
+ evidenceBindingIds: string[];
737
+ explanation: string[];
738
+ authoredBy: RecommendationAuthorV1;
739
+ subjectSelectorId: string;
740
+ relations: RecommendationRelationsV1;
741
+ createdAt: string;
742
+ updatedAt: string;
743
+ extensions?: Record<string, Json>;
744
+ }
745
+
746
+ /** `category` discriminates `payload`; no other combination is representable. */
747
+ export type RecommendationV3CategoryPayloadV1 =
748
+ | { category: "practice"; payload: PracticeRecommendationPayloadV1 }
749
+ | { category: "structural_observation"; payload: StructuralObservationPayloadV1 }
750
+ | { category: "refactor_proposal"; payload: RefactorProposalPayloadV1 };
751
+
752
+ export type RecommendationV3 = RecommendationV3Base & RecommendationV3CategoryPayloadV1;
753
+
658
754
  export interface AgentJobV1 {
659
755
  schemaVersion: typeof AGENT_JOB_SCHEMA_VERSION;
660
756
  jobId: string;
@@ -1,5 +1,5 @@
1
1
  export const ARCHCONTEXT_PRODUCT_NAME = "archctx";
2
- export const ARCHCONTEXT_PRODUCT_VERSION = "0.4.8";
2
+ export const ARCHCONTEXT_PRODUCT_VERSION = "0.5.2";
3
3
  export const ARCHCONTEXT_PACKAGE_MANAGER = "bun@1.4.0";
4
4
  export const ARCHCONTEXT_NODE_RANGE = ">=22.22 <26";
5
5
  export const LOCAL_RUNTIME_RPC_SCHEMA_VERSION = "archcontext.runtime-rpc/v1";
package/src/projection.ts CHANGED
@@ -58,9 +58,13 @@ export const ARCHITECTURE_REFRESH_TARGETS = [
58
58
  export const ARCHCTX_FEATURES = [
59
59
  "architecture-docs-renderer-v2",
60
60
  "architecture-refresh-signal-v1",
61
+ "module-statistics-v1",
61
62
  "projection-apply-receipt-v1",
62
63
  "projection-apply-recovery-v1",
63
- "projection-protocol-v2"
64
+ "projection-protocol-v2",
65
+ "recommendation-v3",
66
+ "refactor-assessment-v1",
67
+ "refactor-resolution-v1"
64
68
  ] as const;
65
69
 
66
70
  export type ProjectionMode = (typeof PROJECTION_MODES)[number];
@@ -0,0 +1,994 @@
1
+ import { RECOMMENDATION_CATEGORIES, RECOMMENDATION_V3_SCHEMA_VERSION } from "./ledger";
2
+ import type {
3
+ ArchitectureActorKind,
4
+ ArchitectureRepositoryIdentityV1,
5
+ ArchitectureWorktreeIdentityV1,
6
+ EvidenceCoverageLevelV2,
7
+ PracticeRecommendationPayloadV1,
8
+ RecommendationAuthorV1,
9
+ RecommendationCategory,
10
+ RecommendationPayloadV1,
11
+ RecommendationV3,
12
+ RecommendationV3Base,
13
+ RefactorProposalPayloadV1,
14
+ StructuralObservationPayloadV1
15
+ } from "./ledger";
16
+ import { ARCHITECTURE_MAJOR_CHANGE_REASON_CODES, type ArchitectureMajorChangeReasonCode } from "./projection";
17
+ import { digestJson, isRepoRelativePosixPath, type Json, type Severity } from "./schema";
18
+
19
+ const DIGEST_PREFIX_LENGTH = "sha256:".length;
20
+ const SHA256_DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
21
+ const BARE_SHA256_PATTERN = /^[a-f0-9]{64}$/;
22
+ const GIT_HEAD_SHA_PATTERN = /^[a-f0-9]{40}$/;
23
+
24
+ /**
25
+ * The bounded grammar an execution evidence `locator` must fit: 1..256 characters drawn from the
26
+ * unreserved plus sub-delims/gen-delims set that repo-relative paths and URLs already live in.
27
+ *
28
+ * A locator is a *reference*, not a body. Left unbounded it is a string field on a record whose
29
+ * envelope promises `privacy.rawDiffPersisted: false`, so a caller could park a whole diff, a
30
+ * credential, or a multi-line prompt in it and the digest check would still pass. Whitespace,
31
+ * newlines and control characters are the shapes that only a body has, and the length ceiling is
32
+ * what stops a single-line body from riding through anyway.
33
+ */
34
+ export const REFACTOR_EXECUTION_EVIDENCE_LOCATOR_PATTERN = /^[A-Za-z0-9._~:\/?#@!$&'()*+,;=%-]{1,256}$/;
35
+
36
+ /** The single sentence every ingress uses to name the locator rule, so they cannot drift apart. */
37
+ export const REFACTOR_EXECUTION_EVIDENCE_LOCATOR_RULE =
38
+ "must be a bounded reference: 1-256 characters from [A-Za-z0-9._~:/?#@!$&'()*+,;=%-], no whitespace or control characters";
39
+
40
+ /** The only top-level keys a verification request may carry; anything else is a caller typo. */
41
+ export const REFACTOR_VERIFICATION_REQUEST_KEYS = [
42
+ "executionEvidenceRefs",
43
+ "expectedHeadSha",
44
+ "expectedWorktreeDigest",
45
+ "recommendationId",
46
+ "schemaVersion"
47
+ ] as const;
48
+
49
+ export const REFACTOR_REQUEST_SCHEMA_VERSION = "archcontext.refactor-request/v1" as const;
50
+ export const REFACTOR_PROPOSAL_SCHEMA_VERSION = "archcontext.refactor-proposal/v1" as const;
51
+ export const MODULE_STATISTICS_SCHEMA_VERSION = "archcontext.module-statistics/v1" as const;
52
+ export const REFACTOR_ASSESSMENT_SCHEMA_VERSION = "archcontext.refactor-assessment/v1" as const;
53
+ export const REFACTOR_RESOLUTION_EVIDENCE_SCHEMA_VERSION = "archcontext.refactor-resolution-evidence/v1" as const;
54
+ export const REFACTOR_VERIFICATION_REQUEST_SCHEMA_VERSION = "archcontext.refactor-verification-request/v1" as const;
55
+
56
+ export const REFACTOR_SCALES = [
57
+ "architecture",
58
+ "cross_module",
59
+ "insufficient_evidence",
60
+ "model_adoption_required",
61
+ "module"
62
+ ] as const;
63
+ export const REFACTOR_SCALE_REASON_CODES = [
64
+ "caller-coverage-unknown",
65
+ "code-facts-missing",
66
+ "code-facts-truncated",
67
+ "major-change-detected",
68
+ "multi-node-scope",
69
+ "node-footprint-undeclared",
70
+ "ownership-ambiguous",
71
+ "single-node-scope",
72
+ "target-unresolved",
73
+ "unowned-paths"
74
+ ] as const;
75
+ export const REFACTOR_OBSERVATION_KINDS = [
76
+ "cycle",
77
+ "direction-violation",
78
+ "evidence-gap",
79
+ "ownership-ambiguous",
80
+ "undeclared-footprint",
81
+ "unowned-paths"
82
+ ] as const;
83
+ export const REFACTOR_RESOLUTION_DISPOSITIONS = [
84
+ "not_improved",
85
+ "partially_resolved",
86
+ "regressed",
87
+ "resolved",
88
+ "stale"
89
+ ] as const;
90
+ export const REFACTOR_OUTCOME_OPERATORS = ["absent", "equals", "greater_than", "less_than", "present"] as const;
91
+ export const REFACTOR_OUTCOME_DIRECTIONS = ["improved", "regressed", "unchanged", "unknown"] as const;
92
+ export const REFACTOR_KILL_LIST_KINDS = ["path", "relation", "symbol"] as const;
93
+ export const REFACTOR_EXECUTION_EVIDENCE_KINDS = [
94
+ "acceptance_receipt",
95
+ "cutover_closure",
96
+ "merge_receipt",
97
+ "task_contract"
98
+ ] as const;
99
+ export const REFACTOR_PROPOSAL_AUTHOR_SOURCES = ["cli", "manual", "mcp", "subagent"] as const;
100
+ export const REFACTOR_PROPOSAL_AUTHOR_KINDS = ["cli", "developer", "mcp", "subagent"] as const;
101
+ /**
102
+ * A refactor proposal is authored by an agent surface acting for itself, or by
103
+ * a human acting manually. `daemon`, `system`, `hook`, and `migration` kinds
104
+ * are ArchContext acting on its own behalf and can never author a proposal.
105
+ */
106
+ export const REFACTOR_PROPOSAL_AUTHOR_PAIRS: Readonly<Record<RefactorProposalAuthorKind, readonly RefactorProposalAuthorSource[]>> = {
107
+ cli: ["cli"],
108
+ developer: ["manual"],
109
+ mcp: ["mcp"],
110
+ subagent: ["subagent"]
111
+ };
112
+ export const MODULE_DYNAMIC_INVOCATION_LEVELS = ["known", "none_observed", "possible", "unknown"] as const;
113
+ export const MODULE_TESTS_COVERAGE_STATUSES = ["measured", "partial", "unknown"] as const;
114
+
115
+ export type RefactorScale = (typeof REFACTOR_SCALES)[number];
116
+ export type RefactorScaleReasonCode = (typeof REFACTOR_SCALE_REASON_CODES)[number];
117
+ export type RefactorObservationKind = (typeof REFACTOR_OBSERVATION_KINDS)[number];
118
+ export type RefactorResolutionDisposition = (typeof REFACTOR_RESOLUTION_DISPOSITIONS)[number];
119
+ export type RefactorOutcomeOperator = (typeof REFACTOR_OUTCOME_OPERATORS)[number];
120
+ export type RefactorOutcomeDirection = (typeof REFACTOR_OUTCOME_DIRECTIONS)[number];
121
+ export type RefactorKillListKind = (typeof REFACTOR_KILL_LIST_KINDS)[number];
122
+ export type RefactorExecutionEvidenceKind = (typeof REFACTOR_EXECUTION_EVIDENCE_KINDS)[number];
123
+ export type RefactorProposalAuthorSource = (typeof REFACTOR_PROPOSAL_AUTHOR_SOURCES)[number];
124
+ export type RefactorProposalAuthorKind = (typeof REFACTOR_PROPOSAL_AUTHOR_KINDS)[number];
125
+ export type ModuleDynamicInvocationLevel = (typeof MODULE_DYNAMIC_INVOCATION_LEVELS)[number];
126
+ export type ModuleTestsCoverageStatus = (typeof MODULE_TESTS_COVERAGE_STATUSES)[number];
127
+
128
+ export type RefactorScopeV1 =
129
+ | { kind: "repository" }
130
+ | { kind: "node"; nodeId: string }
131
+ | { kind: "paths"; paths: string[] };
132
+
133
+ export interface RefactorTargetOutcomeV1 {
134
+ outcomeId: string;
135
+ metric: string;
136
+ subjectSelectorId: string;
137
+ nodeId: string | null;
138
+ operator: RefactorOutcomeOperator;
139
+ value: number | null;
140
+ required: boolean;
141
+ }
142
+
143
+ export interface RefactorObservedOutcomeV1 {
144
+ outcomeId: string;
145
+ observedValue: number | null;
146
+ satisfied: boolean;
147
+ direction: RefactorOutcomeDirection;
148
+ }
149
+
150
+ export interface RefactorKillListEntryV1 {
151
+ kind: RefactorKillListKind;
152
+ selectorId: string;
153
+ required: boolean;
154
+ }
155
+
156
+ export interface ArchitectureTargetDeltaV1 {
157
+ interventionId: string;
158
+ trigger: string[];
159
+ thesis: string;
160
+ targetState: {
161
+ owners: Record<string, string>;
162
+ requiredRelations: string[];
163
+ removedConcepts: string[];
164
+ };
165
+ migrationState: {
166
+ active: boolean;
167
+ compatibilityContracts: string[];
168
+ cleanupBy?: string;
169
+ temporaryRelations: string[];
170
+ };
171
+ completionCriteria: RefactorTargetOutcomeV1[];
172
+ falsifiers: string[];
173
+ benefitLedger: {
174
+ benefits: string[];
175
+ costs: string[];
176
+ rollbackPoint: string;
177
+ };
178
+ unresolvedTargets: string[];
179
+ extensions?: Record<string, Json>;
180
+ }
181
+
182
+ export interface RefactorProposalV1 {
183
+ schemaVersion: typeof REFACTOR_PROPOSAL_SCHEMA_VERSION;
184
+ authoredBy: RecommendationAuthorV1;
185
+ intent: string;
186
+ scopePaths: string[];
187
+ targetDelta?: ArchitectureTargetDeltaV1;
188
+ targetOutcomes: RefactorTargetOutcomeV1[];
189
+ killList: RefactorKillListEntryV1[];
190
+ proposalDigest: string;
191
+ extensions?: Record<string, Json>;
192
+ }
193
+
194
+ export interface RefactorRequestV1 {
195
+ schemaVersion: typeof REFACTOR_REQUEST_SCHEMA_VERSION;
196
+ scope: RefactorScopeV1;
197
+ proposal?: RefactorProposalV1;
198
+ expectedHeadSha?: string;
199
+ expectedWorktreeDigest?: string;
200
+ task?: string;
201
+ }
202
+
203
+ /**
204
+ * The JSON ingress for `refactor verify`. `recommendationId` is the whole subject: a verification
205
+ * always re-measures whatever is at HEAD now, so there is no caller-supplied AFTER state and no
206
+ * outcome override — the recorded recommendation already carries the acceptance test, and letting
207
+ * a caller restate it would let the requester steer its own verdict.
208
+ *
209
+ * `expectedHeadSha` and `expectedWorktreeDigest` are claims about the state the caller believes it
210
+ * is verifying. A claim that no longer holds is refused rather than answered with fresh numbers
211
+ * under the old identity.
212
+ */
213
+ export interface RefactorVerificationRequestV1 {
214
+ schemaVersion: typeof REFACTOR_VERIFICATION_REQUEST_SCHEMA_VERSION;
215
+ recommendationId: string;
216
+ expectedHeadSha?: string;
217
+ expectedWorktreeDigest?: string;
218
+ executionEvidenceRefs?: RefactorExecutionEvidenceRefV1[];
219
+ }
220
+
221
+ export interface ModuleStatisticsV1 {
222
+ nodeId: string;
223
+ nodeDigest: string;
224
+ parentNodeId: string | null;
225
+ footprintDeclared: boolean;
226
+ footprint: {
227
+ fileCount: number;
228
+ lineCount: number;
229
+ sourceFilesDigest: string;
230
+ includePatterns: string[];
231
+ excludePatterns: string[];
232
+ } | null;
233
+ surfaces: {
234
+ declaredEntrypoints: string[];
235
+ observedEntrypoints: string[];
236
+ lifecycleOwners: string[];
237
+ datastoreSubjects: string[];
238
+ };
239
+ dependencyGraph: {
240
+ internalEdgeCount: number;
241
+ inboundModuleEdges: number;
242
+ outboundModuleEdges: number;
243
+ fanIn: number;
244
+ fanOut: number;
245
+ stronglyConnectedComponentId: string | null;
246
+ cycleCount: number;
247
+ instability: number | null;
248
+ directionViolationCount: number | null;
249
+ } | null;
250
+ /**
251
+ * `callerCoverage` is a graph-boundary resolution ratio (the share of the
252
+ * module's inbound call boundary the index could resolve), not a test
253
+ * measurement, so it is independent of `coverageStatus`: a producer may know
254
+ * the boundary ratio while observing no test evidence at all, and it may know
255
+ * test files while the boundary stays unresolved. Only the 0-1 ratio bound is
256
+ * enforced; the two fields are never coupled (PRD 0.3-16).
257
+ */
258
+ tests: {
259
+ testFileCount: number | null;
260
+ observedTestEdges: number | null;
261
+ callerCoverage: number | null;
262
+ coverageStatus: ModuleTestsCoverageStatus;
263
+ };
264
+ uncertainty: {
265
+ unresolvedImports: number;
266
+ dynamicInvocation: ModuleDynamicInvocationLevel;
267
+ ambiguousOwnership: boolean;
268
+ };
269
+ moduleDigest: string;
270
+ extensions?: Record<string, Json>;
271
+ }
272
+
273
+ export interface ModuleStatisticsSnapshotV1 {
274
+ schemaVersion: typeof MODULE_STATISTICS_SCHEMA_VERSION;
275
+ repository: ArchitectureRepositoryIdentityV1;
276
+ worktree: ArchitectureWorktreeIdentityV1;
277
+ modelDigest: string;
278
+ codeFacts: {
279
+ provider: "codegraph";
280
+ version: string;
281
+ binaryDigest: string;
282
+ indexedWorktreeDigest: string | null;
283
+ coverage: EvidenceCoverageLevelV2;
284
+ truncated: boolean;
285
+ edgeLimit: number | null;
286
+ reasonCodes: RefactorScaleReasonCode[];
287
+ };
288
+ modules: ModuleStatisticsV1[];
289
+ repositorySummary: {
290
+ moduleCount: number;
291
+ undeclaredFootprintNodeCount: number;
292
+ ownedFileCount: number;
293
+ unownedFileCount: number;
294
+ multiplyOwnedFileCount: number;
295
+ crossModuleEdgeCount: number;
296
+ crossModuleCycleCount: number;
297
+ stronglyConnectedComponentCount: number;
298
+ unresolvedImportCount: number;
299
+ dynamicInvocationRiskCount: number;
300
+ };
301
+ createdAt: string;
302
+ snapshotDigest: string;
303
+ extensions?: Record<string, Json>;
304
+ }
305
+
306
+ export interface RefactorObservationV1 {
307
+ kind: RefactorObservationKind;
308
+ subjectSelectorId: string;
309
+ signalIds: string[];
310
+ metrics: Record<string, number | null>;
311
+ }
312
+
313
+ export interface RefactorAssessmentV1 {
314
+ schemaVersion: typeof REFACTOR_ASSESSMENT_SCHEMA_VERSION;
315
+ requestId: string;
316
+ statisticsSnapshotDigest: string;
317
+ modelDigest: string;
318
+ codeFactsDigest: string;
319
+ requestedScope: RefactorScopeV1;
320
+ proposalDigest: string | null;
321
+ observations: RefactorObservationV1[];
322
+ scale: RefactorScale | null;
323
+ scaleReasonCodes: RefactorScaleReasonCode[];
324
+ affectedNodeIds: string[];
325
+ majorChangeReasons: ArchitectureMajorChangeReasonCode[];
326
+ pressure: {
327
+ level: "low" | "medium" | "high";
328
+ score: number;
329
+ signalIds: string[];
330
+ };
331
+ confidence: {
332
+ level: "low" | "medium" | "high";
333
+ callerCoverage: number | null;
334
+ testsObserved: boolean | null;
335
+ rollbackObserved: boolean | null;
336
+ unresolvedEvidence: string[];
337
+ };
338
+ createdAt: string;
339
+ assessmentDigest: string;
340
+ extensions?: Record<string, Json>;
341
+ }
342
+
343
+ export interface RefactorResidualV1 {
344
+ code: string;
345
+ subject: string;
346
+ severity: Severity;
347
+ }
348
+
349
+ export interface RefactorExecutionEvidenceRefV1 {
350
+ kind: RefactorExecutionEvidenceKind;
351
+ locator: string;
352
+ sha256: string;
353
+ }
354
+
355
+ export interface RefactorResolutionEvidenceV1 {
356
+ schemaVersion: typeof REFACTOR_RESOLUTION_EVIDENCE_SCHEMA_VERSION;
357
+ recommendationId: string;
358
+ recommendationDigest: string;
359
+ beforeSnapshotDigest: string;
360
+ afterSnapshotDigest: string;
361
+ verifiedHeadSha: string;
362
+ verifiedWorktreeDigest: string;
363
+ expectedOutcomes: RefactorTargetOutcomeV1[];
364
+ observedOutcomes: RefactorObservedOutcomeV1[];
365
+ residuals: RefactorResidualV1[];
366
+ executionEvidenceRefs: RefactorExecutionEvidenceRefV1[];
367
+ disposition: RefactorResolutionDisposition;
368
+ verifiedAt: string;
369
+ resolutionDigest: string;
370
+ extensions?: Record<string, Json>;
371
+ }
372
+
373
+ export interface RecommendationV3FingerprintInputV1 {
374
+ category: RecommendationCategory;
375
+ subjectSelectorId: string;
376
+ practiceId: string | null;
377
+ payload: Record<string, Json>;
378
+ }
379
+
380
+ /**
381
+ * Digest exclusion rule for every helper below: drop the record's own digest
382
+ * field, its top-level `extensions`, and its timestamps. Nested `extensions`
383
+ * (for example `modules[i].extensions`) stay inside the hashed content, so a
384
+ * nested annotation changes the digest. The single exception is the derived
385
+ * `targetDelta` identity below: ArchContext fills `unresolvedTargets` after the
386
+ * agent authored the proposal, so `refactorProposalDigest` hashes the delta
387
+ * under the same exclusions as `architectureTargetDeltaInterventionId`
388
+ * (`interventionId`, `unresolvedTargets`, `extensions`) and the agent-authored
389
+ * identity survives assessment. `authoredBy` stays hashed at the top level:
390
+ * the no-self-authored gate must be digest-bound.
391
+ */
392
+ export function refactorProposalDigest(proposal: RefactorProposalV1): string {
393
+ const { proposalDigest: _proposalDigest, extensions: _extensions, ...hashable } = proposal;
394
+ return digestJson({
395
+ ...hashable,
396
+ ...(hashable.targetDelta ? { targetDelta: authoredTargetDelta(hashable.targetDelta) } : {})
397
+ } as unknown as Json);
398
+ }
399
+
400
+ export function architectureTargetDeltaInterventionId(delta: ArchitectureTargetDeltaV1): string {
401
+ const digest = digestJson(authoredTargetDelta(delta) as unknown as Json);
402
+ return `intervention.${digest.slice(DIGEST_PREFIX_LENGTH, DIGEST_PREFIX_LENGTH + 16)}`;
403
+ }
404
+
405
+ export function moduleStatisticsDigest(module: ModuleStatisticsV1): string {
406
+ const { moduleDigest: _moduleDigest, extensions: _extensions, ...hashable } = module;
407
+ return digestJson(hashable as unknown as Json);
408
+ }
409
+
410
+ export function moduleStatisticsSnapshotDigest(snapshot: ModuleStatisticsSnapshotV1): string {
411
+ const { snapshotDigest: _snapshotDigest, createdAt: _createdAt, extensions: _extensions, ...hashable } = snapshot;
412
+ return digestJson(hashable as unknown as Json);
413
+ }
414
+
415
+ export function refactorAssessmentDigest(assessment: RefactorAssessmentV1): string {
416
+ const {
417
+ assessmentDigest: _assessmentDigest,
418
+ requestId: _requestId,
419
+ createdAt: _createdAt,
420
+ extensions: _extensions,
421
+ ...hashable
422
+ } = assessment;
423
+ return digestJson(hashable as unknown as Json);
424
+ }
425
+
426
+ export function refactorResolutionEvidenceDigest(evidence: RefactorResolutionEvidenceV1): string {
427
+ const {
428
+ resolutionDigest: _resolutionDigest,
429
+ verifiedAt: _verifiedAt,
430
+ extensions: _extensions,
431
+ ...hashable
432
+ } = evidence;
433
+ return digestJson(hashable as unknown as Json);
434
+ }
435
+
436
+ /**
437
+ * Canonical fingerprint input for a v3 recommendation. It deliberately drops
438
+ * `assessmentDigest` and `baselineSnapshotDigest` so that re-detecting the same
439
+ * structural fact at a new HEAD dedups against the previous recommendation and
440
+ * drives `relations.regressesFrom` instead of creating an unrelated record.
441
+ */
442
+ export function recommendationV3FingerprintInput(
443
+ recommendation: Pick<RecommendationV3, "category" | "subjectSelectorId" | "practiceId" | "payload">
444
+ ): RecommendationV3FingerprintInputV1 {
445
+ return {
446
+ category: recommendation.category,
447
+ subjectSelectorId: recommendation.subjectSelectorId,
448
+ practiceId: recommendation.practiceId ?? null,
449
+ payload: fingerprintPayloadSubset(recommendation.category, recommendation.payload)
450
+ };
451
+ }
452
+
453
+ export function refactorProposalInvariantIssues(proposal: RefactorProposalV1, prefix = "proposal"): string[] {
454
+ const issues = [
455
+ ...sortedUniqueIssues(`${prefix}.scopePaths`, proposal.scopePaths),
456
+ ...digestIssues(`${prefix}.proposalDigest`, proposal.proposalDigest),
457
+ ...outcomeIssues(`${prefix}.targetOutcomes`, proposal.targetOutcomes)
458
+ ];
459
+ if (proposal.schemaVersion !== REFACTOR_PROPOSAL_SCHEMA_VERSION) issues.push(`${prefix}.schemaVersion is invalid`);
460
+ if (proposal.intent.trim() === "") issues.push(`${prefix}.intent must not be empty`);
461
+ if (proposal.scopePaths.length === 0) issues.push(`${prefix}.scopePaths must contain at least one path`);
462
+ for (const path of proposal.scopePaths) {
463
+ if (!isRepoRelativePosixPath(path)) issues.push(`${prefix}.scopePaths must be repo-relative POSIX paths: ${path}`);
464
+ }
465
+ issues.push(...refactorProposalAuthorIssues(proposal.authoredBy, prefix));
466
+ issues.push(...sortedUniqueIssues(`${prefix}.killList.selectorId`, proposal.killList.map((entry) => entry.selectorId)));
467
+ if (refactorProposalDigest(proposal) !== proposal.proposalDigest) {
468
+ issues.push(`${prefix}.proposalDigest must bind the authored proposal payload`);
469
+ }
470
+ if (proposal.targetDelta) issues.push(...architectureTargetDeltaInvariantIssues(proposal.targetDelta, `${prefix}.targetDelta`));
471
+ return issues;
472
+ }
473
+
474
+ export function architectureTargetDeltaInvariantIssues(delta: ArchitectureTargetDeltaV1, prefix = "targetDelta"): string[] {
475
+ const issues = [
476
+ ...sortedUniqueIssues(`${prefix}.unresolvedTargets`, delta.unresolvedTargets),
477
+ ...outcomeIssues(`${prefix}.completionCriteria`, delta.completionCriteria)
478
+ ];
479
+ if (delta.thesis.trim() === "") issues.push(`${prefix}.thesis must not be empty`);
480
+ if (delta.trigger.length === 0) issues.push(`${prefix}.trigger must state at least one trigger`);
481
+ if (delta.falsifiers.length === 0) issues.push(`${prefix}.falsifiers must state at least one falsifier`);
482
+ if (delta.completionCriteria.length === 0) issues.push(`${prefix}.completionCriteria must contain at least one outcome`);
483
+ if (delta.benefitLedger.rollbackPoint.trim() === "") issues.push(`${prefix}.benefitLedger.rollbackPoint must not be empty`);
484
+ if (architectureTargetDeltaInterventionId(delta) !== delta.interventionId) {
485
+ issues.push(`${prefix}.interventionId must be derived from the authored delta`);
486
+ }
487
+ return issues;
488
+ }
489
+
490
+ export function refactorRequestInvariantIssues(request: RefactorRequestV1, prefix = "request"): string[] {
491
+ const issues: string[] = [];
492
+ if (request.schemaVersion !== REFACTOR_REQUEST_SCHEMA_VERSION) issues.push(`${prefix}.schemaVersion is invalid`);
493
+ if (request.scope.kind === "node" && request.scope.nodeId.trim() === "") issues.push(`${prefix}.scope.nodeId must not be empty`);
494
+ if (request.scope.kind === "paths") {
495
+ issues.push(...sortedUniqueIssues(`${prefix}.scope.paths`, request.scope.paths));
496
+ if (request.scope.paths.length === 0) issues.push(`${prefix}.scope.paths must contain at least one path`);
497
+ for (const path of request.scope.paths) {
498
+ if (!isRepoRelativePosixPath(path)) issues.push(`${prefix}.scope.paths must be repo-relative POSIX paths: ${path}`);
499
+ }
500
+ }
501
+ if (request.proposal) issues.push(...refactorProposalInvariantIssues(request.proposal, `${prefix}.proposal`));
502
+ return issues;
503
+ }
504
+
505
+ /**
506
+ * Validates a `refactor verify` request before anything measures on its behalf.
507
+ *
508
+ * Top-level keys are closed to `REFACTOR_VERIFICATION_REQUEST_KEYS`. Every ingress rebuilds the
509
+ * request from the declared fields, so a typo (`expectedWorktreeDigset`) would otherwise be
510
+ * dropped in silence and the caller's freshness claim would simply cease to exist — the request
511
+ * would be answered under a weaker precondition than the one it stated.
512
+ *
513
+ * `executionEvidenceRefs` are checked under exactly the rules that bind them into the ledger:
514
+ * three declared keys and nothing else, a kind from the frozen vocabulary, and a bare SHA-256.
515
+ * An extra key would ride a cast into a record whose envelope promises no raw bodies, and the
516
+ * resolution-evidence validator only ever inspects `locator` and `sha256`, so it would never see
517
+ * it. Refusing here is the only place that key is still visible.
518
+ */
519
+ export function refactorVerificationRequestInvariantIssues(
520
+ request: RefactorVerificationRequestV1,
521
+ prefix = "request"
522
+ ): string[] {
523
+ const issues: string[] = [];
524
+ const unknownKeys = Object.keys(request as unknown as Record<string, unknown>)
525
+ .filter((key) => !(REFACTOR_VERIFICATION_REQUEST_KEYS as readonly string[]).includes(key))
526
+ .sort();
527
+ if (unknownKeys.length > 0) issues.push(`${prefix} has unsupported key(s): ${unknownKeys.join(", ")}`);
528
+ if (request.schemaVersion !== REFACTOR_VERIFICATION_REQUEST_SCHEMA_VERSION) issues.push(`${prefix}.schemaVersion is invalid`);
529
+ if (typeof request.recommendationId !== "string" || request.recommendationId.trim() === "") {
530
+ issues.push(`${prefix}.recommendationId must be a non-empty string`);
531
+ }
532
+ if (request.expectedHeadSha !== undefined && !GIT_HEAD_SHA_PATTERN.test(request.expectedHeadSha)) {
533
+ issues.push(`${prefix}.expectedHeadSha must be a 40-hex Git commit sha`);
534
+ }
535
+ if (request.expectedWorktreeDigest !== undefined) {
536
+ issues.push(...digestIssues(`${prefix}.expectedWorktreeDigest`, request.expectedWorktreeDigest));
537
+ }
538
+ if (request.executionEvidenceRefs !== undefined) {
539
+ if (!Array.isArray(request.executionEvidenceRefs)) {
540
+ issues.push(`${prefix}.executionEvidenceRefs must be an array`);
541
+ return issues;
542
+ }
543
+ request.executionEvidenceRefs.forEach((ref, index) => {
544
+ const label = `${prefix}.executionEvidenceRefs[${index}]`;
545
+ if (!ref || typeof ref !== "object" || Array.isArray(ref)) {
546
+ issues.push(`${label} must be an object`);
547
+ return;
548
+ }
549
+ const extras = Object.keys(ref).filter((key) => key !== "kind" && key !== "locator" && key !== "sha256");
550
+ if (extras.length > 0) issues.push(`${label} has unsupported key(s): ${[...extras].sort().join(", ")}`);
551
+ if (!(REFACTOR_EXECUTION_EVIDENCE_KINDS as readonly string[]).includes(ref.kind)) {
552
+ issues.push(`${label}.kind must be one of ${REFACTOR_EXECUTION_EVIDENCE_KINDS.join(", ")}`);
553
+ }
554
+ if (typeof ref.locator !== "string" || ref.locator.trim() === "") {
555
+ issues.push(`${label}.locator must be a non-empty string`);
556
+ } else if (!REFACTOR_EXECUTION_EVIDENCE_LOCATOR_PATTERN.test(ref.locator)) {
557
+ issues.push(`${label}.locator ${REFACTOR_EXECUTION_EVIDENCE_LOCATOR_RULE}`);
558
+ }
559
+ if (typeof ref.sha256 !== "string" || !BARE_SHA256_PATTERN.test(ref.sha256)) {
560
+ issues.push(`${label}.sha256 must be a bare SHA-256 hex digest`);
561
+ }
562
+ });
563
+ }
564
+ return issues;
565
+ }
566
+
567
+ export function moduleStatisticsInvariantIssues(module: ModuleStatisticsV1, prefix = "module"): string[] {
568
+ const issues = [...digestIssues(`${prefix}.moduleDigest`, module.moduleDigest), ...digestIssues(`${prefix}.nodeDigest`, module.nodeDigest)];
569
+ if (module.nodeId.trim() === "") issues.push(`${prefix}.nodeId must not be empty`);
570
+ if (module.footprintDeclared === (module.footprint === null)) {
571
+ issues.push(`${prefix}.footprint must be present exactly when footprintDeclared is true`);
572
+ }
573
+ if (module.footprint) {
574
+ issues.push(
575
+ ...digestIssues(`${prefix}.footprint.sourceFilesDigest`, module.footprint.sourceFilesDigest),
576
+ ...nonNegativeIntegerIssues(`${prefix}.footprint.fileCount`, module.footprint.fileCount),
577
+ ...nonNegativeIntegerIssues(`${prefix}.footprint.lineCount`, module.footprint.lineCount)
578
+ );
579
+ }
580
+ if (module.dependencyGraph) {
581
+ const graph = module.dependencyGraph;
582
+ issues.push(
583
+ ...nonNegativeIntegerIssues(`${prefix}.dependencyGraph.internalEdgeCount`, graph.internalEdgeCount),
584
+ ...nonNegativeIntegerIssues(`${prefix}.dependencyGraph.inboundModuleEdges`, graph.inboundModuleEdges),
585
+ ...nonNegativeIntegerIssues(`${prefix}.dependencyGraph.outboundModuleEdges`, graph.outboundModuleEdges),
586
+ ...nonNegativeIntegerIssues(`${prefix}.dependencyGraph.fanIn`, graph.fanIn),
587
+ ...nonNegativeIntegerIssues(`${prefix}.dependencyGraph.fanOut`, graph.fanOut),
588
+ ...nonNegativeIntegerIssues(`${prefix}.dependencyGraph.cycleCount`, graph.cycleCount),
589
+ ...nonNegativeIntegerIssues(`${prefix}.dependencyGraph.directionViolationCount`, graph.directionViolationCount),
590
+ ...ratioIssues(`${prefix}.dependencyGraph.instability`, graph.instability)
591
+ );
592
+ }
593
+ issues.push(
594
+ ...nonNegativeIntegerIssues(`${prefix}.tests.testFileCount`, module.tests.testFileCount),
595
+ ...nonNegativeIntegerIssues(`${prefix}.tests.observedTestEdges`, module.tests.observedTestEdges),
596
+ ...ratioIssues(`${prefix}.tests.callerCoverage`, module.tests.callerCoverage),
597
+ ...nonNegativeIntegerIssues(`${prefix}.uncertainty.unresolvedImports`, module.uncertainty.unresolvedImports)
598
+ );
599
+ if (moduleStatisticsDigest(module) !== module.moduleDigest) {
600
+ issues.push(`${prefix}.moduleDigest must bind the measured module payload`);
601
+ }
602
+ return issues;
603
+ }
604
+
605
+ export function moduleStatisticsSnapshotInvariantIssues(snapshot: ModuleStatisticsSnapshotV1, prefix = "snapshot"): string[] {
606
+ const issues = [
607
+ ...digestIssues(`${prefix}.snapshotDigest`, snapshot.snapshotDigest),
608
+ ...digestIssues(`${prefix}.modelDigest`, snapshot.modelDigest),
609
+ ...sortedUniqueIssues(`${prefix}.codeFacts.reasonCodes`, snapshot.codeFacts.reasonCodes),
610
+ ...sortedUniqueIssues(`${prefix}.modules.nodeId`, snapshot.modules.map((module) => module.nodeId)),
611
+ ...snapshot.modules.flatMap((module, index) => moduleStatisticsInvariantIssues(module, `${prefix}.modules[${index}]`))
612
+ ];
613
+ if (snapshot.schemaVersion !== MODULE_STATISTICS_SCHEMA_VERSION) issues.push(`${prefix}.schemaVersion is invalid`);
614
+ if (snapshot.codeFacts.coverage === "unknown") {
615
+ if (!snapshot.codeFacts.truncated) issues.push(`${prefix}.codeFacts.truncated must be true when coverage is unknown`);
616
+ for (const [index, module] of snapshot.modules.entries()) {
617
+ if (module.dependencyGraph !== null) {
618
+ issues.push(`${prefix}.modules[${index}].dependencyGraph must be null when codeFacts.coverage is unknown`);
619
+ }
620
+ }
621
+ }
622
+ if (snapshot.codeFacts.coverage !== "unknown") {
623
+ if (snapshot.codeFacts.indexedWorktreeDigest === null) {
624
+ issues.push(`${prefix}.codeFacts.indexedWorktreeDigest must be present when coverage is ${snapshot.codeFacts.coverage}`);
625
+ } else if (snapshot.codeFacts.indexedWorktreeDigest !== snapshot.worktree.worktreeDigest) {
626
+ issues.push(`${prefix}.codeFacts.indexedWorktreeDigest must match the measured worktreeDigest`);
627
+ }
628
+ }
629
+ issues.push(...nonNegativeIntegerIssues(`${prefix}.codeFacts.edgeLimit`, snapshot.codeFacts.edgeLimit));
630
+ for (const [field, value] of Object.entries(snapshot.repositorySummary)) {
631
+ issues.push(...nonNegativeIntegerIssues(`${prefix}.repositorySummary.${field}`, value));
632
+ }
633
+ if (snapshot.repositorySummary.moduleCount !== snapshot.modules.length) {
634
+ issues.push(`${prefix}.repositorySummary.moduleCount must equal the module count`);
635
+ }
636
+ const undeclared = snapshot.modules.filter((module) => !module.footprintDeclared).length;
637
+ if (snapshot.repositorySummary.undeclaredFootprintNodeCount !== undeclared) {
638
+ issues.push(`${prefix}.repositorySummary.undeclaredFootprintNodeCount must equal the undeclared footprint module count`);
639
+ }
640
+ if (moduleStatisticsSnapshotDigest(snapshot) !== snapshot.snapshotDigest) {
641
+ issues.push(`${prefix}.snapshotDigest must bind the measured snapshot payload`);
642
+ }
643
+ return issues;
644
+ }
645
+
646
+ export function refactorAssessmentInvariantIssues(assessment: RefactorAssessmentV1, prefix = "assessment"): string[] {
647
+ const issues = [
648
+ ...digestIssues(`${prefix}.assessmentDigest`, assessment.assessmentDigest),
649
+ ...digestIssues(`${prefix}.statisticsSnapshotDigest`, assessment.statisticsSnapshotDigest),
650
+ ...digestIssues(`${prefix}.modelDigest`, assessment.modelDigest),
651
+ ...digestIssues(`${prefix}.codeFactsDigest`, assessment.codeFactsDigest),
652
+ ...sortedUniqueIssues(`${prefix}.affectedNodeIds`, assessment.affectedNodeIds),
653
+ ...sortedUniqueIssues(`${prefix}.scaleReasonCodes`, assessment.scaleReasonCodes),
654
+ ...sortedUniqueIssues(`${prefix}.majorChangeReasons`, assessment.majorChangeReasons)
655
+ ];
656
+ if (assessment.schemaVersion !== REFACTOR_ASSESSMENT_SCHEMA_VERSION) issues.push(`${prefix}.schemaVersion is invalid`);
657
+ if (assessment.proposalDigest !== null) issues.push(...digestIssues(`${prefix}.proposalDigest`, assessment.proposalDigest));
658
+ if ((assessment.scale === null) !== (assessment.proposalDigest === null)) {
659
+ issues.push(`${prefix}.scale must be null exactly when proposalDigest is null`);
660
+ }
661
+ if (assessment.scale === "architecture" && assessment.majorChangeReasons.length === 0) {
662
+ issues.push(`${prefix}.architecture scale requires at least one majorChangeReason`);
663
+ }
664
+ const allowedReasons = new Set<string>(ARCHITECTURE_MAJOR_CHANGE_REASON_CODES);
665
+ for (const reason of assessment.majorChangeReasons) {
666
+ if (!allowedReasons.has(reason)) issues.push(`${prefix}.majorChangeReasons contains unsupported reason: ${reason}`);
667
+ }
668
+ for (const [index, observation] of assessment.observations.entries()) {
669
+ if (observation.subjectSelectorId.trim() === "") issues.push(`${prefix}.observations[${index}].subjectSelectorId must not be empty`);
670
+ issues.push(...sortedUniqueIssues(`${prefix}.observations[${index}].signalIds`, observation.signalIds));
671
+ }
672
+ issues.push(...ratioIssues(`${prefix}.confidence.callerCoverage`, assessment.confidence.callerCoverage));
673
+ if (!Number.isInteger(assessment.pressure.score) || assessment.pressure.score < 0 || assessment.pressure.score > 100) {
674
+ issues.push(`${prefix}.pressure.score must be an integer between 0 and 100`);
675
+ }
676
+ issues.push(...sortedUniqueIssues(`${prefix}.pressure.signalIds`, assessment.pressure.signalIds));
677
+ if (refactorAssessmentDigest(assessment) !== assessment.assessmentDigest) {
678
+ issues.push(`${prefix}.assessmentDigest must bind the assessed payload`);
679
+ }
680
+ return issues;
681
+ }
682
+
683
+ export function refactorResolutionEvidenceInvariantIssues(
684
+ evidence: RefactorResolutionEvidenceV1,
685
+ prefix = "resolutionEvidence"
686
+ ): string[] {
687
+ const issues = [
688
+ ...digestIssues(`${prefix}.resolutionDigest`, evidence.resolutionDigest),
689
+ ...digestIssues(`${prefix}.recommendationDigest`, evidence.recommendationDigest),
690
+ ...digestIssues(`${prefix}.beforeSnapshotDigest`, evidence.beforeSnapshotDigest),
691
+ ...digestIssues(`${prefix}.afterSnapshotDigest`, evidence.afterSnapshotDigest),
692
+ ...outcomeIssues(`${prefix}.expectedOutcomes`, evidence.expectedOutcomes),
693
+ ...sortedUniqueIssues(`${prefix}.observedOutcomes.outcomeId`, evidence.observedOutcomes.map((outcome) => outcome.outcomeId))
694
+ ];
695
+ if (evidence.schemaVersion !== REFACTOR_RESOLUTION_EVIDENCE_SCHEMA_VERSION) issues.push(`${prefix}.schemaVersion is invalid`);
696
+ const observedById = new Map(evidence.observedOutcomes.map((outcome) => [outcome.outcomeId, outcome]));
697
+ for (const expected of evidence.expectedOutcomes) {
698
+ if (!observedById.has(expected.outcomeId)) issues.push(`${prefix}.observedOutcomes is missing outcome ${expected.outcomeId}`);
699
+ }
700
+ for (const ref of evidence.executionEvidenceRefs) {
701
+ if (!/^[a-f0-9]{64}$/.test(ref.sha256)) issues.push(`${prefix}.executionEvidenceRefs.sha256 must be a bare SHA-256 hex digest`);
702
+ if (ref.locator.trim() === "") issues.push(`${prefix}.executionEvidenceRefs.locator must not be empty`);
703
+ }
704
+ for (const expected of evidence.expectedOutcomes) {
705
+ const observed = observedById.get(expected.outcomeId);
706
+ if (!observed) continue;
707
+ const recomputed = outcomeSatisfied(expected, observed.observedValue);
708
+ if (observed.satisfied !== recomputed) {
709
+ issues.push(
710
+ `${prefix}.observedOutcomes ${expected.outcomeId} claims satisfied=${observed.satisfied} but ${expected.metric} ${expected.operator} ${expected.value} against ${observed.observedValue} is ${recomputed}`
711
+ );
712
+ }
713
+ }
714
+ if (evidence.disposition !== "stale") {
715
+ const required = evidence.expectedOutcomes.filter((outcome) => outcome.required);
716
+ const observedRequired = required
717
+ .map((outcome) => {
718
+ const observed = observedById.get(outcome.outcomeId);
719
+ return observed ? { outcome, satisfied: outcomeSatisfied(outcome, observed.observedValue) } : undefined;
720
+ })
721
+ .filter(isPresent);
722
+ const regressed = evidence.observedOutcomes.some((outcome) => outcome.direction === "regressed");
723
+ const satisfiedCount = observedRequired.filter((entry) => entry.satisfied).length;
724
+ if (regressed && evidence.disposition !== "regressed") {
725
+ issues.push(`${prefix}.disposition must be regressed when any observed outcome regressed`);
726
+ }
727
+ if (!regressed && required.length > 0) {
728
+ if (satisfiedCount === required.length && evidence.disposition !== "resolved") {
729
+ issues.push(`${prefix}.disposition must be resolved when every required outcome is satisfied`);
730
+ }
731
+ if (satisfiedCount === 0 && evidence.disposition !== "not_improved") {
732
+ issues.push(`${prefix}.disposition must be not_improved when no required outcome is satisfied`);
733
+ }
734
+ if (satisfiedCount > 0 && satisfiedCount < required.length && evidence.disposition !== "partially_resolved") {
735
+ issues.push(`${prefix}.disposition must be partially_resolved when only some required outcomes are satisfied`);
736
+ }
737
+ }
738
+ }
739
+ if (refactorResolutionEvidenceDigest(evidence) !== evidence.resolutionDigest) {
740
+ issues.push(`${prefix}.resolutionDigest must bind the verified payload`);
741
+ }
742
+ return issues;
743
+ }
744
+
745
+ /**
746
+ * Guards the one invariant the `RecommendationV3` union cannot enforce at
747
+ * runtime: that `payload` matches `category`. A non-object or mismatched
748
+ * payload yields issues instead of throwing. Every other field is trusted to
749
+ * match its declared type; structural validation of untrusted ingress is the
750
+ * job of the JSON Schemas, not of this validator.
751
+ *
752
+ * The fingerprint hash itself stays owned by `recommendationFingerprint()` in
753
+ * `packages/core/recommendation-engine`: contracts must not import core, and
754
+ * re-deriving the hash here would fork it into two definitions. This validator
755
+ * therefore checks the fingerprint's shape, not its value.
756
+ */
757
+ export function recommendationV3InvariantIssues(recommendation: RecommendationV3, prefix = "recommendation"): string[] {
758
+ const issues = [
759
+ ...sortedUniqueIssues(`${prefix}.evidenceBindingIds`, recommendation.evidenceBindingIds),
760
+ ...digestIssues(`${prefix}.fingerprint`, recommendation.fingerprint)
761
+ ];
762
+ if (recommendation.schemaVersion !== RECOMMENDATION_V3_SCHEMA_VERSION) issues.push(`${prefix}.schemaVersion is invalid`);
763
+ if (!(RECOMMENDATION_CATEGORIES as readonly string[]).includes(recommendation.category)) {
764
+ issues.push(`${prefix}.category is unsupported: ${recommendation.category}`);
765
+ return issues;
766
+ }
767
+ if (recommendation.subjectSelectorId.trim() === "") issues.push(`${prefix}.subjectSelectorId must not be empty`);
768
+ if (recommendation.authoredBy.id.trim() === "") issues.push(`${prefix}.authoredBy.id must not be empty`);
769
+ const shapeIssues = recommendationPayloadShapeIssues(recommendation.category, recommendation.payload, prefix);
770
+ issues.push(...shapeIssues);
771
+ if (recommendation.category === "practice" && !recommendation.practiceId) {
772
+ issues.push(`${prefix}.practiceId is required for practice recommendations`);
773
+ }
774
+ if (recommendation.category === "structural_observation") {
775
+ if (recommendation.authoredBy.source !== "daemon") {
776
+ issues.push(`${prefix}.structural_observation must be authored by the daemon`);
777
+ }
778
+ if (recommendation.authoredBy.kind !== "daemon") {
779
+ issues.push(`${prefix}.structural_observation must be authored by the daemon actor kind`);
780
+ }
781
+ if (recommendation.enforcement !== "advisory") {
782
+ issues.push(`${prefix}.structural_observation enforcement must be advisory`);
783
+ }
784
+ }
785
+ if (recommendation.category === "refactor_proposal") {
786
+ if (!isRefactorProposalAuthorSource(recommendation.authoredBy.source)) {
787
+ issues.push(`${prefix}.refactor_proposal must not be authored by ${recommendation.authoredBy.source}`);
788
+ }
789
+ issues.push(...refactorProposalAuthorIssues(recommendation.authoredBy, prefix));
790
+ if (shapeIssues.length === 0) {
791
+ const payload = recommendation.payload as RefactorProposalPayloadV1;
792
+ const expected = payload.scale === "architecture" ? "complete" : "checkpoint";
793
+ if (recommendation.enforcement !== expected) {
794
+ issues.push(`${prefix}.refactor_proposal with scale ${payload.scale} requires ${expected} enforcement`);
795
+ }
796
+ issues.push(
797
+ ...digestIssues(`${prefix}.payload.proposalDigest`, payload.proposalDigest),
798
+ ...sortedUniqueIssues(`${prefix}.payload.affectedNodeIds`, payload.affectedNodeIds),
799
+ ...sortedUniqueIssues(`${prefix}.payload.majorChangeReasons`, payload.majorChangeReasons)
800
+ );
801
+ }
802
+ }
803
+ return issues;
804
+ }
805
+
806
+ export function refactorScanInvariantIssues(input: {
807
+ snapshot: ModuleStatisticsSnapshotV1;
808
+ assessment: RefactorAssessmentV1;
809
+ proposal?: RefactorProposalV1;
810
+ }): string[] {
811
+ const issues = [
812
+ ...moduleStatisticsSnapshotInvariantIssues(input.snapshot),
813
+ ...refactorAssessmentInvariantIssues(input.assessment),
814
+ ...(input.proposal ? refactorProposalInvariantIssues(input.proposal) : [])
815
+ ];
816
+ if (input.assessment.statisticsSnapshotDigest !== input.snapshot.snapshotDigest) {
817
+ issues.push("assessment.statisticsSnapshotDigest must reference the measured snapshot");
818
+ }
819
+ if (input.assessment.modelDigest !== input.snapshot.modelDigest) {
820
+ issues.push("assessment.modelDigest must match the snapshot modelDigest");
821
+ }
822
+ const proposalDigest = input.proposal ? input.proposal.proposalDigest : null;
823
+ if (input.assessment.proposalDigest !== proposalDigest) {
824
+ issues.push("assessment.proposalDigest must reference the submitted proposal");
825
+ }
826
+ const unresolved = input.proposal?.targetDelta?.unresolvedTargets ?? [];
827
+ if (unresolved.length > 0 && input.assessment.scale !== "insufficient_evidence") {
828
+ issues.push("assessment.scale must be insufficient_evidence while targetDelta.unresolvedTargets is non-empty");
829
+ }
830
+ if (unresolved.length > 0 && !input.assessment.scaleReasonCodes.includes("target-unresolved")) {
831
+ issues.push("assessment.scaleReasonCodes must include target-unresolved while targets stay unresolved");
832
+ }
833
+ return issues;
834
+ }
835
+
836
+ export function refactorVerifyInvariantIssues(
837
+ afterSnapshot: ModuleStatisticsSnapshotV1,
838
+ evidence: RefactorResolutionEvidenceV1
839
+ ): string[] {
840
+ const issues = [
841
+ ...moduleStatisticsSnapshotInvariantIssues(afterSnapshot, "afterSnapshot"),
842
+ ...refactorResolutionEvidenceInvariantIssues(evidence)
843
+ ];
844
+ if (evidence.afterSnapshotDigest !== afterSnapshot.snapshotDigest) {
845
+ issues.push("resolutionEvidence.afterSnapshotDigest must reference the re-measured snapshot");
846
+ }
847
+ if (evidence.verifiedHeadSha !== afterSnapshot.worktree.headSha) {
848
+ issues.push("resolutionEvidence.verifiedHeadSha must match the re-measured worktree HEAD");
849
+ }
850
+ if (evidence.verifiedWorktreeDigest !== afterSnapshot.worktree.worktreeDigest) {
851
+ issues.push("resolutionEvidence.verifiedWorktreeDigest must match the re-measured worktree digest");
852
+ }
853
+ if (afterSnapshot.codeFacts.coverage !== "complete" && evidence.disposition === "resolved") {
854
+ issues.push("resolutionEvidence.disposition must not be resolved while after-snapshot coverage is incomplete");
855
+ }
856
+ const indexCoversWorktree =
857
+ afterSnapshot.codeFacts.indexedWorktreeDigest !== null
858
+ && afterSnapshot.codeFacts.indexedWorktreeDigest === afterSnapshot.worktree.worktreeDigest;
859
+ if (!indexCoversWorktree && evidence.disposition === "resolved") {
860
+ issues.push("resolutionEvidence.disposition must not be resolved while the after-snapshot index does not cover the verified worktree");
861
+ }
862
+ return issues;
863
+ }
864
+
865
+ function authoredTargetDelta(delta: ArchitectureTargetDeltaV1): Omit<ArchitectureTargetDeltaV1, "interventionId" | "unresolvedTargets" | "extensions"> {
866
+ const {
867
+ interventionId: _interventionId,
868
+ unresolvedTargets: _unresolvedTargets,
869
+ extensions: _extensions,
870
+ ...authored
871
+ } = delta;
872
+ return authored;
873
+ }
874
+
875
+ function isRefactorProposalAuthorSource(source: string): source is RefactorProposalAuthorSource {
876
+ return (REFACTOR_PROPOSAL_AUTHOR_SOURCES as readonly string[]).includes(source);
877
+ }
878
+
879
+ function isRefactorProposalAuthorKind(kind: string): kind is RefactorProposalAuthorKind {
880
+ return (REFACTOR_PROPOSAL_AUTHOR_KINDS as readonly string[]).includes(kind);
881
+ }
882
+
883
+ function refactorProposalAuthorIssues(author: RecommendationAuthorV1, prefix: string): string[] {
884
+ const issues: string[] = [];
885
+ if (author.id.trim() === "") issues.push(`${prefix}.authoredBy.id must not be empty`);
886
+ if (!isRefactorProposalAuthorSource(author.source)) {
887
+ issues.push(`${prefix}.authoredBy.source must not be ${author.source}; refactor proposals are agent or human authored`);
888
+ }
889
+ if (!isRefactorProposalAuthorKind(author.kind)) {
890
+ issues.push(`${prefix}.authoredBy.kind must not be ${author.kind}; refactor proposals are agent or human authored`);
891
+ return issues;
892
+ }
893
+ if (isRefactorProposalAuthorSource(author.source) && !REFACTOR_PROPOSAL_AUTHOR_PAIRS[author.kind].includes(author.source)) {
894
+ issues.push(`${prefix}.authoredBy.kind ${author.kind} is not compatible with source ${author.source}`);
895
+ }
896
+ return issues;
897
+ }
898
+
899
+ function outcomeSatisfied(expected: RefactorTargetOutcomeV1, observedValue: number | null): boolean {
900
+ switch (expected.operator) {
901
+ case "absent":
902
+ return observedValue === null || observedValue === 0;
903
+ case "present":
904
+ return observedValue !== null && observedValue !== 0;
905
+ case "equals":
906
+ return observedValue !== null && expected.value !== null && observedValue === expected.value;
907
+ case "greater_than":
908
+ return observedValue !== null && expected.value !== null && observedValue > expected.value;
909
+ case "less_than":
910
+ return observedValue !== null && expected.value !== null && observedValue < expected.value;
911
+ }
912
+ }
913
+
914
+ function recommendationPayloadShapeIssues(
915
+ category: RecommendationCategory,
916
+ payload: RecommendationPayloadV1,
917
+ prefix: string
918
+ ): string[] {
919
+ if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
920
+ return [`${prefix}.payload must be an object for category ${category}`];
921
+ }
922
+ const record = payload as unknown as Record<string, unknown>;
923
+ const required = REQUIRED_PAYLOAD_FIELDS[category];
924
+ const missing = required.filter((field) => !(field in record));
925
+ return missing.length === 0 ? [] : [`${prefix}.payload does not match category ${category}; missing ${missing.sort().join(", ")}`];
926
+ }
927
+
928
+ function nonNegativeIntegerIssues(label: string, value: number | null): string[] {
929
+ if (value === null) return [];
930
+ return Number.isInteger(value) && value >= 0 ? [] : [`${label} must be a non-negative integer`];
931
+ }
932
+
933
+ function ratioIssues(label: string, value: number | null): string[] {
934
+ if (value === null) return [];
935
+ return Number.isFinite(value) && value >= 0 && value <= 1 ? [] : [`${label} must be a ratio between 0 and 1`];
936
+ }
937
+
938
+ const REQUIRED_PAYLOAD_FIELDS: Readonly<Record<RecommendationCategory, readonly string[]>> = {
939
+ practice: ["baselineDigest", "practiceId"],
940
+ refactor_proposal: [
941
+ "affectedNodeIds",
942
+ "assessmentDigest",
943
+ "baselineSnapshotDigest",
944
+ "killList",
945
+ "majorChangeReasons",
946
+ "proposalDigest",
947
+ "scale",
948
+ "targetOutcomes"
949
+ ],
950
+ structural_observation: ["affectedNodeIds", "assessmentDigest", "baselineSnapshotDigest", "derivedOutcomes", "kind"]
951
+ };
952
+
953
+ function fingerprintPayloadSubset(category: RecommendationCategory, payload: RecommendationPayloadV1): Record<string, Json> {
954
+ if (category === "practice") {
955
+ return { baselineDigest: (payload as PracticeRecommendationPayloadV1).baselineDigest };
956
+ }
957
+ if (category === "structural_observation") {
958
+ const observation = payload as StructuralObservationPayloadV1;
959
+ return { kind: observation.kind, affectedNodeIds: [...observation.affectedNodeIds].sort() };
960
+ }
961
+ const proposal = payload as RefactorProposalPayloadV1;
962
+ return {
963
+ proposalDigest: proposal.proposalDigest,
964
+ scale: proposal.scale,
965
+ affectedNodeIds: [...proposal.affectedNodeIds].sort(),
966
+ majorChangeReasons: [...proposal.majorChangeReasons].sort()
967
+ };
968
+ }
969
+
970
+ function outcomeIssues(label: string, outcomes: readonly RefactorTargetOutcomeV1[]): string[] {
971
+ const issues = sortedUniqueIssues(`${label}.outcomeId`, outcomes.map((outcome) => outcome.outcomeId));
972
+ for (const outcome of outcomes) {
973
+ const valueless = outcome.operator === "absent" || outcome.operator === "present";
974
+ if (valueless && outcome.value !== null) issues.push(`${label} operator ${outcome.operator} must not carry a value`);
975
+ if (!valueless && outcome.value === null) issues.push(`${label} operator ${outcome.operator} requires a value`);
976
+ if (outcome.metric.trim() === "") issues.push(`${label} metric must not be empty`);
977
+ }
978
+ return issues;
979
+ }
980
+
981
+ function digestIssues(label: string, value: string): string[] {
982
+ return SHA256_DIGEST_PATTERN.test(value) ? [] : [`${label} must be a sha256:<64-hex> digest`];
983
+ }
984
+
985
+ function isPresent<T>(value: T | undefined): value is T {
986
+ return value !== undefined;
987
+ }
988
+
989
+ function sortedUniqueIssues(label: string, values: readonly string[]): string[] {
990
+ const expected = [...new Set(values)].sort();
991
+ return expected.length === values.length && expected.every((value, index) => value === values[index])
992
+ ? []
993
+ : [`${label} must be sorted and unique`];
994
+ }
package/src/schema.ts CHANGED
@@ -28,7 +28,11 @@ export type ArchContextErrorCode =
28
28
  | "AC_USER_CONFIRMATION_REQUIRED"
29
29
  | "AC_ENTITLEMENT_REQUIRED"
30
30
  | "AC_ATTESTATION_REJECTED"
31
- | "AC_TUNNEL_SCOPE_DENIED";
31
+ | "AC_TUNNEL_SCOPE_DENIED"
32
+ | "AC_MODEL_ADOPTION_REQUIRED"
33
+ | "AC_REFACTOR_STALE"
34
+ | "AC_REFACTOR_EVIDENCE_REQUIRED"
35
+ | "AC_REFACTOR_PROPOSAL_UNAUTHORED";
32
36
 
33
37
  export interface ArchContextError {
34
38
  code: ArchContextErrorCode;
@@ -65,7 +69,11 @@ export const ERROR_CATALOG: Record<ArchContextErrorCode, Omit<ArchContextError,
65
69
  AC_USER_CONFIRMATION_REQUIRED: { code: "AC_USER_CONFIRMATION_REQUIRED", severity: "warning", retryable: true, action: "show-human-decision" },
66
70
  AC_ENTITLEMENT_REQUIRED: { code: "AC_ENTITLEMENT_REQUIRED", severity: "error", retryable: false, action: "login-or-subscribe" },
67
71
  AC_ATTESTATION_REJECTED: { code: "AC_ATTESTATION_REJECTED", severity: "error", retryable: false, action: "review-again" },
68
- AC_TUNNEL_SCOPE_DENIED: { code: "AC_TUNNEL_SCOPE_DENIED", severity: "error", retryable: false, action: "reduce-scope" }
72
+ AC_TUNNEL_SCOPE_DENIED: { code: "AC_TUNNEL_SCOPE_DENIED", severity: "error", retryable: false, action: "reduce-scope" },
73
+ AC_MODEL_ADOPTION_REQUIRED: { code: "AC_MODEL_ADOPTION_REQUIRED", severity: "error", retryable: false, action: "adopt-architecture-model" },
74
+ AC_REFACTOR_STALE: { code: "AC_REFACTOR_STALE", severity: "warning", retryable: true, action: "rerun-refactor-scan" },
75
+ AC_REFACTOR_EVIDENCE_REQUIRED: { code: "AC_REFACTOR_EVIDENCE_REQUIRED", severity: "error", retryable: false, action: "run-refactor-verify" },
76
+ AC_REFACTOR_PROPOSAL_UNAUTHORED: { code: "AC_REFACTOR_PROPOSAL_UNAUTHORED", severity: "error", retryable: false, action: "attach-authoring-actor" }
69
77
  };
70
78
 
71
79
  export function okEnvelope<T extends Json>(requestId: string, data: T): JsonEnvelope<T> {