pi-codex-marketplace 0.1.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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/extensions/pi/git-registration.ts +138 -0
  4. package/extensions/pi/index.ts +293 -0
  5. package/extensions/pi/installation.ts +90 -0
  6. package/extensions/pi/journal.ts +80 -0
  7. package/extensions/pi/lifecycle.ts +285 -0
  8. package/extensions/pi/registration.ts +143 -0
  9. package/extensions/pi/scope-overrides.ts +170 -0
  10. package/package.json +60 -0
  11. package/src/barrier/global-barrier.ts +105 -0
  12. package/src/bridge-state/atomic.ts +237 -0
  13. package/src/bridge-state/index.ts +5 -0
  14. package/src/bridge-state/migrate.ts +261 -0
  15. package/src/bridge-state/paths.ts +75 -0
  16. package/src/bridge-state/repair.ts +185 -0
  17. package/src/bridge-state/schema.ts +70 -0
  18. package/src/bridge-state/store.ts +489 -0
  19. package/src/bridge-state/types.ts +170 -0
  20. package/src/cache/index.ts +2 -0
  21. package/src/cache/paths.ts +42 -0
  22. package/src/cache/source-cache.ts +365 -0
  23. package/src/compatibility/index.ts +1 -0
  24. package/src/compatibility/profile.ts +328 -0
  25. package/src/installation/flow.ts +443 -0
  26. package/src/installation/index.ts +1 -0
  27. package/src/installation/inspection.ts +129 -0
  28. package/src/journal/active-chains.ts +99 -0
  29. package/src/journal/index.ts +3 -0
  30. package/src/journal/journal.ts +215 -0
  31. package/src/journal/types.ts +49 -0
  32. package/src/lifecycle/index.ts +5 -0
  33. package/src/lifecycle/rebind.ts +290 -0
  34. package/src/lifecycle/refresh.ts +407 -0
  35. package/src/lifecycle/removal.ts +457 -0
  36. package/src/lifecycle/update-plan.ts +222 -0
  37. package/src/lifecycle/update.ts +303 -0
  38. package/src/projection/collision.ts +120 -0
  39. package/src/projection/effective-state.ts +182 -0
  40. package/src/projection/index.ts +4 -0
  41. package/src/projection/overrides.ts +230 -0
  42. package/src/projection/project.ts +359 -0
  43. package/src/reconciliation/startup.ts +144 -0
  44. package/src/registration/budget.ts +28 -0
  45. package/src/registration/catalog.ts +224 -0
  46. package/src/registration/contained.ts +140 -0
  47. package/src/registration/fence.ts +86 -0
  48. package/src/registration/findings.ts +188 -0
  49. package/src/registration/flow.ts +619 -0
  50. package/src/registration/git-acquisition.ts +481 -0
  51. package/src/registration/git-flow.ts +654 -0
  52. package/src/registration/git-locator.ts +380 -0
  53. package/src/registration/git-selector.ts +279 -0
  54. package/src/registration/index.ts +16 -0
  55. package/src/registration/receipt.ts +305 -0
  56. package/src/registration/registration.ts +102 -0
  57. package/src/registration/snapshot.ts +382 -0
  58. package/src/registration/source-key.ts +111 -0
