Skip to content

Commit 5cc20f2

Browse files
chitcommitclaude
andcommitted
fix(meta): replay-by-key + single failIntent on refusal (PR #106 criticals; importants deferred)
FIX 1 (cascade-risk critical): dispatch's replay short-circuit matched on intent_id alone, ORDER BY executed_at DESC LIMIT 1. Any intent that ever produced a terminal cc_actions_log row would short-circuit forever, no matter what idempotency_key the new attempt computed — making per-attempt retries unreachable. Reordered to (a) compute attempt+key first, then (b) lookup by (intent_id, idempotency_key) which is the canonical per-attempt invariant the partial unique index backs. FIX 2 (cascade-risk critical): both refusal paths in dispatch wrote audit + called failIntent, then returned without `replayed: true`, so executeIntent's `!result.replayed` guard called failIntent a SECOND time. The DB no-ops the second call (status guard), but the semantic is wrong and the error_message could be clobbered on adjacent paths. Both refusal returns now set replayed:true; executeIntent's guard skips correctly. Updated existing executor.spec.ts: the second-invocation block encoded the pre-fix bug (asserted same-intent re-call replays). Under the new contract, a second executeIntent against the same intent is a new attempt with a new key → executor re-runs (idempotent for update-obligation-status), audit count goes 1→2, attempt sequence [1,2]. Added tests/meta/executor-pr106-criticals.spec.ts: - FIX 1 regression: hand-insert a terminal row with a sentinel key ('a'*64) for an intent, then executeIntent — key mismatch must NOT short-circuit, executor must run, obligation must transition. - FIX 2 regression: stale snapshot + unroutable CHITTYTRUST_URL (RFC 5737 192.0.2.1) forces real fetch failure → sovereignty 'blocked' → refusal path. Asserts result.replayed === true (the canonical FIX 2 signal) and exactly one sovereignty_refusal audit row. No mocks. Real Neon only — skipped without DATABASE_URL. Validated the FIX 1 SELECT shape against a real Neon branch (cool-bar-13270800 / pr-106-criticals-validation, since deleted): the key-matched query returned the seeded row for matching key and empty for non-matching key. Deferred to follow-up PR (intentionally out of scope here): - FIX 3: race-safety on concurrent dispatchers (writeAuditRowOrReplay) - FIX 4: schema predicate gap (status enum vs literal IN-clause) - FIX 5: cold-start registry import on direct dispatch entry Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2f1c65f commit 5cc20f2

3 files changed

Lines changed: 259 additions & 35 deletions

File tree

meta/executors/dispatch.ts

Lines changed: 29 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -84,53 +84,49 @@ export async function dispatch(
8484
const sql = getSql(env);
8585
const freshnessMs = options.freshnessMs ?? SOVEREIGNTY_FRESHNESS_MS;
8686

87-
// 1. Replay short-circuit: if any prior cc_actions_log row exists for this
88-
// intent that hit a terminal status ('completed' or 'failed'), the intent
89-
// has already been dispatched and the second call must NOT re-execute.
90-
// The unique partial index on (intent_id, idempotency_key) backs this
91-
// invariant for retry attempts; the latest-terminal-row lookup backs the
92-
// "intent already done" case.
87+
// 1. Compute attempt number (prior rows + 1) and per-attempt idempotency
88+
// key. The partial unique index on (intent_id, idempotency_key) backs
89+
// the per-attempt invariant.
90+
const [{ count: priorCount } = { count: 0 }] = (await sql`
91+
SELECT COUNT(*)::int AS count FROM cc_actions_log WHERE intent_id = ${intent.id}::uuid
92+
`) as unknown as Array<{ count: number }>;
93+
const attempt = (priorCount ?? 0) + 1;
94+
const idempotencyKey = await computeIdempotencyKey(
95+
intent.id,
96+
attempt,
97+
intent.intentType,
98+
);
99+
100+
// 2. Replay short-circuit (FIX 1, PR #106 critical): match on
101+
// (intent_id, idempotency_key) — NOT intent_id alone. Matching on
102+
// intent_id alone would short-circuit any new attempt (whose key
103+
// differs by `attempt`), making per-attempt retries unreachable for
104+
// any intent that ever produced a terminal row.
93105
const priorRows = (await sql`
94-
SELECT id, status, response_payload, error_message, idempotency_key, attempt
106+
SELECT id, status, response_payload, error_message
95107
FROM cc_actions_log
96108
WHERE intent_id = ${intent.id}::uuid
109+
AND idempotency_key = ${idempotencyKey}
97110
AND status IN ('completed', 'failed')
98-
ORDER BY executed_at DESC
99111
LIMIT 1
100112
`) as unknown as Array<{
101113
id: string;
102114
status: string;
103115
response_payload: Record<string, unknown> | null;
104116
error_message: string | null;
105-
idempotency_key: string;
106-
attempt: number;
107117
}>;
108118
if (priorRows[0]) {
109119
const prior = priorRows[0];
110120
return {
111121
ok: prior.status === 'completed',
112-
idempotencyKey: prior.idempotency_key,
113-
actionLogId: prior.id,
122+
idempotencyKey,
123+
actionLogId: String(prior.id),
114124
data: prior.response_payload ?? undefined,
115125
error: prior.error_message ?? undefined,
116126
replayed: true,
117127
};
118128
}
119129

120-
// 2. Compute attempt number (prior rows + 1) and idempotency key for the
121-
// new audit row. The partial unique index on (intent_id, idempotency_key)
122-
// prevents two concurrent dispatchers from writing duplicate rows for
123-
// the same attempt.
124-
const [{ count: priorCount } = { count: 0 }] = (await sql`
125-
SELECT COUNT(*)::int AS count FROM cc_actions_log WHERE intent_id = ${intent.id}::uuid
126-
`) as unknown as Array<{ count: number }>;
127-
const attempt = (priorCount ?? 0) + 1;
128-
const idempotencyKey = await computeIdempotencyKey(
129-
intent.id,
130-
attempt,
131-
intent.intentType,
132-
);
133-
134130
// 3. Re-reckon sovereignty if snapshot stale.
135131
let sovereignty: SovereigntyAssessmentSnapshot;
136132
if (isAssessmentFresh(intent.sovereigntyAssessment, freshnessMs)) {
@@ -162,7 +158,10 @@ export async function dispatch(
162158
metadata: { reason: 'no_actor_for_reckon' },
163159
});
164160
await failIntent(env, intent.id, errMsg).catch(() => null);
165-
return { ok: false, idempotencyKey, error: errMsg };
161+
// FIX 2 (PR #106 critical): replayed:true tells executeIntent's
162+
// `!result.replayed` guard to skip its own failIntent — dispatch has
163+
// already written the audit row + transitioned status.
164+
return { ok: false, idempotencyKey, error: errMsg, replayed: true };
166165
}
167166
const result = await assessSovereignty(
168167
actor,
@@ -192,11 +191,14 @@ export async function dispatch(
192191
metadata: { sovereignty },
193192
});
194193
await failIntent(env, intent.id, refusal).catch(() => null);
194+
// FIX 2 (PR #106 critical): replayed:true tells executeIntent's
195+
// `!result.replayed` guard to skip its own failIntent.
195196
return {
196197
ok: false,
197198
idempotencyKey,
198199
actionLogId: auditId,
199200
error: refusal,
201+
replayed: true,
200202
};
201203
}
202204
}
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
/**
2+
* PR #106 critical-bug regression tests.
3+
*
4+
* FIX 1 — Replay short-circuit must match (intent_id, idempotency_key),
5+
* not intent_id alone. A pre-existing terminal row for an OLD
6+
* attempt's key must NOT short-circuit a new attempt whose
7+
* computed key differs.
8+
*
9+
* FIX 2 — On a sovereignty refusal, dispatch writes the audit row and
10+
* calls failIntent itself. It must return `replayed: true` so
11+
* executeIntent's `!result.replayed` guard skips its own
12+
* failIntent — yielding exactly ONE failIntent invocation
13+
* (visible as a single status row in cc_intent_status_history,
14+
* or by SQL-level counting of NULL→failed transitions).
15+
*
16+
* Real Neon only — skipped without DATABASE_URL.
17+
*/
18+
19+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
20+
import { neon } from '@neondatabase/serverless';
21+
import {
22+
createGoal,
23+
createPlan,
24+
createIntent,
25+
executeIntent,
26+
type IntentEnv,
27+
type SovereigntyAssessmentSnapshot,
28+
} from '../../meta/intent';
29+
import '../../meta/executors';
30+
import { UPDATE_OBLIGATION_STATUS_INTENT } from '../../meta/executors/update-obligation-status';
31+
32+
const DATABASE_URL = process.env.DATABASE_URL;
33+
const SKIP = !DATABASE_URL || process.env.SKIP_INTEGRATION === '1';
34+
35+
const env: IntentEnv & Record<string, unknown> = { DATABASE_URL };
36+
const OWNER = '01-A-NB-0001-P-66-1-2';
37+
const TEST_TAG = `pr106-crit-${Date.now()}-${Math.floor(Math.random() * 1e6)}`;
38+
39+
const created: { goalIds: string[]; obligationIds: string[] } = {
40+
goalIds: [],
41+
obligationIds: [],
42+
};
43+
44+
async function freshSovereignty(): Promise<SovereigntyAssessmentSnapshot> {
45+
return {
46+
decision: 'autonomous',
47+
trustScore: 0.95,
48+
reasoning: 'pre-seeded for PR #106 critical regression',
49+
assessedAt: new Date().toISOString(),
50+
};
51+
}
52+
53+
async function cleanup() {
54+
if (!DATABASE_URL) return;
55+
const sql = neon(DATABASE_URL);
56+
await sql`DELETE FROM cc_goals WHERE owner_chitty_id = ${OWNER} AND title LIKE ${TEST_TAG + '%'}`;
57+
for (const oid of created.obligationIds) {
58+
await sql`DELETE FROM cc_actions_log WHERE target_id = ${oid}::uuid`;
59+
await sql`DELETE FROM cc_obligations WHERE id = ${oid}::uuid`;
60+
}
61+
}
62+
63+
describe.skipIf(SKIP)('PR #106 criticals — replay-by-key + single failIntent', () => {
64+
beforeAll(async () => {
65+
await cleanup();
66+
});
67+
afterAll(async () => {
68+
await cleanup();
69+
});
70+
71+
it('FIX 1: a prior terminal row with a DIFFERENT key does NOT short-circuit a new attempt', async () => {
72+
const sql = neon(DATABASE_URL!);
73+
74+
// Seed a real obligation for the executor to update.
75+
const oblRows = await sql`
76+
INSERT INTO cc_obligations (payee, category, due_date, status, metadata)
77+
VALUES (${TEST_TAG + '-fix1-payee'}, 'utilities', CURRENT_DATE + 7, 'pending', '{}'::jsonb)
78+
RETURNING id`;
79+
const obligationId = String(oblRows[0].id);
80+
created.obligationIds.push(obligationId);
81+
82+
const goal = await createGoal(env, {
83+
ownerChittyId: OWNER,
84+
title: `${TEST_TAG}-fix1-goal`,
85+
});
86+
created.goalIds.push(goal.id);
87+
const plan = await createPlan(env, {
88+
goalId: goal.id,
89+
title: `${TEST_TAG}-fix1-plan`,
90+
});
91+
const intent = await createIntent(env, {
92+
planId: plan.id,
93+
goalId: goal.id,
94+
intentType: UPDATE_OBLIGATION_STATUS_INTENT,
95+
payload: { obligation_id: obligationId, status: 'paid', notes: TEST_TAG },
96+
sovereigntyAssessment: await freshSovereignty(),
97+
metadata: { actorChittyId: OWNER },
98+
});
99+
100+
// Hand-insert a terminal cc_actions_log row for this intent with a key
101+
// that we know will NOT match the key dispatch() will compute for
102+
// attempt=1 (sha256("{id}:1:{type}")). Use a sentinel hex string.
103+
const bogusKey = 'a'.repeat(64);
104+
await sql`
105+
INSERT INTO cc_actions_log
106+
(intent_id, attempt, idempotency_key, action_type, target_type, target_id,
107+
description, status, error_message, request_payload, response_payload, metadata)
108+
VALUES
109+
(${intent.id}::uuid, 0, ${bogusKey}, 'status_change', 'obligation',
110+
${obligationId}::uuid, 'pre-existing terminal row from a different key',
111+
'completed', NULL, '{}'::jsonb, '{}'::jsonb, '{}'::jsonb)
112+
`;
113+
114+
// Now execute. Pre-fix behavior: replay short-circuit fires on the bogus
115+
// row, executor never runs, obligation stays 'pending'.
116+
// Post-fix: key mismatch → executor runs → obligation becomes 'paid'.
117+
const result = await executeIntent(env, intent.id, { actorChittyId: OWNER });
118+
expect(result.ok).toBe(true);
119+
expect(result.replayed).toBeFalsy();
120+
expect(result.idempotencyKey).not.toBe(bogusKey);
121+
122+
const oblStatus = (await sql`
123+
SELECT status FROM cc_obligations WHERE id = ${obligationId}::uuid
124+
`) as unknown as Array<{ status: string }>;
125+
expect(oblStatus[0].status).toBe('paid');
126+
127+
// Two rows now: the bogus seed (attempt=0) + the real run (attempt=1).
128+
const auditRows = (await sql`
129+
SELECT attempt, idempotency_key FROM cc_actions_log
130+
WHERE intent_id = ${intent.id}::uuid ORDER BY attempt ASC
131+
`) as unknown as Array<{ attempt: number; idempotency_key: string }>;
132+
expect(auditRows.length).toBe(2);
133+
expect(auditRows[0].attempt).toBe(0);
134+
expect(auditRows[0].idempotency_key).toBe(bogusKey);
135+
expect(auditRows[1].attempt).toBe(1);
136+
expect(auditRows[1].idempotency_key).toBe(result.idempotencyKey);
137+
});
138+
139+
it('FIX 2: a sovereignty refusal results in exactly ONE failIntent transition', async () => {
140+
const sql = neon(DATABASE_URL!);
141+
142+
const goal = await createGoal(env, {
143+
ownerChittyId: OWNER,
144+
title: `${TEST_TAG}-fix2-goal`,
145+
});
146+
created.goalIds.push(goal.id);
147+
const plan = await createPlan(env, {
148+
goalId: goal.id,
149+
title: `${TEST_TAG}-fix2-plan`,
150+
});
151+
152+
// Seed an obligation we can target (executor never runs on the refusal
153+
// path, but createIntent's payload still needs a valid shape).
154+
const oblRows = await sql`
155+
INSERT INTO cc_obligations (payee, category, due_date, status, metadata)
156+
VALUES (${TEST_TAG + '-fix2-payee'}, 'utilities', CURRENT_DATE + 7, 'pending', '{}'::jsonb)
157+
RETURNING id`;
158+
const obligationId = String(oblRows[0].id);
159+
created.obligationIds.push(obligationId);
160+
161+
// STALE snapshot forces dispatch to re-reckon sovereignty. We point
162+
// CHITTYTRUST_URL at a non-routable address so assessSovereignty's
163+
// catch branch returns decision='blocked' (real network failure — no
164+
// mock, just a real DNS hole). Refusal path triggers.
165+
const stale: SovereigntyAssessmentSnapshot = {
166+
decision: 'autonomous',
167+
trustScore: 0.95,
168+
reasoning: 'pre-seeded stale',
169+
assessedAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 365).toISOString(),
170+
};
171+
172+
const intent = await createIntent(env, {
173+
planId: plan.id,
174+
goalId: goal.id,
175+
intentType: UPDATE_OBLIGATION_STATUS_INTENT,
176+
payload: { obligation_id: obligationId, status: 'paid', notes: TEST_TAG },
177+
sovereigntyAssessment: stale,
178+
metadata: { actorChittyId: OWNER },
179+
});
180+
181+
const refusalEnv: IntentEnv & Record<string, unknown> = {
182+
DATABASE_URL,
183+
// RFC 5737 TEST-NET-1 — guaranteed unroutable. Triggers fetch failure
184+
// → assessSovereignty returns decision='blocked' (refusal path).
185+
CHITTYTRUST_URL: 'http://192.0.2.1:1/',
186+
};
187+
188+
const result = await executeIntent(refusalEnv, intent.id, {
189+
actorChittyId: OWNER,
190+
freshnessMs: 1, // force stale-snapshot branch even if clock skews
191+
});
192+
expect(result.ok).toBe(false);
193+
// FIX 2: dispatch returned replayed:true so executeIntent did not
194+
// re-call failIntent.
195+
expect(result.replayed).toBe(true);
196+
197+
// Exactly one sovereignty_refusal audit row was written by dispatch.
198+
const auditRows = (await sql`
199+
SELECT action_type, status FROM cc_actions_log
200+
WHERE intent_id = ${intent.id}::uuid
201+
`) as unknown as Array<{ action_type: string; status: string }>;
202+
expect(auditRows.length).toBe(1);
203+
expect(auditRows[0].action_type).toBe('sovereignty_refusal');
204+
expect(auditRows[0].status).toBe('failed');
205+
206+
// cc_intents reached 'failed' with the dispatch-side error message.
207+
// (failIntent's WHERE clause guards on status IN ('claimed','running'),
208+
// so a second invocation is a no-op at the DB; the canonical FIX 2
209+
// signal is `result.replayed === true` above.)
210+
const intentRows = (await sql`
211+
SELECT status, error_message FROM cc_intents WHERE id = ${intent.id}::uuid
212+
`) as unknown as Array<{ status: string; error_message: string | null }>;
213+
expect(intentRows[0].status).toBe('failed');
214+
expect(intentRows[0].error_message).toMatch(/sovereignty re-reckon/);
215+
});
216+
});

