gentle-pi 1.1.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/README.md +58 -15
  2. package/assets/agents/review-readability.md +0 -1
  3. package/assets/agents/review-reliability.md +0 -1
  4. package/assets/agents/review-resilience.md +0 -1
  5. package/assets/agents/review-risk.md +0 -1
  6. package/assets/orchestrator-delegation.md +3 -3
  7. package/contracts/review-integration/v1/fixtures/binding-revision-conflict.fixture.json +25 -0
  8. package/contracts/review-integration/v1/fixtures/capabilities-v1.1.fixture.json +45 -0
  9. package/contracts/review-integration/v1/fixtures/capabilities-v1.2.fixture.json +263 -0
  10. package/contracts/review-integration/v1/fixtures/capabilities-v1.3.fixture.json +301 -0
  11. package/contracts/review-integration/v1/fixtures/capabilities-v1.4.fixture.json +312 -0
  12. package/contracts/review-integration/v1/fixtures/capabilities-v1.5.fixture.json +321 -0
  13. package/contracts/review-integration/v1/fixtures/consent.fixture.json +36 -0
  14. package/contracts/review-integration/v1/fixtures/final-verification-incident.fixture.json +1 -0
  15. package/contracts/review-integration/v1/fixtures/operation.fixture.json +54 -1
  16. package/contracts/review-integration/v1/fixtures/repair-preflight.fixture.json +54 -0
  17. package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +97 -0
  18. package/contracts/review-integration/v1/fixtures/start.fixture.json +25 -1
  19. package/contracts/review-integration/v1/fixtures/status-ambiguous.fixture.json +52 -1
  20. package/contracts/review-integration/v1/fixtures/status-corrupted.fixture.json +52 -1
  21. package/contracts/review-integration/v1/fixtures/status-recover.fixture.json +103 -0
  22. package/contracts/review-integration/v1/fixtures/status-unrelated.fixture.json +48 -1
  23. package/contracts/review-integration/v1/fixtures/status-v2-ambiguous.fixture.json +110 -0
  24. package/contracts/review-integration/v1/fixtures/status-v2-corrupted.fixture.json +107 -0
  25. package/contracts/review-integration/v1/fixtures/status-v2-final-verification-retry.fixture.json +192 -0
  26. package/contracts/review-integration/v1/fixtures/status-v2-recover.fixture.json +130 -0
  27. package/contracts/review-integration/v1/fixtures/status-v2-repair.fixture.json +161 -0
  28. package/contracts/review-integration/v1/fixtures/status-v2-unrelated.fixture.json +103 -0
  29. package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +193 -0
  30. package/contracts/review-integration/v1/fixtures/status.fixture.json +97 -1
  31. package/contracts/review-integration/v1/fixtures/verification-evidence.fixture.json +1 -0
  32. package/contracts/review-integration/v1/schemas/admitted-result.schema.json +35 -0
  33. package/contracts/review-integration/v1/schemas/artifact-subject.schema.json +33 -0
  34. package/contracts/review-integration/v1/schemas/authority-repair-assessment.schema.json +247 -0
  35. package/contracts/review-integration/v1/schemas/capabilities-v1.1.schema.json +25 -2
  36. package/contracts/review-integration/v1/schemas/capabilities-v1.2.schema.json +249 -0
  37. package/contracts/review-integration/v1/schemas/capabilities-v1.3.schema.json +429 -0
  38. package/contracts/review-integration/v1/schemas/capabilities-v1.4.schema.json +432 -0
  39. package/contracts/review-integration/v1/schemas/capabilities-v1.5.schema.json +154 -0
  40. package/contracts/review-integration/v1/schemas/consent.schema.json +86 -0
  41. package/contracts/review-integration/v1/schemas/failure.schema.json +54 -8
  42. package/contracts/review-integration/v1/schemas/final-verification-incident.schema.json +51 -0
  43. package/contracts/review-integration/v1/schemas/operation.schema.json +204 -29
  44. package/contracts/review-integration/v1/schemas/repair.schema.json +251 -0
  45. package/contracts/review-integration/v1/schemas/result-artifact-v2.schema.json +38 -0
  46. package/contracts/review-integration/v1/schemas/start-v2.schema.json +216 -0
  47. package/contracts/review-integration/v1/schemas/start.schema.json +79 -1
  48. package/contracts/review-integration/v1/schemas/status-v2.schema.json +313 -0
  49. package/contracts/review-integration/v1/schemas/status.schema.json +141 -2
  50. package/contracts/review-integration/v1/schemas/targeted-validation-request.schema.json +46 -0
  51. package/contracts/review-integration/v1/schemas/verification-evidence.schema.json +64 -0
  52. package/contracts/review-integration/v2/fixtures/capabilities.fixture.json +111 -0
  53. package/contracts/review-integration/v2/fixtures/consent.fixture.json +36 -0
  54. package/contracts/review-integration/v2/fixtures/start.fixture.json +93 -0
  55. package/contracts/review-integration/v2/fixtures/status.fixture.json +113 -0
  56. package/contracts/review-integration/v2/schemas/admitted-result.schema.json +14 -0
  57. package/contracts/review-integration/v2/schemas/artifact-subject.schema.json +36 -0
  58. package/contracts/review-integration/v2/schemas/capabilities.schema.json +111 -0
  59. package/contracts/review-integration/v2/schemas/consent.schema.json +70 -0
  60. package/contracts/review-integration/v2/schemas/failure.schema.json +35 -0
  61. package/contracts/review-integration/v2/schemas/operation.schema.json +46 -0
  62. package/contracts/review-integration/v2/schemas/repair.schema.json +22 -0
  63. package/contracts/review-integration/v2/schemas/start.schema.json +106 -0
  64. package/contracts/review-integration/v2/schemas/status.schema.json +104 -0
  65. package/docs/native-authority-architecture.md +123 -0
  66. package/docs/review-integration.md +185 -25
  67. package/extensions/gentle-ai.ts +761 -960
  68. package/lib/gentle-ai-binary.ts +1 -1
  69. package/lib/git-commit-transaction.ts +5 -0
  70. package/lib/native-review-cli.ts +1047 -61
  71. package/lib/review-candidate-view.ts +131 -1
  72. package/lib/review-compact-contract.ts +22 -4
  73. package/lib/review-consent-latch.ts +51 -0
  74. package/lib/review-correction-lifecycle.ts +170 -0
  75. package/lib/review-integration-v2.ts +1563 -0
  76. package/lib/review-publication-gate.ts +513 -0
  77. package/lib/review-repository.ts +12 -14
  78. package/lib/review-snapshot.ts +0 -59
  79. package/lib/review-transaction.ts +40 -834
  80. package/package.json +15 -11
  81. package/runtime/gentle-ai-binary.mjs +1 -1
  82. package/runtime/git-commit-transaction.mjs +5 -0
  83. package/runtime/native-review-cli.mjs +1031 -45
  84. package/runtime/review-integration-v2.mjs +1564 -0
  85. package/scripts/build-git-commit-transaction-runner.mjs +1 -1
  86. package/scripts/gentle-ai-installer.mjs +16 -11
  87. package/scripts/install-gentle-ai.mjs +7 -3
  88. package/scripts/measure-native-authority-slimming.mjs +111 -0
  89. package/scripts/test-packed-runner.mjs +2 -2
  90. package/scripts/verify-package-files.mjs +218 -65
  91. package/skills/_shared/review-ledger-contract.md +6 -6
  92. package/skills/gentle-ai/SKILL.md +4 -2
  93. package/skills/release/SKILL.md +38 -11
  94. package/tests/devbinary/native-review-parity.devtest.ts +348 -0
  95. package/tests/gentle-ai-binary.test.ts +8 -1
  96. package/tests/gentle-ai-installer.test.ts +25 -22
  97. package/tests/gentle-ai.test.ts +1 -1
  98. package/tests/git-commit-transaction.test.ts +128 -5
  99. package/tests/native-binary-gate.test.ts +145 -0
  100. package/tests/native-review-capability-contract.test.ts +87 -0
  101. package/tests/native-review-cli.test.ts +102 -0
  102. package/tests/native-review-parity-runtime.test.ts +78 -25
  103. package/tests/native-review-parity.test.ts +673 -0
  104. package/tests/native-risk-evidence-parity.test.ts +91 -0
  105. package/tests/package-manifest.test.ts +90 -4
  106. package/tests/review-actor-tool-deny.test.ts +20 -5
  107. package/tests/review-authority-recovery-docs.test.ts +16 -11
  108. package/tests/review-candidate-view.test.ts +331 -1
  109. package/tests/review-compact-contract.test.ts +58 -0
  110. package/tests/review-consent-latch.test.ts +67 -0
  111. package/tests/review-controller-lock-status.test.ts +145 -0
  112. package/tests/review-controller-native-recovery.test.ts +961 -0
  113. package/tests/review-controller-native-routing.test.ts +792 -462
  114. package/tests/review-controller-retired-ops.test.ts +54 -0
  115. package/tests/review-controller-workspace-root.test.ts +29 -6
  116. package/tests/review-controller.test.ts +19 -794
  117. package/tests/review-correction-lifecycle.test.ts +174 -0
  118. package/tests/review-gate.test.ts +16 -7
  119. package/tests/review-integration-v2.test.ts +539 -0
  120. package/tests/review-ledger-contract.test.ts +4 -2
  121. package/tests/review-snapshot.test.ts +2 -15
  122. package/tests/review-transaction.test.ts +27 -51
  123. package/tests/runtime-harness.mjs +193 -6
  124. package/tests/support/native-binary-gate.ts +86 -0
  125. package/tests/verify-package-files.test.ts +119 -0
  126. package/lib/review-authority-supersession.ts +0 -1056
  127. package/lib/review-bundle.ts +0 -286
  128. package/lib/review-checkpoint.ts +0 -127
  129. package/lib/review-compact-gate.ts +0 -219
  130. package/lib/review-compact-store.ts +0 -724
  131. package/lib/review-facade.ts +0 -508
  132. package/lib/review-graph-reducer.ts +0 -55
  133. package/lib/review-integration-v1.ts +0 -713
  134. package/lib/review-mirror.ts +0 -46
  135. package/lib/review-reset.ts +0 -262
  136. package/runtime/review-integration-v1.mjs +0 -714
  137. package/tests/native-review-integration-v1.test.ts +0 -102
  138. package/tests/review-authority-supersession.test.ts +0 -581
  139. package/tests/review-bundle.test.ts +0 -253
  140. package/tests/review-checkpoint.test.ts +0 -61
  141. package/tests/review-compact-gate.test.ts +0 -376
  142. package/tests/review-compact-store.test.ts +0 -124
  143. package/tests/review-facade.test.ts +0 -382
  144. package/tests/review-graph.test.ts +0 -27
  145. package/tests/review-integration-v1.test.ts +0 -510
  146. package/tests/review-mirror.test.ts +0 -20
  147. package/tests/review-reset.test.ts +0 -456
  148. package/tests/review-runtime-contract.test.ts +0 -70
