@decidio/sdk 0.3.0 → 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.
- package/README.md +88 -2
- package/dist/adapters/_gate.js +1 -1
- package/dist/cli.js +65 -9
- package/dist/evidence.d.ts +9 -0
- package/dist/evidence.js +5 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +1 -1
- package/dist/resume.d.ts +51 -25
- package/dist/resume.js +266 -159
- package/dist/signing.js +11 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -148,8 +148,12 @@ evidence is yours, not ours. To also prove *who* issued it, pin your workspace's
|
|
|
148
148
|
record verify panel — or simply read it off the `receipt` command's output, which prints your
|
|
149
149
|
workspace's issuer DID from the authenticated response so you never have to hunt for it).
|
|
150
150
|
|
|
151
|
-
**What VALID means — and what it does not.** A pinned `VALID` proves the receipt's
|
|
152
|
-
sealed by your workspace's issuer and
|
|
151
|
+
**What VALID means — and what it does not.** A pinned `VALID` proves the receipt's **claims** were
|
|
152
|
+
sealed by your workspace's issuer and that none of them has changed since. Not its *bytes*: the
|
|
153
|
+
signature covers the record's canonical form (JCS), so reformatting the file — reindenting it,
|
|
154
|
+
minifying it, reordering keys — still verifies, and should, because none of that alters a claim.
|
|
155
|
+
If you need byte-for-byte custody, hash the file and keep that hash yourself; this proves the
|
|
156
|
+
content, which is what an auditor is asking about. It does **not** prove the approved
|
|
153
157
|
action ran. Execution is confirmed *after* the seal, and a sealed record is immutable, so the
|
|
154
158
|
receipt's own `authority.result` and `confirmation.status` are a snapshot taken at seal time — a
|
|
155
159
|
receipt for an approved-but-never-executed action verifies `VALID`, correctly. The verifier now
|
|
@@ -445,6 +449,88 @@ for engines you don't. Each adapter's shipped `.d.ts` header documents its wirin
|
|
|
445
449
|
@decidio/sdk quickstart` is the fastest runnable end-to-end.
|
|
446
450
|
|
|
447
451
|
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
## The durable store is a revision chain (0.3.2)
|
|
455
|
+
|
|
456
|
+
`FilePendingStore` keeps each decision's execution record as an **append-only revision chain** —
|
|
457
|
+
`<id>.rev.1`, `<id>.rev.2`, … — and the head is the highest number. Every state change reads the
|
|
458
|
+
head, validates against exactly that record, writes the successor to a temp file, and publishes it
|
|
459
|
+
with `link()` to the next revision number. `link()` fails if that name already exists, so **exactly
|
|
460
|
+
one writer wins each revision**, with no lock, no timeout and no daemon — on any volume that
|
|
461
|
+
supports hard links (NTFS, ext4, APFS, XFS; not FAT/exFAT, and not every network or FUSE mount —
|
|
462
|
+
on those every write fails closed rather than silently degrading). A loser re-reads the new head
|
|
463
|
+
and validates again — against what actually happened, not a snapshot. **No revision file is ever
|
|
464
|
+
deleted:** a name that could be reused is a race that could be lost, so the chain is genuinely
|
|
465
|
+
append-only and a decision's whole history stays readable in place.
|
|
466
|
+
|
|
467
|
+
This replaced the 0.2/0.3 one-marker-per-state layout after an external retest showed why it had to:
|
|
468
|
+
that layout made a transition in three separate steps (read the markers, write the new one, sweep
|
|
469
|
+
the weaker ones), so a process holding `claimed_preinvoke` could validate its snapshot, lose to a
|
|
470
|
+
second process that durably wrote *and returned* a denial, then write `invoking` anyway — and its
|
|
471
|
+
sweep deleted the tombstone. The SDK reported a denial as final and ran the denied action. Every
|
|
472
|
+
*sequential* schedule was safe; the window was inside one transition. The chain has no such window:
|
|
473
|
+
nothing ever deletes the head, and the rule that refuses a denied decision now runs against the
|
|
474
|
+
denial that just landed.
|
|
475
|
+
|
|
476
|
+
**Upgrading.** A directory in the older layout is migrated once, at the first construction of a
|
|
477
|
+
0.3.2 store: each decision's markers become the head of a fresh chain and the markers are removed.
|
|
478
|
+
Corrupt markers are left exactly as found and the decision reads as unreadable until a person
|
|
479
|
+
inspects it. **Stop every older worker that shares the directory BEFORE the first 0.3.2 process
|
|
480
|
+
constructs its store, then upgrade them together.** The one-time migration reads the markers it
|
|
481
|
+
finds and removes exactly those; an older SDK still writing leaves a marker beside a chain, and
|
|
482
|
+
that decision is then *unreadable* by design — neither reading can be trusted over the other, so
|
|
483
|
+
the store refuses to pick one.
|
|
484
|
+
|
|
485
|
+
**Custom stores.** The `PendingStore` contract has said since 0.2.0 that every method must be atomic
|
|
486
|
+
against concurrent callers. The shipped file store did not honor that until now; a custom store must.
|
|
487
|
+
The deterministic two-process schedules that proved the defect live in
|
|
488
|
+
`packages/conformance/acceptance.mjs` (criteria 7a, 7b and 7c): a denial landing while an invoker
|
|
489
|
+
is paused between validating and publishing; two conflicting settlements from one head; and a denier
|
|
490
|
+
paused while the approver publishes four revisions past it. A store that cannot pass them is not
|
|
491
|
+
atomic, whatever its methods say.
|
|
492
|
+
|
|
493
|
+
## Reading a receipt
|
|
494
|
+
|
|
495
|
+
The cryptography is only half the evidence. These are the fields that an outside reader has
|
|
496
|
+
actually misread on a first run, and what each one means.
|
|
497
|
+
|
|
498
|
+
**`ratified: yes` next to `deciders: 0` is not a contradiction.** `deciders` is the list of people
|
|
499
|
+
nominated **in advance** to decide. When an agent's request is *routed* rather than pre-assigned,
|
|
500
|
+
that list is empty — nobody was nominated, because the request had not happened yet. The person who
|
|
501
|
+
actually decided is the **ratifier** (`ratifiedBy`). So a receipt for a human-approved action reads
|
|
502
|
+
`deciders: 0, ratified: yes`, and the human is recorded, in the field named for the role they
|
|
503
|
+
played. The verifier prints this explanation whenever the pair appears.
|
|
504
|
+
|
|
505
|
+
**`appliedLimits` is what the request was MEASURED AGAINST, not a rule that fired.** It holds up to
|
|
506
|
+
three entries, and only those that have a value: `requested_amount` (the amount actually asked for),
|
|
507
|
+
`auto_approve_threshold` and `hard_cap` (the ceilings on the agent's own token). So a decision routed
|
|
508
|
+
to a human because *no rule matched* can still list an `auto_approve_threshold` — that is the bar the
|
|
509
|
+
request was compared to and did not clear, not a limit that was applied to it. Read
|
|
510
|
+
`requested_amount` as the ask, the other two as ceilings.
|
|
511
|
+
|
|
512
|
+
**`firedRules` is what the policy engine actually matched — for any verdict.** It is not an
|
|
513
|
+
auto-approve indicator: a **blocked** request carries the deny-by-default backstop rule here, so
|
|
514
|
+
non-empty does not mean "approved" and empty does not mean "blocked". The field that states the
|
|
515
|
+
disposition is `authority.result`; `firedRules` tells you which named, versioned rules produced it.
|
|
516
|
+
|
|
517
|
+
**`payloadSchemaUri` is a `urn:`, not a URL.** It is content-addressed —
|
|
518
|
+
`urn:decidio:sealed-payload:1:sha256:<hash>` — so it *identifies* the exact schema the record was
|
|
519
|
+
sealed under and pins it against substitution. It does not locate a document, and nothing will
|
|
520
|
+
resolve it. (Shipping the schema alongside the verifier so you can check the hash yourself is on
|
|
521
|
+
the roadmap; today the URN is an identifier only, and this note exists so it does not look broken.)
|
|
522
|
+
|
|
523
|
+
**`prevHash` proves a pointer, not an ancestry.** A non-null `prevHash` shows this record was
|
|
524
|
+
sealed after a specific predecessor, but a single-file verification cannot check that predecessor
|
|
525
|
+
exists or is what it claims. Verify a chain (`verify a.json b.json c.json`) when the sequence
|
|
526
|
+
matters.
|
|
527
|
+
|
|
528
|
+
**Pinning is trust-on-first-use.** The first time you fetch a receipt, the record *and* the issuer
|
|
529
|
+
DID arrive in the same authenticated response — so that first check trusts the connection, not the
|
|
530
|
+
key. What pinning buys is everything afterwards: record the DID once, out of band if you can, and
|
|
531
|
+
every later verification is checked against a key you chose rather than one you were handed. The
|
|
532
|
+
verifier says so on every unpinned run.
|
|
533
|
+
|
|
448
534
|
## Upgrading from 0.2.x to 0.3.0
|
|
449
535
|
|
|
450
536
|
0.3.0 is a breaking release. Every item below changes behaviour a 0.2.x caller may depend on, so
|
package/dist/adapters/_gate.js
CHANGED
|
@@ -14,7 +14,7 @@ export function assertAuthorizeVerdict(auth, fallbackDecisionId = "unknown") {
|
|
|
14
14
|
return a;
|
|
15
15
|
}
|
|
16
16
|
export async function gateAuthorize(config, ctx) {
|
|
17
|
-
canonicalize(ctx, "describe(...)");
|
|
17
|
+
ctx = canonicalize(ctx, "describe(...)");
|
|
18
18
|
assertSafeTransport(config.apiUrl, carriesCredential(config));
|
|
19
19
|
const f = config.fetchImpl ?? fetch;
|
|
20
20
|
const resumeUrl = config.resumeUrl ?? config.resume?.resumeUrl;
|
package/dist/cli.js
CHANGED
|
@@ -119,6 +119,18 @@ function writeSecretFile(path, body) {
|
|
|
119
119
|
console.warn(`⚠ could not restrict ${path} to owner-only on this Windows volume (${why}) — it keeps the directory's inherited permissions. For production, move these values to a secret manager.`);
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
|
+
async function revokeFreshMint(jti, bearer, writeError, target) {
|
|
123
|
+
err(`✘ could not write ${target} (${writeError?.message ?? writeError}). The token minted a moment ago (jti ${jti}) is LIVE until revoked - revoking it now.`);
|
|
124
|
+
const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(jti)}`, { method: "DELETE" }, bearer);
|
|
125
|
+
if (rv.status >= 200 && rv.status < 300) {
|
|
126
|
+
err(` ✔ the token minted a moment ago (jti ${jti}) was REVOKED again, so nothing live was left behind. Fix the cause above and re-run - it will mint a fresh one.`);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
err(` ⚠ the token minted a moment ago (jti ${jti}) is LIVE and could NOT be revoked (${rv.status}). Revoke it by hand, or re-run init - the server keeps one active token per agent, so a fresh mint supersedes it:`);
|
|
130
|
+
err(` npx @decidio/sdk tokens revoke ${jti}`);
|
|
131
|
+
err(` npx @decidio/sdk tokens list --mine`);
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
122
134
|
function upsertEnv(updates) {
|
|
123
135
|
const remaining = { ...updates };
|
|
124
136
|
const updatedKeys = new Set();
|
|
@@ -341,8 +353,19 @@ async function cmdInit(agentId, opts = {}) {
|
|
|
341
353
|
envOut.DECIDIO_WORKSPACE_ID = workspaceId;
|
|
342
354
|
if (tokenJti)
|
|
343
355
|
envOut.DECIDIO_TOKEN_JTI = tokenJti;
|
|
344
|
-
|
|
345
|
-
|
|
356
|
+
try {
|
|
357
|
+
prepareEnvForWrite();
|
|
358
|
+
upsertEnv(envOut);
|
|
359
|
+
}
|
|
360
|
+
catch (e) {
|
|
361
|
+
if (agentToken && tokenJti)
|
|
362
|
+
await revokeFreshMint(tokenJti, mintBearer, e, ENV_PATH);
|
|
363
|
+
else if (agentToken)
|
|
364
|
+
err(`✘ could not write ${ENV_PATH} (${e?.message ?? e}). The token minted a moment ago is LIVE and the server returned no revocation id - find and revoke it: npx @decidio/sdk tokens list --mine`);
|
|
365
|
+
else
|
|
366
|
+
err(`✘ could not write ${ENV_PATH} (${e?.message ?? e}). No token was minted this run.`);
|
|
367
|
+
return 1;
|
|
368
|
+
}
|
|
346
369
|
for (const [k, v] of Object.entries(envOut))
|
|
347
370
|
process.env[k] = v;
|
|
348
371
|
log(`✔ wrote ${ENV_PATH} (owner-only where the OS supports it) — every decidio command reads it from this directory (your script loads it with node --env-file=.env)`);
|
|
@@ -597,10 +620,13 @@ async function cmdReceipt(id, jsonMode = false) {
|
|
|
597
620
|
}
|
|
598
621
|
log(` Verify it offline — no Decidio account or network needed (the verifier is built in):`);
|
|
599
622
|
if (issuer) {
|
|
623
|
+
log(` Record your workspace's issuer DID — copy this line, and pin it forever:`);
|
|
624
|
+
log(``);
|
|
625
|
+
log(`${issuer}`);
|
|
626
|
+
log(``);
|
|
627
|
+
log(` Then verify, pinned to it (the same DID, inline — copy from the line above, not this one,`);
|
|
628
|
+
log(` which your terminal may wrap):`);
|
|
600
629
|
log(` npx @decidio/sdk verify --issuer ${issuer} ${name}`);
|
|
601
|
-
log(` That pins the check to your workspace's issuer DID (read from the authenticated`);
|
|
602
|
-
log(` response above — record it once, then pin it forever):`);
|
|
603
|
-
log(` ${issuer}`);
|
|
604
630
|
log(` Without --issuer the verifier proves the receipt is internally consistent, but NOT who`);
|
|
605
631
|
log(` issued it; it says so in its output.`);
|
|
606
632
|
}
|
|
@@ -621,6 +647,10 @@ function cmdVerify(argsIn) {
|
|
|
621
647
|
err("✘ --issuer needs a did:key value (a bare or EMPTY --issuer must not silently verify unpinned).");
|
|
622
648
|
return 2;
|
|
623
649
|
}
|
|
650
|
+
if (expectedIssuer !== undefined && expectedIssuer !== v) {
|
|
651
|
+
err(`✘ --issuer given twice with different values (${expectedIssuer} then ${v}) — refusing to choose which one you meant to pin to.`);
|
|
652
|
+
return 2;
|
|
653
|
+
}
|
|
624
654
|
expectedIssuer = v;
|
|
625
655
|
i++;
|
|
626
656
|
}
|
|
@@ -630,6 +660,10 @@ function cmdVerify(argsIn) {
|
|
|
630
660
|
err("✘ --issuer needs a did:key value.");
|
|
631
661
|
return 2;
|
|
632
662
|
}
|
|
663
|
+
if (expectedIssuer !== undefined && expectedIssuer !== v) {
|
|
664
|
+
err(`✘ --issuer given twice with different values (${expectedIssuer} then ${v}) — refusing to choose which one you meant to pin to.`);
|
|
665
|
+
return 2;
|
|
666
|
+
}
|
|
633
667
|
expectedIssuer = v;
|
|
634
668
|
}
|
|
635
669
|
else if (!a.startsWith("-")) {
|
|
@@ -711,14 +745,14 @@ async function cmdQuickstart(opts = {}) {
|
|
|
711
745
|
const prev = readFileSync(envFile, "utf8");
|
|
712
746
|
const prevId = prev.match(/^DECIDIO_AGENT_ID=(.+)$/m)?.[1];
|
|
713
747
|
const prevJti = prev.match(/^DECIDIO_TOKEN_JTI=(.+)$/m)?.[1];
|
|
748
|
+
const carried = new Set((prev.match(/^DECIDIO_ORPHANED_TOKEN_JTI=(.+)$/m)?.[1] ?? "").split(",").map((s) => s.trim()).filter(Boolean));
|
|
714
749
|
if (prevId && prevId !== agentId) {
|
|
715
750
|
if (prevJti) {
|
|
716
751
|
const rv = await api(`/api/workspace/agent-token/${encodeURIComponent(prevJti)}`, { method: "DELETE" }, session);
|
|
717
752
|
if (rv.status >= 200 && rv.status < 300)
|
|
718
753
|
log(` ✔ the previous tour agent (${prevId}) had its token REVOKED before this tour replaced it — no live credential is left behind`);
|
|
719
754
|
else {
|
|
720
|
-
|
|
721
|
-
envOut.DECIDIO_ORPHANED_TOKEN_JTI = [...new Set(carried)].join(",");
|
|
755
|
+
carried.add(prevJti);
|
|
722
756
|
log(` ⚠ could not revoke the previous tour's token (jti ${prevJti}, ${rv.status}) — it is still LIVE. Its id is kept in ${envFile} as DECIDIO_ORPHANED_TOKEN_JTI, and \`quickstart cleanup\` will retry it; or revoke it by hand: npx @decidio/sdk tokens revoke ${prevJti}`);
|
|
723
757
|
}
|
|
724
758
|
}
|
|
@@ -726,12 +760,34 @@ async function cmdQuickstart(opts = {}) {
|
|
|
726
760
|
log(` ⚠ the previous tour file carried no revocation id, so its token (if any) must be revoked from your Decidio app.`);
|
|
727
761
|
}
|
|
728
762
|
}
|
|
763
|
+
if (carried.size) {
|
|
764
|
+
envOut.DECIDIO_ORPHANED_TOKEN_JTI = [...carried].join(",");
|
|
765
|
+
log(` • ${carried.size} orphaned token id(s) from earlier replacements carried forward — \`quickstart cleanup\` will retry them.`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
try {
|
|
769
|
+
writeSecretFile(envFile, Object.entries(envOut).map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
|
|
770
|
+
}
|
|
771
|
+
catch (e) {
|
|
772
|
+
if (mint.body?.jti)
|
|
773
|
+
await revokeFreshMint(mint.body.jti, session, e, envFile);
|
|
774
|
+
else
|
|
775
|
+
err(`✘ could not write ${envFile} (${e?.message ?? e}). The tour token minted a moment ago is LIVE and the server returned no revocation id - find and revoke it: npx @decidio/sdk tokens list --mine`);
|
|
776
|
+
return 1;
|
|
729
777
|
}
|
|
730
|
-
writeSecretFile(envFile, Object.entries(envOut).map(([k, v]) => `${k}=${v}`).join("\n") + "\n");
|
|
731
778
|
log(` ✔ this directory already has an agent in .env — tour credentials went to ${envFile} (your agent is untouched)`);
|
|
732
779
|
}
|
|
733
780
|
else {
|
|
734
|
-
|
|
781
|
+
try {
|
|
782
|
+
upsertEnv(envOut);
|
|
783
|
+
}
|
|
784
|
+
catch (e) {
|
|
785
|
+
if (mint.body?.jti)
|
|
786
|
+
await revokeFreshMint(mint.body.jti, session, e, envFile);
|
|
787
|
+
else
|
|
788
|
+
err(`✘ could not write ${envFile} (${e?.message ?? e}). The tour token minted a moment ago is LIVE and the server returned no revocation id - find and revoke it: npx @decidio/sdk tokens list --mine`);
|
|
789
|
+
return 1;
|
|
790
|
+
}
|
|
735
791
|
}
|
|
736
792
|
const failTour = async (code) => {
|
|
737
793
|
log("");
|
package/dist/evidence.d.ts
CHANGED
|
@@ -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;
|
|
@@ -47,7 +51,7 @@ export function readConfirmResponse(body, opts) {
|
|
|
47
51
|
outcome.executionReportReason = rpt.reason;
|
|
48
52
|
if (proofExplicitlyFailed) {
|
|
49
53
|
const why = typeof rpt.reason === "string" && rpt.reason ? ` (${rpt.reason})` : "";
|
|
50
|
-
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;
|
|
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.`;
|
|
51
55
|
}
|
|
52
56
|
}
|
|
53
57
|
return outcome;
|
package/dist/index.d.ts
CHANGED
|
@@ -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
|
|
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
|
@@ -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/resume.d.ts
CHANGED
|
@@ -129,11 +129,12 @@ export interface ExecutionRecord {
|
|
|
129
129
|
at: string;
|
|
130
130
|
workerStopped?: boolean;
|
|
131
131
|
};
|
|
132
|
-
/**
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
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. */
|
|
137
138
|
executionDespiteDenial?: {
|
|
138
139
|
state: ExecutionState;
|
|
139
140
|
at: string;
|
|
@@ -346,19 +347,37 @@ export declare class FilePendingStore implements PendingStore {
|
|
|
346
347
|
constructor(dir: string);
|
|
347
348
|
private key;
|
|
348
349
|
private path;
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
private
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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. */
|
|
356
358
|
private writeAtomic;
|
|
357
|
-
/** The
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
|
|
361
|
-
|
|
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. */
|
|
362
381
|
private setState;
|
|
363
382
|
put(p: PendingAction): Promise<void>;
|
|
364
383
|
get(id: string): Promise<PendingAction | null>;
|
|
@@ -374,10 +393,11 @@ export declare class FilePendingStore implements PendingStore {
|
|
|
374
393
|
markDiscarded(id: string, verdict: "rejected" | "blocked", reason?: string): Promise<void>;
|
|
375
394
|
getExecution(id: string): Promise<ExecutionRecord | null>;
|
|
376
395
|
listExecutions(): Promise<ExecutionRecord[]>;
|
|
377
|
-
/** OPERATOR RECONCILIATION — see MemoryPendingStore.settle.
|
|
378
|
-
*
|
|
379
|
-
* the
|
|
380
|
-
*
|
|
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. */
|
|
381
401
|
settle(id: string, outcome: SettleOutcome, opts?: {
|
|
382
402
|
evidence?: unknown;
|
|
383
403
|
reason?: string;
|
|
@@ -385,14 +405,20 @@ export declare class FilePendingStore implements PendingStore {
|
|
|
385
405
|
workerStopped?: boolean;
|
|
386
406
|
}): Promise<void>;
|
|
387
407
|
releasePreinvoke(id: string, attemptId: string): Promise<void>;
|
|
388
|
-
/** ONE-WAY MIGRATION
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
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. */
|
|
392
417
|
migrateLegacy(): {
|
|
393
418
|
migrated: number;
|
|
394
419
|
indeterminate: number;
|
|
395
420
|
reports: number;
|
|
421
|
+
chained: number;
|
|
396
422
|
};
|
|
397
423
|
/** 0.1.x wrote execution reports to `<pendingDir>/reports/<id>.json` — evidence that an action
|
|
398
424
|
* ALREADY RAN and still owes Decidio a report. 0.2.0 keeps that in the ledger, so without this
|
package/dist/resume.js
CHANGED
|
@@ -5,8 +5,8 @@ import { userInfo } from "node:os";
|
|
|
5
5
|
import { execFileSync } from "node:child_process";
|
|
6
6
|
import { signConfirm, canonicalize as jcs, assertSafeTransport, carriesCredential } from "./signing.js";
|
|
7
7
|
import { canonicalize as toCanonical, DecidioUnsafeArgumentsError } from "./canonical.js";
|
|
8
|
-
import { readConfirmResponse, buildConfirmBody } from "./evidence.js";
|
|
9
|
-
import { DecidioHttpError } from "./errors.js";
|
|
8
|
+
import { readConfirmResponse, buildConfirmBody, tierConfirms, tombstoneTier } from "./evidence.js";
|
|
9
|
+
import { DecidioHttpError, decisionIdProblem } from "./errors.js";
|
|
10
10
|
const argsBindingBytes = (p) => Buffer.from(jcs({ decisionId: p.decisionId, action: p.action, amount: p.amount, ...(p.scope !== undefined ? { scope: p.scope } : {}), args: p.args }), "utf8");
|
|
11
11
|
function signArgs(p, agentKey, webhookSecret) {
|
|
12
12
|
const bytes = argsBindingBytes(p);
|
|
@@ -321,14 +321,40 @@ export class FilePendingStore {
|
|
|
321
321
|
this.migrateLegacy();
|
|
322
322
|
}
|
|
323
323
|
catch (e) {
|
|
324
|
-
console.error(`[decidio] could not upgrade the
|
|
324
|
+
console.error(`[decidio] could not upgrade the older pending layout in ${dir} (${e?.message ?? e}). Entries written by an older SDK may not appear in recover() — inspect the directory before assuming it is empty.`);
|
|
325
325
|
}
|
|
326
326
|
}
|
|
327
327
|
key(id) { return id.replace(/[^\w.-]/g, "_"); }
|
|
328
328
|
path(id) { return join(this.dir, `${this.key(id)}.json`); }
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
329
|
+
revPath(key, n) { return join(this.dir, `${key}.rev.${n}`); }
|
|
330
|
+
markerPath(key, s) { return join(this.dir, `${key}.${s}`); }
|
|
331
|
+
listRevs(key, names) {
|
|
332
|
+
const prefix = `${key}.rev.`;
|
|
333
|
+
const out = [];
|
|
334
|
+
if (!names) {
|
|
335
|
+
try {
|
|
336
|
+
names = readdirSync(this.dir);
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
for (const name of names) {
|
|
343
|
+
if (!name.startsWith(prefix))
|
|
344
|
+
continue;
|
|
345
|
+
const tail = name.slice(prefix.length);
|
|
346
|
+
if (tail.length && /^\d+$/.test(tail))
|
|
347
|
+
out.push(Number(tail));
|
|
348
|
+
}
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
legacyMarkers(key, names) {
|
|
352
|
+
if (names)
|
|
353
|
+
return EXECUTION_STATES.filter((s) => names.includes(`${key}.${s}`));
|
|
354
|
+
return EXECUTION_STATES.filter((s) => existsSync(this.markerPath(key, s)));
|
|
355
|
+
}
|
|
356
|
+
writeTemp(target, text) {
|
|
357
|
+
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
332
358
|
const fd = openSync(tmp, "w", 0o600);
|
|
333
359
|
try {
|
|
334
360
|
writeFileSync(fd, text);
|
|
@@ -337,14 +363,83 @@ export class FilePendingStore {
|
|
|
337
363
|
finally {
|
|
338
364
|
closeSync(fd);
|
|
339
365
|
}
|
|
366
|
+
return tmp;
|
|
367
|
+
}
|
|
368
|
+
writeAtomic(file, text) {
|
|
369
|
+
const tmp = this.writeTemp(file, text);
|
|
340
370
|
renameSync(tmp, file);
|
|
341
371
|
}
|
|
342
|
-
|
|
372
|
+
readHead(id, names) {
|
|
373
|
+
const key = this.key(id);
|
|
374
|
+
for (let tries = 0; tries < 4; tries++) {
|
|
375
|
+
const revs = this.listRevs(key, names);
|
|
376
|
+
const markers = this.legacyMarkers(key, names);
|
|
377
|
+
names = undefined;
|
|
378
|
+
if (revs.length === 0) {
|
|
379
|
+
if (markers.length === 0)
|
|
380
|
+
return { rec: null, rev: 0, corrupt: [] };
|
|
381
|
+
const legacy = this.resolveLegacyMarkers(key);
|
|
382
|
+
return { rec: legacy.rec, rev: 0, corrupt: legacy.corrupt };
|
|
383
|
+
}
|
|
384
|
+
if (markers.length) {
|
|
385
|
+
return { rec: null, rev: Math.max(...revs), corrupt: markers.map((s) => `${this.markerPath(key, s)}: a legacy state marker beside the revision chain — written by an older SDK after this directory was upgraded, or left by an interrupted upgrade; the two cannot be reconciled automatically`) };
|
|
386
|
+
}
|
|
387
|
+
const top = Math.max(...revs);
|
|
388
|
+
const f = this.revPath(key, top);
|
|
389
|
+
try {
|
|
390
|
+
const r = JSON.parse(readFileSync(f, "utf8"));
|
|
391
|
+
if (!r || typeof r !== "object" || !EXECUTION_STATES.includes(r.state)) {
|
|
392
|
+
return { rec: null, rev: top, corrupt: [`${f}: the record carries no recognised state (${JSON.stringify(r?.state ?? null)})`] };
|
|
393
|
+
}
|
|
394
|
+
return { rec: r, rev: top, corrupt: [] };
|
|
395
|
+
}
|
|
396
|
+
catch (e) {
|
|
397
|
+
if (e?.code === "ENOENT")
|
|
398
|
+
continue;
|
|
399
|
+
return { rec: null, rev: top, corrupt: [`${f}: ${e?.message ?? e}`] };
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return { rec: null, rev: 0, corrupt: [`${key}: the revision chain changed under every read attempt`] };
|
|
403
|
+
}
|
|
404
|
+
_barrier(_op, _id, _next) { }
|
|
405
|
+
transition(id, op, compute) {
|
|
406
|
+
const key = this.key(id);
|
|
407
|
+
for (let attempt = 0; attempt < 16; attempt++) {
|
|
408
|
+
const { rec, rev, corrupt } = this.readHead(id);
|
|
409
|
+
if (corrupt.length)
|
|
410
|
+
throw new DecidioStateCorruptError(id, corrupt);
|
|
411
|
+
const next = compute(rec, rev);
|
|
412
|
+
if (next === null)
|
|
413
|
+
return null;
|
|
414
|
+
this._barrier(op, id, next.state);
|
|
415
|
+
const target = this.revPath(key, rev + 1);
|
|
416
|
+
const tmp = this.writeTemp(target, JSON.stringify(next, null, 2));
|
|
417
|
+
try {
|
|
418
|
+
linkSync(tmp, target);
|
|
419
|
+
}
|
|
420
|
+
catch (e) {
|
|
421
|
+
try {
|
|
422
|
+
rmSync(tmp);
|
|
423
|
+
}
|
|
424
|
+
catch { }
|
|
425
|
+
if (e?.code === "EEXIST")
|
|
426
|
+
continue;
|
|
427
|
+
throw e;
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
rmSync(tmp);
|
|
431
|
+
}
|
|
432
|
+
catch { }
|
|
433
|
+
return next;
|
|
434
|
+
}
|
|
435
|
+
throw new Error(`[decidio] ${id}: could not publish a state transition after 16 attempts — another writer kept winning the next revision. Retry, or look for a process writing this decision in a loop.`);
|
|
436
|
+
}
|
|
437
|
+
resolveLegacyMarkers(key) {
|
|
343
438
|
const corrupt = [];
|
|
344
439
|
let best = null;
|
|
345
440
|
let denial = null;
|
|
346
441
|
for (const s of EXECUTION_STATES) {
|
|
347
|
-
const f = this.
|
|
442
|
+
const f = this.markerPath(key, s);
|
|
348
443
|
if (!existsSync(f))
|
|
349
444
|
continue;
|
|
350
445
|
try {
|
|
@@ -363,39 +458,23 @@ export class FilePendingStore {
|
|
|
363
458
|
const ran = best && rank(best.state) >= rank("invoking") ? best : null;
|
|
364
459
|
return { rec: ran ? { ...denial, executionDespiteDenial: { state: ran.state, at: ran.at, hasEvidence: !!ran.hasEvidence } } : denial, corrupt };
|
|
365
460
|
}
|
|
366
|
-
setState(id, attemptId, state, patch = {}) {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
}
|
|
379
|
-
const next = { ...cur, ...patch, decisionId: id, attemptId, state, at: new Date().toISOString() };
|
|
380
|
-
this.writeAtomic(this.statePath(id, state), JSON.stringify(next, null, 2));
|
|
381
|
-
for (const s of EXECUTION_STATES) {
|
|
382
|
-
if (s === state)
|
|
383
|
-
continue;
|
|
384
|
-
if (rank(s) >= rank(state))
|
|
385
|
-
continue;
|
|
386
|
-
const f = this.statePath(id, s);
|
|
387
|
-
try {
|
|
388
|
-
if (existsSync(f))
|
|
389
|
-
rmSync(f);
|
|
390
|
-
}
|
|
391
|
-
catch { }
|
|
392
|
-
}
|
|
461
|
+
setState(id, attemptId, state, patch = {}, pre) {
|
|
462
|
+
this.transition(id, "advance", (rec) => {
|
|
463
|
+
const cur = rec ?? { decisionId: id, state: "parked", attemptId: null, at: new Date().toISOString() };
|
|
464
|
+
pre?.(cur);
|
|
465
|
+
if (cur.attemptId && cur.attemptId !== attemptId)
|
|
466
|
+
throw new DecidioStaleAttemptError(id, cur.attemptId, cur.state);
|
|
467
|
+
assertNotDenied(id, cur, state);
|
|
468
|
+
assertNotSettled(id, cur, state);
|
|
469
|
+
if (rank(state) < rank(cur.state))
|
|
470
|
+
throw new Error(`[decidio] ${id}: refusing to move ${cur.state} → ${state}; execution state only ever advances.`);
|
|
471
|
+
return { ...cur, ...patch, decisionId: id, attemptId, state, at: new Date().toISOString() };
|
|
472
|
+
});
|
|
393
473
|
}
|
|
394
474
|
async put(p) {
|
|
395
475
|
this.writeAtomic(this.path(p.decisionId), JSON.stringify(p, null, 2));
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
476
|
+
this.transition(p.decisionId, "put", (cur) => cur ? null
|
|
477
|
+
: { decisionId: p.decisionId, state: "parked", attemptId: null, action: p.action, at: new Date().toISOString() });
|
|
399
478
|
}
|
|
400
479
|
async get(id) {
|
|
401
480
|
const f = this.path(id);
|
|
@@ -403,7 +482,7 @@ export class FilePendingStore {
|
|
|
403
482
|
}
|
|
404
483
|
async delete(id, attemptId) {
|
|
405
484
|
if (attemptId) {
|
|
406
|
-
const owner = this.
|
|
485
|
+
const owner = this.readHead(id).rec?.attemptId;
|
|
407
486
|
if (owner && owner !== attemptId)
|
|
408
487
|
return;
|
|
409
488
|
}
|
|
@@ -417,7 +496,8 @@ export class FilePendingStore {
|
|
|
417
496
|
if (!existsSync(this.dir))
|
|
418
497
|
return [];
|
|
419
498
|
const out = [];
|
|
420
|
-
|
|
499
|
+
const names = readdirSync(this.dir);
|
|
500
|
+
for (const f of names.filter((f) => f.endsWith(".json"))) {
|
|
421
501
|
let p;
|
|
422
502
|
try {
|
|
423
503
|
p = JSON.parse(readFileSync(join(this.dir, f), "utf8"));
|
|
@@ -426,7 +506,7 @@ export class FilePendingStore {
|
|
|
426
506
|
console.error(`[decidio] skipping unreadable parked entry ${f} (${e.message}) — the other parked actions still resume; inspect or delete this file.`);
|
|
427
507
|
continue;
|
|
428
508
|
}
|
|
429
|
-
const { rec } = this.
|
|
509
|
+
const { rec } = this.readHead(p.decisionId, names);
|
|
430
510
|
if (!rec || rec.state === "parked")
|
|
431
511
|
out.push(p);
|
|
432
512
|
}
|
|
@@ -435,62 +515,41 @@ export class FilePendingStore {
|
|
|
435
515
|
async claim(id, attemptId) {
|
|
436
516
|
if (!existsSync(this.path(id)))
|
|
437
517
|
return { ok: false, reason: "no parked entry" };
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
if (state === "claimed_preinvoke")
|
|
445
|
-
return { ok: false, state, reason: "claimed by another attempt" };
|
|
446
|
-
const target = this.statePath(id, "claimed_preinvoke");
|
|
447
|
-
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
|
448
|
-
const payload = JSON.stringify({ ...(rec ?? {}), decisionId: id, state: "claimed_preinvoke", attemptId, action: rec?.action, at: new Date().toISOString() }, null, 2);
|
|
449
|
-
const fd = openSync(tmp, "w", 0o600);
|
|
450
|
-
try {
|
|
451
|
-
writeFileSync(fd, payload);
|
|
452
|
-
fsyncSync(fd);
|
|
453
|
-
}
|
|
454
|
-
finally {
|
|
455
|
-
closeSync(fd);
|
|
456
|
-
}
|
|
457
|
-
try {
|
|
458
|
-
linkSync(tmp, target);
|
|
459
|
-
}
|
|
460
|
-
catch {
|
|
461
|
-
try {
|
|
462
|
-
rmSync(tmp);
|
|
518
|
+
let refusal = null;
|
|
519
|
+
this.transition(id, "claim", (cur) => {
|
|
520
|
+
const state = cur?.state ?? "parked";
|
|
521
|
+
if (NON_REPLAYABLE.includes(state)) {
|
|
522
|
+
refusal = { ok: false, state, reason: `already ${state}` };
|
|
523
|
+
return null;
|
|
463
524
|
}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
try {
|
|
472
|
-
const p = this.statePath(id, "parked");
|
|
473
|
-
if (existsSync(p))
|
|
474
|
-
rmSync(p);
|
|
475
|
-
}
|
|
476
|
-
catch { }
|
|
477
|
-
return { ok: true };
|
|
525
|
+
if (state === "claimed_preinvoke") {
|
|
526
|
+
refusal = { ok: false, state, reason: "claimed by another attempt" };
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
return { ...(cur ?? {}), decisionId: id, state: "claimed_preinvoke", attemptId, action: cur?.action, at: new Date().toISOString() };
|
|
530
|
+
});
|
|
531
|
+
return refusal ?? { ok: true };
|
|
478
532
|
}
|
|
479
533
|
async markInvoking(id, attemptId) {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
assertNotDenied(id, cur, "invoking");
|
|
485
|
-
assertClaimedBy(id, cur, attemptId);
|
|
486
|
-
this.setState(id, attemptId, "invoking");
|
|
534
|
+
this.setState(id, attemptId, "invoking", {}, (cur) => {
|
|
535
|
+
assertNotDenied(id, cur, "invoking");
|
|
536
|
+
assertClaimedBy(id, cur, attemptId);
|
|
537
|
+
});
|
|
487
538
|
}
|
|
488
539
|
async recordExecuted(id, attemptId, evidence, evidenceSig, evidenceError) { this.setState(id, attemptId, "executed_unreported", { evidence, hasEvidence: true, evidenceSig, evidenceError }); }
|
|
489
540
|
async markIndeterminate(id, attemptId, error) { this.setState(id, attemptId, "indeterminate", { error }); }
|
|
490
541
|
async markReported(id, attemptId, confirmation) { this.setState(id, attemptId, "reported", { confirmation }); }
|
|
491
|
-
async markDiscarded(id, verdict, reason) {
|
|
542
|
+
async markDiscarded(id, verdict, reason) {
|
|
543
|
+
this.transition(id, "discard", (rec) => {
|
|
544
|
+
const cur = rec ?? { decisionId: id, state: "parked", attemptId: null, at: new Date().toISOString() };
|
|
545
|
+
assertNotSettled(id, cur, "discarded");
|
|
546
|
+
if (rank("discarded") < rank(cur.state))
|
|
547
|
+
throw new Error(`[decidio] ${id}: refusing to move ${cur.state} → discarded; execution state only ever advances.`);
|
|
548
|
+
return { ...cur, decisionId: id, attemptId: cur.attemptId ?? "", state: "discarded", verdict, ...(reason ? { discardReason: reason } : {}), at: new Date().toISOString() };
|
|
549
|
+
});
|
|
550
|
+
}
|
|
492
551
|
async getExecution(id) {
|
|
493
|
-
const { rec, corrupt } = this.
|
|
552
|
+
const { rec, corrupt } = this.readHead(id);
|
|
494
553
|
if (corrupt.length)
|
|
495
554
|
throw new DecidioStateCorruptError(id, corrupt);
|
|
496
555
|
return rec;
|
|
@@ -498,21 +557,26 @@ export class FilePendingStore {
|
|
|
498
557
|
async listExecutions() {
|
|
499
558
|
if (!existsSync(this.dir))
|
|
500
559
|
return [];
|
|
501
|
-
const
|
|
502
|
-
|
|
560
|
+
const keys = new Set();
|
|
561
|
+
const names = readdirSync(this.dir);
|
|
562
|
+
for (const name of names) {
|
|
563
|
+
const m = /^(.*)\.rev\.\d+$/.exec(name);
|
|
564
|
+
if (m) {
|
|
565
|
+
keys.add(m[1]);
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
503
568
|
const dot = name.lastIndexOf(".");
|
|
504
569
|
if (dot < 0)
|
|
505
570
|
continue;
|
|
506
571
|
const suffix = name.slice(dot + 1);
|
|
507
|
-
if (
|
|
508
|
-
|
|
509
|
-
ids.add(name.slice(0, dot));
|
|
572
|
+
if (EXECUTION_STATES.includes(suffix))
|
|
573
|
+
keys.add(name.slice(0, dot));
|
|
510
574
|
}
|
|
511
575
|
const out = [];
|
|
512
|
-
for (const key of
|
|
513
|
-
const { rec, corrupt } = this.
|
|
576
|
+
for (const key of keys) {
|
|
577
|
+
const { rec, corrupt } = this.readHead(key, names);
|
|
514
578
|
if (corrupt.length) {
|
|
515
|
-
out.push({ decisionId: key, state: "indeterminate", attemptId: null, error: `state files unreadable — ${corrupt.join("; ")}`, at: new Date().toISOString() });
|
|
579
|
+
out.push({ decisionId: rec?.decisionId ?? key, state: "indeterminate", attemptId: null, error: `state files unreadable — ${corrupt.join("; ")}`, at: new Date().toISOString() });
|
|
516
580
|
continue;
|
|
517
581
|
}
|
|
518
582
|
if (rec)
|
|
@@ -521,60 +585,92 @@ export class FilePendingStore {
|
|
|
521
585
|
return out;
|
|
522
586
|
}
|
|
523
587
|
async settle(id, outcome, opts = {}) {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
: { ...cur, state: "parked", attemptId: null, error: undefined, reconciled, at };
|
|
534
|
-
this.writeAtomic(this.statePath(id, next.state), JSON.stringify(next, null, 2));
|
|
535
|
-
for (const st of EXECUTION_STATES) {
|
|
536
|
-
if (st === next.state)
|
|
537
|
-
continue;
|
|
538
|
-
try {
|
|
539
|
-
const f = this.statePath(id, st);
|
|
540
|
-
if (existsSync(f))
|
|
541
|
-
rmSync(f);
|
|
542
|
-
}
|
|
543
|
-
catch { }
|
|
544
|
-
}
|
|
588
|
+
this.transition(id, "settle", (rec) => {
|
|
589
|
+
const cur = rec ?? { decisionId: id, state: "parked", attemptId: null, at: new Date().toISOString() };
|
|
590
|
+
assertSettlable(id, cur.state, outcome, { hasObservedEvidence: !cur.evidenceError, workerStopped: opts.workerStopped });
|
|
591
|
+
const at = new Date().toISOString();
|
|
592
|
+
const reconciled = { outcome, reason: opts.reason, at, ...(opts.workerStopped ? { workerStopped: true } : {}) };
|
|
593
|
+
return outcome === "committed"
|
|
594
|
+
? { ...cur, state: "executed_unreported", evidence: opts.evidence ?? null, hasEvidence: true, evidenceSig: opts.evidenceSig, evidenceError: undefined, reconciled, at }
|
|
595
|
+
: { ...cur, state: "parked", attemptId: null, error: undefined, reconciled, at };
|
|
596
|
+
});
|
|
545
597
|
}
|
|
546
598
|
async releasePreinvoke(id, attemptId) {
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
rmSync(c);
|
|
558
|
-
}
|
|
559
|
-
catch { }
|
|
599
|
+
this.transition(id, "release", (rec) => {
|
|
600
|
+
const state = rec?.state ?? "parked";
|
|
601
|
+
if (NON_REPLAYABLE.includes(state))
|
|
602
|
+
throw new Error(`[decidio] ${id}: refusing to release this decision — its durable state is ${state}, which is terminal or past the invocation boundary. releasePreinvoke only ever releases a claim nothing has acted on.`);
|
|
603
|
+
if (rec?.attemptId && rec.attemptId !== attemptId)
|
|
604
|
+
return null;
|
|
605
|
+
if (state === "parked")
|
|
606
|
+
return null;
|
|
607
|
+
return { ...(rec ?? {}), decisionId: id, state: "parked", attemptId: null, action: rec?.action, at: new Date().toISOString() };
|
|
608
|
+
});
|
|
560
609
|
}
|
|
561
610
|
migrateLegacy() {
|
|
562
611
|
if (!existsSync(this.dir))
|
|
563
|
-
return { migrated: 0, indeterminate: 0, reports: 0 };
|
|
564
|
-
let migrated = 0, indeterminate = 0;
|
|
612
|
+
return { migrated: 0, indeterminate: 0, reports: 0, chained: 0 };
|
|
613
|
+
let migrated = 0, indeterminate = 0, chained = 0;
|
|
614
|
+
const markerKeys = new Set();
|
|
615
|
+
for (const name of readdirSync(this.dir)) {
|
|
616
|
+
const dot = name.lastIndexOf(".");
|
|
617
|
+
if (dot < 0)
|
|
618
|
+
continue;
|
|
619
|
+
if (EXECUTION_STATES.includes(name.slice(dot + 1)))
|
|
620
|
+
markerKeys.add(name.slice(0, dot));
|
|
621
|
+
}
|
|
622
|
+
for (const key of markerKeys) {
|
|
623
|
+
if (this.listRevs(key).length)
|
|
624
|
+
continue;
|
|
625
|
+
const present = this.legacyMarkers(key);
|
|
626
|
+
const { rec, corrupt } = this.resolveLegacyMarkers(key);
|
|
627
|
+
if (corrupt.length || !rec) {
|
|
628
|
+
console.error(`[decidio] ${key}: the older state marker(s) for this decision could not be read (${corrupt.join("; ") || "no readable marker"}) — left in place; the decision reads as unreadable until a person inspects it.`);
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
const target = this.revPath(key, 1);
|
|
632
|
+
const tmp = this.writeTemp(target, JSON.stringify(rec, null, 2));
|
|
633
|
+
try {
|
|
634
|
+
linkSync(tmp, target);
|
|
635
|
+
chained++;
|
|
636
|
+
}
|
|
637
|
+
catch (e) {
|
|
638
|
+
if (e?.code !== "EEXIST") {
|
|
639
|
+
try {
|
|
640
|
+
rmSync(tmp);
|
|
641
|
+
}
|
|
642
|
+
catch { }
|
|
643
|
+
throw e;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
try {
|
|
647
|
+
rmSync(tmp);
|
|
648
|
+
}
|
|
649
|
+
catch { }
|
|
650
|
+
for (const s of present) {
|
|
651
|
+
try {
|
|
652
|
+
rmSync(this.markerPath(key, s));
|
|
653
|
+
}
|
|
654
|
+
catch { }
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (chained)
|
|
658
|
+
console.error(`[decidio] upgraded ${chained} decision${chained === 1 ? "" : "s"} from the one-marker-per-state layout to the revision chain. Do not run an older SDK against this directory again: it would write markers this layout refuses to read.`);
|
|
565
659
|
for (const name of readdirSync(this.dir)) {
|
|
566
660
|
if (name.endsWith(".claimed")) {
|
|
567
661
|
const key = name.slice(0, -".claimed".length);
|
|
568
662
|
try {
|
|
569
663
|
const p = JSON.parse(readFileSync(join(this.dir, name), "utf8"));
|
|
570
|
-
const
|
|
571
|
-
|
|
664
|
+
const published = this.transition(p.decisionId, "migrate", (cur) => cur && NON_REPLAYABLE.includes(cur.state) ? null : ({
|
|
665
|
+
...(cur ?? {}), decisionId: p.decisionId, state: "indeterminate", attemptId: null, action: p.action,
|
|
666
|
+
error: "upgraded from 0.1.x while claimed — whether it ran is not knowable; reconcile it", at: new Date().toISOString(),
|
|
667
|
+
}));
|
|
668
|
+
if (published === null) {
|
|
572
669
|
renameSync(join(this.dir, name), join(this.dir, `${name}.migrated`));
|
|
573
670
|
continue;
|
|
574
671
|
}
|
|
575
|
-
this.writeAtomic(this.statePath(p.decisionId, "indeterminate"), JSON.stringify({ decisionId: p.decisionId, state: "indeterminate", attemptId: null, action: p.action, error: "upgraded from 0.1.x while claimed — whether it ran is not knowable; reconcile it", at: new Date().toISOString() }, null, 2));
|
|
576
672
|
this.writeAtomic(this.path(p.decisionId), JSON.stringify(p, null, 2));
|
|
577
|
-
|
|
673
|
+
renameSync(join(this.dir, name), join(this.dir, `${name}.migrated`));
|
|
578
674
|
indeterminate++;
|
|
579
675
|
}
|
|
580
676
|
catch {
|
|
@@ -588,9 +684,10 @@ export class FilePendingStore {
|
|
|
588
684
|
const p = JSON.parse(readFileSync(join(this.dir, name), "utf8"));
|
|
589
685
|
if (!p?.decisionId)
|
|
590
686
|
continue;
|
|
591
|
-
if (this.
|
|
687
|
+
if (this.readHead(p.decisionId).rec)
|
|
592
688
|
continue;
|
|
593
|
-
this.
|
|
689
|
+
this.transition(p.decisionId, "migrate", (cur) => cur ? null
|
|
690
|
+
: { decisionId: p.decisionId, state: "parked", attemptId: null, action: p.action, at: new Date().toISOString() });
|
|
594
691
|
migrated++;
|
|
595
692
|
}
|
|
596
693
|
catch { }
|
|
@@ -598,7 +695,7 @@ export class FilePendingStore {
|
|
|
598
695
|
if (indeterminate)
|
|
599
696
|
console.error(`[decidio] upgraded ${indeterminate} in-flight 0.1.x entr${indeterminate === 1 ? "y" : "ies"} to INDETERMINATE — they will not auto-run; review them with guard.resume.recover().`);
|
|
600
697
|
const reports = this.migrateLegacyReports();
|
|
601
|
-
return { migrated, indeterminate, reports };
|
|
698
|
+
return { migrated, indeterminate, reports, chained };
|
|
602
699
|
}
|
|
603
700
|
migrateLegacyReports() {
|
|
604
701
|
const reportsDir = join(this.dir, "reports");
|
|
@@ -613,22 +710,18 @@ export class FilePendingStore {
|
|
|
613
710
|
const e = JSON.parse(readFileSync(file, "utf8"));
|
|
614
711
|
if (!e?.decisionId)
|
|
615
712
|
continue;
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
renameSync(file, `${file}.migrated`);
|
|
619
|
-
continue;
|
|
620
|
-
}
|
|
621
|
-
if (existing?.state === "discarded") {
|
|
622
|
-
renameSync(file, `${file}.migrated`);
|
|
623
|
-
console.error(`[decidio] ${e.decisionId}: a 0.1.x execution report was found for a decision that was DENIED (${existing.verdict ?? "rejected"}). It has NOT been added to the ledger — a denial is terminal and this report will not be sent. The file is kept as ${file}.migrated: if that action really ran, check the downstream system and correct it there.`);
|
|
624
|
-
continue;
|
|
625
|
-
}
|
|
626
|
-
this.writeAtomic(this.statePath(e.decisionId, "executed_unreported"), JSON.stringify({
|
|
627
|
-
decisionId: e.decisionId, state: "executed_unreported", attemptId: null, action: e.action,
|
|
713
|
+
const published = this.transition(e.decisionId, "migrate", (cur) => cur && (cur.state === "discarded" || rank(cur.state) >= rank("executed_unreported")) ? null : ({
|
|
714
|
+
...(cur ?? {}), decisionId: e.decisionId, state: "executed_unreported", attemptId: null, action: e.action,
|
|
628
715
|
evidence: e.reported, hasEvidence: true, migratedFrom: "0.1.x report journal",
|
|
629
716
|
at: e.at ?? new Date().toISOString(),
|
|
630
|
-
}
|
|
717
|
+
}));
|
|
631
718
|
renameSync(file, `${file}.migrated`);
|
|
719
|
+
if (published === null) {
|
|
720
|
+
const head = this.readHead(e.decisionId).rec;
|
|
721
|
+
if (head?.state === "discarded")
|
|
722
|
+
console.error(`[decidio] ${e.decisionId}: a 0.1.x execution report was found for a decision that was DENIED (${head.verdict ?? "rejected"}). It has NOT been added to the ledger — a denial is terminal and this report will not be sent. The file is kept as ${file}.migrated: if that action really ran, check the downstream system and correct it there.`);
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
632
725
|
moved++;
|
|
633
726
|
}
|
|
634
727
|
catch {
|
|
@@ -738,6 +831,7 @@ function webhookBody(o) {
|
|
|
738
831
|
ok: true,
|
|
739
832
|
outcome: {
|
|
740
833
|
status: o.status, decisionId: o.decisionId,
|
|
834
|
+
...(o.reason ? { reason: o.reason } : {}),
|
|
741
835
|
...(o.reportRecorded !== undefined ? { reportRecorded: o.reportRecorded } : {}),
|
|
742
836
|
...(o.executionConfirmed !== undefined ? { executionConfirmed: o.executionConfirmed } : {}),
|
|
743
837
|
...(o.evidenceTier ? { evidenceTier: o.evidenceTier } : {}),
|
|
@@ -842,7 +936,7 @@ export function createDecidioResume(opts) {
|
|
|
842
936
|
};
|
|
843
937
|
}
|
|
844
938
|
if (ev.reportRecorded)
|
|
845
|
-
await ctrl.store.markReported(decisionId, attemptId, ev
|
|
939
|
+
await ctrl.store.markReported(decisionId, attemptId, tombstoneTier(ev)).catch(() => { });
|
|
846
940
|
const diagnostic = buildProblem
|
|
847
941
|
? `${ev.diagnostic ? ev.diagnostic + " " : ""}Also: the action's return value was not reportable (${buildProblem}), so an empty response was reported - map it with the \`evidence\` option to earn a confirming tier.`
|
|
848
942
|
: ev.diagnostic;
|
|
@@ -876,6 +970,9 @@ export function createDecidioResume(opts) {
|
|
|
876
970
|
registry.set(action, { fn, evidence: evidence ?? prev?.evidence, withContext: withContext ?? prev?.withContext });
|
|
877
971
|
},
|
|
878
972
|
async park(p) {
|
|
973
|
+
const idProblem = decisionIdProblem(p.decisionId);
|
|
974
|
+
if (idProblem)
|
|
975
|
+
throw new DecidioUnsafeArgumentsError(`[decidio] refusing to park ${JSON.stringify(p.decisionId ?? null)}: it is not a decision id this SDK will act on (${idProblem}). Nothing was parked and nothing will execute - a decision id comes from the gate's answer, verbatim.`, typeof p.decisionId === "string" ? p.decisionId : "");
|
|
879
976
|
try {
|
|
880
977
|
toCanonical(p.args, "args");
|
|
881
978
|
}
|
|
@@ -890,6 +987,9 @@ export function createDecidioResume(opts) {
|
|
|
890
987
|
async resolve({ decisionId, verdict, reason }) {
|
|
891
988
|
const finish = (o) => { if (o.status !== "unknown")
|
|
892
989
|
opts.onResolved?.(o); return o; };
|
|
990
|
+
const idProblem = decisionIdProblem(decisionId);
|
|
991
|
+
if (idProblem)
|
|
992
|
+
return { status: "unknown", decisionId: String(decisionId ?? ""), reason: `not a decision id this SDK will act on (${idProblem})` };
|
|
893
993
|
const pending = await ctrl.store.get(decisionId);
|
|
894
994
|
if (!pending)
|
|
895
995
|
return { status: "unknown", decisionId };
|
|
@@ -986,7 +1086,7 @@ export function createDecidioResume(opts) {
|
|
|
986
1086
|
}
|
|
987
1087
|
const body = JSON.parse(raw || "{}");
|
|
988
1088
|
const verdict = body.verdict;
|
|
989
|
-
const outcome = await ctrl.resolve({ decisionId:
|
|
1089
|
+
const outcome = await ctrl.resolve({ decisionId: body.decisionId, verdict, reason: body.reason });
|
|
990
1090
|
res.statusCode = 200;
|
|
991
1091
|
res.setHeader("content-type", "application/json");
|
|
992
1092
|
res.end(JSON.stringify(webhookBody(outcome)));
|
|
@@ -1012,7 +1112,7 @@ export function createDecidioResume(opts) {
|
|
|
1012
1112
|
}
|
|
1013
1113
|
try {
|
|
1014
1114
|
const body = JSON.parse(raw || "{}");
|
|
1015
|
-
const outcome = await ctrl.resolve({ decisionId:
|
|
1115
|
+
const outcome = await ctrl.resolve({ decisionId: body.decisionId, verdict: body.verdict, reason: body.reason });
|
|
1016
1116
|
return new Response(JSON.stringify(webhookBody(outcome)), { status: 200, headers: { "content-type": "application/json" } });
|
|
1017
1117
|
}
|
|
1018
1118
|
catch (e) {
|
|
@@ -1021,6 +1121,9 @@ export function createDecidioResume(opts) {
|
|
|
1021
1121
|
};
|
|
1022
1122
|
},
|
|
1023
1123
|
async reconcile(decisionId, opts) {
|
|
1124
|
+
const idProblem = decisionIdProblem(decisionId);
|
|
1125
|
+
if (idProblem)
|
|
1126
|
+
return { reportRecorded: false, executionConfirmed: false, diagnostic: `${JSON.stringify(decisionId ?? null)} is not a decision id this SDK will act on (${idProblem}). Nothing was settled.` };
|
|
1024
1127
|
let entry;
|
|
1025
1128
|
try {
|
|
1026
1129
|
entry = await ctrl.store.getExecution(decisionId);
|
|
@@ -1066,13 +1169,16 @@ export function createDecidioResume(opts) {
|
|
|
1066
1169
|
return { reportRecorded: false, executionConfirmed: false, diagnostic: `the reconciled report for ${decisionId} did not reach Decidio (${e?.message ?? e}). The evidence IS recorded locally — call reconfirm("${decisionId}") when the service is healthy. Do NOT re-run the action.` };
|
|
1067
1170
|
}
|
|
1068
1171
|
if (ev.reportRecorded)
|
|
1069
|
-
await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconcile", ev
|
|
1172
|
+
await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconcile", tombstoneTier(ev)).catch(() => { });
|
|
1070
1173
|
return {
|
|
1071
1174
|
...ev,
|
|
1072
1175
|
diagnostic: `${ev.diagnostic ? `${ev.diagnostic} ` : ""}This report was RECONCILED BY AN OPERATOR${opts.reason ? ` (${opts.reason})` : ""}, not observed by the SDK: its tier reflects the evidence you supplied, not a return value this process saw.`,
|
|
1073
1176
|
};
|
|
1074
1177
|
},
|
|
1075
1178
|
async reconfirm(decisionId, opts) {
|
|
1179
|
+
const idProblem = decisionIdProblem(decisionId);
|
|
1180
|
+
if (idProblem)
|
|
1181
|
+
return { reportRecorded: false, executionConfirmed: false, diagnostic: `${JSON.stringify(decisionId ?? null)} is not a decision id this SDK will act on (${idProblem}). Nothing was sent.` };
|
|
1076
1182
|
let entry;
|
|
1077
1183
|
try {
|
|
1078
1184
|
entry = await ctrl.store.getExecution(decisionId);
|
|
@@ -1081,7 +1187,8 @@ export function createDecidioResume(opts) {
|
|
|
1081
1187
|
return { reportRecorded: false, executionConfirmed: false, diagnostic: `the durable state for ${decisionId} could not be read (${e?.message ?? e}). Refusing to guess whether it executed — inspect the store; do NOT re-run the action to find out.` };
|
|
1082
1188
|
}
|
|
1083
1189
|
if (entry?.state === "reported") {
|
|
1084
|
-
|
|
1190
|
+
const confirmed = tierConfirms(entry.confirmation);
|
|
1191
|
+
return { reportRecorded: true, executionConfirmed: confirmed, evidenceTier: entry.confirmation, diagnostic: `${decisionId} was already reported (${entry.confirmation ?? "recorded"}${confirmed ? ", a confirming tier" : ", not a confirming tier"}) — nothing to re-send.` };
|
|
1085
1192
|
}
|
|
1086
1193
|
if (entry?.state === "indeterminate") {
|
|
1087
1194
|
return { reportRecorded: false, executionConfirmed: false, diagnostic: `${decisionId} is INDETERMINATE: it was invoked and its outcome is unknown, so there is no captured result to re-send. Settle it with reconcile("${decisionId}", …) once you have checked the downstream system.` };
|
|
@@ -1130,7 +1237,7 @@ export function createDecidioResume(opts) {
|
|
|
1130
1237
|
return { reportRecorded: false, executionConfirmed: false, diagnostic: `the execution report for ${decisionId} still did not reach Decidio (${e?.message ?? e}). The recorded evidence is KEPT — call reconfirm("${decisionId}") again when the service is healthy. Your action already ran; do NOT re-run it.` };
|
|
1131
1238
|
}
|
|
1132
1239
|
if (ev.reportRecorded)
|
|
1133
|
-
await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconfirm", ev
|
|
1240
|
+
await ctrl.store.markReported(decisionId, entry.attemptId ?? "reconfirm", tombstoneTier(ev)).catch(() => { });
|
|
1134
1241
|
return ev;
|
|
1135
1242
|
},
|
|
1136
1243
|
async recover() {
|
package/dist/signing.js
CHANGED
|
@@ -124,6 +124,17 @@ export function assertSafeTransport(url, hasCredential) {
|
|
|
124
124
|
if (u.username || u.password) {
|
|
125
125
|
throw new Error(`[decidio] the gate URL carries credentials in the URL itself (${u.protocol}//user:***@${u.host}). Decidio authenticates with a bearer token; remove the user:password from the URL. Nothing was sent.`);
|
|
126
126
|
}
|
|
127
|
+
if (u.hostname === "") {
|
|
128
|
+
throw new Error(`[decidio] the gate URL ${JSON.stringify(url)} has no host — there is nowhere for the request to go. Decidio needs a full https URL (or http://localhost for local dev). Nothing was sent.`);
|
|
129
|
+
}
|
|
130
|
+
{
|
|
131
|
+
const spelled = url.replace(/[\t\r\n]/g, "").replace(/^[\s\u0000-\u001f\u0085\ufeff]+/, "").toLowerCase();
|
|
132
|
+
const origin = `${u.protocol}//${u.host}`.toLowerCase();
|
|
133
|
+
const next = spelled.charAt(origin.length);
|
|
134
|
+
if (!spelled.startsWith(origin) || (next !== "" && next !== "/" && next !== "?" && next !== "#" && next !== ":")) {
|
|
135
|
+
throw new Error(`[decidio] the gate URL ${JSON.stringify(url)} is not written in canonical form — this parser reads it as ${origin}, but the raw spelling is what a transport receives, and a parser that normalizes differently can read it as another host entirely. Write it as ${origin}. Nothing was sent.`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
127
138
|
if (u.protocol !== "http:")
|
|
128
139
|
return;
|
|
129
140
|
const host = u.hostname.toLowerCase();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decidio/sdk",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "One-line approval gate for AI-agent actions. Wrap a risky tool call with guard.protect(); Decidio's policy engine decides proceed | route | block, a human approves routed actions, and every outcome is sealed as a verifiable Authority Receipt.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -79,10 +79,10 @@
|
|
|
79
79
|
"scripts": {
|
|
80
80
|
"typecheck": "tsc --noEmit",
|
|
81
81
|
"build": "node -e \"fs.rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && tsc -p tsconfig.types.json",
|
|
82
|
-
"prepublishOnly": "npm run typecheck && npm run build"
|
|
82
|
+
"prepublishOnly": "npm run typecheck && npm run build && node ../publish-boundary/check.mjs --only @decidio/sdk"
|
|
83
83
|
},
|
|
84
84
|
"dependencies": {
|
|
85
|
-
"@decidio/verify": "0.3.
|
|
85
|
+
"@decidio/verify": "0.3.3"
|
|
86
86
|
},
|
|
87
87
|
"author": "OmniTwin Technologies Inc.",
|
|
88
88
|
"bugs": {
|