@volter/twin 0.1.0 → 0.1.1

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 (84) hide show
  1. package/README.md +16 -2
  2. package/inject.cjs +453 -59
  3. package/package.json +12 -22
  4. package/src/actions.ts +234 -49
  5. package/src/blob-store.ts +136 -0
  6. package/src/changeset.ts +807 -0
  7. package/src/cli.ts +60 -10
  8. package/src/connector.ts +30 -7
  9. package/src/control-plane.ts +17 -1
  10. package/src/emit.ts +242 -0
  11. package/src/fork.ts +19 -7
  12. package/src/index.ts +139 -6
  13. package/src/lease.ts +4 -6
  14. package/src/lifecycle.ts +8 -0
  15. package/src/packRegistry.ts +248 -2
  16. package/src/plan.ts +131 -23
  17. package/src/proxy.ts +5 -2
  18. package/src/pushLedger.ts +116 -11
  19. package/src/queueLifecycle.ts +3 -4
  20. package/src/rateBudget.ts +1115 -0
  21. package/src/refs.ts +9 -10
  22. package/src/remote-execute.ts +16 -0
  23. package/src/scenario.ts +387 -0
  24. package/src/serve.ts +397 -15
  25. package/src/shadow.ts +86 -7
  26. package/src/storage.ts +76 -147
  27. package/src/sync.ts +63 -17
  28. package/src/twin-fetch.ts +115 -0
  29. package/src/validate.ts +6 -5
  30. package/src/world-clock.ts +33 -0
  31. package/src/world-store.ts +482 -0
  32. package/src/worldConfig.ts +4 -3
  33. package/dist/src/actions.d.ts +0 -138
  34. package/dist/src/actions.js +0 -201
  35. package/dist/src/args.d.ts +0 -3
  36. package/dist/src/args.js +0 -12
  37. package/dist/src/cli.d.ts +0 -2
  38. package/dist/src/cli.js +0 -425
  39. package/dist/src/connector.d.ts +0 -106
  40. package/dist/src/connector.js +0 -129
  41. package/dist/src/control-plane.d.ts +0 -21
  42. package/dist/src/control-plane.js +0 -40
  43. package/dist/src/egress.d.ts +0 -93
  44. package/dist/src/egress.js +0 -264
  45. package/dist/src/fork.d.ts +0 -126
  46. package/dist/src/fork.js +0 -206
  47. package/dist/src/index.d.ts +0 -42
  48. package/dist/src/index.js +0 -52
  49. package/dist/src/lease.d.ts +0 -50
  50. package/dist/src/lease.js +0 -80
  51. package/dist/src/packRegistry.d.ts +0 -34
  52. package/dist/src/packRegistry.js +0 -22
  53. package/dist/src/plan.d.ts +0 -97
  54. package/dist/src/plan.js +0 -151
  55. package/dist/src/proxy.d.ts +0 -25
  56. package/dist/src/proxy.js +0 -152
  57. package/dist/src/pushLedger.d.ts +0 -81
  58. package/dist/src/pushLedger.js +0 -130
  59. package/dist/src/queueLifecycle.d.ts +0 -62
  60. package/dist/src/queueLifecycle.js +0 -95
  61. package/dist/src/reconcile.d.ts +0 -58
  62. package/dist/src/reconcile.js +0 -137
  63. package/dist/src/refs.d.ts +0 -29
  64. package/dist/src/refs.js +0 -68
  65. package/dist/src/schemas.d.ts +0 -78
  66. package/dist/src/schemas.js +0 -50
  67. package/dist/src/serve.d.ts +0 -44
  68. package/dist/src/serve.js +0 -93
  69. package/dist/src/shadow.d.ts +0 -77
  70. package/dist/src/shadow.js +0 -138
  71. package/dist/src/status.d.ts +0 -31
  72. package/dist/src/status.js +0 -42
  73. package/dist/src/storage.d.ts +0 -119
  74. package/dist/src/storage.js +0 -535
  75. package/dist/src/sync.d.ts +0 -91
  76. package/dist/src/sync.js +0 -121
  77. package/dist/src/types.d.ts +0 -40
  78. package/dist/src/types.js +0 -1
  79. package/dist/src/validate.d.ts +0 -27
  80. package/dist/src/validate.js +0 -68
  81. package/dist/src/visualizer.d.ts +0 -13
  82. package/dist/src/visualizer.js +0 -133
  83. package/dist/src/worldConfig.d.ts +0 -9
  84. package/dist/src/worldConfig.js +0 -16