@@ -1,1056 +0,0 @@
1
- import { execFileSync } from "node:child_process";
2
- import {
3
- closeSync,
4
- fsyncSync,
5
- linkSync,
6
- mkdirSync,
7
- openSync,
8
- lstatSync,
9
- readFileSync,
10
- existsSync,
11
- readdirSync,
12
- rmSync,
13
- writeFileSync,
14
- } from "node:fs";
15
- import { join, relative } from "node:path";
16
- import { canonicalJsonV1, domainHashV1, parseCanonicalJsonV1 } from "./review-canonical.ts";
17
- import { COMPACT_REVIEW_STATE, type CompactReviewStateV2 } from "./review-compact.ts";
18
- import { CompactReviewStoreV2, discoverCompactReviewStores, hasGraphV1Authority } from "./review-compact-store.ts";
19
- import { ReviewGraphObjectStoreV1 } from "./review-object-store.ts";
20
- import { assertManagedStorePathV1, resolveRepositoryAuthorityV1, reviewGitEnvironment, type RepositoryAuthorityV1 } from "./review-repository.ts";
21
- import { ReviewMutationLockV1, type ReviewLockPlatformAdapterV1 } from "./review-lock.ts";
22
- import { discoverReviewUntrackedPaths } from "./review-snapshot.ts";
23
- import { assertFrozenLedgerIntegrity, canonicalHash, createReceiptForState, validateReviewGraphReplayV1, type ReviewStateV1 } from "./review-transaction.ts";
24
-
25
- const DIGEST = /^[0-9a-f]{64}$/;
26
- const OPERATION_ID = /^[0-9a-f]{64}$/;
27
- const CHANGE_NAME = /^[a-z0-9][a-z0-9-]*$/;
28
-
29
- export const REVIEW_AUTHORITY_SUPERSESSION_ERROR = {
30
- INVALID: "REVIEW_SUPERSESSION_INVALID",
31
- INELIGIBLE_SOURCE: "REVIEW_SUPERSESSION_INELIGIBLE_SOURCE",
32
- EQUIVALENCE_MISMATCH: "REVIEW_SUPERSESSION_EQUIVALENCE_MISMATCH",
33
- OPERATION_CONFLICT: "REVIEW_SUPERSESSION_OPERATION_CONFLICT",
34
- CHAIN_AMBIGUOUS: "REVIEW_SUPERSESSION_CHAIN_AMBIGUOUS",
35
- UNTRACKED_DRIFT: "REVIEW_SUPERSESSION_UNTRACKED_DRIFT",
36
- STALE_AUTHORIZATION: "REVIEW_SUPERSESSION_STALE_AUTHORIZATION",
37
- REPOSITORY_MISMATCH: "REVIEW_SUPERSESSION_REPOSITORY_MISMATCH",
38
- RACE_UNSAFE: "REVIEW_SUPERSESSION_RACE_UNSAFE",
39
- } as const;
40
-
41
- export type ReviewAuthoritySupersessionErrorCode =
42
- (typeof REVIEW_AUTHORITY_SUPERSESSION_ERROR)[keyof typeof REVIEW_AUTHORITY_SUPERSESSION_ERROR];
43
-
44
- export class ReviewAuthoritySupersessionError extends Error {
45
- readonly code: ReviewAuthoritySupersessionErrorCode;
46
-
47
- constructor(code: ReviewAuthoritySupersessionErrorCode, message: string) {
48
- super(`${code}: ${message}`);
49
- this.name = "ReviewAuthoritySupersessionError";
50
- this.code = code;
51
- }
52
- }
53
-
54
- export interface RepositoryBindingV1 {
55
- repository_id: string;
56
- authority_id: string;
57
- common_directory_hash: string;
58
- repository_identity_hash: string;
59
- }
60
-
61
- export interface OpenSpecChangeBindingV1 {
62
- change_name: string;
63
- change_name_hash: string;
64
- change_root_relative_path: string;
65
- }
66
-
67
- export interface GraphAuthorityBindingV1 {
68
- format: "graph-v1";
69
- lineage_id: string;
70
- head_event_id: string;
71
- head_sequence: number;
72
- reduced_state_hash: string;
73
- source_closure_hash: string;
74
- receipt_hash: string;
75
- frozen_ledger_hash: string;
76
- }
77
-
78
- export interface CompactAuthorityBindingV1 {
79
- format: "compact-v2";
80
- lineage_id: string;
81
- authority_revision: string;
82
- state_hash: string;
83
- receipt_hash: string;
84
- ledger_hash: string;
85
- runtime_identity_hash: string;
86
- }
87
-
88
- export const INTENDED_UNTRACKED_PROOF_STATE = {
89
- ALL_UNTRACKED: "all-untracked",
90
- COMPLETE_INDEX: "complete-index",
91
- } as const;
92
-
93
- export type IntendedUntrackedProofStateV1 =
94
- (typeof INTENDED_UNTRACKED_PROOF_STATE)[keyof typeof INTENDED_UNTRACKED_PROOF_STATE];
95
-
96
- export interface IntendedUntrackedProofV1 {
97
- state: IntendedUntrackedProofStateV1;
98
- candidate_tree: string;
99
- index_tree: string | null;
100
- paths: string[];
101
- proof_hash: string;
102
- }
103
-
104
- export interface RecoveryEquivalenceBindingV1 {
105
- base_tree: string;
106
- complete_snapshot_tree: string;
107
- initial_review_tree: string;
108
- final_candidate_tree: string;
109
- review_projection_hash: string;
110
- genesis_paths_hash: string;
111
- scope_digest: string;
112
- intended_untracked_hash: string;
113
- intended_untracked_proof_hash: string;
114
- policy_hash: string;
115
- policy_evidence_hash: string;
116
- source_receipt_hash: string;
117
- successor_receipt_hash: string;
118
- source_ledger_hash: string;
119
- successor_ledger_hash: string;
120
- }
121
-
122
- export interface SupersessionBodyV1 {
123
- schema: "gentle-ai.review-authority-supersession/v1";
124
- operation_id: string;
125
- request_hash: string;
126
- sequence: number;
127
- predecessor_recovery_id: string | null;
128
- repository: RepositoryBindingV1;
129
- change: OpenSpecChangeBindingV1;
130
- source: GraphAuthorityBindingV1;
131
- successor: CompactAuthorityBindingV1;
132
- equivalence: RecoveryEquivalenceBindingV1;
133
- authorization_hash: string;
134
- }
135
-
136
- export interface SupersessionEnvelopeV1 {
137
- body: SupersessionBodyV1;
138
- recovery_id: string;
139
- }
140
-
141
- /** Read-only inspection result required before a graph-v1 source can be superseded. */
142
- export interface RecoverableSourceEligibilityV1 {
143
- repository: RepositoryBindingV1;
144
- change: OpenSpecChangeBindingV1;
145
- source: GraphAuthorityBindingV1;
146
- immutable: boolean;
147
- readable: boolean;
148
- graph_replay_valid: boolean;
149
- receipt_valid: boolean;
150
- frozen_ledger_valid: boolean;
151
- identity_broken: boolean;
152
- reset_in_progress: boolean;
153
- mixed_authority: boolean;
154
- duplicate_authority: boolean;
155
- }
156
-
157
- /** Independently derived evidence from one authority; it is never supplied by a controller hash. */
158
- export interface RecoveryAuthorityEvidenceV1 {
159
- base_tree: string;
160
- complete_snapshot_tree: string;
161
- initial_review_tree: string;
162
- final_candidate_tree: string;
163
- review_projection_hash: string;
164
- genesis_paths_hash: string;
165
- scope_digest: string;
166
- intended_untracked_hash: string;
167
- intended_untracked_proof_hash: string;
168
- policy_hash: string;
169
- policy_evidence_hash: string;
170
- receipt_hash: string;
171
- ledger_hash: string;
172
- }
173
-
174
- export interface RecoverySuccessorEquivalenceV1 {
175
- repository: RepositoryBindingV1;
176
- change: OpenSpecChangeBindingV1;
177
- source: GraphAuthorityBindingV1;
178
- successor: CompactAuthorityBindingV1;
179
- source_evidence: RecoveryAuthorityEvidenceV1;
180
- successor_evidence: RecoveryAuthorityEvidenceV1;
181
- }
182
-
183
- export interface PrepareSupersessionInputV1 {
184
- operation_id: string;
185
- eligibility: RecoverableSourceEligibilityV1;
186
- equivalence: RecoverySuccessorEquivalenceV1;
187
- predecessor_recovery_id?: string | null;
188
- sequence?: number;
189
- }
190
-
191
- export interface PreparedSupersessionV1 {
192
- body: SupersessionBodyV1;
193
- request_hash: string;
194
- challenge: string;
195
- }
196
-
197
- function assertDigest(value: unknown, field: string): asserts value is string {
198
- if (typeof value !== "string" || !DIGEST.test(value)) {
199
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.INVALID, `${field} must be a SHA-256 digest`);
200
- }
201
- }
202
-
203
- function assertObject(value: unknown, field: string): asserts value is Record<string, unknown> {
204
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
205
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.INVALID, `${field} must be an object`);
206
- }
207
- }
208
-
209
- function assertExactKeys(value: Record<string, unknown>, keys: readonly string[], field: string): void {
210
- if (Object.keys(value).length !== keys.length || keys.some((key) => !(key in value))) {
211
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.INVALID, `${field} has an invalid schema`);
212
- }
213
- }
214
-
215
- function assertBinding(value: unknown, keys: readonly string[], field: string): Record<string, unknown> {
216
- assertObject(value, field);
217
- assertExactKeys(value, keys, field);
218
- for (const key of keys) {
219
- if (key !== "format" && key !== "lineage_id" && key !== "head_sequence") assertDigest(value[key], `${field}.${key}`);
220
- }
221
- return value;
222
- }
223
-
224
- function assertBody(value: unknown): asserts value is SupersessionBodyV1 {
225
- assertObject(value, "body");
226
- assertExactKeys(value, ["schema", "operation_id", "request_hash", "sequence", "predecessor_recovery_id", "repository", "change", "source", "successor", "equivalence", "authorization_hash"], "body");
227
- if (value.schema !== "gentle-ai.review-authority-supersession/v1") throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.INVALID, "unsupported supersession schema");
228
- if (typeof value.operation_id !== "string" || !OPERATION_ID.test(value.operation_id)) throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.INVALID, "operation_id must be a digest");
229
- assertDigest(value.request_hash, "request_hash");
230
- assertDigest(value.authorization_hash, "authorization_hash");
231
- if (!Number.isSafeInteger(value.sequence) || value.sequence < 0) throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.INVALID, "sequence must be a non-negative integer");
232
- if (value.predecessor_recovery_id !== null) assertDigest(value.predecessor_recovery_id, "predecessor_recovery_id");
233
- assertRepositoryBinding(value.repository);
234
- assertChangeBinding(value.change);
235
- assertSourceBinding(value.source);
236
- assertSuccessorBinding(value.successor);
237
- assertBinding(value.equivalence, ["base_tree", "complete_snapshot_tree", "initial_review_tree", "final_candidate_tree", "review_projection_hash", "genesis_paths_hash", "scope_digest", "intended_untracked_hash", "intended_untracked_proof_hash", "policy_hash", "policy_evidence_hash", "source_receipt_hash", "successor_receipt_hash", "source_ledger_hash", "successor_ledger_hash"], "equivalence");
238
- }
239
-
240
- function assertRecoveryEvidence(value: unknown, field: string): asserts value is RecoveryAuthorityEvidenceV1 {
241
- assertObject(value, field);
242
- const keys = ["base_tree", "complete_snapshot_tree", "initial_review_tree", "final_candidate_tree", "review_projection_hash", "genesis_paths_hash", "scope_digest", "intended_untracked_hash", "intended_untracked_proof_hash", "policy_hash", "policy_evidence_hash", "receipt_hash", "ledger_hash"] as const;
243
- assertExactKeys(value, keys, field);
244
- for (const key of keys) assertDigest(value[key], `${field}.${key}`);
245
- }
246
-
247
- function assertSourceBinding(value: unknown): asserts value is GraphAuthorityBindingV1 {
248
- const source = assertBinding(value, ["format", "lineage_id", "head_event_id", "head_sequence", "reduced_state_hash", "source_closure_hash", "receipt_hash", "frozen_ledger_hash"], "source");
249
- if (source.format !== "graph-v1" || typeof source.lineage_id !== "string" || !source.lineage_id || !Number.isSafeInteger(source.head_sequence) || (source.head_sequence as number) < 0) {
250
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.INVALID, "source binding is invalid");
251
- }
252
- }
253
-
254
- function assertSuccessorBinding(value: unknown): asserts value is CompactAuthorityBindingV1 {
255
- const successor = assertBinding(value, ["format", "lineage_id", "authority_revision", "state_hash", "receipt_hash", "ledger_hash", "runtime_identity_hash"], "successor");
256
- if (successor.format !== "compact-v2" || typeof successor.lineage_id !== "string" || !successor.lineage_id) {
257
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.INVALID, "successor binding is invalid");
258
- }
259
- }
260
-
261
- function assertRepositoryBinding(value: unknown): asserts value is RepositoryBindingV1 {
262
- assertBinding(value, ["repository_id", "authority_id", "common_directory_hash", "repository_identity_hash"], "repository");
263
- }
264
-
265
- function assertChangeBinding(value: unknown): asserts value is OpenSpecChangeBindingV1 {
266
- assertObject(value, "change");
267
- assertExactKeys(value, ["change_name", "change_name_hash", "change_root_relative_path"], "change");
268
- if (typeof value.change_name !== "string" || !CHANGE_NAME.test(value.change_name) || value.change_root_relative_path !== `openspec/changes/${value.change_name}`) throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.INVALID, "change binding is not canonical");
269
- assertDigest(value.change_name_hash, "change.change_name_hash");
270
- if (value.change_name_hash !== domainHashV1("openspec-change-name", value.change_name)) {
271
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.INVALID, "change binding hash does not match the canonical change name");
272
- }
273
- }
274
-
275
- export function assertRecoverableSourceEligibilityV1(input: unknown): RecoverableSourceEligibilityV1 {
276
- assertObject(input, "source eligibility");
277
- const keys = ["repository", "change", "source", "immutable", "readable", "graph_replay_valid", "receipt_valid", "frozen_ledger_valid", "identity_broken", "reset_in_progress", "mixed_authority", "duplicate_authority"] as const;
278
- assertExactKeys(input, keys, "source eligibility");
279
- assertRepositoryBinding(input.repository);
280
- assertChangeBinding(input.change);
281
- assertSourceBinding(input.source);
282
- for (const key of keys.slice(3)) if (typeof input[key] !== "boolean") throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.INVALID, `source eligibility.${key} must be boolean`);
283
- if (!input.immutable || !input.readable || !input.graph_replay_valid || !input.receipt_valid || !input.frozen_ledger_valid || input.identity_broken || input.reset_in_progress || input.mixed_authority || input.duplicate_authority) {
284
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.INELIGIBLE_SOURCE, "source is not one readable immutable unambiguous approved graph-v1 authority");
285
- }
286
- return input as RecoverableSourceEligibilityV1;
287
- }
288
-
289
- function assertCanonicalRepositoryPathV1(path: string): void {
290
- if (!path || path.startsWith("/") || path.split("/").some((part) => part === "" || part === "." || part === "..")) {
291
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.UNTRACKED_DRIFT, "intended-untracked path is not canonical");
292
- }
293
- }
294
-
295
- function runProofGitV1(cwd: string, args: readonly string[]): string {
296
- return execFileSync("git", args, {
297
- cwd,
298
- encoding: "utf8",
299
- stdio: ["ignore", "pipe", "pipe"],
300
- env: reviewGitEnvironment(),
301
- }).trim();
302
- }
303
-
304
- function candidateTreeEntryV1(cwd: string, candidateTree: string, path: string): { mode: string; object: string } {
305
- const entry = execFileSync("git", ["ls-tree", "-z", candidateTree, "--", path], {
306
- cwd,
307
- encoding: "utf8",
308
- stdio: ["ignore", "pipe", "pipe"],
309
- env: reviewGitEnvironment(),
310
- });
311
- const record = entry.split("\0").filter(Boolean);
312
- if (record.length !== 1) {
313
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.UNTRACKED_DRIFT, `candidate tree is missing intended-untracked path ${path}`);
314
- }
315
- const match = /^(100644|100755|120000) blob ([0-9a-f]{40,64})\t/.exec(record[0]!);
316
- if (!match) {
317
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.UNTRACKED_DRIFT, `candidate tree has an invalid intended-untracked entry for ${path}`);
318
- }
319
- return { mode: match[1]!, object: match[2]! };
320
- }
321
-
322
- function workingTreeEntryV1(cwd: string, path: string): { mode: string; object: string } {
323
- const entry = lstatSync(join(cwd, path));
324
- const mode = entry.isSymbolicLink() ? "120000" : entry.isFile() ? (entry.mode & 0o111 ? "100755" : "100644") : null;
325
- if (mode === null) {
326
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.UNTRACKED_DRIFT, `intended-untracked path is not a file or symlink: ${path}`);
327
- }
328
- return { mode, object: runProofGitV1(cwd, ["hash-object", "--path", path, path]) };
329
- }
330
-
331
- /**
332
- * Re-derives the only two permitted live states for frozen intended-untracked paths.
333
- * It never trusts a prior proof: every path, blob, mode, untracked set, and index tree
334
- * is read from the repository at invocation time.
335
- */
336
- export function deriveIntendedUntrackedProofV1(cwd: string, candidateTree: string, intendedPaths: readonly string[]): IntendedUntrackedProofV1 {
337
- const paths = [...intendedPaths];
338
- if (paths.length === 0 || new Set(paths).size !== paths.length) {
339
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.UNTRACKED_DRIFT, "intended-untracked paths must be a non-empty unique set");
340
- }
341
- for (const path of paths) assertCanonicalRepositoryPathV1(path);
342
- paths.sort();
343
- const root = runProofGitV1(cwd, ["rev-parse", "--show-toplevel"]);
344
- const resolvedCandidate = runProofGitV1(root, ["rev-parse", `${candidateTree}^{tree}`]);
345
- const untracked = discoverReviewUntrackedPaths(root);
346
- const allUntracked = untracked.length === paths.length && untracked.every((path, index) => path === paths[index]);
347
- if (allUntracked) {
348
- for (const path of paths) {
349
- const candidate = candidateTreeEntryV1(root, resolvedCandidate, path);
350
- const working = workingTreeEntryV1(root, path);
351
- if (candidate.mode !== working.mode || candidate.object !== working.object) {
352
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.UNTRACKED_DRIFT, `working-tree content or mode drifted for ${path}`);
353
- }
354
- }
355
- const state = INTENDED_UNTRACKED_PROOF_STATE.ALL_UNTRACKED;
356
- return { state, candidate_tree: resolvedCandidate, index_tree: null, paths, proof_hash: domainHashV1("review-intended-untracked-proof", { state, candidate_tree: resolvedCandidate, paths }) };
357
- }
358
- if (untracked.length !== 0) {
359
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.UNTRACKED_DRIFT, "intended-untracked paths are in a mixed, missing, or extra untracked state");
360
- }
361
- for (const path of paths) {
362
- if (!runProofGitV1(root, ["ls-files", "--error-unmatch", "--", path])) {
363
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.UNTRACKED_DRIFT, `index is missing intended-untracked path ${path}`);
364
- }
365
- }
366
- const indexTree = runProofGitV1(root, ["write-tree"]);
367
- if (indexTree !== resolvedCandidate) {
368
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.UNTRACKED_DRIFT, "complete index tree does not exactly match the approved candidate");
369
- }
370
- const state = INTENDED_UNTRACKED_PROOF_STATE.COMPLETE_INDEX;
371
- return { state, candidate_tree: resolvedCandidate, index_tree: indexTree, paths, proof_hash: domainHashV1("review-intended-untracked-proof", { state, candidate_tree: resolvedCandidate, index_tree: indexTree, paths }) };
372
- }
373
-
374
- export function assertRecoverySuccessorEquivalenceV1(input: unknown): RecoverySuccessorEquivalenceV1 {
375
- assertObject(input, "successor equivalence");
376
- assertExactKeys(input, ["repository", "change", "source", "successor", "source_evidence", "successor_evidence"], "successor equivalence");
377
- assertRepositoryBinding(input.repository);
378
- assertChangeBinding(input.change);
379
- assertSourceBinding(input.source);
380
- assertSuccessorBinding(input.successor);
381
- assertRecoveryEvidence(input.source_evidence, "source evidence");
382
- assertRecoveryEvidence(input.successor_evidence, "successor evidence");
383
- const source = input.source_evidence;
384
- const successor = input.successor_evidence;
385
- const shared = ["base_tree", "complete_snapshot_tree", "initial_review_tree", "final_candidate_tree", "review_projection_hash", "genesis_paths_hash", "scope_digest", "intended_untracked_hash", "intended_untracked_proof_hash", "policy_hash", "policy_evidence_hash"] as const;
386
- if (shared.some((key) => source[key] !== successor[key]) || source.receipt_hash !== input.source.receipt_hash || source.ledger_hash !== input.source.frozen_ledger_hash || successor.receipt_hash !== input.successor.receipt_hash || successor.ledger_hash !== input.successor.ledger_hash) {
387
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.EQUIVALENCE_MISMATCH, "source and successor do not prove complete target and evidence equivalence");
388
- }
389
- return input as RecoverySuccessorEquivalenceV1;
390
- }
391
-
392
- function repositoryBindingV1(cwd: string): RepositoryBindingV1 {
393
- const authority = resolveRepositoryAuthorityV1(cwd);
394
- return {
395
- repository_id: authority.repository_id,
396
- authority_id: authority.authority_id,
397
- common_directory_hash: domainHashV1("common-directory", authority.common_directory),
398
- repository_identity_hash: domainHashV1("repository-identity", authority.repository_identity),
399
- };
400
- }
401
-
402
- function assertNoSupersessionResetInProgressV1(storeRoot: string): void {
403
- const resetPath = join(storeRoot, "control", "reset-state.json");
404
- if (!existsSync(resetPath)) return;
405
- try {
406
- const reset = JSON.parse(readFileSync(resetPath, "utf8")) as { body?: { phase?: unknown } };
407
- if (reset.body?.phase === "complete") return;
408
- } catch {
409
- // An unreadable reset marker is authority ambiguity, never permission to recover.
410
- }
411
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.INELIGIBLE_SOURCE, "destructive reset is in progress or unreadable");
412
- }
413
-
414
- function changeBindingV1(changeName: string): OpenSpecChangeBindingV1 {
415
- if (!CHANGE_NAME.test(changeName)) throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.INVALID, "change name is not canonical");
416
- return { change_name: changeName, change_name_hash: domainHashV1("openspec-change-name", changeName), change_root_relative_path: `openspec/changes/${changeName}` };
417
- }
418
-
419
- /** Read a graph-v1 source directly from its immutable repository store without mutation. */
420
- export function hasEligibleGraphV1RecoveryAuthorityV1(cwd: string): boolean {
421
- if (!hasGraphV1Authority(cwd)) return false;
422
- try {
423
- const authority = resolveRepositoryAuthorityV1(cwd);
424
- assertNoSupersessionResetInProgressV1(authority.store_root);
425
- const graph = new ReviewGraphObjectStoreV1(join(authority.store_root, "graph-v1"), authority.repository_id, authority.authority_id);
426
- const entries = graph.readCurrent().body.lineages as Array<Record<string, unknown>>;
427
- for (const entry of entries) {
428
- if (entry.mode !== "graph") continue;
429
- if (typeof entry.lineage_id !== "string" || typeof entry.head_event_id !== "string" || typeof entry.sequence !== "number" || typeof entry.reduced_state_hash !== "string") {
430
- throw new Error("graph authority head is malformed");
431
- }
432
- const reversed: ReturnType<ReviewGraphObjectStoreV1["readEvent"]>[] = [];
433
- let eventId: string | null = entry.head_event_id;
434
- let sequence = entry.sequence;
435
- while (eventId !== null) {
436
- const event = graph.readEvent(eventId);
437
- if (event.body.lineage_id !== entry.lineage_id || event.body.sequence !== sequence) throw new Error("graph authority closure is discontinuous");
438
- reversed.push(event);
439
- eventId = event.body.predecessor_event_id;
440
- sequence -= 1;
441
- }
442
- const state = validateReviewGraphReplayV1(reversed.reverse());
443
- if (state.mode !== "ordinary" || state.terminal_state !== "approved" || !state.frozen_ledger) continue;
444
- assertFrozenLedgerIntegrity(state.frozen_ledger);
445
- if (canonicalHash(state) !== entry.reduced_state_hash || createReceiptForState(state).body.terminal_state !== "approved") {
446
- throw new Error("graph authority replay or receipt is invalid");
447
- }
448
- return true;
449
- }
450
- return false;
451
- } catch (error) {
452
- if (error instanceof ReviewAuthoritySupersessionError) throw error;
453
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.INELIGIBLE_SOURCE, `graph recovery eligibility inspection failed: ${error instanceof Error ? error.message : String(error)}`);
454
- }
455
- }
456
-
457
- export function inspectRecoverableGraphSourceV1(cwd: string, changeName: string, lineageId: string): RecoverableSourceEligibilityV1 {
458
- try {
459
- const authority = resolveRepositoryAuthorityV1(cwd);
460
- assertNoSupersessionResetInProgressV1(authority.store_root);
461
- const graph = new ReviewGraphObjectStoreV1(join(authority.store_root, "graph-v1"), authority.repository_id, authority.authority_id);
462
- const root = graph.readCurrent();
463
- const graphEntries = root.body.lineages as Array<Record<string, unknown>>;
464
- const entries = graphEntries.filter((entry) => entry.lineage_id === lineageId && entry.mode === "graph");
465
- if (entries.length !== 1) throw new Error("source lineage is absent or duplicated");
466
- const entry = entries[0]!;
467
- if (typeof entry.head_event_id !== "string" || typeof entry.sequence !== "number" || typeof entry.reduced_state_hash !== "string") throw new Error("source graph head is malformed");
468
- const reversed: ReturnType<ReviewGraphObjectStoreV1["readEvent"]>[] = [];
469
- let eventId: string | null = entry.head_event_id;
470
- let sequence = entry.sequence;
471
- while (eventId !== null) {
472
- const event = graph.readEvent(eventId);
473
- if (event.body.lineage_id !== lineageId || event.body.sequence !== sequence) throw new Error("source graph closure is discontinuous");
474
- reversed.push(event);
475
- eventId = event.body.predecessor_event_id;
476
- sequence -= 1;
477
- }
478
- const events = reversed.reverse();
479
- const state = validateReviewGraphReplayV1(events);
480
- if (state.mode !== "ordinary" || state.terminal_state !== "approved" || !state.frozen_ledger) throw new Error("source is not an approved ordinary graph lineage");
481
- assertFrozenLedgerIntegrity(state.frozen_ledger);
482
- const receipt = createReceiptForState(state);
483
- if (receipt.body.terminal_state !== "approved") throw new Error("source receipt is not approved");
484
- return assertRecoverableSourceEligibilityV1({
485
- repository: repositoryBindingV1(cwd),
486
- change: changeBindingV1(changeName),
487
- source: {
488
- format: "graph-v1", lineage_id: lineageId, head_event_id: entry.head_event_id, head_sequence: entry.sequence,
489
- reduced_state_hash: entry.reduced_state_hash, source_closure_hash: domainHashV1("graph-source-closure", events),
490
- receipt_hash: receipt.receipt_hash, frozen_ledger_hash: state.frozen_ledger.frozen_ledger_hash,
491
- },
492
- immutable: true, readable: true, graph_replay_valid: canonicalHash(state) === entry.reduced_state_hash,
493
- receipt_valid: true, frozen_ledger_valid: true, identity_broken: false, reset_in_progress: false, mixed_authority: false, duplicate_authority: false,
494
- });
495
- } catch (error) {
496
- if (error instanceof ReviewAuthoritySupersessionError) throw error;
497
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.INELIGIBLE_SOURCE, `source inspection failed: ${error instanceof Error ? error.message : String(error)}`);
498
- }
499
- }
500
-
501
- /** Load and validate an independently approved compact-v2 successor from its repository store. */
502
- export function inspectApprovedCompactSuccessorV1(cwd: string, lineageId: string): CompactAuthorityBindingV1 {
503
- try {
504
- const claimants = discoverCompactReviewStores(cwd);
505
- if (claimants.length !== 1) {
506
- throw new Error("successor authority is absent or ambiguous");
507
- }
508
- const terminal = claimants[0]!.loadTerminalReceipt();
509
- if (terminal.record.state.lineage_id !== lineageId) {
510
- throw new Error("successor authority is claimed by another compact lineage");
511
- }
512
- if (terminal.record.state.state !== COMPACT_REVIEW_STATE.APPROVED) throw new Error("successor is not approved");
513
- return {
514
- format: "compact-v2", lineage_id: terminal.record.state.lineage_id, authority_revision: terminal.record.revision,
515
- state_hash: canonicalHash(terminal.record.state), receipt_hash: terminal.receipt.receipt_hash,
516
- ledger_hash: domainHashV1("compact-findings", terminal.record.state.findings), runtime_identity_hash: terminal.record.state.runtime_identity.identity_hash,
517
- };
518
- } catch (error) {
519
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.INELIGIBLE_SOURCE, `successor inspection failed: ${error instanceof Error ? error.message : String(error)}`);
520
- }
521
- }
522
-
523
- export function prepareSupersessionV1(input: PrepareSupersessionInputV1): PreparedSupersessionV1 {
524
- if (typeof input.operation_id !== "string" || !OPERATION_ID.test(input.operation_id)) {
525
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.INVALID, "operation_id must be a digest");
526
- }
527
- const eligibility = assertRecoverableSourceEligibilityV1(input.eligibility);
528
- const equivalence = assertRecoverySuccessorEquivalenceV1(input.equivalence);
529
- if (
530
- canonicalJsonV1(eligibility.repository) !== canonicalJsonV1(equivalence.repository) ||
531
- canonicalJsonV1(eligibility.change) !== canonicalJsonV1(equivalence.change) ||
532
- canonicalJsonV1(eligibility.source) !== canonicalJsonV1(equivalence.source)
533
- ) {
534
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.EQUIVALENCE_MISMATCH, "inspection identities do not bind the same source");
535
- }
536
- const predecessor = input.predecessor_recovery_id ?? null;
537
- if (predecessor !== null) assertDigest(predecessor, "predecessor_recovery_id");
538
- const sequence = input.sequence ?? 0;
539
- if (!Number.isSafeInteger(sequence) || sequence < 0) {
540
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.INVALID, "sequence must be a non-negative integer");
541
- }
542
- const equivalenceBinding: RecoveryEquivalenceBindingV1 = {
543
- base_tree: equivalence.source_evidence.base_tree,
544
- complete_snapshot_tree: equivalence.source_evidence.complete_snapshot_tree,
545
- initial_review_tree: equivalence.source_evidence.initial_review_tree,
546
- final_candidate_tree: equivalence.source_evidence.final_candidate_tree,
547
- review_projection_hash: equivalence.source_evidence.review_projection_hash,
548
- genesis_paths_hash: equivalence.source_evidence.genesis_paths_hash,
549
- scope_digest: equivalence.source_evidence.scope_digest,
550
- intended_untracked_hash: equivalence.source_evidence.intended_untracked_hash,
551
- intended_untracked_proof_hash: equivalence.source_evidence.intended_untracked_proof_hash,
552
- policy_hash: equivalence.source_evidence.policy_hash,
553
- policy_evidence_hash: equivalence.source_evidence.policy_evidence_hash,
554
- source_receipt_hash: eligibility.source.receipt_hash,
555
- successor_receipt_hash: equivalence.successor.receipt_hash,
556
- source_ledger_hash: eligibility.source.frozen_ledger_hash,
557
- successor_ledger_hash: equivalence.successor.ledger_hash,
558
- };
559
- const request = { operation_id: input.operation_id, sequence, predecessor_recovery_id: predecessor, repository: eligibility.repository, change: eligibility.change, source: eligibility.source, successor: equivalence.successor, equivalence: equivalenceBinding };
560
- const request_hash = domainHashV1("review-authority-supersession-request", request);
561
- const challenge = `SUPERSEDE REVIEW AUTHORITY ${eligibility.repository.repository_id} CHANGE ${eligibility.change.change_name}\nSOURCE graph-v1:${eligibility.source.lineage_id}@${eligibility.source.head_event_id}\nWITH compact-v2:${equivalence.successor.lineage_id}@${equivalence.successor.authority_revision}\nREQUEST ${request_hash}`;
562
- return {
563
- request_hash,
564
- challenge,
565
- body: {
566
- schema: "gentle-ai.review-authority-supersession/v1",
567
- ...request,
568
- request_hash,
569
- authorization_hash: domainHashV1("review-authority-supersession-pending-authorization", { challenge, request_hash }),
570
- },
571
- };
572
- }
573
-
574
- export function createSupersessionEnvelopeV1(body: SupersessionBodyV1): SupersessionEnvelopeV1 {
575
- assertBody(body);
576
- const canonicalBody = JSON.parse(canonicalJsonV1(body)) as SupersessionBodyV1;
577
- return { body: canonicalBody, recovery_id: domainHashV1("review-authority-supersession", canonicalBody) };
578
- }
579
-
580
- export function parseSupersessionEnvelopeV1(input: string): SupersessionEnvelopeV1 {
581
- const parsed = parseCanonicalJsonV1(input);
582
- assertObject(parsed, "supersession envelope");
583
- assertExactKeys(parsed, ["body", "recovery_id"], "supersession envelope");
584
- assertBody(parsed.body);
585
- assertDigest(parsed.recovery_id, "recovery_id");
586
- const expected = createSupersessionEnvelopeV1(parsed.body);
587
- if (expected.recovery_id !== parsed.recovery_id) throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.INVALID, "recovery_id does not bind the body");
588
- return expected;
589
- }
590
-
591
- /** Atomically append one operation record. Existing identical bytes are an idempotent retry. */
592
- export interface SupersessionWriteOptionsV1 {
593
- faultInjector?: (point: "before-temp-fsync" | "before-link" | "after-link" | "before-directory-fsync") => void;
594
- }
595
-
596
- function fsyncDirectoryV1(directory: string): void {
597
- if (process.platform === "win32") return;
598
- const handle = openSync(directory, "r");
599
- try { fsyncSync(handle); } finally { closeSync(handle); }
600
- }
601
-
602
- function fsyncSupersessionDirectoryV1(directory: string, options: SupersessionWriteOptionsV1): void {
603
- options.faultInjector?.("before-directory-fsync");
604
- fsyncDirectoryV1(directory);
605
- }
606
-
607
- export function writeSupersessionRecordV1(directory: string, envelope: SupersessionEnvelopeV1, options: SupersessionWriteOptionsV1 = {}): string {
608
- const canonical = createSupersessionEnvelopeV1(envelope.body);
609
- if (canonical.recovery_id !== envelope.recovery_id) throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.INVALID, "envelope is not content-bound");
610
- mkdirSync(directory, { recursive: true, mode: 0o700 });
611
- const path = join(directory, `${canonical.body.operation_id}.json`);
612
- const bytes = canonicalJsonV1(canonical);
613
- try {
614
- const existing = readFileSync(path, "utf8");
615
- if (existing === bytes) {
616
- fsyncSupersessionDirectoryV1(directory, options);
617
- return path;
618
- }
619
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.OPERATION_CONFLICT, "operation ID already has different semantics");
620
- } catch (error) {
621
- if (error instanceof ReviewAuthoritySupersessionError) throw error;
622
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
623
- }
624
- const temporary = join(directory, `.${canonical.body.operation_id}.${process.pid}.tmp`);
625
- try {
626
- writeFileSync(temporary, bytes, { flag: "wx", mode: 0o600 });
627
- options.faultInjector?.("before-temp-fsync");
628
- const file = openSync(temporary, "r+");
629
- try { fsyncSync(file); } finally { closeSync(file); }
630
- options.faultInjector?.("before-link");
631
- try { linkSync(temporary, path); } catch (error) {
632
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
633
- const existing = readFileSync(path, "utf8");
634
- if (existing !== bytes) throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.OPERATION_CONFLICT, "operation ID already has different semantics");
635
- }
636
- options.faultInjector?.("after-link");
637
- fsyncSupersessionDirectoryV1(directory, options);
638
- return path;
639
- } finally {
640
- rmSync(temporary, { force: true });
641
- }
642
- }
643
-
644
- function chainError(message: string): never {
645
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.CHAIN_AMBIGUOUS, message);
646
- }
647
-
648
- /**
649
- * Read immutable records for one change and derive their sole linear head.
650
- * Filesystem ordering is deliberately ignored; every predecessor and sequence
651
- * is bound by record content.
652
- */
653
- export function loadSupersessionChainV1(directory: string): SupersessionEnvelopeV1[] {
654
- let names: string[];
655
- try {
656
- names = readdirSync(directory);
657
- } catch (error) {
658
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
659
- throw error;
660
- }
661
- const records: SupersessionEnvelopeV1[] = [];
662
- for (const name of names) {
663
- if (/^\.[0-9a-f]{64}\.\d+\.tmp$/.test(name)) continue;
664
- if (!/^[0-9a-f]{64}\.json$/.test(name)) chainError("supersession directory contains an unexpected entry");
665
- const path = join(directory, name);
666
- if (!lstatSync(path).isFile()) chainError("supersession record is not a regular file");
667
- const record = parseSupersessionEnvelopeV1(readFileSync(path, "utf8"));
668
- if (name !== `${record.body.operation_id}.json`) chainError("supersession record filename does not bind its operation");
669
- records.push(record);
670
- }
671
- if (records.length === 0) return [];
672
- const byRecoveryId = new Map(records.map((record) => [record.recovery_id, record]));
673
- if (byRecoveryId.size !== records.length) chainError("duplicate recovery identity");
674
- const children = new Map<string | null, SupersessionEnvelopeV1[]>();
675
- for (const record of records) {
676
- const predecessor = record.body.predecessor_recovery_id;
677
- if (predecessor !== null && !byRecoveryId.has(predecessor)) chainError("supersession predecessor is missing");
678
- const siblings = children.get(predecessor) ?? [];
679
- siblings.push(record);
680
- children.set(predecessor, siblings);
681
- }
682
- if ((children.get(null) ?? []).length !== 1) chainError("supersession chain must have one root");
683
- const chain: SupersessionEnvelopeV1[] = [];
684
- let current = children.get(null)![0]!;
685
- while (true) {
686
- if (current.body.sequence !== chain.length) chainError("supersession sequence is not contiguous");
687
- chain.push(current);
688
- const next = children.get(current.recovery_id) ?? [];
689
- if (next.length === 0) break;
690
- if (next.length !== 1) chainError("supersession chain forks");
691
- current = next[0]!;
692
- }
693
- if (chain.length !== records.length) chainError("supersession chain contains a cycle or disconnected record");
694
- return chain;
695
- }
696
-
697
-
698
- export interface SupersessionStoreOptionsV1 extends SupersessionWriteOptionsV1 {
699
- mutationLockPlatform?: ReviewLockPlatformAdapterV1;
700
- }
701
-
702
- export interface InstalledSupersessionV1 {
703
- recovery_id: string;
704
- path: string;
705
- }
706
-
707
- /** A caller-owned live observation that is checked only after acquiring the mutation lock. */
708
- export interface SupersessionCasCheckV1 {
709
- label: string;
710
- expected: string;
711
- observe: () => string;
712
- }
713
-
714
- export interface SupersessionInstallOptionsV1 {
715
- casChecks?: readonly SupersessionCasCheckV1[];
716
- }
717
-
718
- /**
719
- * Repository-anchored append-only supersession storage. This is deliberately
720
- * storage-only: change-aware authority selection remains Task 4.
721
- */
722
- export class SupersessionStoreV1 {
723
- readonly root: string;
724
- readonly #authority: RepositoryAuthorityV1;
725
- readonly #lock: ReviewMutationLockV1;
726
- readonly #options: SupersessionStoreOptionsV1;
727
-
728
- static forRepository(cwd: string, options: SupersessionStoreOptionsV1 = {}): SupersessionStoreV1 {
729
- return new SupersessionStoreV1(resolveRepositoryAuthorityV1(cwd), options);
730
- }
731
-
732
- private constructor(authority: RepositoryAuthorityV1, options: SupersessionStoreOptionsV1) {
733
- this.#authority = authority;
734
- this.#options = options;
735
- this.root = assertManagedStorePathV1(authority.common_directory, join(authority.store_root, "control", "authority-supersession-v1"));
736
- this.#lock = new ReviewMutationLockV1(join(authority.store_root, "control"), authority.repository_id, authority.authority_id, options.mutationLockPlatform);
737
- }
738
-
739
- load(changeName: string): SupersessionEnvelopeV1[] {
740
- return loadSupersessionChainV1(this.directoryForChange(changeName, false));
741
- }
742
-
743
- hasRecoveryRequiredMarker(changeName?: string): boolean {
744
- if (changeName !== undefined) return this.isRecoveryRequiredMarker(this.recoveryMarkerPath(changeName, false));
745
- try {
746
- return readdirSync(this.recoveryMarkerDirectory(false)).some((name) =>
747
- /^[0-9a-f]{64}\.json$/.test(name) && this.isRecoveryRequiredMarker(join(this.recoveryMarkerDirectory(false), name)),
748
- );
749
- } catch (error) {
750
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
751
- return true;
752
- }
753
- }
754
-
755
- hasValidRecoveryRequiredMarker(changeName: string): boolean {
756
- const path = this.recoveryMarkerPath(changeName, false);
757
- try {
758
- const entry = lstatSync(path);
759
- if (!entry.isFile() || entry.isSymbolicLink()) throw new Error("marker is not a regular file");
760
- const expected = { schema: "gentle-ai.recovery-required/v1", change_name: changeName };
761
- const raw = readFileSync(path, "utf8");
762
- if (canonicalJsonV1(JSON.parse(raw)) !== canonicalJsonV1(expected)) throw new Error("marker content is invalid");
763
- return true;
764
- } catch (error) {
765
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
766
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.CHAIN_AMBIGUOUS, `recovery-required marker is unreadable or invalid: ${error instanceof Error ? error.message : String(error)}`);
767
- }
768
- }
769
-
770
- install(changeName: string, input: SupersessionEnvelopeV1, installOptions: SupersessionInstallOptionsV1 = {}): InstalledSupersessionV1 {
771
- const envelope = createSupersessionEnvelopeV1(input.body);
772
- if (envelope.recovery_id !== input.recovery_id) {
773
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.INVALID, "installation envelope is not content-bound");
774
- }
775
- const owner = this.#lock.acquire();
776
- try {
777
- const directory = this.directoryForChange(changeName, true);
778
- this.assertCurrentRepository(envelope, changeName);
779
- const chain = loadSupersessionChainV1(directory);
780
- for (const check of installOptions.casChecks ?? []) {
781
- if (!check.label || check.observe() !== check.expected) {
782
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.STALE_AUTHORIZATION, `live ${check.label || "authority"} binding changed after preparation`);
783
- }
784
- }
785
- const existing = chain.find((record) => record.body.operation_id === envelope.body.operation_id);
786
- if (existing) {
787
- if (canonicalJsonV1(existing) !== canonicalJsonV1(envelope)) {
788
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.OPERATION_CONFLICT, "operation ID already has different semantics");
789
- }
790
- return { recovery_id: existing.recovery_id, path: writeSupersessionRecordV1(directory, envelope, this.#options) };
791
- }
792
- const predecessor = chain.at(-1);
793
- if (envelope.body.predecessor_recovery_id !== (predecessor?.recovery_id ?? null) || envelope.body.sequence !== chain.length) {
794
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.STALE_AUTHORIZATION, "prepared predecessor or sequence is stale");
795
- }
796
- for (const record of chain) this.assertSameChainBinding(record, envelope);
797
- this.writeRecoveryRequiredMarker(changeName);
798
- const path = writeSupersessionRecordV1(directory, envelope, this.#options);
799
- const reread = loadSupersessionChainV1(directory);
800
- if (reread.at(-1)?.recovery_id !== envelope.recovery_id || reread.length !== chain.length + 1) {
801
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.RACE_UNSAFE, "supersession readback did not produce one expected chain head");
802
- }
803
- return { recovery_id: envelope.recovery_id, path };
804
- } finally {
805
- this.#lock.release(owner);
806
- }
807
- }
808
-
809
- private directoryForChange(changeName: string, create: boolean): string {
810
- if (!CHANGE_NAME.test(changeName)) throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.INVALID, "change name is not canonical");
811
- const directory = assertManagedStorePathV1(this.#authority.common_directory, join(this.root, domainHashV1("openspec-change-name", changeName)));
812
- try {
813
- if (create) this.ensureAnchoredDirectory(directory);
814
- else if (existsSync(directory)) this.assertAnchoredDirectory(directory);
815
- return directory;
816
- } catch (error) {
817
- if (error instanceof ReviewAuthoritySupersessionError) throw error;
818
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.REPOSITORY_MISMATCH, `supersession store path is unsafe: ${error instanceof Error ? error.message : String(error)}`);
819
- }
820
- }
821
-
822
- private recoveryMarkerDirectory(create: boolean): string {
823
- const directory = assertManagedStorePathV1(this.#authority.common_directory, join(this.root, "recovery-required-v1"));
824
- if (create) this.ensureAnchoredDirectory(directory);
825
- else if (existsSync(directory)) this.assertAnchoredDirectory(directory);
826
- return directory;
827
- }
828
-
829
- private recoveryMarkerPath(changeName: string, create: boolean): string {
830
- if (!CHANGE_NAME.test(changeName)) throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.INVALID, "change name is not canonical");
831
- return join(this.recoveryMarkerDirectory(create), `${domainHashV1("openspec-change-name", changeName)}.json`);
832
- }
833
-
834
- private isRecoveryRequiredMarker(path: string): boolean {
835
- try {
836
- const entry = lstatSync(path);
837
- return entry.isFile() && !entry.isSymbolicLink();
838
- } catch (error) {
839
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
840
- return true;
841
- }
842
- }
843
-
844
- private writeRecoveryRequiredMarker(changeName: string): void {
845
- const path = this.recoveryMarkerPath(changeName, true);
846
- if (!this.isRecoveryRequiredMarker(path)) {
847
- writeFileSync(path, canonicalJsonV1({ schema: "gentle-ai.recovery-required/v1", change_name: changeName }), { flag: "wx", mode: 0o600 });
848
- const file = openSync(path, "r+");
849
- try { fsyncSync(file); } finally { closeSync(file); }
850
- }
851
- fsyncDirectoryV1(this.recoveryMarkerDirectory(false));
852
- }
853
-
854
- private ensureAnchoredDirectory(directory: string): void {
855
- let current = this.#authority.common_directory;
856
- for (const part of relative(current, directory).split(/[\\/]/).filter(Boolean)) {
857
- const parent = current;
858
- current = join(parent, part);
859
- if (!existsSync(current)) {
860
- mkdirSync(current, { mode: 0o700 });
861
- fsyncDirectoryV1(parent);
862
- }
863
- this.assertAnchoredDirectory(current);
864
- }
865
- }
866
-
867
- private assertAnchoredDirectory(directory: string): void {
868
- const entry = lstatSync(directory);
869
- if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error("supersession store directory is not a real directory");
870
- assertManagedStorePathV1(this.#authority.common_directory, directory);
871
- }
872
-
873
- private assertCurrentRepository(envelope: SupersessionEnvelopeV1, changeName: string): void {
874
- const expected = repositoryBindingV1(this.#authority.common_directory);
875
- if (canonicalJsonV1(envelope.body.repository) !== canonicalJsonV1(expected) || envelope.body.change.change_name !== changeName) {
876
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.REPOSITORY_MISMATCH, "supersession does not bind this repository and change");
877
- }
878
- }
879
-
880
- private assertSameChainBinding(previous: SupersessionEnvelopeV1, next: SupersessionEnvelopeV1): void {
881
- if (canonicalJsonV1(previous.body.repository) !== canonicalJsonV1(next.body.repository) || canonicalJsonV1(previous.body.change) !== canonicalJsonV1(next.body.change) || canonicalJsonV1(previous.body.source) !== canonicalJsonV1(next.body.source)) {
882
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.CHAIN_AMBIGUOUS, "supersession chain changes its repository, change, or source binding");
883
- }
884
- }
885
- }
886
-
887
- /**
888
- * Re-derive successor-owned recovery bindings from live compact authority. This is
889
- * intentionally read-only and is called on both sides of a lifecycle final recheck.
890
- */
891
- export function assertLiveRecoveredSuccessorBindingV1(cwd: string, recovery: SupersessionEnvelopeV1, state: CompactReviewStateV2): void {
892
- const proofHash = state.intended_untracked.length === 0
893
- ? domainHashV1("review-recovery-empty-untracked", { candidate_tree: state.current_candidate_tree, paths: [] })
894
- : deriveIntendedUntrackedProofV1(cwd, state.current_candidate_tree, state.intended_untracked).proof_hash;
895
- const live = {
896
- base_tree: domainHashV1("review-recovery-tree", state.initial_snapshot.base_tree),
897
- complete_snapshot_tree: domainHashV1("review-recovery-tree", state.initial_snapshot.complete_snapshot_tree),
898
- initial_review_tree: domainHashV1("review-recovery-tree", state.initial_snapshot.initial_review_tree),
899
- final_candidate_tree: domainHashV1("review-recovery-tree", state.current_candidate_tree),
900
- review_projection_hash: domainHashV1("review-recovery-projection", state.initial_snapshot.review_projection),
901
- genesis_paths_hash: domainHashV1("compact-paths", state.genesis_paths),
902
- scope_digest: domainHashV1("review-recovery-scope", {
903
- base_tree: state.initial_snapshot.base_tree,
904
- complete_snapshot_tree: state.initial_snapshot.complete_snapshot_tree,
905
- initial_review_tree: state.initial_snapshot.initial_review_tree,
906
- final_candidate_tree: state.current_candidate_tree,
907
- genesis_paths: state.genesis_paths,
908
- intended_untracked: state.intended_untracked,
909
- }),
910
- intended_untracked_hash: domainHashV1("compact-untracked", state.intended_untracked),
911
- intended_untracked_proof_hash: proofHash,
912
- policy_hash: state.policy_hash,
913
- policy_evidence_hash: domainHashV1("review-recovery-policy-evidence", { policy_hash: state.policy_hash, runtime_identity_hash: state.runtime_identity.identity_hash }),
914
- successor_ledger_hash: domainHashV1("compact-findings", state.findings),
915
- };
916
- const bound = recovery.body.equivalence;
917
- for (const [field, value] of Object.entries(live)) {
918
- if (bound[field as keyof RecoveryEquivalenceBindingV1] !== value) {
919
- throw new ReviewAuthoritySupersessionError(
920
- field.includes("untracked") ? REVIEW_AUTHORITY_SUPERSESSION_ERROR.UNTRACKED_DRIFT : REVIEW_AUTHORITY_SUPERSESSION_ERROR.CHAIN_AMBIGUOUS,
921
- `recovery ${field} no longer matches live compact authority`,
922
- );
923
- }
924
- }
925
- if (bound.successor_receipt_hash !== recovery.body.successor.receipt_hash || bound.successor_ledger_hash !== recovery.body.successor.ledger_hash) {
926
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.CHAIN_AMBIGUOUS, "recovery receipt or ledger binding is inconsistent");
927
- }
928
- }
929
-
930
- /**
931
- * Re-derive graph-v1 source-owned evidence from immutable authority before and
932
- * after a recovered lifecycle target evaluation. Graph-v1 has no runtime
933
- * identity field, so policy evidence is established by its immutable policy
934
- * hash while the compact-side validator rederives the shared runtime evidence.
935
- */
936
- export function assertLiveRecoveredSourceBindingV1(cwd: string, recovery: SupersessionEnvelopeV1): void {
937
- const source = inspectRecoverableGraphSourceV1(cwd, recovery.body.change.change_name, recovery.body.source.lineage_id);
938
- if (
939
- canonicalJsonV1(source.repository) !== canonicalJsonV1(recovery.body.repository) ||
940
- canonicalJsonV1(source.change) !== canonicalJsonV1(recovery.body.change) ||
941
- canonicalJsonV1(source.source) !== canonicalJsonV1(recovery.body.source)
942
- ) {
943
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.CHAIN_AMBIGUOUS, "recovery source identity no longer matches live graph authority");
944
- }
945
- const authority = resolveRepositoryAuthorityV1(cwd);
946
- const graph = new ReviewGraphObjectStoreV1(join(authority.store_root, "graph-v1"), authority.repository_id, authority.authority_id);
947
- const entry = (graph.readCurrent().body.lineages as Array<Record<string, unknown>>).find((candidate) => candidate.lineage_id === source.source.lineage_id && candidate.mode === "graph");
948
- if (!entry || typeof entry.head_event_id !== "string" || typeof entry.sequence !== "number") {
949
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.CHAIN_AMBIGUOUS, "live graph source head is unavailable");
950
- }
951
- const reversed: ReturnType<ReviewGraphObjectStoreV1["readEvent"]>[] = [];
952
- let eventId: string | null = entry.head_event_id;
953
- let sequence = entry.sequence;
954
- while (eventId !== null) {
955
- const event = graph.readEvent(eventId);
956
- if (event.body.lineage_id !== source.source.lineage_id || event.body.sequence !== sequence) {
957
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.CHAIN_AMBIGUOUS, "live graph source closure is discontinuous");
958
- }
959
- reversed.push(event);
960
- eventId = event.body.predecessor_event_id;
961
- sequence -= 1;
962
- }
963
- const state = validateReviewGraphReplayV1(reversed.reverse());
964
- if (!state.frozen_ledger || !state.final_candidate_tree) {
965
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.CHAIN_AMBIGUOUS, "live graph source lacks terminal evidence");
966
- }
967
- // Graph-v1 did not persist an intended-untracked path set. Its immutable source
968
- // target therefore proves the only representable graph-v1 state: an empty set.
969
- const intendedUntracked: string[] = [];
970
- const intendedUntrackedProofHash = domainHashV1("review-recovery-empty-untracked", {
971
- candidate_tree: state.final_candidate_tree,
972
- paths: intendedUntracked,
973
- });
974
- const genesisPaths = state.genesis_paths ?? [];
975
- const live = {
976
- base_tree: domainHashV1("review-recovery-tree", state.base_tree),
977
- complete_snapshot_tree: domainHashV1("review-recovery-tree", state.complete_snapshot_tree),
978
- initial_review_tree: domainHashV1("review-recovery-tree", state.initial_review_tree),
979
- final_candidate_tree: domainHashV1("review-recovery-tree", state.final_candidate_tree),
980
- review_projection_hash: domainHashV1("review-recovery-projection", state.review_projection),
981
- genesis_paths_hash: domainHashV1("compact-paths", genesisPaths),
982
- scope_digest: domainHashV1("review-recovery-scope", {
983
- base_tree: state.base_tree,
984
- complete_snapshot_tree: state.complete_snapshot_tree,
985
- initial_review_tree: state.initial_review_tree,
986
- final_candidate_tree: state.final_candidate_tree,
987
- genesis_paths: genesisPaths,
988
- intended_untracked: intendedUntracked,
989
- }),
990
- intended_untracked_hash: domainHashV1("compact-untracked", intendedUntracked),
991
- intended_untracked_proof_hash: intendedUntrackedProofHash,
992
- policy_hash: state.policy_hash,
993
- source_receipt_hash: createReceiptForState(state).receipt_hash,
994
- source_ledger_hash: state.frozen_ledger.frozen_ledger_hash,
995
- };
996
- for (const [field, value] of Object.entries(live)) {
997
- if (recovery.body.equivalence[field as keyof RecoveryEquivalenceBindingV1] !== value) {
998
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.CHAIN_AMBIGUOUS, `recovery ${field} no longer matches live graph authority`);
999
- }
1000
- }
1001
- }
1002
-
1003
- /** A compact authority selected only through a complete, change-scoped recovery chain. */
1004
- export interface ResolvedReviewAuthorityForChangeV1 {
1005
- recovery: SupersessionEnvelopeV1;
1006
- store: CompactReviewStoreV2;
1007
- record: ReturnType<CompactReviewStoreV2["load"]>;
1008
- }
1009
-
1010
- /**
1011
- * Resolve recovery authority for one canonical OpenSpec change. Generic compact
1012
- * discovery intentionally does not call this: without the change name it cannot
1013
- * prove which supersession applies and must preserve graph/compact ambiguity.
1014
- */
1015
- export function resolveReviewAuthorityForChange(cwd: string, changeName: string): ResolvedReviewAuthorityForChangeV1 | undefined {
1016
- const supersessions = SupersessionStoreV1.forRepository(cwd);
1017
- const eligibleGraphAuthority = hasEligibleGraphV1RecoveryAuthorityV1(cwd);
1018
- const markerIsValid = supersessions.hasValidRecoveryRequiredMarker(changeName);
1019
- const chain = supersessions.load(changeName);
1020
- if (chain.length === 0) {
1021
- if (eligibleGraphAuthority || markerIsValid) {
1022
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.CHAIN_AMBIGUOUS, "recovery-required authority has no bound supersession record");
1023
- }
1024
- return undefined;
1025
- }
1026
- if (!markerIsValid) {
1027
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.CHAIN_AMBIGUOUS, "recovery-required supersession is missing its valid marker");
1028
- }
1029
- const recovery = chain.at(-1)!;
1030
- let source: RecoverableSourceEligibilityV1;
1031
- let successor: CompactAuthorityBindingV1;
1032
- try {
1033
- source = inspectRecoverableGraphSourceV1(cwd, changeName, recovery.body.source.lineage_id);
1034
- successor = inspectApprovedCompactSuccessorV1(cwd, recovery.body.successor.lineage_id);
1035
- } catch (error) {
1036
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.CHAIN_AMBIGUOUS, `recovery authority is unreadable or ambiguous: ${error instanceof Error ? error.message : String(error)}`);
1037
- }
1038
- if (
1039
- canonicalJsonV1(source.repository) !== canonicalJsonV1(recovery.body.repository) ||
1040
- canonicalJsonV1(source.change) !== canonicalJsonV1(recovery.body.change) ||
1041
- canonicalJsonV1(source.source) !== canonicalJsonV1(recovery.body.source)
1042
- ) {
1043
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.CHAIN_AMBIGUOUS, "recovery source or repository binding no longer matches live authority");
1044
- }
1045
- if (canonicalJsonV1(successor) !== canonicalJsonV1(recovery.body.successor)) {
1046
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.CHAIN_AMBIGUOUS, "recovery successor binding no longer matches live authority");
1047
- }
1048
- const store = CompactReviewStoreV2.forRepository(cwd, successor.lineage_id);
1049
- const record = store.load();
1050
- if (record.state.state !== COMPACT_REVIEW_STATE.APPROVED || !existsSync(store.receiptPath)) {
1051
- throw new ReviewAuthoritySupersessionError(REVIEW_AUTHORITY_SUPERSESSION_ERROR.CHAIN_AMBIGUOUS, "recovery successor is not terminally approved");
1052
- }
1053
- assertLiveRecoveredSourceBindingV1(cwd, recovery);
1054
- assertLiveRecoveredSuccessorBindingV1(cwd, recovery, record.state);
1055
- return { recovery, store, record };
1056
- }