archctx-contracts 0.5.10 → 0.5.12

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.
@@ -71,3 +71,82 @@ export interface ArchitectureNotApplicableFlowV1 extends ArchitectureFlowBaseV1
71
71
  applicability: "not-applicable";
72
72
  rationale: string;
73
73
  }
74
+
75
+ /**
76
+ * A `forbid-dependency` constraint (`archcontext.constraint/v1`), narrowed to the fields the
77
+ * dependency gate evaluates. A file owned by a `scope.nodes` node (or a descendant) must not
78
+ * import a file owned by a `rule.targets` node (or a descendant). v1 has no `allowedVia` escape.
79
+ */
80
+ export interface DependencyConstraintV1 {
81
+ id: string;
82
+ severity: "error" | "warning";
83
+ scope: { nodes: string[] };
84
+ rule: { type: "forbid-dependency"; targets: string[] };
85
+ rationale: string;
86
+ }
87
+
88
+ export const DEPENDENCY_CONSTRAINT_STATUSES = ["not-applicable", "pass", "undetermined", "violated"] as const;
89
+ export type DependencyConstraintStatus = (typeof DEPENDENCY_CONSTRAINT_STATUSES)[number];
90
+
91
+ /**
92
+ * Why a dependency result is not a complete observation: no index answered, the index attested
93
+ * to a different worktree, the import dump hit its limit, a constrained file carries a
94
+ * repository-local import that resolved to no file, or the workspace package map could not be
95
+ * read, so no bare workspace specifier could be resolved.
96
+ */
97
+ export const DEPENDENCY_CONSTRAINT_REASON_CODES = [
98
+ "code-facts-stale",
99
+ "code-facts-truncated",
100
+ "code-facts-unavailable",
101
+ "unresolved-import",
102
+ "workspace-resolution-failed"
103
+ ] as const;
104
+ export type DependencyConstraintReasonCode = (typeof DEPENDENCY_CONSTRAINT_REASON_CODES)[number];
105
+
106
+ export interface DependencyConstraintViolationV1 {
107
+ constraintId: string;
108
+ fromPath: string;
109
+ toPath: string;
110
+ fromNode: string;
111
+ toNode: string;
112
+ severity: "error" | "warning";
113
+ }
114
+
115
+ export interface DependencyConstraintEvaluationV1 {
116
+ schemaVersion: "archcontext.dependency-constraint-evaluation/v1";
117
+ status: DependencyConstraintStatus;
118
+ /** `complete` only when an index attested to the evaluated worktree and was not truncated. */
119
+ coverage: "complete" | "partial" | "unknown";
120
+ reasonCodes: DependencyConstraintReasonCode[];
121
+ constraintIds: string[];
122
+ /** Import observations the evaluation read; zero when the index did not attest to this tree. */
123
+ importEdgeCount: number;
124
+ /** Repository-local specifiers from constrained files that resolved to no file. */
125
+ unresolvedImports: { from: string; specifier: string }[];
126
+ violations: DependencyConstraintViolationV1[];
127
+ }
128
+
129
+ /**
130
+ * The closed `failOn` vocabulary of `.archcontext/policies/review.yaml`. A review finding in one
131
+ * of these categories blocks only when its category is listed; findings outside the vocabulary
132
+ * are never affected by the policy.
133
+ */
134
+ export const REVIEW_FAIL_ON_CATEGORIES = [
135
+ "incomplete-intervention",
136
+ "invalid-schema",
137
+ "prohibited-dependency",
138
+ "stale-context",
139
+ "unjustified-compatibility"
140
+ ] as const;
141
+ export type ReviewFailOnCategory = (typeof REVIEW_FAIL_ON_CATEGORIES)[number];
142
+
143
+ /**
144
+ * The effective review policy. An error in a category missing from `failOn` is downgraded to a
145
+ * warning, with one exception: the task-snapshot HEAD-mismatch `stale-context` finding always stays
146
+ * an error, because a stale snapshot skips every other review gate.
147
+ */
148
+ export interface ReviewPolicyV1 {
149
+ failOn: ReviewFailOnCategory[];
150
+ /** `default` when the policy file is missing or invalid in any way: every category blocks. */
151
+ source: "policy-file" | "default";
152
+ }
package/src/ports.ts CHANGED
@@ -140,13 +140,38 @@ export interface LocalStorePort {
140
140
  saveReviewResult(reviewId: string, result: unknown): Promise<void>;
141
141
  }