package/src/plan.ts CHANGED
@@ -7,17 +7,18 @@
7
7
  // orchestrator: recompute requiresApproval from the plan's own data (never trust
8
8
  // the stored bit) → refuse a stale base → acquire lease → push each call through
9
9
  // phases → release.
10
- import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
10
+ import { createHash, randomUUID } from 'node:crypto';
11
11
  import { join } from 'node:path';
12
- import { pendingActions, projectResources } from './actions.ts';
13
- import type { TwinAction, TwinActionPrecondition } from './actions.ts';
12
+ import { getActiveWorldStore } from './world-store.ts';
13
+ import { checkPrecondition, pendingActions, projectResources } from './actions.ts';
14
+ import type { TwinAction } from './actions.ts';
14
15
  import { isBaseStale } from './refs.ts';
15
16
  import type { WorldLocalRef, WorldRemoteRef } from './refs.ts';
16
17
  import { acquireLease, releaseLease } from './lease.ts';
17
18
  import { pushTransaction } from './pushLedger.ts';
18
19
  import type { PushOutcome, WorldPushRecord } from './pushLedger.ts';
19
20
  import type { TwinResource } from './serve.ts';
20
- import { readJsonFile, worldPaths } from './storage.ts';
21
+ import { appendDurable, readJsonFile, worldPaths } from './storage.ts';
21
22
 
