@decidio/sdk 0.2.2 → 0.3.3

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.
@@ -3,6 +3,15 @@
3
3
  * we do not understand is never treated as evidence. */
4
4
  export declare const EVIDENCE_TIERS: readonly ["pending", "execution_unconfirmed", "agent_asserted", "sensor_observed", "application_confirmed", "independently_reconciled"];
5
5
  export type EvidenceTier = (typeof EVIDENCE_TIERS)[number];
6
+ /** Whether a recorded tier attests a COMMITTED execution - the same set the live reader uses, so a
7
+ * tombstone that kept only the tier can still answer the question honestly (retest V030-07). */
8
+ export declare function tierConfirms(tier: string | undefined | null): boolean;
9
+ /** The tier a `reported` tombstone records. The server caps a confirming tier whose proof failed to
10
+ * `execution_unconfirmed` (apps/api agent-confirm.ts, lint-locked CONFIRM-TRUTH), so a body that
11
+ * says otherwise is one the current server cannot emit; the tombstone applies the same rule, so
12
+ * reconfirm() - which derives from the tier - can never answer "confirmed" where resolve() answered
13
+ * "not confirmed" over the same record (invariant-guard, 0.3.3). Every other tier is stored verbatim. */
14
+ export declare function tombstoneTier(ev: EvidenceOutcome): string;
6
15
  export interface EvidenceOutcome {
7
16
  /** Decidio accepted the report and named the tier it recorded. */
8
17
  reportRecorded: boolean;
package/dist/evidence.js CHANGED
@@ -7,6 +7,10 @@ export const EVIDENCE_TIERS = [
7
7
  "independently_reconciled",
8
8
  ];
9
9
  const CONFIRMING = ["sensor_observed", "application_confirmed", "independently_reconciled"];
10
+ export function tierConfirms(tier) { return typeof tier === "string" && CONFIRMING.includes(tier); }
11
+ export function tombstoneTier(ev) {
12
+ return ev.executionReportVerified === false && tierConfirms(ev.evidenceTier) ? "execution_unconfirmed" : (ev.evidenceTier ?? "recorded");
13
+ }
10
14
  export function describeTierGap(capturedResponse) {
11
15
  const r = (capturedResponse ?? {});
12
16
  const id = r.id ?? r.recordId ?? r.Id ?? null;
@@ -32,20 +36,22 @@ export function readConfirmResponse(body, opts) {
32
36
  if (!EVIDENCE_TIERS.includes(tier)) {
33
37
  return { reportRecorded: false, executionConfirmed: false, confirmationRetryable: false, reportJournalled: journalled, diagnostic: `the gate reported an evidence tier this SDK does not recognise (${JSON.stringify(tier)}) — refusing to treat it as evidence. Upgrade @decidio/sdk; your action DID run, and ${retry}` };
34
38
  }
35
- const executionConfirmed = CONFIRMING.includes(tier);
39
+ const rpt = body?.executionReport;
40
+ const hasVerdict = !!rpt && typeof rpt === "object" && typeof rpt.verified === "boolean";
41
+ const proofExplicitlyFailed = hasVerdict && rpt.verified === false;
42
+ const executionConfirmed = CONFIRMING.includes(tier) && !proofExplicitlyFailed;
36
43
  const outcome = { reportRecorded: true, evidenceTier: tier, executionConfirmed, reportJournalled: journalled };
37
- if (!executionConfirmed && opts.signed)
44
+ if (!executionConfirmed && !proofExplicitlyFailed && opts.signed)
38
45
  outcome.diagnostic = describeTierGap(opts.capturedResponse);
39
- else if (!executionConfirmed)
46
+ else if (!executionConfirmed && !proofExplicitlyFailed)
40
47
  outcome.diagnostic = "no signing key is configured, so the gate capped this report at an unproven agent assertion. Run `npx @decidio/sdk init` to generate the agent's key and earn a confirming tier.";
41
- const rpt = body?.executionReport;
42
- if (rpt && typeof rpt === "object" && typeof rpt.verified === "boolean") {
48
+ if (hasVerdict) {
43
49
  outcome.executionReportVerified = rpt.verified;
44
50
  if (typeof rpt.reason === "string" && rpt.reason)
45
51
  outcome.executionReportReason = rpt.reason;
46
- if (!rpt.verified) {
52
+ if (proofExplicitlyFailed) {
47
53
  const why = typeof rpt.reason === "string" && rpt.reason ? ` (${rpt.reason})` : "";
48
- outcome.diagnostic = `${outcome.diagnostic ? outcome.diagnostic + " " : ""}The signed execution report did NOT verify${why} — the report was recorded without a proven attestation. Check the agent's key/DID/registration (npx @decidio/sdk doctor).`;
54
+ outcome.diagnostic = `the signed execution report did NOT verify${why}, so this is NOT a confirmed execution — the gate reported tier "${tier}", and this SDK refuses to read an unproven attestation as confirmation. Your action DID run and the report IS recorded at that tier - there is nothing to re-send, and re-sending would not change the verdict; the record stands as unverified. Check the agent's key/DID/registration (npx @decidio/sdk doctor) so the NEXT execution verifies.`;
49
55
  }
50
56
  }
51
57
  return outcome;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ResumeController } from "./resume.js";
2
2
  import { type EvidenceOutcome } from "./evidence.js";
3
- export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
3
+ export { DecidioSuspendedError, DecidioDeniedError, DecidioRecoveryUnavailableError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
4
4
  export type { PendingAction, PendingStore, ResumeController, ResumeOutcome } from "./resume.js";
5
5
  export { guard, Guard } from "./guard.js";
6
6
  export type { GuardFacadeConfig, ProtectOptions } from "./guard.js";
@@ -12,7 +12,10 @@ export { readConfirmResponse, buildConfirmBody, describeTierGap, EVIDENCE_TIERS
12
12
  export type { EvidenceOutcome, EvidenceTier } from "./evidence.js";
13
13
  export type { RequesterIdentity } from "./signing.js";
14
14
  export interface GuardConfig {
15
- /** Base URL of the Decidio gate, e.g. "https://api.decidio.io". */
15
+ /** Base URL of the Decidio gate. The hosted sandbox is "https://decidio-api.onrender.com",
16
+ * which is also the default when this is unset; a self-hosted deployment is your own origin.
17
+ * (This example named a host the product does not use — it shipped in the .d.ts, so it was the
18
+ * first URL a typing-aware editor suggested to anyone configuring the SDK.) */
16
19
  apiUrl: string;
17
20
  /** The agent identity registered with Decidio (onboarding wizard), e.g. "salesforce/SalesforceBot". */
18
21
  agentId: string;
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import { DecidioSuspendedError } from "./resume.js";
2
2
  import { signBoundProof, signConfirm, assertSafeTransport, carriesCredential } from "./signing.js";
3
3
  import { canonicalize, canonicalizeArgs } from "./canonical.js";
4
4
  import { readConfirmResponse, buildConfirmBody } from "./evidence.js";
5
- export { DecidioSuspendedError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
5
+ export { DecidioSuspendedError, DecidioDeniedError, DecidioRecoveryUnavailableError, FilePendingStore, MemoryPendingStore, createDecidioResume, assertConfirmRecorded } from "./resume.js";
6
6
  export { guard, Guard } from "./guard.js";
7
7
  export { DecidioHttpError, decisionIdProblem } from "./errors.js";
8
8
  import { DecidioHttpError, decisionIdProblem } from "./errors.js";
@@ -138,10 +138,10 @@ export function withApproval(fn, describe, config, opts) {
138
138
  }
139
139
  throw e;
140
140
  }
141
+ ctx = canonicalize(ctx, "describe(...)");
141
142
  const requesterIdentity = (config.agentKey && config.agentDid)
142
143
  ? signBoundProof(config.agentKey, { agentId: config.agentId, did: config.agentDid, action: ctx.action, amount: ctx.amount, scope: ctx.scope, workspaceId: config.workspaceId })
143
144
  : undefined;
144
- canonicalize(ctx, "describe(...)");
145
145
  if (typeof ctx?.action !== "string" || ctx.action.trim().length === 0) {
146
146
  throw new Error(`[decidio] describe(...) must return an \`action\` string naming what is being asked (got ${JSON.stringify(ctx?.action ?? null)}). Nothing was routed or executed.`);
147
147
  }
package/dist/inngest.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { RequestListener } from "node:http";
2
2
  import { verifyResumeSignature } from "./resume.js";
3
- import type { GuardConfig, ApprovalContext } from "./index.js";
3
+ import type { GuardConfig, ApprovalContext, EvidenceOutcome } from "./index.js";
4
4
  /** The Inngest event Decidio's verdict is translated into. The waiting function
5
5
  * correlates on `data.decisionId`. */
6
6
  export declare const DECIDIO_RESUME_EVENT = "decidio/decision.resolved";
@@ -24,10 +24,18 @@ export interface InngestClientLike {
24
24
  data: Record<string, any>;
25
25
  }): Promise<unknown>;
26
26
  }
27
+ /** ONE RESULT SHAPE ACROSS EVERY DOOR (F-008), with the one field this door genuinely needs on
28
+ * top of it. `status` stays because Inngest's durable path can legitimately end in `rejected` or
29
+ * `blocked` without ever calling your action — the other adapters signal that by throwing. What
30
+ * changed: the value is `value` (not `result`) so it reads the same as every other door, and
31
+ * `confirmation` is CARRIED rather than discarded. It used to be thrown away inside the confirm
32
+ * step, which made "it ran and Decidio recorded it" and "it ran and Decidio does not know"
33
+ * indistinguishable to the caller. */
27
34
  export type GateStepOutcome<R> = {
28
35
  status: "executed" | "rejected" | "blocked";
29
36
  decisionId: string;
30
- result?: R;
37
+ value?: R;
38
+ confirmation?: EvidenceOutcome;
31
39
  };
32
40
  /**
33
41
  * Gate an action INSIDE an Inngest function, with durable suspend on routing.
package/dist/inngest.js CHANGED
@@ -22,8 +22,8 @@ export async function decidioGateStep(args) {
22
22
  return { status: "blocked", decisionId: auth.decisionId };
23
23
  }
24
24
  const result = await step.run("agent-action", args.run);
25
- await step.run("decidio-confirm", async () => { await gateConfirm(config, auth.decisionId, result, args.evidence); return true; });
26
- return { status: "executed", decisionId: auth.decisionId, result };
25
+ const confirmation = await step.run("decidio-confirm", async () => gateConfirm(config, auth.decisionId, result, args.evidence));
26
+ return { status: "executed", decisionId: auth.decisionId, value: result, confirmation };
27
27
  }
28
28
  export function createInngestResumeBridge(opts) {
29
29
  const secret = opts.webhookSecret ?? "";
package/dist/resume.d.ts CHANGED
@@ -46,13 +46,32 @@ export interface PendingAction {
46
46
  * `indeterminate` is the honest answer to the case an SDK cannot decide: the handler was
47
47
  * invoked and then threw. Whether the downstream write committed is not knowable from here, so
48
48
  * the action is never automatically retried — it waits for `reconcile()`. */
49
- export type ExecutionState = "parked" | "claimed_preinvoke" | "invoking" | "executed_unreported" | "indeterminate" | "reported";
49
+ export type ExecutionState = "parked" | "claimed_preinvoke"
50
+ /** A human or policy said NO. Terminal: the handler must never run for this decision. */
51
+ | "discarded" | "invoking" | "executed_unreported" | "indeterminate" | "reported";
50
52
  /** Ordered weakest→strongest. Resolution ALWAYS takes the strongest state present for a
51
53
  * decision, which is what makes a partially-failed transition safe: if a crash leaves both the
52
54
  * old and new markers on disk, the stronger one wins and an executed action can never read
53
55
  * back as parked. */
56
+ /** `discarded` sits ABOVE the pre-invocation states and BELOW the execution ones, and the position
57
+ * is load-bearing in both directions:
58
+ *
59
+ * - above `parked`/`claimed_preinvoke`, so a denial beats a payload whose deletion FAILED. That is
60
+ * the whole F-001 fix: the old code deleted the payload and wrote no state, the shipped file
61
+ * store swallows unlink errors, and the ledger therefore still read `parked` — so a later,
62
+ * out-of-order `approved` signal executed an action a human had explicitly refused.
63
+ * - below `invoking`…`reported` — but rank is NOT how the two are reconciled, and relying on it
64
+ * was wrong in both directions. A stronger marker written beside a tombstone outranked the
65
+ * denial AND was then deleted by setState's weaker-marker sweep, so a refused decision read back
66
+ * as runnable. `resolve()` therefore prefers the DENIAL whenever its marker is present, at any
67
+ * rank. That decides authority, not truth: if an execution marker is there too, something DID
68
+ * run, and swallowing that is the opposite failure — so resolve() flags the conflict on the
69
+ * record (`executionDespiteDenial`), recover() reports it, and reconfirm() says so instead of
70
+ * "this process never executed it". A denial governs the decision; it never edits history. */
54
71
  export declare const EXECUTION_STATES: readonly ExecutionState[];
55
- /** States from which the handler must NEVER be invoked again automatically. */
72
+ /** States from which the handler must NEVER be invoked again automatically. `discarded` is here
73
+ * FOREVER — `claim()` gates on this list, so a denied decision can never be claimed again, by this
74
+ * process or any future one. */
56
75
  export declare const NON_REPLAYABLE: readonly ExecutionState[];
57
76
  export interface ExecutionRecord {
58
77
  decisionId: string;
@@ -84,6 +103,11 @@ export interface ExecutionRecord {
84
103
  kind: "ed25519" | "hmac";
85
104
  sig: string;
86
105
  };
106
+ /** WHY this decision is `discarded` — the verdict a human or policy returned, and any reason
107
+ * text. Recorded on the tombstone so recovery can say "a human rejected this" rather than
108
+ * reporting a bare terminal state an operator would have to go and look up. */
109
+ verdict?: "rejected" | "blocked";
110
+ discardReason?: string;
87
111
  /** Set when this row was reconstructed by an upgrade rather than written by a live execution —
88
112
  * currently only `"0.1.x report journal"`. Such a row predates evidence binding and can never
89
113
  * carry a valid signature, so the strict check exempts it EXPLICITLY. The exemption must be a
@@ -95,10 +119,26 @@ export interface ExecutionRecord {
95
119
  confirmation?: string;
96
120
  /** Set when an OPERATOR settled this decision rather than the SDK observing it. Kept so an
97
121
  * audit of the agent-side ledger can tell an observed execution from an asserted one. */
122
+ /** `workerStopped` records that a HUMAN asserted the claiming worker was stopped before this
123
+ * decision was released — the assertion that made the release legal. It is stamped here because
124
+ * the whole point of requiring it is to put a name against that judgement: without it the ledger
125
+ * showed a decision released from `invoking` with no trace that anyone had claimed to check. */
98
126
  reconciled?: {
99
127
  outcome: SettleOutcome;
100
128
  reason?: string;
101
129
  at: string;
130
+ workerStopped?: boolean;
131
+ };
132
+ /** Set when a DENIED decision also carries an execution record. That pair is the single most
133
+ * alarming state the machine can hold — a human refused it and something ran anyway — so it must
134
+ * never resolve to silence. The revision chain cannot produce it (no transition publishes over
135
+ * a denial); it is DERIVED by the legacy-marker reader, and PERSISTED only when a 0.2/0.3
136
+ * directory that held a denial marker beside an execution marker is migrated onto the chain —
137
+ * there it is the record of what that layout held. */
138
+ executionDespiteDenial?: {
139
+ state: ExecutionState;
140
+ at: string;
141
+ hasEvidence: boolean;
102
142
  };
103
143
  at: string;
104
144
  }
@@ -127,8 +167,38 @@ export declare class DecidioAlreadySettledError extends Error {
127
167
  decisionId: string;
128
168
  constructor(decisionId: string);
129
169
  }
170
+ /** A DENIAL IS TERMINAL, and that has to be enforced where the state is WRITTEN — not only where
171
+ * it is reconciled. Refusing `settle` from `discarded` closed the operator's door and left three
172
+ * others open, each of which walked a refused action all the way to execution:
173
+ * - `discarded` sits mid-rank in EXECUTION_STATES, so the advance-only rule happily accepted
174
+ * `discarded → invoking → executed_unreported`;
175
+ * - `markDiscarded` leaves the record UNOWNED (`attemptId: ""`) when nothing had claimed it, so
176
+ * the ownership fence — which skips when there is no owner — did not stop that walk either;
177
+ * - reachable through the shipped controller, not just the store API: an `approved` signal
178
+ * claims, a `rejected` signal writes the tombstone, and the first call then invokes anyway.
179
+ * Typed, for the same reason DecidioAlreadySettledError is: the reporting stage deliberately keeps
180
+ * going when the ledger fails, so a generic error here would read as "the disk write failed" and
181
+ * send an authority report for an action a human refused. */
182
+ /** `recover()` could not enumerate the ledger. It is a THROW, not an empty list, because those two
183
+ * are the same value to a caller and mean opposite things: `[]` is "you owe nothing", and this is
184
+ * "I cannot tell you what you owe". The old code logged to stderr and returned `[]`, so a script
185
+ * reading `if (!(await recover()).length) allClear()` treated an unreadable store — a permissions
186
+ * change, a mounted volume gone, a corrupt directory — as proof that no action was left
187
+ * outstanding. A crash is the correct failure here; silence is not. */
188
+ export declare class DecidioRecoveryUnavailableError extends Error {
189
+ cause: unknown;
190
+ constructor(cause: unknown);
191
+ }
192
+ export declare class DecidioDeniedError extends Error {
193
+ decisionId: string;
194
+ verdict: string;
195
+ constructor(decisionId: string, verdict: string);
196
+ }
130
197
  export declare const SETTLE_OUTCOMES: readonly SettleOutcome[];
131
- export declare function assertSettlable(id: string, state: ExecutionState, outcome: SettleOutcome, hasObservedEvidence?: boolean): void;
198
+ export declare function assertSettlable(id: string, state: ExecutionState, outcome: SettleOutcome, opts?: {
199
+ hasObservedEvidence?: boolean;
200
+ workerStopped?: boolean;
201
+ }): void;
132
202
  export interface ClaimResult {
133
203
  /** True only when THIS attempt may proceed to invoke. */
134
204
  ok: boolean;
@@ -148,8 +218,13 @@ export interface ClaimResult {
148
218
  export interface PendingStore {
149
219
  put(p: PendingAction): Promise<void>;
150
220
  get(decisionId: string): Promise<PendingAction | null>;
151
- /** CLEANUP ONLY. Removing a row must never be what prevents a second execution. */
152
- delete(decisionId: string): Promise<void>;
221
+ /** CLEANUP ONLY. Removing a row must never be what prevents a second execution.
222
+ *
223
+ * `attemptId` scopes the cleanup to its OWNER. A superseded attempt tidies up on its way out
224
+ * (see the stale-attempt branch of the reporting stage), and without this it deleted the payload
225
+ * of the attempt that had legitimately taken over — the live worker's own recovery data, removed
226
+ * by the dead one. Omit it for genuine housekeeping that is not attributable to an attempt. */
227
+ delete(decisionId: string, attemptId?: string): Promise<void>;
153
228
  /** Parked entries only — anything claimed or beyond is excluded. */
154
229
  list(): Promise<PendingAction[]>;
155
230
  /** Atomically take ownership for THIS attempt. Must refuse when the decision is already
@@ -164,6 +239,12 @@ export interface PendingStore {
164
239
  markIndeterminate(decisionId: string, attemptId: string, error: string): Promise<void>;
165
240
  /** Decidio accepted the execution report. */
166
241
  markReported(decisionId: string, attemptId: string, confirmation: string): Promise<void>;
242
+ /** A human or policy DENIED this decision. Writes the terminal tombstone that makes the decision
243
+ * permanently unclaimable — and it must be written BEFORE any payload cleanup, because cleanup
244
+ * is best-effort and a store that swallows its failure would otherwise leave the row readable as
245
+ * `parked`. A conforming store persists this durably; returning without persisting reintroduces
246
+ * F-001. Safe to call more than once (a denial is idempotent). */
247
+ markDiscarded(decisionId: string, verdict: "rejected" | "blocked", reason?: string): Promise<void>;
167
248
  /** The authoritative state for one decision, or null if this store has never seen it. */
168
249
  getExecution(decisionId: string): Promise<ExecutionRecord | null>;
169
250
  /** Every execution record the store holds — the basis for honest recovery reporting. */
@@ -181,6 +262,7 @@ export interface PendingStore {
181
262
  evidence?: unknown;
182
263
  reason?: string;
183
264
  evidenceSig?: ExecutionRecord["evidenceSig"];
265
+ workerStopped?: boolean;
184
266
  }): Promise<void>;
185
267
  /** Release a claim taken BEFORE invocation, so a pre-invoke failure stays retryable. It is a
186
268
  * contract error to call this once `markInvoking` has been written, and a conforming store
@@ -200,13 +282,16 @@ export declare class MemoryPendingStore implements PendingStore {
200
282
  private advance;
201
283
  put(p: PendingAction): Promise<void>;
202
284
  get(id: string): Promise<PendingAction | null>;
203
- delete(id: string): Promise<void>;
285
+ /** Cleanup only — the execution record STAYS. Scoped to the owning attempt: see PendingStore. */
286
+ delete(id: string, attemptId?: string): Promise<void>;
287
+ private ownsPayload;
204
288
  list(): Promise<PendingAction[]>;
205
289
  claim(id: string, attemptId: string): Promise<ClaimResult>;
206
290
  markInvoking(id: string, attemptId: string): Promise<void>;
207
291
  recordExecuted(id: string, attemptId: string, evidence: unknown, evidenceSig?: ExecutionRecord["evidenceSig"], evidenceError?: string): Promise<void>;
208
292
  markIndeterminate(id: string, attemptId: string, error: string): Promise<void>;
209
293
  markReported(id: string, attemptId: string, confirmation: string): Promise<void>;
294
+ markDiscarded(id: string, verdict: "rejected" | "blocked", reason?: string): Promise<void>;
210
295
  getExecution(id: string): Promise<ExecutionRecord | null>;
211
296
  listExecutions(): Promise<ExecutionRecord[]>;
212
297
  /** OPERATOR RECONCILIATION — the ONE legal backwards transition, and the only exit from
@@ -220,9 +305,28 @@ export declare class MemoryPendingStore implements PendingStore {
220
305
  evidence?: unknown;
221
306
  reason?: string;
222
307
  evidenceSig?: ExecutionRecord["evidenceSig"];
308
+ workerStopped?: boolean;
223
309
  }): Promise<void>;
224
310
  releasePreinvoke(id: string, attemptId: string): Promise<void>;
225
311
  }
312
+ /** The ACL principal for THIS process, resolved from the token SID rather than the login name.
313
+ *
314
+ * Shared by every hardening site (F-009): the durable pending directory AND the `.env` the CLI
315
+ * writes. `writeSecretFile` had its own weaker copy that granted `userInfo().username` — who
316
+ * logged IN, which is not always who is writing. A Windows service, a scheduled task or an
317
+ * impersonated thread has a different effective identity, and granting the wrong name while
318
+ * `/inheritance:r` strips every other ACE is how a hardening step locks the writer out of its own
319
+ * secrets. A SID also needs no localisation or escaping, where a name can carry spaces, a domain
320
+ * prefix, or be renamed out from under the ACE. */
321
+ export declare function windowsPrincipal(): string;
322
+ /** Restrict a FILE to its owner on Windows, with the same verify-then-roll-back discipline as
323
+ * `hardenDir`. Returns null on success, or the reason it could not be done.
324
+ *
325
+ * The verification is the point, and it is what the CLI's own version lacked: a SUCCESSFUL
326
+ * `icacls` that locks this process out of the file it just wrote is more dangerous than a failed
327
+ * one, and it printed nothing at all. Here the file is read back, and if that fails the
328
+ * inheritance goes back rather than leaving credentials the SDK can no longer read. */
329
+ export declare function hardenFile(file: string): string | null;
226
330
  /** Restrict a directory to its owner. On Windows POSIX modes are a no-op, so strip inheritance
227
331
  * and grant only the effective user. A FAILURE IS SAID, never swallowed: these files hold the
228
332
  * arguments of actions awaiting approval, and "silently kept the directory's inherited
@@ -243,51 +347,78 @@ export declare class FilePendingStore implements PendingStore {
243
347
  constructor(dir: string);
244
348
  private key;
245
349
  private path;
246
- /** One marker file per state. The FILENAME carries the state so a scan never has to parse,
247
- * and the content carries the record. */
248
- private statePath;
249
- /** Durable write: temp file in the SAME directory → fsync → atomic rename. A crash can then
250
- * only leave the old complete file or the new complete file, never a truncated one. The
251
- * 0.1.x journal wrote straight to its final path with O_TRUNC, so a crash mid-write
252
- * destroyed the record that proved an action had already run. */
350
+ private revPath;
351
+ /** Legacy (0.2/0.3) marker path, read for migration and never written. */
352
+ private markerPath;
353
+ private listRevs;
354
+ private legacyMarkers;
355
+ /** Write `text` to a fresh temp file beside `target`, fsynced. Returns the temp path. */
356
+ private writeTemp;
357
+ /** Durable write for the PAYLOAD file (not the chain): temp → fsync → atomic rename. */
253
358
  private writeAtomic;
254
- /** The authoritative record: the STRONGEST state present on disk. A crash between "write the
255
- * new marker" and "remove the old one" leaves two markers; taking the strongest means an
256
- * executed action can never read back as parked, so a partial transition is safe rather than
257
- * merely unlikely. Corruption is reported, never silently treated as absent. */
258
- private resolve;
359
+ /** The head of the chain, or the migrated reading of a legacy layout that has not been
360
+ * upgraded. Corruption is REPORTED, never treated as absent: an unreadable record is the one
361
+ * that proves an action already ran. */
362
+ private readHead;
363
+ /** TEST SEAM, a no-op in production. Called with the operation name and the state about to be
364
+ * published, AFTER validation and BEFORE the publish — the exact window a concurrent writer
365
+ * can land in. The deterministic two-process suites subclass the store and block here so the
366
+ * race the retest found is reproduced on purpose, every run, rather than by luck. */
367
+ protected _barrier(_op: string, _id: string, _next: ExecutionState): void;
368
+ /** ONE TRANSITION = read head → compute successor → publish with link(). `compute` receives the
369
+ * current record (null if none) and returns the successor, or null for "nothing to write" (a
370
+ * refusal that is a return value, like a lost claim, rather than an error). Every rule runs
371
+ * INSIDE `compute`, against the revision that is about to be superseded; if a rival publishes
372
+ * first, `compute` runs again against the rival's record. */
373
+ private transition;
374
+ /** The 0.2/0.3 reading of a marker layout — strongest marker wins, a denial marker wins at any
375
+ * rank, and a denial beside an execution marker is reported as both. Used ONLY to migrate an
376
+ * older directory and to report one that could not be migrated; nothing writes markers. */
377
+ private resolveLegacyMarkers;
378
+ /** The generic ADVANCE, with every fence evaluated against the head the publish will supersede.
379
+ * `pre` is a per-method check (markInvoking's claim requirement) that must ALSO run inside the
380
+ * transition — a check that runs before it is a check against a snapshot. */
259
381
  private setState;
260
382
  put(p: PendingAction): Promise<void>;
261
383
  get(id: string): Promise<PendingAction | null>;
262
384
  /** CLEANUP ONLY — the payload goes, the execution record STAYS. That is the whole point of
263
385
  * the v2 model: a failed or skipped delete can no longer make an executed action runnable. */
264
- delete(id: string): Promise<void>;
386
+ delete(id: string, attemptId?: string): Promise<void>;
265
387
  list(): Promise<PendingAction[]>;
266
388
  claim(id: string, attemptId: string): Promise<ClaimResult>;
267
389
  markInvoking(id: string, attemptId: string): Promise<void>;
268
390
  recordExecuted(id: string, attemptId: string, evidence: unknown, evidenceSig?: ExecutionRecord["evidenceSig"], evidenceError?: string): Promise<void>;
269
391
  markIndeterminate(id: string, attemptId: string, error: string): Promise<void>;
270
392
  markReported(id: string, attemptId: string, confirmation: string): Promise<void>;
393
+ markDiscarded(id: string, verdict: "rejected" | "blocked", reason?: string): Promise<void>;
271
394
  getExecution(id: string): Promise<ExecutionRecord | null>;
272
395
  listExecutions(): Promise<ExecutionRecord[]>;
273
- /** OPERATOR RECONCILIATION — see MemoryPendingStore.settle. Writes the new marker first and
274
- * then clears every other state file for this decision, including the STRONGER ones: this is
275
- * the single place where a state legitimately moves backwards, so leaving a stronger marker
276
- * behind would let resolve()'s strongest-wins rule undo the operator's decision. */
396
+ /** OPERATOR RECONCILIATION — see MemoryPendingStore.settle. The one legal backwards move, and
397
+ * now a transition like any other: two conflicting settlements both validate, both try to
398
+ * publish the next revision, exactly one wins, and the loser re-validates against the winner's
399
+ * record — where `assertSettlable` refuses it (captured evidence protects a `committed`; a
400
+ * `parked` record refuses `committed`). Before this, both won and the second erased the first. */
277
401
  settle(id: string, outcome: SettleOutcome, opts?: {
278
402
  evidence?: unknown;
279
403
  reason?: string;
280
404
  evidenceSig?: ExecutionRecord["evidenceSig"];
405
+ workerStopped?: boolean;
281
406
  }): Promise<void>;
282
407
  releasePreinvoke(id: string, attemptId: string): Promise<void>;
283
- /** ONE-WAY MIGRATION from the 0.1.x layout, run once at construction. `<id>.json` → parked;
284
- * `<id>.claimed` → INDETERMINATE, never parked: a claimed 0.1.x entry may have been mid-flight
285
- * when the process died, and assuming it did not run is the assumption that duplicates a
286
- * payment. Nothing ambiguous is made executable by an upgrade. */
408
+ /** ONE-WAY MIGRATION of older layouts, run at construction.
409
+ * 0.2/0.3 one-marker-per-state → the head of a fresh revision chain (markers removed)
410
+ * 0.1.x `<id>.json` with no state → parked
411
+ * 0.1.x `<id>.claimed` → INDETERMINATE, never parked: a claimed 0.1.x entry may have
412
+ * been mid-flight when the process died, and assuming it did
413
+ * not run is the assumption that duplicates a payment
414
+ * 0.1.x `reports/<id>.json` → executed_unreported (the report can be re-sent)
415
+ * Nothing ambiguous is made executable by an upgrade, and nothing unreadable is silently
416
+ * dropped: a key whose markers cannot be parsed is left exactly as found and reads as corrupt. */
287
417
  migrateLegacy(): {
288
418
  migrated: number;
289
419
  indeterminate: number;
290
420
  reports: number;
421
+ chained: number;
291
422
  };
292
423
  /** 0.1.x wrote execution reports to `<pendingDir>/reports/<id>.json` — evidence that an action
293
424
  * ALREADY RAN and still owes Decidio a report. 0.2.0 keeps that in the ledger, so without this
@@ -331,6 +462,11 @@ export type RecoveryState =
331
462
  * operator releases it. Reporting this as "parked" (as 0.2.0's first draft did) told a UI an
332
463
  * approved action was still queued to resume when in fact it had been silently dropped. */
333
464
  | "claimed_stalled"
465
+ /** DENIED, and an execution marker exists anyway. Nothing here will run it and nothing will
466
+ * report it — the decision is closed — but an effect may exist downstream, so this is the one
467
+ * recovery row that asks a human to go and look. It cannot be reconciled or retried; a denial is
468
+ * terminal. Callers switching exhaustively on this union must handle it. */
469
+ | "denied_with_evidence"
334
470
  /** Claimed for execution and NOT journalled: the process died somewhere between claiming and
335
471
  * recording the result. The action MAY OR MAY NOT have run — this cannot be resolved locally
336
472
  * and must never be retried automatically. */
@@ -492,6 +628,7 @@ export interface ResumeController extends ResumeConfig {
492
628
  outcome: SettleOutcome;
493
629
  evidence?: unknown;
494
630
  reason?: string;
631
+ workerStopped?: boolean;
495
632
  }): Promise<EvidenceOutcome>;
496
633
  /** Every durable item that needs a human's attention, with its state named — including the one
497
634
  * state that CANNOT be resolved automatically: `indeterminate`, where a process died between