142
142
 
143
+ export interface ModelValidationResult {
144
+ valid: boolean;
145
+ errors: string[];
146
+ /**
147
+ * The subset of `errors` that are cross-file references resolving to nothing, such as an ADR
148
+ * `appliesTo` id with no node. Omitted when empty.
149
+ */
150
+ referenceErrors?: string[];
151
+ /** Problems that do not invalidate the model, such as an ignored legacy setting. Omitted when empty. */
152
+ warnings?: string[];
153
+ modelDigest: string;
154
+ }
155
+
143
156
  export interface ModelStorePort {
144
157
  loadManifest(workspace: WorkspaceRef): Promise<unknown>;
145
158
  loadModel(workspace: WorkspaceRef): Promise<unknown[]>;
146
- validateModel(workspace: WorkspaceRef): Promise<{ valid: boolean; errors: string[]; modelDigest: string }>;
159
+ validateModel(workspace: WorkspaceRef): Promise<ModelValidationResult>;
147
160
  writeChangeSetPreview(changeSet: unknown): Promise<{ digest: string; summary: string }>;
148
161
  }
149
162
 
163
+ /**
164
+ * Errors that make a model unusable as the base of a write. Dangling references are excluded so
165
+ * that a change repairing them (for example restoring a deleted node) is not blocked; the
166
+ * after-apply model is still validated in full.
167
+ */
168
+ export function baseModelBlockingErrors(result: ModelValidationResult): string[] {
169
+ if (result.valid) return [];
170
+ if (result.errors.length === 0) return ["unknown validation error"];
171
+ const references = new Set(result.referenceErrors ?? []);
172
+ return result.errors.filter((error) => !references.has(error));
173
+ }
174
+
150
175
  export interface PolicyPort {
151
176
  evaluateChangeSet(changeSet: unknown): Promise<{ allowed: boolean; violations: string[] }>;
152
177
  evaluateReview(input: unknown): Promise<{ result: "pass" | "fail"; findings: unknown[] }>;
@@ -750,3 +775,66 @@ export interface ChatGptGaToolContract {
750
775
  requiresLocalConfirmationForWrite: boolean;
751
776
  disclosure: string;
752
777
  }
778
+
779
+ export type DetachedReviewWorktreeReason =
780
+ | "HEAD_UNAVAILABLE"
781
+ | "HEAD_SHA_MISMATCH"
782
+ | "TREE_OID_MISMATCH"
783
+ | "WORKTREE_NOT_DETACHED"
784
+ | "WORKTREE_NOT_CLEAN";
785
+
786
+ export interface DetachedReviewWorktreeVerification {
787
+ schemaVersion: "archcontext.detached-review-worktree-verification/v1";
788
+ accepted: boolean;
789
+ reasonCode?: DetachedReviewWorktreeReason;
790
+ expected: {
791
+ headSha: string;
792
+ headTreeOid?: string;
793
+ };
794
+ observed: {
795
+ headSha?: string;
796
+ headTreeOid?: string;
797
+ detached?: boolean;
798
+ clean?: boolean;
799
+ };
800
+ }
801
+
802
+
803
+ export interface ReviewCheckoutGitPort {
804
+ findRepositoryRoot(start: string): string;
805
+ readOriginUrl(root: string): string | null;
806
+ verifyDetachedWorktree(input: {
807
+ worktreeRoot: string;
808
+ expectedHeadSha: string;
809
+ expectedHeadTreeOid?: string;
810
+ }): DetachedReviewWorktreeVerification;
811
+ }
812
+
813
+ export interface NonLocalEgressChannel {
814
+ channel: "context7" | "agent-audit" | "github-issue-publishing" | "npm-update-check" | "codegraph-telemetry";
815
+ destination: string;
816
+ trigger: string;
817
+ data: string;
818
+ status: "enabled" | "declared-awaiting-user-consent" | "blocked-by-policy";
819
+ }
820
+
821
+ export interface LocalEgressReport {
822
+ ok: boolean;
823
+ policyMode: "configured" | "local-only";
824
+ enforcement: "application-admission";
825
+ defaultOutbound: "local-only";
826
+ effectiveOutbound: "local-only" | "non-local";
827
+ nonLocalEgress: NonLocalEgressChannel[];
828
+ cloudContentUpload: "deny";
829
+ secureMcpTunnel: "disabled-by-default";
830
+ thirdPartyTelemetry: "disabled" | "not-disabled-by-env";
831
+ codeGraph: {
832
+ provider: "codegraph";
833
+ telemetry: "disabled" | "not-disabled-by-env";
834
+ envVar: string;
835
+ configuredValue: string | null;
836
+ effectiveValue: string;
837
+ source: "archcontext-default" | "environment";
838
+ };
839
+ warnings: string[];
840
+ }
@@ -1,5 +1,5 @@
1
1
  export const ARCHCONTEXT_PRODUCT_NAME = "archctx";
2
- export const ARCHCONTEXT_PRODUCT_VERSION = "0.5.10";
2
+ export const ARCHCONTEXT_PRODUCT_VERSION = "0.5.12";
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
@@ -8,6 +8,7 @@ export const PROJECTION_APPLY_RECOVERY_BINDING_SCHEMA_VERSION = "archcontext.pro
8
8
  export const PROJECTION_APPLY_RECOVERY_INTENT_SCHEMA_VERSION = "archcontext.projection-apply-recovery-intent/v1" as const;
9
9
  export const PROJECTION_APPLY_RECOVERY_PROOF_SCHEMA_VERSION = "archcontext.projection-apply-recovery-proof/v1" as const;
10
10
  export const PROJECTION_APPLY_RECOVERY_RESULT_SCHEMA_VERSION = "archcontext.projection-apply-recovery-result/v1" as const;
11
+ export const PROJECTION_APPLY_READBACK_RESULT_SCHEMA_VERSION = "archcontext.projection-apply-readback-result/v1" as const;
11
12
  export const ARCHITECTURE_REFRESH_SIGNAL_SCHEMA_VERSION = "archcontext.architecture-refresh-signal/v1" as const;
12
13
  export const ARCHCTX_CAPABILITIES_SCHEMA_VERSION = "archcontext.capabilities/v1" as const;
13
14
  export const ARCHITECTURE_DOCS_RENDERER_VERSION = "archcontext.docs-renderer/v4" as const;
@@ -60,6 +61,7 @@ export const ARCHCTX_FEATURES = [
60
61
  "architecture-docs-renderer-v2",
61
62
  "architecture-refresh-signal-v1",
62
63
  "module-statistics-v1",
64
+ "projection-apply-readback-v1",
63
65
  "projection-apply-receipt-v1",
64
66
  "projection-apply-recovery-v1",
65
67
  "projection-prior-committed-applies-v1",
@@ -291,6 +293,28 @@ export interface ProjectionApplyRecoveryResultV1 {
291
293
  refreshSignals: ArchitectureRefreshSignalV1[];
292
294
  }
293
295
 
296
+ /** Non-consuming receipt retrieval; current state is rebuilt for every response. */
297
+ export interface ProjectionApplyReadbackResultV1 {
298
+ schemaVersion: typeof PROJECTION_APPLY_READBACK_RESULT_SCHEMA_VERSION;
299
+ requestId: string;
300
+ requestDigest: Sha256Digest;
301
+ receipt: ProjectionApplyReceiptV1;
302
+ current: ProjectionApplyRecoveryProofV1["current"];
303
+ readbackDigest: Sha256Digest;
304
+ }
305
+
306
+ /** Exact-request absence observed under the same writer boundary used by apply. */
307
+ export interface ProjectionApplyAbsenceV1 {
308
+ schemaVersion: "archcontext.projection-apply-absence/v1";
309
+ requestId: string;
310
+ requestDigest: Sha256Digest;
311
+ lookupKey: Sha256Digest;
312
+ current: ProjectionExpectedSnapshotV1;
313
+ absenceDigest: Sha256Digest;
314
+ }
315
+
316
+ export type ProjectionApplyReadbackV1 = ProjectionApplyReadbackResultV1 | ProjectionApplyAbsenceV1;
317
+
294
318
  export interface ArchctxCapabilitiesV1 {
295
319
  schemaVersion: typeof ARCHCTX_CAPABILITIES_SCHEMA_VERSION;
296
320
  package: {
@@ -550,6 +574,89 @@ export function projectionApplyRecoveryBindingInvariantIssues(
550
574
  return issues;
551
575
  }
552
576
 
577
+ /** Readback carries the original accepted apply request, never caller-authored receipt data. */
578
+ export function projectionApplyReadbackRequestInvariantIssues(request: ProjectionRequestV1): string[] {
579
+ const issues = projectionRequestInvariantIssues(request);
580
+ const allowed = new Set(["schemaVersion", "requestId", "profile", "mode", "targets", "changedPaths", "expected", "acceptedChange"]);
581
+ if (Object.keys(request).some((key) => !allowed.has(key))) issues.push("readback request contains unsupported fields");
582
+ if (request.mode !== "apply" || !request.acceptedChange) issues.push("readback requires an accepted apply request");
583
+ if (request.schemaVersion !== PROJECTION_REQUEST_SCHEMA_VERSION || request.profile !== "repo-harness/v1") issues.push("readback request protocol is invalid");
584
+ if (typeof request.requestId !== "string"
585
+ || request.targets.some((target) => !(PROJECTION_TARGETS as readonly string[]).includes(target))
586
+ || request.changedPaths.some((path) => typeof path !== "string" || !isRepoRelativePosixPath(path))) issues.push("readback request fields are invalid");
587
+ const expectedKeys = ["repositoryId", "workspaceId", "headSha", "worktreeDigest"];
588
+ if (Object.keys(request.expected).some((key) => !expectedKeys.includes(key))
589
+ || expectedKeys.some((key) => typeof request.expected[key as keyof ProjectionExpectedSnapshotV1] !== "string")
590
+ || !request.expected.repositoryId || !request.expected.workspaceId
591
+ || !/^[a-f0-9]{40}$/.test(request.expected.headSha)
592
+ || !SHA256_DIGEST.test(request.expected.worktreeDigest)) issues.push("readback expected snapshot is invalid");
593
+ if (request.acceptedChange && Object.keys(request.acceptedChange).some((key) => !["changeSetId", "eventId", "reasonCodes", "affectedNodeIds"].includes(key))) {
594
+ issues.push("readback accepted change contains unsupported fields");
595
+ }
596
+ return issues;
597
+ }
598
+
599
+ export function projectionApplyAbsenceInvariantIssues(input: ProjectionApplyAbsenceV1, request: ProjectionRequestV1): string[] {
600
+ const issues = projectionApplyReadbackRequestInvariantIssues(request);
601
+ if (input.schemaVersion !== "archcontext.projection-apply-absence/v1") issues.push("absence schemaVersion is invalid");
602
+ if (Object.keys(input).some((key) => !["schemaVersion", "requestId", "requestDigest", "lookupKey", "current", "absenceDigest"].includes(key))) issues.push("absence contains unsupported fields");
603
+ const { absenceDigest, ...body } = input;
604
+ if (absenceDigest !== digestJson(body as unknown as Json)) issues.push("absenceDigest must bind the complete absence result");
605
+ if (input.requestId !== request.requestId || input.requestDigest !== digestJson(request as unknown as Json)) issues.push("absence request identity mismatch");
606
+ if (digestJson(input.current as unknown as Json) !== digestJson(request.expected as unknown as Json)) issues.push("absence current snapshot differs from request");
607
+ if (!request.acceptedChange || input.lookupKey !== projectionApplyLookupKey({
608
+ repositoryId: request.expected.repositoryId, workspaceId: request.expected.workspaceId, acceptedChange: request.acceptedChange
609
+ })) issues.push("absence lookup identity mismatch");
610
+ return issues;
611
+ }
612
+
613
+ export function projectionApplyReadbackResultDigest(input: Omit<ProjectionApplyReadbackResultV1, "readbackDigest">): Sha256Digest {
614
+ return digestJson(input as unknown as Json) as Sha256Digest;
615
+ }
616
+
617
+ export function projectionApplyReadbackResultInvariantIssues(
618
+ input: ProjectionApplyReadbackResultV1,
619
+ request?: ProjectionRequestV1
620
+ ): string[] {
621
+ const issues = projectionApplyReceiptInvariantIssues(input.receipt);
622
+ const { readbackDigest, ...body } = input;
623
+ if (input.schemaVersion !== PROJECTION_APPLY_READBACK_RESULT_SCHEMA_VERSION) issues.push("readback schemaVersion is invalid");
624
+ if (projectionApplyReadbackResultDigest(body) !== readbackDigest) issues.push("readbackDigest must bind the complete readback result");
625
+ if (!/^[a-zA-Z0-9_.:-]+$/.test(input.requestId)) issues.push("readback requestId is invalid");
626
+ for (const value of [input.requestDigest, input.current.ownedOutputDigest, input.current.fixedPointDigest]) {
627
+ if (!/^sha256:[a-f0-9]{64}$/.test(value)) issues.push("readback digest is invalid");
628
+ }
629
+ const receipt = input.receipt;
630
+ if (receipt.schemaVersion !== PROJECTION_APPLY_RECEIPT_SCHEMA_VERSION) issues.push("readback receipt schemaVersion is invalid");
631
+ const binding = receipt.recovery;
632
+ if (!binding) return [...issues, "committed receipt lacks its original recovery binding"];
633
+ for (const field of ["repositoryId", "workspaceId", "headSha", "worktreeDigest"] as const) {
634
+ if (input.current.snapshot[field] !== binding.originalExpectedSnapshot[field]) issues.push(`readback current ${field} differs from approval`);
635
+ }
636
+ if (input.current.snapshot.generatedFrom.codeGraphStatus !== "ready"
637
+ || input.current.snapshot.rendererVersion !== binding.rendererVersion
638
+ || input.current.snapshot.layoutVersion !== binding.layoutVersion
639
+ || input.current.snapshot.projectionInputDigest !== receipt.result.outputSnapshot.projectionInputDigest
640
+ || input.current.snapshot.codeGraphDigest !== receipt.result.outputSnapshot.codeGraphDigest
641
+ || digestJson(input.current.snapshot.generatedFrom as unknown as Json) !== digestJson(binding.generatedFrom as unknown as Json)
642
+ || digestJson(input.current.resultingDigests as unknown as Json) !== digestJson(binding.expectedResultingDigests as unknown as Json)
643
+ || input.current.ownedOutputDigest !== binding.ownedOutputDigest) {
644
+ issues.push("readback current state differs from committed recovery binding");
645
+ }
646
+ if (input.requestId !== receipt.result.requestId) issues.push("readback requestId differs from committed request");
647
+ if (request) {
648
+ issues.push(...projectionApplyReadbackRequestInvariantIssues(request));
649
+ if (input.requestDigest !== digestJson(request as unknown as Json) || input.requestId !== request.requestId) issues.push("readback request identity mismatch");
650
+ if (digestJson(request.expected as unknown as Json) !== digestJson(binding.originalExpectedSnapshot as unknown as Json)
651
+ || digestJson(request.acceptedChange as unknown as Json) !== digestJson(receipt.identity.acceptedChange as unknown as Json)
652
+ || digestJson(request.targets as unknown as Json) !== digestJson(binding.targets as unknown as Json)
653
+ || digestJson(request.changedPaths as unknown as Json) !== digestJson(binding.changedPaths as unknown as Json)) {
654
+ issues.push("readback request differs from committed approval binding");
655
+ }
656
+ }
657
+ return issues;
658
+ }
659
+
553
660
  export function projectionApplyRecoveryIntentInvariantIssues(input: ProjectionApplyRecoveryIntentV1): string[] {
554
661
  const issues: string[] = [];
555
662
  if (input.schemaVersion !== PROJECTION_APPLY_RECOVERY_INTENT_SCHEMA_VERSION) issues.push("recovery intent schemaVersion is invalid");