@@ -0,0 +1,261 @@
1
+ /**
2
+ * Bridge State WAL Migration — schemaVersion binding.
3
+ *
4
+ * Closed rules (per CONTEXT.md / Issue #24):
5
+ * - Versioned JSON schema with `schemaVersion` bound to Bridge Package version.
6
+ * - Supported forward migrations are applied atomically via WAL (write-ahead log).
7
+ * - Unknown / newer `schemaVersion` (> CURRENT) is `incompatible` — fail-closed, no auto-migrate, no rollback.
8
+ * - Downgrade (attempting to persist an older schemaVersion over a newer durable file) never writes back.
9
+ * - No implicit activation or automatic rollback on migration.
10
+ * - WAL is per-document (`state.json.wal` sibling), fsynced before commit, replayed on read, cleaned after success.
11
+ * - Corruption during migration leaves the previous durable revision verifiable or the file treated as Indeterminate.
12
+ */
13
+
14
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, unlinkSync, writeSync } from 'node:fs';
15
+ import { dirname } from 'node:path';
16
+
17
+ import { atomicWriteFile } from './atomic.js';
18
+ import { getWalPath } from './paths.js';
19
+ import { parseJson } from './schema.js';
20
+ import { CURRENT_SCHEMA_VERSION, type BridgeState, createEmptyState } from './types.js';
21
+
22
+ /** Known forward migrations: fromVersion -> migrator. Only migrations listed here are supported. */
23
+ type Migrator = (state: BridgeState) => BridgeState;
24
+
25
+ const MIGRATIONS: Record<number, Migrator> = {
26
+ // Future: 1 -> 2 example
27
+ // 1: (state) => ({ ...state, schemaVersion: 2, registrations: state.registrations.map(...) })
28
+ };
29
+
30
+ export interface MigrationResult {
31
+ ok: boolean;
32
+ state?: BridgeState;
33
+ migrated?: boolean;
34
+ /** From version before migration, when migrated. */
35
+ fromVersion?: number;
36
+ toVersion?: number;
37
+ error?: string;
38
+ code?: 'INCOMPATIBLE_NEWER' | 'UNKNOWN_OLD_VERSION' | 'MIGRATION_FAILED' | 'CORRUPTED' | 'DOWNGRADE_BLOCKED';
39
+ }
40
+
41
+ /**
42
+ * Attempt to migrate a parsed BridgeState forward to CURRENT_SCHEMA_VERSION.
43
+ * Returns:
44
+ * - ok:true + migrated:false when already current
45
+ * - ok:true + migrated:true when migrated via known chain
46
+ * - ok:false with INCOMPATIBLE_NEWER when file is newer than current (do not auto-migrate)
47
+ * - ok:false with UNKNOWN_OLD_VERSION when no migration path exists for an older version
48
+ */
49
+ export function migrateForward(state: BridgeState): MigrationResult {
50
+ const from = state.schemaVersion;
51
+
52
+ if (from === CURRENT_SCHEMA_VERSION) {
53
+ return { ok: true, state, migrated: false, fromVersion: from, toVersion: CURRENT_SCHEMA_VERSION };
54
+ }
55
+
56
+ if (!Number.isInteger(from) || from < 1) {
57
+ return {
58
+ ok: false,
59
+ code: 'CORRUPTED',
60
+ error: `Invalid schemaVersion ${from} — treated as corrupted`,
61
+ };
62
+ }
63
+
64
+ if (from > CURRENT_SCHEMA_VERSION) {
65
+ return {
66
+ ok: false,
67
+ code: 'INCOMPATIBLE_NEWER',
68
+ error: `Incompatible schemaVersion ${from} > supported ${CURRENT_SCHEMA_VERSION} — requires newer Bridge Package (no downgrade write-back)`,
69
+ };
70
+ }
71
+
72
+ // from < CURRENT: need forward chain
73
+ let cur: BridgeState = structuredClone(state);
74
+ let version = from;
75
+ while (version < CURRENT_SCHEMA_VERSION) {
76
+ const migrator = MIGRATIONS[version];
77
+ if (!migrator) {
78
+ return {
79
+ ok: false,
80
+ code: 'UNKNOWN_OLD_VERSION',
81
+ error: `No supported migration path from schemaVersion ${from} to ${CURRENT_SCHEMA_VERSION} (missing ${version}→${version + 1}) — fail-closed`,
82
+ };
83
+ }
84
+ try {
85
+ cur = migrator(cur);
86
+ } catch (e) {
87
+ const msg = e instanceof Error ? e.message : String(e);
88
+ return {
89
+ ok: false,
90
+ code: 'MIGRATION_FAILED',
91
+ error: `Migration ${version}→${version + 1} failed: ${msg}`,
92
+ };
93
+ }
94
+ // Migrator must bump schemaVersion exactly by one; enforce closed invariant
95
+ if (cur.schemaVersion !== version + 1) {
96
+ return {
97
+ ok: false,
98
+ code: 'MIGRATION_FAILED',
99
+ error: `Migration ${version}→${version + 1} did not set schemaVersion to ${version + 1} (got ${cur.schemaVersion})`,
100
+ };
101
+ }
102
+ version = cur.schemaVersion;
103
+ }
104
+
105
+ return { ok: true, state: cur, migrated: true, fromVersion: from, toVersion: CURRENT_SCHEMA_VERSION };
106
+ }
107
+
108
+ /**
109
+ * Downgrade guard: refuse to persist a state whose schemaVersion is older than the durable file.
110
+ * Returns true if write should be blocked (never write back to an older version).
111
+ */
112
+ export function isDowngradeAttempt(durableVersion: number, targetVersion: number): boolean {
113
+ return targetVersion < durableVersion;
114
+ }
115
+
116
+ /**
117
+ * WAL helpers — per-document write-ahead log at `state.json.wal`.
118
+ * The WAL holds the *target* state JSON before the atomic rename, plus a header with from/to versions.
119
+ * On read, if WAL exists but state.json still holds the old revision, the WAL can be replayed or cleaned.
120
+ */
121
+
122
+ interface WalRecord {
123
+ fromVersion: number;
124
+ toVersion: number;
125
+ fromRevision: string;
126
+ targetState: BridgeState;
127
+ createdAt: string;
128
+ }
129
+
130
+ function writeWalSync(statePath: string, record: WalRecord): void {
131
+ const walPath = getWalPath(statePath);
132
+ const dir = dirname(statePath);
133
+ mkdirSync(dir, { recursive: true });
134
+ const data = JSON.stringify(record, null, 2) + '\n';
135
+ const fd = openSync(walPath, 'w', 0o600);
136
+ try {
137
+ writeSync(fd, data, null, 'utf-8');
138
+ fsyncSync(fd);
139
+ } finally {
140
+ closeSync(fd);
141
+ }
142
+ try {
143
+ const dirFd = openSync(dir, 'r');
144
+ try {
145
+ fsyncSync(dirFd);
146
+ } finally {
147
+ closeSync(dirFd);
148
+ }
149
+ } catch {}
150
+ }
151
+
152
+ function readWalSync(statePath: string): WalRecord | undefined {
153
+ const walPath = getWalPath(statePath);
154
+ if (!existsSync(walPath)) return undefined;
155
+ try {
156
+ const raw = readFileSync(walPath, 'utf-8');
157
+ const parsed = JSON.parse(raw) as WalRecord;
158
+ if (typeof parsed.fromVersion !== 'number' || typeof parsed.toVersion !== 'number' || !parsed.targetState) return undefined;
159
+ return parsed;
160
+ } catch {
161
+ return undefined;
162
+ }
163
+ }
164
+
165
+ function removeWalSync(statePath: string): void {
166
+ const walPath = getWalPath(statePath);
167
+ if (!existsSync(walPath)) return;
168
+ try {
169
+ unlinkSync(walPath);
170
+ } catch {}
171
+ }
172
+
173
+ /**
174
+ * Perform a WAL-guarded migration commit: write WAL, then atomic rename.
175
+ * Caller must already hold the file lock.
176
+ * Returns true when the migrated state was durable, false otherwise.
177
+ */
178
+ export function commitMigratedState(statePath: string, migrated: BridgeState, fromVersion: number, fromRevision: string): boolean {
179
+ const record: WalRecord = {
180
+ fromVersion,
181
+ toVersion: migrated.schemaVersion,
182
+ fromRevision,
183
+ targetState: migrated,
184
+ createdAt: new Date().toISOString(),
185
+ };
186
+ try {
187
+ writeWalSync(statePath, record);
188
+ } catch {
189
+ return false;
190
+ }
191
+
192
+ const data = JSON.stringify(migrated, null, 2) + '\n';
193
+ const result = atomicWriteFile(statePath, data);
194
+ if (!result.success) {
195
+ // WAL remains for replay on next read; caller treats as Persistence Failed/Indeterminate
196
+ return false;
197
+ }
198
+
199
+ // Success: remove WAL
200
+ try {
201
+ removeWalSync(statePath);
202
+ } catch {}
203
+ return true;
204
+ }
205
+
206
+ /**
207
+ * Attempt to replay or clean a stale WAL on read.
208
+ * If WAL's target matches the current file's revision/version, WAL is just cleaned.
209
+ * If WAL's source matches the current file but target is newer, WAL target is re-applied (recovery without new confirmation).
210
+ * In all other cases WAL is removed as orphaned.
211
+ */
212
+ export function recoverWalIfNeeded(statePath: string, currentState: BridgeState | null): { recovered: boolean; state?: BridgeState } {
213
+ const wal = readWalSync(statePath);
214
+ if (!wal) return { recovered: false };
215
+
216
+ if (!currentState) {
217
+ // No durable file — apply WAL target if it looks valid
218
+ const ok = wal.targetState && wal.targetState.schemaVersion === CURRENT_SCHEMA_VERSION;
219
+ if (!ok) {
220
+ removeWalSync(statePath);
221
+ return { recovered: false };
222
+ }
223
+ const data = JSON.stringify(wal.targetState, null, 2) + '\n';
224
+ const res = atomicWriteFile(statePath, data);
225
+ if (res.success) {
226
+ removeWalSync(statePath);
227
+ return { recovered: true, state: wal.targetState };
228
+ }
229
+ return { recovered: false };
230
+ }
231
+
232
+ // File exists; compare
233
+ if (currentState.schemaVersion === wal.toVersion && currentState.stateRevision === wal.targetState.stateRevision) {
234
+ // Already applied
235
+ removeWalSync(statePath);
236
+ return { recovered: false };
237
+ }
238
+ if (currentState.schemaVersion === wal.fromVersion && currentState.stateRevision === wal.fromRevision) {
239
+ // Replay WAL: durable still holds old revision, WAL holds migrated target
240
+ const data = JSON.stringify(wal.targetState, null, 2) + '\n';
241
+ const res = atomicWriteFile(statePath, data);
242
+ if (res.success) {
243
+ removeWalSync(statePath);
244
+ return { recovered: true, state: wal.targetState };
245
+ }
246
+ return { recovered: false };
247
+ }
248
+
249
+ // Orphan / mismatch — clean
250
+ removeWalSync(statePath);
251
+ return { recovered: false };
252
+ }
253
+
254
+ /** For tests: expose internals */
255
+ export const _internal = {
256
+ MIGRATIONS,
257
+ writeWalSync,
258
+ readWalSync,
259
+ removeWalSync,
260
+ CURRENT_SCHEMA_VERSION,
261
+ };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Dual-path helpers for Bridge State documents.
3
+ * - Global: {getAgentDir()}/codex-marketplace/state.json
4
+ * - Project: {cwd}/.pi/codex-marketplace/state.json
5
+ *
6
+ * getAgentDir() mirrors Pi's config.getAgentDir(): honors PI_CODING_AGENT_DIR,
7
+ * otherwise ~/.pi/agent. Project path uses CONFIG_DIR_NAME ".pi".
8
+ */
9
+
10
+ import { homedir } from 'node:os';
11
+ import { join } from 'node:path';
12
+
13
+ export const CONFIG_DIR_NAME = '.pi';
14
+ export const BRIDGE_SUBDIR = 'codex-marketplace';
15
+ export const STATE_FILENAME = 'state.json';
16
+ export const RECEIPTS_FILENAME = 'receipts.jsonl';
17
+ export const LOCK_SUFFIX = '.lock';
18
+ export const WAL_SUFFIX = '.wal';
19
+ export const FENCE_SUFFIX = '.fence';
20
+
21
+ /** Mirrors pi's getAgentDir() — env PI_CODING_AGENT_DIR wins, else ~/.pi/agent */
22
+ export function getAgentDir(): string {
23
+ const env = process.env.PI_CODING_AGENT_DIR ?? process.env.PI_AGENT_DIR;
24
+ if (env && env.trim().length > 0) {
25
+ // Pi does tilde + normalize; we do minimal
26
+ if (env.startsWith('~/')) return join(homedir(), env.slice(2));
27
+ return env;
28
+ }
29
+ return join(homedir(), CONFIG_DIR_NAME, 'agent');
30
+ }
31
+
32
+ export function getGlobalStateDir(agentDir = getAgentDir()): string {
33
+ return join(agentDir, BRIDGE_SUBDIR);
34
+ }
35
+
36
+ export function getGlobalStatePath(agentDir = getAgentDir()): string {
37
+ return join(getGlobalStateDir(agentDir), STATE_FILENAME);
38
+ }
39
+
40
+ export function getProjectStateDir(cwd: string = process.cwd()): string {
41
+ return join(cwd, CONFIG_DIR_NAME, BRIDGE_SUBDIR);
42
+ }
43
+
44
+ export function getProjectStatePath(cwd: string = process.cwd()): string {
45
+ return join(getProjectStateDir(cwd), STATE_FILENAME);
46
+ }
47
+
48
+ export function getStatePath(
49
+ scope: 'global' | 'project',
50
+ opts: { cwd?: string; agentDir?: string } = {},
51
+ ): string {
52
+ if (scope === 'global') return getGlobalStatePath(opts.agentDir);
53
+ return getProjectStatePath(opts.cwd);
54
+ }
55
+
56
+ export function getReceiptsJournalPath(
57
+ scope: 'global' | 'project',
58
+ opts: { cwd?: string; agentDir?: string } = {},
59
+ ): string {
60
+ const stateDir = scope === 'global' ? getGlobalStateDir(opts.agentDir) : getProjectStateDir(opts.cwd);
61
+ return join(stateDir, RECEIPTS_FILENAME);
62
+ }
63
+
64
+ export function getLockPath(statePath: string): string {
65
+ return `${statePath}${LOCK_SUFFIX}`;
66
+ }
67
+
68
+ export function getWalPath(statePath: string): string {
69
+ return `${statePath}${WAL_SUFFIX}`;
70
+ }
71
+
72
+ export function getFencePath(statePath: string): string {
73
+ return `${statePath}${FENCE_SUFFIX}`;
74
+ }
75
+
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Repair State — Recovery Action to verify and repair Bridge State consistency.
3
+ * See CONTEXT.md: Persistence Indeterminate, Recovery Action.
4
+ *
5
+ * Checks whether the state file on disk is readable and schema-valid, and resolves
6
+ * any active Persistence Indeterminate recovery chain.
7
+ */
8
+
9
+ import { existsSync, readFileSync } from 'node:fs';
10
+
11
+ import { getStatePath } from './paths.js';
12
+ import { parseJson, validateSchema } from './schema.js';
13
+ import type { BridgeState, Scope } from './types.js';
14
+ import { acquireAttemptFence } from '../registration/fence.js';
15
+ import { appendReceipt, readReceiptJournal } from '../journal/journal.js';
16
+ import { createReceipt, type AttemptReceipt } from '../registration/receipt.js';
17
+ import { blocking, CODE, RULE, type ValidationFinding } from '../registration/findings.js';
18
+
19
+ export interface RepairStateResult {
20
+ success: boolean;
21
+ state?: BridgeState;
22
+ error?: string;
23
+ receipt: AttemptReceipt;
24
+ }
25
+
26
+ export async function repairBridgeState(
27
+ scope: Scope,
28
+ opts: { cwd?: string; agentDir?: string; fenceTimeoutMs?: number } = {},
29
+ ): Promise<RepairStateResult> {
30
+ const fence = await acquireAttemptFence(scope, opts);
31
+ if (!fence.ok) {
32
+ const receipt = createReceipt({
33
+ kind: 'State Repair',
34
+ operation: 'Repair State',
35
+ scope,
36
+ trigger: `repair state ${scope}`,
37
+ expectedStateRevision: '?',
38
+ summary: 'Blocked',
39
+ findings: [fence.finding!],
40
+ });
41
+ await appendReceipt(scope, receipt, opts);
42
+ return { success: false, error: fence.finding?.outcome, receipt };
43
+ }
44
+
45
+ const handle = fence.handle!;
46
+ const statePath = getStatePath(scope, opts);
47
+
48
+ try {
49
+ const journal = await readReceiptJournal(scope, opts);
50
+ const indetChain = journal.activeChains.find(
51
+ (c) => c.condition === 'persistence-indeterminate' || c.condition === 'journal-degradation',
52
+ );
53
+
54
+ if (!existsSync(statePath)) {
55
+ // Empty / missing state is valid (reconstructed as revision 0)
56
+ const receipt = createReceipt({
57
+ kind: 'State Repair',
58
+ operation: 'Repair State',
59
+ scope,
60
+ trigger: `repair state ${scope}`,
61
+ expectedStateRevision: '0',
62
+ observedStateRevision: '0',
63
+ durableOutcome: 'unchanged',
64
+ runtimeOutcome: 'none',
65
+ summary: 'Completed',
66
+ recoversReceiptId: indetChain?.rootReceiptId,
67
+ });
68
+ await appendReceipt(scope, receipt, opts);
69
+ handle.release();
70
+ return { success: true, receipt };
71
+ }
72
+
73
+ let content: string;
74
+ try {
75
+ content = readFileSync(statePath, 'utf-8');
76
+ } catch (e) {
77
+ const msg = e instanceof Error ? e.message : String(e);
78
+ const finding: ValidationFinding = blocking({
79
+ code: CODE.PERSISTENCE_INDETERMINATE,
80
+ phase: 'persistence',
81
+ target: 'attempt',
82
+ scope,
83
+ pointer: statePath,
84
+ rule: RULE.STATE_CORRUPT,
85
+ outcome: `Failed to read ${statePath}: ${msg}`,
86
+ });
87
+ const receipt = createReceipt({
88
+ kind: 'State Repair',
89
+ operation: 'Repair State',
90
+ scope,
91
+ trigger: `repair state ${scope}`,
92
+ expectedStateRevision: '?',
93
+ durableOutcome: 'indeterminate',
94
+ summary: 'Persistence Indeterminate',
95
+ findings: [finding],
96
+ });
97
+ await appendReceipt(scope, receipt, opts);
98
+ handle.release();
99
+ return { success: false, error: msg, receipt };
100
+ }
101
+
102
+ const parsed = parseJson(content);
103
+ if (!parsed.ok) {
104
+ const finding: ValidationFinding = blocking({
105
+ code: CODE.PERSISTENCE_INDETERMINATE,
106
+ phase: 'persistence',
107
+ target: 'attempt',
108
+ scope,
109
+ pointer: statePath,
110
+ rule: RULE.STATE_CORRUPT,
111
+ outcome: `Corrupted JSON: ${parsed.error}`,
112
+ });
113
+ const receipt = createReceipt({
114
+ kind: 'State Repair',
115
+ operation: 'Repair State',
116
+ scope,
117
+ trigger: `repair state ${scope}`,
118
+ expectedStateRevision: '?',
119
+ durableOutcome: 'indeterminate',
120
+ summary: 'Persistence Indeterminate',
121
+ findings: [finding],
122
+ });
123
+ await appendReceipt(scope, receipt, opts);
124
+ handle.release();
125
+ return { success: false, error: parsed.error, receipt };
126
+ }
127
+
128
+ const validation = validateSchema(parsed.value);
129
+ if (!validation.ok) {
130
+ const finding: ValidationFinding = blocking({
131
+ code: CODE.PERSISTENCE_INDETERMINATE,
132
+ phase: 'persistence',
133
+ target: 'attempt',
134
+ scope,
135
+ pointer: statePath,
136
+ rule: validation.code === 'INCOMPATIBLE_SCHEMA_VERSION' ? RULE.STATE_SCHEMA_UNKNOWN : RULE.STATE_CORRUPT,
137
+ outcome: `Invalid schema: ${validation.error}`,
138
+ });
139
+ const receipt = createReceipt({
140
+ kind: 'State Repair',
141
+ operation: 'Repair State',
142
+ scope,
143
+ trigger: `repair state ${scope}`,
144
+ expectedStateRevision: '?',
145
+ durableOutcome: 'indeterminate',
146
+ summary: 'Persistence Indeterminate',
147
+ findings: [finding],
148
+ });
149
+ await appendReceipt(scope, receipt, opts);
150
+ handle.release();
151
+ return { success: false, error: validation.error, receipt };
152
+ }
153
+
154
+ const validState = parsed.value as BridgeState;
155
+ const receipt = createReceipt({
156
+ kind: 'State Repair',
157
+ operation: 'Repair State',
158
+ scope,
159
+ trigger: `repair state ${scope}`,
160
+ expectedStateRevision: validState.stateRevision,
161
+ observedStateRevision: validState.stateRevision,
162
+ durableOutcome: 'unchanged',
163
+ runtimeOutcome: 'none',
164
+ summary: 'Completed',
165
+ recoversReceiptId: indetChain?.rootReceiptId,
166
+ });
167
+ await appendReceipt(scope, receipt, opts);
168
+ handle.release();
169
+ return { success: true, state: validState, receipt };
170
+ } catch (e) {
171
+ handle.release();
172
+ const msg = e instanceof Error ? e.message : String(e);
173
+ const receipt = createReceipt({
174
+ kind: 'State Repair',
175
+ operation: 'Repair State',
176
+ scope,
177
+ trigger: `repair state ${scope}`,
178
+ expectedStateRevision: '?',
179
+ durableOutcome: 'indeterminate',
180
+ summary: 'Persistence Indeterminate',
181
+ });
182
+ await appendReceipt(scope, receipt, opts);
183
+ return { success: false, error: msg, receipt };
184
+ }
185
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Versioned JSON schema validation for Bridge State.
3
+ * Closed handling: corrupted or unknown new schemaVersion => treat as
4
+ * Indeterminate / incompatible, never auto-rollback or auto-migrate forward beyond known versions.
5
+ */
6
+
7
+ import { CURRENT_SCHEMA_VERSION, type BridgeState, isBridgeState } from './types.js';
8
+
9
+ export interface SchemaValidation {
10
+ ok: boolean;
11
+ error?: string;
12
+ code?: 'CORRUPTED_JSON' | 'INVALID_SCHEMA' | 'INCOMPATIBLE_SCHEMA_VERSION';
13
+ }
14
+
15
+ /** Validate parsed JSON as BridgeState; check schemaVersion compatibility. */
16
+ export function validateSchema(parsed: unknown): SchemaValidation {
17
+ if (!isBridgeState(parsed)) {
18
+ return {
19
+ ok: false,
20
+ code: 'INVALID_SCHEMA',
21
+ error:
22
+ 'Invalid Bridge State: expected { schemaVersion:number, stateRevision:string, registrations:[], installations:[], scopeOverrides:[] }',
23
+ };
24
+ }
25
+
26
+ const state = parsed as BridgeState;
27
+
28
+ if (!Number.isInteger(state.schemaVersion) || state.schemaVersion < 1) {
29
+ return {
30
+ ok: false,
31
+ code: 'INVALID_SCHEMA',
32
+ error: `Invalid schemaVersion: ${state.schemaVersion}`,
33
+ };
34
+ }
35
+
36
+ if (state.schemaVersion > CURRENT_SCHEMA_VERSION) {
37
+ return {
38
+ ok: false,
39
+ code: 'INCOMPATIBLE_SCHEMA_VERSION',
40
+ error: `Incompatible schemaVersion ${state.schemaVersion} > supported ${CURRENT_SCHEMA_VERSION} — requires newer Bridge Package`,
41
+ };
42
+ }
43
+
44
+ // monotonic revision must be numeric string
45
+ try {
46
+ BigInt(state.stateRevision);
47
+ } catch {
48
+ return {
49
+ ok: false,
50
+ code: 'INVALID_SCHEMA',
51
+ error: `Invalid stateRevision (not numeric opaque): ${state.stateRevision}`,
52
+ };
53
+ }
54
+
55
+ // scopeOverrides only meaningful for project, but global may have empty — allow both
56
+ // registrations/installations elements are not deeply validated at scaffold level
57
+
58
+ return { ok: true };
59
+ }
60
+
61
+ /** Check if raw file content parses as JSON, return parsed or error */
62
+ export function parseJson(content: string): { ok: true; value: unknown } | { ok: false; error: string } {
63
+ try {
64
+ const value = JSON.parse(content);
65
+ return { ok: true, value };
66
+ } catch (e) {
67
+ const msg = e instanceof Error ? e.message : String(e);
68
+ return { ok: false, error: `Corrupted JSON: ${msg}` };
69
+ }
70
+ }