tests/meta/executor.spec.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -139,16 +139,22 @@ describe.skipIf(SKIP)('meta/executors — executeIntent round-trip', () => {
139139
`) as unknown as Array<{ status: string }>;
140140
expect(oblig[0].status).toBe('deferred');
141141

142-
// Second execution — must replay, not re-execute. attempt stays 1; no
143-
// new audit row appears.
142+
// Second execution — under FIX 1 (PR #106), idempotency is per-attempt-key,
143+
// not per-intent. A second executeIntent against the same intent produces
144+
// attempt=2 with a NEW key, the prior terminal row does NOT short-circuit
145+
// it, and the executor re-runs (update-obligation-status is itself
146+
// idempotent, so the obligation remains 'deferred'). Replay-by-key is
147+
// exercised by the dedicated test below; this assertion captures the
148+
// post-fix contract for same-intent re-invocation.
144149
const second = await executeIntent(env, intent.id, { actorChittyId: OWNER });
145-
expect(second.replayed).toBe(true);
146-
expect(second.idempotencyKey).toBe(first.idempotencyKey);
147-
expect(second.actionLogId).toBe(first.actionLogId);
150+
expect(second.replayed).toBeFalsy();
151+
expect(second.idempotencyKey).not.toBe(first.idempotencyKey);
152+
expect(second.actionLogId).not.toBe(first.actionLogId);
148153

149154
const auditRowsAfter = (await sql`
150-
SELECT id FROM cc_actions_log WHERE intent_id = ${intent.id}::uuid
151-
`) as unknown as Array<{ id: string }>;
152-
expect(auditRowsAfter.length).toBe(1);
155+
SELECT attempt FROM cc_actions_log WHERE intent_id = ${intent.id}::uuid ORDER BY attempt ASC
156+
`) as unknown as Array<{ attempt: number }>;
157+
expect(auditRowsAfter.length).toBe(2);
158+
expect(auditRowsAfter.map((r) => r.attempt)).toEqual([1, 2]);
153159
});
154160
});

0 commit comments

Comments
 (0)