22
23
  export type ProviderCall = {
23
24
  actionId: string;
@@ -41,6 +42,121 @@ export type WorldApplyPlan = {
41
42
  createdAt: string;
42
43
  };
43
44
 
45
+ export type PlanReviewDecision = 'approved' | 'rejected';
46
+ export type PlanReviewRecord = {
47
+ id: string;
48
+ service: string;
49
+ transactionSetId: string;
50
+ transactions: string[];
51
+ decision: PlanReviewDecision;
52
+ actor: { kind: 'agent' | 'human'; id: string };
53
+ reason?: string;
54
+ occurredAt: string;
55
+ };
56
+ export type LocalActionPlan = {
57
+ id: string;
58
+ service: string;
59
+ transactions: Array<{ id: string; operation?: string; subject: { type: string; id: string } }>;
60
+ conflicts: Array<{ actionId: string; reason: string }>;
61
+ review: PlanReviewRecord | null;
62
+ requiresApproval: boolean;
63
+ };
64
+
65
+ /** Stable identity for the exact ordered pending-action set being reviewed. */
66
+ export function transactionSetId(service: string, transactions: string[]): string {
67
+ const digest = createHash('sha256').update(JSON.stringify([service, transactions])).digest('hex').slice(0, 16);
68
+ return `plan:${service}:${digest}`;
69
+ }
70
+
71
+ function reviewsPath(service: string, root?: string): string {
72
+ return join(worldPaths(service, root).dir, 'plan-reviews.jsonl');
73
+ }
74
+
75
+ export function listPlanReviews(service: string, root?: string): PlanReviewRecord[] {
76
+ const path = reviewsPath(service, root);
77
+ return getActiveWorldStore().readLines(path).filter(Boolean).map((line) => JSON.parse(line) as PlanReviewRecord);
78
+ }
79
+
80
+ export function latestPlanReview(service: string, transactions: string[], root?: string): PlanReviewRecord | null {
81
+ const id = transactionSetId(service, transactions);
82
+ return listPlanReviews(service, root).filter((review) => review.transactionSetId === id).at(-1) ?? null;
83
+ }
84
+
85
+ /** Review view over the same pending actions written by SDK-backed twin traffic. */
86
+ export function buildLocalActionPlan(service: string, root?: string): LocalActionPlan {
87
+ const pending = pendingActions(service, root);
88
+ const ids = pending.map((action) => action.id);
89
+ const review = latestPlanReview(service, ids, root);
90
+ return {
91
+ id: transactionSetId(service, ids),
92
+ service,
93
+ transactions: pending.map((action) => ({ id: action.id, operation: action.operation, subject: action.subject })),
94
+ conflicts: pendingConflicts(service, root),
95
+ review,
96
+ requiresApproval: ids.length > 0 && review?.decision !== 'approved',
97
+ };
98
+ }
99
+
100
+ export function recordPlanReview(opts: {
101
+ service: string;
102
+ expectedTransactionSetId: string;
103
+ decision: PlanReviewDecision;
104
+ actor: { kind: 'agent' | 'human'; id: string };
105
+ reason?: string;
106
+ occurredAt?: string;
107
+ root?: string;
108
+ }): PlanReviewRecord {
109
+ const transactions = pendingActions(opts.service, opts.root).map((action) => action.id);
110
+ if (transactions.length === 0) throw new Error(`cannot review ${opts.service}: no pending transactions`);
111
+ const currentTransactionSetId = transactionSetId(opts.service, transactions);
112
+ if (opts.expectedTransactionSetId !== currentTransactionSetId) {
113
+ throw new Error(`cannot review ${opts.service}: transaction set changed (expected ${opts.expectedTransactionSetId}, current ${currentTransactionSetId})`);
114
+ }
115
+ if (!opts.actor.id.trim()) throw new Error('plan review actor id is required');
116
+ if (opts.decision === 'rejected' && !opts.reason?.trim()) throw new Error('rejected plan review requires a reason');
117
+ const record: PlanReviewRecord = {
118
+ id: `review:${randomUUID()}`,
119
+ service: opts.service,
120
+ transactionSetId: currentTransactionSetId,
121
+ transactions,
122
+ decision: opts.decision,
123
+ actor: opts.actor,
124
+ ...(opts.reason ? { reason: opts.reason } : {}),
125
+ occurredAt: opts.occurredAt ?? new Date().toISOString(),
126
+ };
127
+ const path = reviewsPath(opts.service, opts.root);
128
+ getActiveWorldStore().mkdir(join(path, '..'));
129
+ appendDurable(path, `${JSON.stringify(record)}\n`);
130
+ return record;
131
+ }
132
+
133
+ export type ApprovedPendingActionSet = {
134
+ review: PlanReviewRecord | null;
135
+ actions: TwinAction[];
136
+ };
137
+
138
+ /**
139
+ * Credential-bound connector snapshot: refuse an unapproved exact set and return only
140
+ * the actions that were compared with that approval. Callers must consume this snapshot
141
+ * instead of rereading pending actions, or a concurrent append could enter the push after
142
+ * the approval check.
143
+ */
144
+ export function approvedPendingActionSet(service: string, root?: string): ApprovedPendingActionSet {
145
+ const actions = pendingActions(service, root);
146
+ const transactions = actions.map((action) => action.id);
147
+ if (transactions.length === 0) return { review: null, actions };
148
+ const review = latestPlanReview(service, transactions, root);
149
+ if (review?.decision !== 'approved') {
150
+ throw new Error(`push refused: transaction set ${transactionSetId(service, transactions)} requires a durable approval`);
151
+ }
152
+ return { review, actions };
153
+ }
154
+
155
+ /** Credential-bound connector guard retained for callers that only need the verdict. */
156
+ export function assertPendingActionsApproved(service: string, root?: string): PlanReviewRecord | null {
157
+ return approvedPendingActionSet(service, root).review;
158
+ }
159
+
44
160
  /** Map a local transaction commit → the provider call that materializes it (vendor-specific). Return null to skip. */
45
161
  export type ActionMapper = (action: TwinAction) => Omit<ProviderCall, 'actionId'> | null;
46
162
 
@@ -52,20 +168,11 @@ function preconditionFailure(action: TwinAction, byId: Map<string, TwinResource>
52
168
  for (const p of action.preconditions ?? []) {
53
169
  const resource = byId.get(`${p.subject.type}:${p.subject.id}`);
54
170
  const actual = resource ? (resource as Record<string, unknown>)[p.field] : undefined;
55
- const ok = evalPrecondition(p, actual);
171
+ const ok = checkPrecondition(p, actual);
56
172
  if (!ok) return `${p.subject.type}:${p.subject.id}.${p.field} ${p.op}${p.value !== undefined ? ` ${JSON.stringify(p.value)}` : ''}`;
57
173
  }
58
174
  return null;
59
175
  }
60
- function evalPrecondition(p: TwinActionPrecondition, actual: unknown): boolean {
61
- switch (p.op) {
62
- case 'exists': return actual !== undefined;
63
- case 'not_exists': return actual === undefined;
64
- case 'eq': case 'version_eq': return Object.is(actual, p.value);
65
- case 'neq': return !Object.is(actual, p.value);
66
- default: return false;
67
- }
68
- }
69
176
 
70
177
  /** Pending transactions whose preconditions would fail against current projected state. */
71
178
  export function pendingConflicts(service: string, root?: string): Array<{ actionId: string; reason: string }> {
@@ -106,7 +213,7 @@ export function buildApplyPlan(opts: {
106
213
  return {
107
214
  id: opts.id, service: opts.service, forkId: opts.forkId, baseRemoteRef: opts.baseRemoteRef,
108
215
  transactions: pending.map((a) => a.id), providerCalls, conflicts,
109
- requiresApproval: conflicts.length > 0 || providerCalls.some((c) => c.destructive),
216
+ requiresApproval: pending.length > 0,
110
217
  createdAt: opts.createdAt,
111
218
  };
112
219
  }
@@ -117,18 +224,16 @@ function plansDir(service: string, root?: string): string {
117
224
  export function writePlan(plan: WorldApplyPlan, root?: string): WorldApplyPlan {
118
225
  if (!/^[A-Za-z0-9_.-]+$/.test(plan.id)) throw new Error(`invalid plan id: ${plan.id}`);
119
226
  const path = join(plansDir(plan.service, root), `${plan.id}.json`);
120
- mkdirSync(join(path, '..'), { recursive: true });
121
- writeFileSync(path, `${JSON.stringify(plan, null, 2)}\n`);
227
+ getActiveWorldStore().write(path, `${JSON.stringify(plan, null, 2)}\n`);
122
228
  return plan;
123
229
  }
124
230
  export function readPlan(service: string, id: string, root?: string): WorldApplyPlan | null {
125
231
  const path = join(plansDir(service, root), `${id}.json`);
126
- return existsSync(path) ? readJsonFile<WorldApplyPlan>(path) : null;
232
+ return getActiveWorldStore().exists(path) ? readJsonFile<WorldApplyPlan>(path) : null;
127
233
  }
128
234
  export function listPlans(service: string, root?: string): WorldApplyPlan[] {
129
235
  const dir = plansDir(service, root);
130
- if (!existsSync(dir)) return [];
131
- return readdirSync(dir).filter((f) => f.endsWith('.json')).map((f) => readJsonFile<WorldApplyPlan>(join(dir, f)));
236
+ return getActiveWorldStore().list(dir).filter((f) => f.endsWith('.json')).map((f) => readJsonFile<WorldApplyPlan>(join(dir, f)));
132
237
  }
133
238
 
134
239
  export type ApplyResult = { planId: string; leaseId: string; pushed: WorldPushRecord[]; skipped: string[] };
@@ -143,7 +248,7 @@ export type ApplyResult = { planId: string; leaseId: string; pushed: WorldPushRe
143
248
  * removing the destructive calls/conflicts themselves does.
144
249
  */
145
250
  export function planRequiresApproval(plan: WorldApplyPlan): boolean {
146
- return plan.conflicts.length > 0 || plan.providerCalls.some((c) => c.destructive);
251
+ return plan.transactions.length > 0;
147
252
  }
148
253
 
149
254
  /**
@@ -159,9 +264,12 @@ export function planRequiresApproval(plan: WorldApplyPlan): boolean {
159
264
  export async function applyPlan(
160
265
  plan: WorldApplyPlan,
161
266
  writeFn: (call: ProviderCall) => Promise<PushOutcome>,
162
- opts: { holder: { kind: 'agent' | 'human' | 'system'; id: string }; leaseId: string; acquiredAt: string; expiresAt: string; occurredAt: string; approve?: boolean; overrideStaleBase?: boolean; root?: string },
267
+ opts: { holder: { kind: 'agent' | 'human' | 'system'; id: string }; leaseId: string; acquiredAt: string; expiresAt: string; occurredAt: string; overrideStaleBase?: boolean; root?: string },
163
268
  ): Promise<ApplyResult> {
164
- if (planRequiresApproval(plan) && !opts.approve) throw new Error(`plan ${plan.id} requires approval (conflicts or destructive calls); pass approve:true to apply`);
269
+ const review = latestPlanReview(plan.service, plan.transactions, opts.root);
270
+ if (planRequiresApproval(plan) && review?.decision !== 'approved') {
271
+ throw new Error(`plan ${plan.id} requires approval: record a durable approval for transaction set ${transactionSetId(plan.service, plan.transactions)}`);
272
+ }
165
273
  const localRefView: WorldLocalRef = { service: plan.service, forkId: plan.forkId, baseRemoteRef: plan.baseRemoteRef, recordedAt: plan.createdAt };
166
274
  if (isBaseStale(localRefView, opts.root) && !opts.overrideStaleBase) {
167
275
  throw new Error(`plan ${plan.id} base (${plan.baseRemoteRef.provider}/${plan.baseRemoteRef.name}) is stale — the remote ref advanced since this plan's base was recorded; rebase and rebuild the plan, or pass overrideStaleBase:true to apply anyway`);
package/src/proxy.ts CHANGED
@@ -154,9 +154,12 @@ export function createTwinProxy(opts: TwinProxyOptions): TwinProxy {
154
154
  }, redirectOrigins);
155
155
  });
156
156
 
157
- // WebSocket / HTTP upgrade passthrough to the app.
157
+ // WebSocket / HTTP upgrade passthrough routed by the SAME vendor map as plain
158
+ // requests, so a vendor whose browser surface speaks WebSocket (a media SFU's
159
+ // signal path, a realtime API) reaches its twin instead of the app.
158
160
  server.on('upgrade', (req, socket, head) => {
159
- const target = new URL(req.url || '/', opts.target);
161
+ const upgradeVendor = vendorForPath(req.url || '/', map);
162
+ const target = new URL(req.url || '/', upgradeVendor ? map[upgradeVendor].origin : opts.target);
160
163
  const upstream = net.connect(Number(target.port || 80), target.hostname, () => {
161
164
  upstream.write(`${req.method} ${target.pathname}${target.search} HTTP/${req.httpVersion}\r\n`);
162
165
  for (const [name, value] of Object.entries(req.headers)) upstream.write(`${name}: ${value}\r\n`);
package/src/pushLedger.ts CHANGED
@@ -16,9 +16,10 @@
16
16
  // (UnreconciledPushError) unless the caller verifies external state and passes
17
17
  // onUnreconciled: 'retry' (mirrors egress.ts's UnreconciledWriteIntentError).
18
18
  import { randomUUID } from 'node:crypto';
19
- import { existsSync, mkdirSync, readFileSync } from 'node:fs';
19
+ import { getActiveWorldStore } from './world-store.ts';
20
20
  import { join } from 'node:path';
21
- import { confirmAction } from './actions.ts';
21
+ import { confirmAction, listActions } from './actions.ts';
22
+ import { remoteRefs } from './shadow.ts';
22
23
  import type { SubjectFields } from './shadow.ts';
23
24
  import { appendDurable, twinLog, withFileLock, worldPaths } from './storage.ts';
24
25
 
@@ -70,7 +71,7 @@ function ledgerLockPath(service: string, root?: string): string {
70
71
 
71
72
  export function appendPushRecord(record: WorldPushRecord, root?: string): WorldPushRecord {
72
73
  const path = ledgerPath(record.service, root);
73
- mkdirSync(join(path, '..'), { recursive: true });
74
+ getActiveWorldStore().mkdir(join(path, '..'));
74
75
  appendDurable(path, `${JSON.stringify(record)}\n`);
75
76
  twinLog('push.append', { service: record.service, id: record.id, actionId: record.actionId, status: record.status, correlationId: record.correlationId });
76
77
  return record;
@@ -78,8 +79,7 @@ export function appendPushRecord(record: WorldPushRecord, root?: string): WorldP
78
79
 
79
80
  export function listPushLedger(service: string, root?: string): WorldPushRecord[] {
80
81
  const path = ledgerPath(service, root);
81
- if (!existsSync(path)) return [];
82
- return readFileSync(path, 'utf8').split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l) as WorldPushRecord);
82
+ return getActiveWorldStore().readLines(path).filter((l) => l.trim()).map((l) => JSON.parse(l) as WorldPushRecord);
83
83
  }
84
84
 
85
85
  /** Latest phase per push id (append order is the tiebreak). */
@@ -106,6 +106,86 @@ export class UnreconciledPushError extends Error {
106
106
  }
107
107
  }
108
108
 
109
+ /** Runtime contract R14: the remote moved since this action was authored — a push now
110
+ * would overwrite changes we have already observed but never merged (the lost-update
111
+ * class two §9 rounds paid for). Fetch + reconcile (reconcile.ts, three-way over the
112
+ * stamped base), then revert or re-author the action; there is no rebase. */
113
+ export class NonFastForwardPushError extends Error {
114
+ readonly pushId: string;
115
+ /** subjectKey → { base: ref at authoring, current: ref now } for every drifted subject. */
116
+ readonly drifted: Record<string, { base: string | null; current: string | null }>;
117
+
118
+ constructor(pushId: string, drifted: Record<string, { base: string | null; current: string | null }>) {
119
+ const subjects = Object.keys(drifted).join(', ');
120
+ super(
121
+ `Push ${pushId} is not a fast-forward: the observed mirror moved past this action's shadow basis for ${subjects}. ` +
122
+ `Fetch + reconcile, then revert or re-author the action (or pass onDrift: 'force' only after a reviewed reconcile).`,
123
+ );
124
+ this.name = 'NonFastForwardPushError';
125
+ this.pushId = pushId;
126
+ this.drifted = drifted;
127
+ }
128
+ }
129
+
130
+ /** Compare an action's stamped shadow basis against the mirror's current remote refs.
131
+ * Returns the drifted subjects (empty = fast-forward). Unstamped actions (pre-R14
132
+ * ledgers, hand-built rows) have nothing to compare — the stamp is the opt-in; the
133
+ * skip is logged so ungated pushes stay visible in the audit trail.
134
+ *
135
+ * Ancestry rule (the git semantics): a confirmation of an action authored BEFORE the
136
+ * pushed one is a fast-forward (our own earlier push moved the remote — pushing in
137
+ * order stacks). A confirmation of an action authored AFTER it counts as DRIFT:
138
+ * pushing a stale action once a newer one already landed remotely is the out-of-order
139
+ * lost update, and the gate refuses it. */
140
+ function shadowDrift(
141
+ service: string,
142
+ actionId: string,
143
+ root?: string,
144
+ ): Record<string, { base: string | null; current: string | null }> {
145
+ const actions = listActions(service, root);
146
+ const index = actions.findIndex((a) => a.id === actionId);
147
+ const basis = index === -1 ? undefined : actions[index]!.shadowBasis;
148
+ if (!basis) {
149
+ twinLog('push.drift.unstamped', { service, actionId, found: index !== -1 });
150
+ return {};
151
+ }
152
+ // Descendant confirmations count as drift; ancestor (and the pushed action's own)
153
+ // confirmations are fast-forward, and ORPHANED confirm events (no confirm row —
154
+ // confirmAction crash artifacts) stay excluded like they were at stamp time.
155
+ const ancestorIds = new Set(actions.slice(0, index + 1).map((a) => a.id));
156
+ const countAsDrift = new Set<string>();
157
+ for (const a of actions) {
158
+ if (a.op !== 'confirm' || !a.confirmsActionId || ancestorIds.has(a.confirmsActionId)) continue;
159
+ for (const id of a.observedEventIds ?? (a.observedEventId ? [a.observedEventId] : [])) countAsDrift.add(id);
160
+ }
161
+ const subjects = Object.keys(basis).map((key) => {
162
+ const sep = key.indexOf(':');
163
+ return { type: key.slice(0, sep), id: key.slice(sep + 1) };
164
+ });
165
+ const current = remoteRefs(service, subjects, root, { countAsDrift });
166
+ const drifted: Record<string, { base: string | null; current: string | null }> = {};
167
+ for (const [key, base] of Object.entries(basis)) {
168
+ if (current[key] !== base) drifted[key] = { base, current: current[key] ?? null };
169
+ }
170
+ return drifted;
171
+ }
172
+
173
+ /**
174
+ * The R14 gate as a standalone choke, for push paths that do their vendor I/O
175
+ * themselves: connectors doing `pendingActions → vendor HTTP → confirmAction` call this
176
+ * IMMEDIATELY BEFORE the vendor write (the sweep enrolling all of them is tracked in the
177
+ * roadmap; pushTransaction and syncPush are wired already). Throws
178
+ * NonFastForwardPushError on drift; silent for unstamped actions (logged).
179
+ *
180
+ * Honest bound: this check narrows the lost-update window, it cannot close it — a delta
181
+ * observed between this check and the vendor call still loses; only vendor-side
182
+ * preconditions (etags, versions) inside the write itself close that residue.
183
+ */
184
+ export function assertFastForward(service: string, actionId: string, root?: string): void {
185
+ const drifted = shadowDrift(service, actionId, root);
186
+ if (Object.keys(drifted).length > 0) throw new NonFastForwardPushError(`push:${service}:${actionId}`, drifted);
187
+ }
188
+
109
189
  /**
110
190
  * Drive one local transaction commit through the push phases against the real
111
191
  * provider via the injected writeFn (the ONLY real I/O — kernel holds no creds).
@@ -132,10 +212,14 @@ export async function pushTransaction(
132
212
  occurredAt: string;
133
213
  root?: string;
134
214
  onUnreconciled?: 'fail' | 'retry';
215
+ /** R14 non-fast-forward posture. 'refuse' (default): drifted remote refs abort the
216
+ * push with NonFastForwardPushError before any ledger row lands. 'force': push
217
+ * anyway — only after a reviewed reconcile has decided the twin's value wins. */
218
+ onDrift?: 'refuse' | 'force';
135
219
  },
136
220
  writeFn: () => Promise<PushOutcome>,
137
221
  ): Promise<WorldPushRecord> {
138
- const { service, action, provider, operation, idempotencyKey, occurredAt, root, onUnreconciled = 'fail' } = opts;
222
+ const { service, action, provider, operation, idempotencyKey, occurredAt, root, onUnreconciled = 'fail', onDrift = 'refuse' } = opts;
139
223
  const pushId = `push:${service}:${action.id}`;
140
224
  // Inherit the action's correlationId (D3) so every phase row this push writes joins
141
225
  // back to the action row on that id alone. Actions appended after D3 always carry
@@ -147,9 +231,18 @@ export async function pushTransaction(
147
231
  // Read (prior status) + decide (terminal replay / unreconciled refusal) + the
148
232
  // fresh intent/attempted append, all under one lock — the atomic critical
149
233
  // section a cross-process race would otherwise be able to interleave (TWIN-56).
234
+ let resumeFrom: WorldPushRecord | null = null;
150
235
  const early = withFileLock(ledgerLockPath(service, root), (): WorldPushRecord | null => {
151
236
  const prior = latestPushByActionId(service, root).get(action.id);
152
237
  if (prior && TERMINAL.includes(prior.status)) return prior; // idempotent replay
238
+ if (prior && (prior.status === 'provider_accepted' || prior.status === 'observed_confirmed')) {
239
+ // The provider call DEFINITELY landed (final audit M9): a crash between
240
+ // provider_accepted and the confirm chain must resume the CONFIRM — re-invoking
241
+ // writeFn here would double-apply against a non-idempotent vendor, strictly worse
242
+ // than the 'attempted' ambiguity this function already refuses.
243
+ resumeFrom = prior;
244
+ return null;
245
+ }
153
246
  if (prior && prior.status === 'attempted' && onUnreconciled === 'fail') {
154
247
  throw new UnreconciledPushError(
155
248
  pushId,
@@ -157,6 +250,12 @@ export async function pushTransaction(
157
250
  `Verify external state, then retry with onUnreconciled: 'retry'.`,
158
251
  );
159
252
  }
253
+ // R14 non-fast-forward gate, inside the same critical section as the intent append:
254
+ // the drift decision and the rows it guards cannot interleave with another pusher.
255
+ if (onDrift === 'refuse') {
256
+ const drifted = shadowDrift(service, action.id, root);
257
+ if (Object.keys(drifted).length > 0) throw new NonFastForwardPushError(pushId, drifted);
258
+ }
160
259
  appendPushRecord({ ...base, status: 'intent' }, root);
161
260
  appendPushRecord({ ...base, status: 'attempted' }, root);
162
261
  return null;
@@ -164,12 +263,16 @@ export async function pushTransaction(
164
263
  if (early) return early;
165
264
 
166
265
  let outcome: PushOutcome;
167
- try {
168
- outcome = await writeFn();
169
- } catch (error) {
170
- return appendPushRecord({ ...base, status: 'failed', error: error instanceof Error ? error.message : String(error) }, root);
266
+ if (resumeFrom !== null) {
267
+ outcome = { externalId: (resumeFrom as WorldPushRecord).external?.id ?? '', ...((resumeFrom as WorldPushRecord).data ? { data: (resumeFrom as WorldPushRecord).data } : {}) };
268
+ } else {
269
+ try {
270
+ outcome = await writeFn();
271
+ } catch (error) {
272
+ return appendPushRecord({ ...base, status: 'failed', error: error instanceof Error ? error.message : String(error) }, root);
273
+ }
274
+ appendPushRecord({ ...base, status: 'provider_accepted', external: { id: outcome.externalId, ...(outcome.url ? { url: outcome.url } : {}) }, ...(outcome.data ? { data: outcome.data } : {}) }, root);
171
275
  }
172
- appendPushRecord({ ...base, status: 'provider_accepted', external: { id: outcome.externalId, ...(outcome.url ? { url: outcome.url } : {}) }, ...(outcome.data ? { data: outcome.data } : {}) }, root);
173
276
 
174
277
  // Confirm: append the observed provider fact + map it back, suppressing the local projection.
175
278
  const { observedEventId } = confirmAction({ service, actionId: action.id, subject: action.subject, fields: action.fields, occurredAt, root });
@@ -180,10 +283,12 @@ export async function pushTransaction(
180
283
 
181
284
  /** Mark a push intent abandoned (e.g. operator decided not to push). */
182
285
  export function abandonPush(service: string, actionId: string, opts: { root?: string; at?: string; reason?: string } = {}): WorldPushRecord {
286
+ return withFileLock(ledgerLockPath(service, opts.root), () => {
183
287
  const prior = latestPushByActionId(service, opts.root).get(actionId);
184
288
  return appendPushRecord({
185
289
  id: `push:${service}:${actionId}`, service, actionId, provider: prior?.provider ?? '', operation: prior?.operation ?? '',
186
290
  idempotencyKey: prior?.idempotencyKey ?? `abandon:${actionId}`, createdAt: opts.at ?? new Date().toISOString(),
187
291
  status: 'abandoned', ...(opts.reason ? { error: opts.reason } : {}),
188
292
  }, opts.root);
293
+ });
189
294
  }
@@ -5,9 +5,9 @@
5
5
  // poisoned — as an append-only status ledger over the existing queue, so the
6
6
  // queue file itself stays immutable delivery history. Current status = the latest
7
7
  // transition (default `queued`). Deterministic: caller may supply `at`.
8
- import { existsSync, mkdirSync, readFileSync } from 'node:fs';
9
8
  import { join } from 'node:path';
10
9
  import { appendDurable, appendEvent, listQueuedEvents, rebuildGenericState, worldPaths } from './storage.ts';
10
+ import { getActiveWorldStore } from './world-store.ts';
11
11
  import type { AppendEventResult, QueuedWorldServiceEvent } from './types.ts';
12
12
 
13
13
  export type QueueRowStatus = 'queued' | 'committed' | 'ignored' | 'superseded' | 'poisoned';
@@ -35,8 +35,7 @@ function statusPath(service: string, root?: string): string {
35
35
 
36
36
  function readTransitions(service: string, root?: string): QueueStatusTransition[] {
37
37
  const path = statusPath(service, root);
38
- if (!existsSync(path)) return [];
39
- return readFileSync(path, 'utf8').split('\n').filter((l) => l.trim()).map((l) => JSON.parse(l) as QueueStatusTransition);
38
+ return getActiveWorldStore().readLines(path).filter((l) => l.trim()).map((l) => JSON.parse(l) as QueueStatusTransition);
40
39
  }
41
40
 
42
41
  /** Latest transition per queue row (append order is the tiebreak — last wins). */
@@ -53,7 +52,7 @@ export function setQueueRowStatus(
53
52
  opts: { root?: string; at?: string; reason?: string; eventId?: string; supersededBy?: string } = {},
54
53
  ): QueueStatusTransition {
55
54
  const path = statusPath(service, opts.root);
56
- mkdirSync(join(path, '..'), { recursive: true });
55
+ getActiveWorldStore().mkdir(join(path, '..'));
57
56
  const transition: QueueStatusTransition = {
58
57
  queueId,
59
58
  status,