@cotal-ai/auth 0.11.6 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admission-mediator.d.ts +319 -0
- package/dist/admission-mediator.d.ts.map +1 -0
- package/dist/admission-mediator.js +1033 -0
- package/dist/admission-mediator.js.map +1 -0
- package/dist/auth-admin.d.ts +29 -0
- package/dist/auth-admin.d.ts.map +1 -0
- package/dist/auth-admin.js +224 -0
- package/dist/auth-admin.js.map +1 -0
- package/dist/authority-client.d.ts +215 -0
- package/dist/authority-client.d.ts.map +1 -0
- package/dist/authority-client.js +346 -0
- package/dist/authority-client.js.map +1 -0
- package/dist/barrier-evict.d.ts +16 -0
- package/dist/barrier-evict.d.ts.map +1 -0
- package/dist/barrier-evict.js +100 -0
- package/dist/barrier-evict.js.map +1 -0
- package/dist/commands.js +16 -12
- package/dist/commands.js.map +1 -1
- package/dist/connect-reader.d.ts +72 -0
- package/dist/connect-reader.d.ts.map +1 -0
- package/dist/connect-reader.js +154 -0
- package/dist/connect-reader.js.map +1 -0
- package/dist/continuity.d.ts +23 -0
- package/dist/continuity.d.ts.map +1 -0
- package/dist/continuity.js +145 -0
- package/dist/continuity.js.map +1 -0
- package/dist/credential-ledger.d.ts +343 -0
- package/dist/credential-ledger.d.ts.map +1 -0
- package/dist/credential-ledger.js +732 -0
- package/dist/credential-ledger.js.map +1 -0
- package/dist/drain-repair.d.ts +95 -0
- package/dist/drain-repair.d.ts.map +1 -0
- package/dist/drain-repair.js +305 -0
- package/dist/drain-repair.js.map +1 -0
- package/dist/idp.d.ts +19 -0
- package/dist/idp.d.ts.map +1 -1
- package/dist/idp.js +17 -0
- package/dist/idp.js.map +1 -1
- package/dist/index.d.ts +7 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +31 -3
- package/dist/index.js.map +1 -1
- package/dist/issuer.d.ts +8 -0
- package/dist/issuer.d.ts.map +1 -1
- package/dist/issuer.js +4 -2
- package/dist/issuer.js.map +1 -1
- package/dist/ledger-scanner.d.ts +98 -0
- package/dist/ledger-scanner.d.ts.map +1 -0
- package/dist/ledger-scanner.js +352 -0
- package/dist/ledger-scanner.js.map +1 -0
- package/dist/ledger.d.ts +12 -0
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +103 -13
- package/dist/ledger.js.map +1 -1
- package/dist/lifecycle-registry.d.ts +397 -0
- package/dist/lifecycle-registry.d.ts.map +1 -0
- package/dist/lifecycle-registry.js +819 -0
- package/dist/lifecycle-registry.js.map +1 -0
- package/dist/permissions.d.ts +5 -1
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +29 -6
- package/dist/permissions.js.map +1 -1
- package/dist/plane-claim.d.ts +83 -0
- package/dist/plane-claim.d.ts.map +1 -0
- package/dist/plane-claim.js +290 -0
- package/dist/plane-claim.js.map +1 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +45 -16
- package/dist/provider.js.map +1 -1
- package/dist/records-scanner.d.ts +78 -0
- package/dist/records-scanner.d.ts.map +1 -0
- package/dist/records-scanner.js +320 -0
- package/dist/records-scanner.js.map +1 -0
- package/dist/retirement-barrier.d.ts +217 -0
- package/dist/retirement-barrier.d.ts.map +1 -0
- package/dist/retirement-barrier.js +624 -0
- package/dist/retirement-barrier.js.map +1 -0
- package/dist/retirement-cleaner.d.ts +54 -0
- package/dist/retirement-cleaner.d.ts.map +1 -0
- package/dist/retirement-cleaner.js +158 -0
- package/dist/retirement-cleaner.js.map +1 -0
- package/dist/root-credential.d.ts +26 -0
- package/dist/root-credential.d.ts.map +1 -0
- package/dist/root-credential.js +116 -0
- package/dist/root-credential.js.map +1 -0
- package/dist/service.d.ts +73 -5
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +349 -24
- package/dist/service.js.map +1 -1
- package/dist/session-ledger.d.ts +224 -0
- package/dist/session-ledger.d.ts.map +1 -0
- package/dist/session-ledger.js +713 -0
- package/dist/session-ledger.js.map +1 -0
- package/dist/store.d.ts +18 -8
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +74 -51
- package/dist/store.js.map +1 -1
- package/dist/token.d.ts +17 -0
- package/dist/token.d.ts.map +1 -1
- package/dist/token.js +30 -2
- package/dist/token.js.map +1 -1
- package/package.json +7 -4
|
@@ -0,0 +1,624 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The D13 (5) TERMINAL RETIREMENT BARRIER + the EXACT-POOL TERMINAL CLEANER (SPEC §13.1, as
|
|
3
|
+
* amended; the §13.9 cleaner matrix row). The third sibling of `lifecycle-registry.ts` /
|
|
4
|
+
* `credential-ledger.ts`: the SAME minting authority over the SAME stores, reached only through
|
|
5
|
+
* the sealed registry ({@link registryStores}).
|
|
6
|
+
*
|
|
7
|
+
* The barrier, in the NORMATIVE order (§13.1) — every boundary crash-resumable from the durable
|
|
8
|
+
* `stage.<opId>` intent, and only the SAME operation resumes it:
|
|
9
|
+
* 1. durable retirement intent (create-only, captured BEFORE any movement; it carries only the
|
|
10
|
+
* frontier stream set: the barrier DISCOVERS the whole cleaner inventory from the target's
|
|
11
|
+
* own accepted pool obligations, taking no caller-supplied pool hint, §13.1 #F);
|
|
12
|
+
* 2. CAS the issuance gate `open → frozen` carrying the intent (the bar: a staged mint loses
|
|
13
|
+
* its finalize touch) — a retirement freeze NEVER reopens (§13.1: its only exit is the
|
|
14
|
+
* terminal);
|
|
15
|
+
* 3. CAS the head `active → retiring` bound to the same op (every currency seam now yields no
|
|
16
|
+
* current mapping and no current epoch; the alias is NOT replaceable);
|
|
17
|
+
* 4. the shared containment core ({@link containLifecycleFamily}): revoke every ledger row
|
|
18
|
+
* under `cred.<uid>.>`, reconcile both halves of every session-derived credential, and
|
|
19
|
+
* VERIFIED cluster-wide eviction of every holder principal + the alias principal;
|
|
20
|
+
* 5. drain the target's acceptance obligations to quiescence (§13.8): enumerate
|
|
21
|
+
* `oblig.<targetUid>.>` for endpoint discovery, drive each endpoint's injected drain
|
|
22
|
+
* ({@link drainTargetForEndpoint} over that endpoint's mediator), then RE-ENUMERATE and
|
|
23
|
+
* verify — the barrier OWNS the quiescence check, never the injected seam; then, BEFORE
|
|
24
|
+
* anything else proceeds, fence the drain's per-op repair principals (the commit applier /
|
|
25
|
+
* pool-route reconciler / effects canceller, {@link drainRepairPrincipals}) with a
|
|
26
|
+
* cluster-verified eviction — the APPLIER especially, whose records-KV last-value write is
|
|
27
|
+
* visible to a normal reader regardless of the frontier cutoff (#4);
|
|
28
|
+
* 6. the exact-pool terminal cleaner ({@link runExactPoolCleaner}) under a DISTINCT,
|
|
29
|
+
* separately minted, bounded-lived profile per (op × endpoint), then — BEFORE any frontier
|
|
30
|
+
* records — revoke the cleaner's own credential and cluster-verify eviction of its
|
|
31
|
+
* principal (the §13.1 cleaner fence: no in-flight cleaner can ACK a redelivery or write a
|
|
32
|
+
* terminal after the alias is reused);
|
|
33
|
+
* 7. record the per-stream retirement frontiers (`frontier.<lifecycleUid>`, create-only,
|
|
34
|
+
* never deleted — the cutoffs that bound the predecessor's half-open interval);
|
|
35
|
+
* 8. CAS the gate `frozen → retired` (terminal; unlike takeover, never reopened);
|
|
36
|
+
* 9. CAS the head `retiring → retired` — only now is the alias replaceable, and `retired`
|
|
37
|
+
* ASSERTS the completed barrier.
|
|
38
|
+
*
|
|
39
|
+
* FAIL-CLOSED CONTRACT: any failure after the freeze leaves the gate FROZEN and the head at its
|
|
40
|
+
* current containment state (nothing mints, the alias is not replaceable); eviction failure is
|
|
41
|
+
* `unavailable`; a live, unexpired, FOREIGN-target pool item is never settled or ACKed and the
|
|
42
|
+
* barrier refuses to close frontiers while one remains (§13.9).
|
|
43
|
+
*
|
|
44
|
+
* SURFACE: NOTHING here is exported from the package index (the same discipline as the
|
|
45
|
+
* takeover barrier); production wiring lands with the activation slices. The executor seam is
|
|
46
|
+
* the sealed registry; an `opId` is an identifier, never a bearer capability.
|
|
47
|
+
*/
|
|
48
|
+
import { AckPolicy } from "@nats-io/jetstream";
|
|
49
|
+
import { EpEnvelopeError, RETIREMENT_FRONTIER, assertLifecycleToken, assertPoolToken, createRecordEntry, endpointToken, epfStreamName, epfSubject, epwSubject, epwStreamName, parseDecisionFact, parseWorkTerminalFact, poolConsumerConfig, poolDurable, readLastFact, recordAtomicKey, reconcileWorkItem, retireWorkItem, retirementFrontierStreams, workTerminalSubject, } from "@cotal-ai/core";
|
|
50
|
+
import { registryStores, registryRecordsScanner, observeGate, freezeGate, retireGate, readLifecycleHeadForOperation, beginHeadRetirementWithinBarrier, completeHeadRetirementWithinBarrier, } from "./lifecycle-registry.js";
|
|
51
|
+
import { containLifecycleFamily, createRowByteIdempotent, stageIntentKey, } from "./credential-ledger.js";
|
|
52
|
+
import { enumerateObligationRows } from "./admission-mediator.js";
|
|
53
|
+
import { drainRepairPrincipals } from "./drain-repair.js";
|
|
54
|
+
const dec = new TextDecoder();
|
|
55
|
+
const isRec = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
56
|
+
const uint = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
57
|
+
function assertFrontierStreams(v, what, space) {
|
|
58
|
+
if (!Array.isArray(v) || v.some((s) => typeof s !== "string" || s.length === 0))
|
|
59
|
+
throw new EpEnvelopeError("failed-precondition", `${what} must be an array of non-empty stream names (SPEC 13.1)`);
|
|
60
|
+
// CLOSED set (SPEC 13.1): a frontier stream is a per-space lifecycle-data stream the barrier is
|
|
61
|
+
// GRANTED STREAM.INFO for, never a caller-selected arbitrary name. An out-of-set entry would
|
|
62
|
+
// permission-deny at the frontier step on a real broker and wedge the resume; refuse it up front.
|
|
63
|
+
const allowed = new Set(retirementFrontierStreams(space));
|
|
64
|
+
for (const s of v)
|
|
65
|
+
if (!allowed.has(s))
|
|
66
|
+
throw new EpEnvelopeError("failed-precondition", `${what}: ${JSON.stringify(s)} is not a retirement frontier stream; only the per-space lifecycle-data streams (${[...allowed].join(", ")}) may be fenced (SPEC 13.1)`);
|
|
67
|
+
return v;
|
|
68
|
+
}
|
|
69
|
+
function parseRetirementIntent(raw, key, space) {
|
|
70
|
+
let o;
|
|
71
|
+
try {
|
|
72
|
+
o = JSON.parse(dec.decode(raw));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw new EpEnvelopeError("internal", `the operation intent ${key} is not JSON (SPEC 13.1)`);
|
|
76
|
+
}
|
|
77
|
+
if (!isRec(o))
|
|
78
|
+
throw new EpEnvelopeError("internal", `the operation intent ${key} is not an object`);
|
|
79
|
+
const allowed = new Set(["kind", "lifecycleUid", "owner", "actor", "fromGeneration", "frontierStreams"]);
|
|
80
|
+
for (const k of Object.keys(o))
|
|
81
|
+
if (!allowed.has(k))
|
|
82
|
+
throw new EpEnvelopeError("internal", `the operation intent ${key} carries the unknown field "${k}" (closed schema, SPEC 13.1)`);
|
|
83
|
+
if (o.kind !== "retirement" || typeof o.lifecycleUid !== "string" || typeof o.owner !== "string" || o.owner.length === 0 ||
|
|
84
|
+
typeof o.actor !== "string" || o.actor.length === 0 || !uint(o.fromGeneration) || o.fromGeneration < 1)
|
|
85
|
+
throw new EpEnvelopeError("internal", `the operation intent ${key} does not validate as a retirement intent (SPEC 13.1)`);
|
|
86
|
+
try {
|
|
87
|
+
assertLifecycleToken(o.lifecycleUid);
|
|
88
|
+
assertFrontierStreams(o.frontierStreams, `intent ${key} frontierStreams`, space);
|
|
89
|
+
}
|
|
90
|
+
catch (e) {
|
|
91
|
+
throw new EpEnvelopeError("internal", `the operation intent ${key} carries malformed coordinates: ${e.message}`);
|
|
92
|
+
}
|
|
93
|
+
return o;
|
|
94
|
+
}
|
|
95
|
+
async function readRetirementIntent(authKv, opId, space) {
|
|
96
|
+
const key = stageIntentKey(opId);
|
|
97
|
+
const entry = await authKv.get(key);
|
|
98
|
+
if (!entry)
|
|
99
|
+
return undefined;
|
|
100
|
+
if (entry.operation !== "PUT")
|
|
101
|
+
throw new EpEnvelopeError("failed-precondition", `the operation intent ${key} carries a ${entry.operation} marker; an intent is never deleted while resumable (corruption, SPEC 13.12)`);
|
|
102
|
+
return parseRetirementIntent(entry.value, key, space);
|
|
103
|
+
}
|
|
104
|
+
/** Rebuild-and-compare parse of a delivered EPW message subject against the bound pool: the
|
|
105
|
+
* trailing four tokens are the acceptance identity, and the rebuilt subject must equal the
|
|
106
|
+
* delivered one exactly (bijective — a truncated or foreign-family subject never settles). */
|
|
107
|
+
function itemRefOf(space, endpoint, pool, subject) {
|
|
108
|
+
const toks = subject.split(".");
|
|
109
|
+
const tail = toks.slice(-4);
|
|
110
|
+
const ref = { endpoint, pool, acceptance: { owner: tail[0], actor: tail[1], uid: tail[2], id: tail[3] } };
|
|
111
|
+
let rebuilt;
|
|
112
|
+
try {
|
|
113
|
+
rebuilt = epwSubject(space, endpoint, pool, ref.acceptance);
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
throw new EpEnvelopeError("internal", `the delivered pool message ${subject} does not carry a valid acceptance identity: ${e.message} (SPEC 13.2)`);
|
|
117
|
+
}
|
|
118
|
+
if (rebuilt !== subject)
|
|
119
|
+
throw new EpEnvelopeError("internal", `the delivered pool message ${subject} does not rebuild from the bound pool coordinates (${rebuilt}); the durable's filter proof does not cover it (SPEC 13.9); refused`);
|
|
120
|
+
return ref;
|
|
121
|
+
}
|
|
122
|
+
/** ACK a settled delivery with confirmation (§13.9: a fire-and-forget ACK is confirmed with
|
|
123
|
+
* AckSync or re-proven, never assumed). */
|
|
124
|
+
async function ackSync(m, subject) {
|
|
125
|
+
if ((await m.ackAck()) !== true)
|
|
126
|
+
throw new EpEnvelopeError("unavailable", `the cleaner's ACK for ${subject} was not confirmed by the server; re-run the barrier; the settled terminal makes the retry idempotent (SPEC 13.9)`);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Run the exact-pool terminal cleaner over ONE named pool (§13.9 matrix row), on the CLEANER's
|
|
130
|
+
* own bind. Bind-time re-proof first: the pre-created durable's filter is exactly the named
|
|
131
|
+
* pool's subtree, pull mode, explicit ack, unlimited delivery ceiling. Then drain to a PROVEN
|
|
132
|
+
* quiescent pool: for each delivered message — ACK only if the item is durably terminal; settle
|
|
133
|
+
* `expired` when its own `workExpiry` passed (any target); settle `retired` ONLY after
|
|
134
|
+
* re-binding the item to THIS operation's retiring target through its acceptance decision fact
|
|
135
|
+
* (the `epw` subject carries no target); a live, unexpired, FOREIGN-target item is never
|
|
136
|
+
* settled or ACKed and quiescence fails loud while one remains. Quiescence is a FRESH consumer
|
|
137
|
+
* read showing zero `num_pending` and zero `num_ack_pending` (pre-existing owner ACKs drain
|
|
138
|
+
* through `AckWait` redelivery into this loop).
|
|
139
|
+
*/
|
|
140
|
+
export async function runExactPoolCleaner(bind, args) {
|
|
141
|
+
const { space, endpoint, pool } = args;
|
|
142
|
+
const targetUid = assertLifecycleToken(args.targetUid, "targetUid");
|
|
143
|
+
const stream = epwStreamName(space);
|
|
144
|
+
const durable = poolDurable(endpoint, pool);
|
|
145
|
+
const expect = poolConsumerConfig(space, endpoint, pool);
|
|
146
|
+
const expiresMs = args.fetchExpiresMs ?? 1_000;
|
|
147
|
+
if (expiresMs < 1_000)
|
|
148
|
+
throw new EpEnvelopeError("failed-precondition", `fetchExpiresMs ${expiresMs} is below the pull-consumer floor (1000ms); the client refuses shorter expirations`);
|
|
149
|
+
const maxStalled = args.maxStalledPasses ?? 10;
|
|
150
|
+
// Bind-time re-proof (§13.9): the durable this profile may NEXT/ACK is exactly the named
|
|
151
|
+
// pool's pre-created consumer — never a wider filter, a push consumer, or a finite ceiling.
|
|
152
|
+
let consumer;
|
|
153
|
+
try {
|
|
154
|
+
consumer = await bind.js.consumers.get(stream, durable);
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
throw new EpEnvelopeError("failed-precondition", `the cleaner cannot bind the pre-created pool durable ${durable} on ${stream}; this operation's discovered inventory named a pool that is not provisioned (SPEC 13.9): ${e?.message ?? String(e)}`);
|
|
158
|
+
}
|
|
159
|
+
const cfg = (await consumer.info(false)).config;
|
|
160
|
+
if (cfg.filter_subject !== expect.filter_subject)
|
|
161
|
+
throw new EpEnvelopeError("failed-precondition", `the pool durable ${durable} filters ${String(cfg.filter_subject)}, not exactly ${String(expect.filter_subject)}; the cleaner refuses a drifted filter (SPEC 13.9)`);
|
|
162
|
+
if (cfg.deliver_subject !== undefined && cfg.deliver_subject !== "")
|
|
163
|
+
throw new EpEnvelopeError("failed-precondition", `the pool durable ${durable} is a PUSH consumer; the cleaner binds pull mode only (SPEC 13.9)`);
|
|
164
|
+
if (cfg.ack_policy !== AckPolicy.Explicit)
|
|
165
|
+
throw new EpEnvelopeError("failed-precondition", `the pool durable ${durable} has ack policy ${String(cfg.ack_policy)}, not explicit; terminal-only ACK needs explicit acks (SPEC 13.9)`);
|
|
166
|
+
if (cfg.max_deliver !== -1)
|
|
167
|
+
throw new EpEnvelopeError("failed-precondition", `the pool durable ${durable} caps delivery at ${String(cfg.max_deliver)}; an exhausted item leaves the counters and falsifies quiescence (SPEC 13.9); the ceiling must be unlimited`);
|
|
168
|
+
const counts = { ackedTerminal: 0, settledExpired: 0, settledRetired: 0 };
|
|
169
|
+
const foreignLive = new Map(); // subject → why it may not be settled
|
|
170
|
+
let stalled = 0;
|
|
171
|
+
for (;;) {
|
|
172
|
+
// The quiescence probe is a FRESH read (§13.9), never the cached info of an earlier pass.
|
|
173
|
+
const info = await consumer.info(false);
|
|
174
|
+
if (info.num_pending === 0 && info.num_ack_pending === 0)
|
|
175
|
+
return counts;
|
|
176
|
+
let progressed = 0;
|
|
177
|
+
const iter = await consumer.fetch({ max_messages: 64, expires: expiresMs });
|
|
178
|
+
for await (const m of iter) {
|
|
179
|
+
const ref = itemRefOf(space, endpoint, pool, m.subject);
|
|
180
|
+
const termSubject = workTerminalSubject(space, ref);
|
|
181
|
+
const existing = await readLastFact(bind.jsm, epfStreamName(space), termSubject);
|
|
182
|
+
if (existing !== undefined) {
|
|
183
|
+
parseWorkTerminalFact(existing, termSubject, ref); // a garbled terminal never authorizes an ACK
|
|
184
|
+
await ackSync(m, m.subject);
|
|
185
|
+
counts.ackedTerminal++;
|
|
186
|
+
progressed++;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
// No terminal: re-bind through the item's acceptance decision (§13.9: the epw subject
|
|
190
|
+
// carries no target). An enqueued item without an acceptance is corruption, not absence.
|
|
191
|
+
const decSubject = epfSubject(space, endpoint, ["dec", ref.acceptance.owner, ref.acceptance.actor, ref.acceptance.uid, ref.acceptance.id]);
|
|
192
|
+
const decRaw = await readLastFact(bind.jsm, epfStreamName(space), decSubject);
|
|
193
|
+
if (decRaw === undefined)
|
|
194
|
+
throw new EpEnvelopeError("internal", `the pool item ${m.subject} has no decision fact; an enqueued item derives from an acceptance (corruption, SPEC 13.8); the cleaner refuses`);
|
|
195
|
+
const fact = parseDecisionFact(decRaw, decSubject);
|
|
196
|
+
if (fact.decision !== "accepted")
|
|
197
|
+
throw new EpEnvelopeError("internal", `the pool item ${m.subject} sits under a REJECTED identity; a rejection never enqueues work (corruption, SPEC 13.8); the cleaner refuses`);
|
|
198
|
+
if (fact.workExpiry === undefined)
|
|
199
|
+
throw new EpEnvelopeError("internal", `the pool item ${m.subject} was accepted without a workExpiry; a pool-routed acceptance always carries the absolute horizon (corruption, SPEC 13.8); the cleaner refuses`);
|
|
200
|
+
const now = args.now();
|
|
201
|
+
if (now >= fact.workExpiry) {
|
|
202
|
+
const settled = await args.settleItem({ ref, itemBytes: m.data, disposition: "expired" });
|
|
203
|
+
const observedRaw = await readLastFact(bind.jsm, epfStreamName(space), termSubject);
|
|
204
|
+
if (observedRaw === undefined)
|
|
205
|
+
throw new EpEnvelopeError("internal", `the executor settled ${m.subject} but no terminal ${termSubject} is readable; the cleaner never ACKs an unproven settlement (SPEC 13.9)`);
|
|
206
|
+
const observed = parseWorkTerminalFact(observedRaw, termSubject, ref);
|
|
207
|
+
if (JSON.stringify(observed) !== JSON.stringify(settled))
|
|
208
|
+
throw new EpEnvelopeError("internal", `the terminal ${termSubject} does not match the executor's lease-derived settlement; the cleaner refuses to ACK (SPEC 13.5/13.9)`);
|
|
209
|
+
await ackSync(m, m.subject);
|
|
210
|
+
if (observed.disposition === "expired")
|
|
211
|
+
counts.settledExpired++;
|
|
212
|
+
else if (observed.disposition === "retired")
|
|
213
|
+
counts.settledRetired++;
|
|
214
|
+
else
|
|
215
|
+
counts.ackedTerminal++;
|
|
216
|
+
progressed++;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (fact.target !== undefined && fact.target.lifecycleUid === targetUid) {
|
|
220
|
+
const settled = await args.settleItem({ ref, itemBytes: m.data, disposition: "retired" });
|
|
221
|
+
const observedRaw = await readLastFact(bind.jsm, epfStreamName(space), termSubject);
|
|
222
|
+
if (observedRaw === undefined)
|
|
223
|
+
throw new EpEnvelopeError("internal", `the executor settled ${m.subject} but no terminal ${termSubject} is readable; the cleaner never ACKs an unproven settlement (SPEC 13.9)`);
|
|
224
|
+
const observed = parseWorkTerminalFact(observedRaw, termSubject, ref);
|
|
225
|
+
if (JSON.stringify(observed) !== JSON.stringify(settled))
|
|
226
|
+
throw new EpEnvelopeError("internal", `the terminal ${termSubject} does not match the executor's lease-derived settlement; the cleaner refuses to ACK (SPEC 13.5/13.9)`);
|
|
227
|
+
await ackSync(m, m.subject);
|
|
228
|
+
if (observed.disposition === "expired")
|
|
229
|
+
counts.settledExpired++;
|
|
230
|
+
else if (observed.disposition === "retired")
|
|
231
|
+
counts.settledRetired++;
|
|
232
|
+
else
|
|
233
|
+
counts.ackedTerminal++;
|
|
234
|
+
progressed++;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
// Live, unexpired, foreign target: NEVER settled or ACKed (§13.9). It stays delivered but
|
|
238
|
+
// un-ACKed; quiescence cannot be declared while it remains, and the stall bound below
|
|
239
|
+
// fails the barrier loud with its identity.
|
|
240
|
+
foreignLive.set(m.subject, fact.target === undefined ? "untargeted acceptance" : `target ${fact.target.lifecycleUid}`);
|
|
241
|
+
}
|
|
242
|
+
if (progressed > 0) {
|
|
243
|
+
stalled = 0;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (++stalled >= maxStalled) {
|
|
247
|
+
const after = await consumer.info(false);
|
|
248
|
+
const blockers = [...foreignLive.entries()].map(([s, why]) => `${s} (${why})`).join(", ");
|
|
249
|
+
throw new EpEnvelopeError("unavailable", `the pool ${durable} did not reach quiescence (num_pending ${after.num_pending}, ack_pending ${after.num_ack_pending} after ${maxStalled} stalled passes)${blockers ? `; live foreign-target items are never settled by a retirement: ${blockers}` : ""}; the barrier refuses to close frontiers and stays resumable (SPEC 13.9)`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function parseFrontier(raw, key) {
|
|
254
|
+
if (!isRec(raw))
|
|
255
|
+
throw new EpEnvelopeError("internal", `the frontier record ${key} is not an object (SPEC 13.1)`);
|
|
256
|
+
const allowed = new Set(["lifecycleUid", "opId", "streams"]);
|
|
257
|
+
for (const k of Object.keys(raw))
|
|
258
|
+
if (!allowed.has(k))
|
|
259
|
+
throw new EpEnvelopeError("internal", `the frontier record ${key} carries the unknown field "${k}" (closed schema, SPEC 13.1)`);
|
|
260
|
+
if (typeof raw.lifecycleUid !== "string" || typeof raw.opId !== "string" || !isRec(raw.streams) ||
|
|
261
|
+
Object.values(raw.streams).some((v) => !uint(v)))
|
|
262
|
+
throw new EpEnvelopeError("internal", `the frontier record ${key} does not validate (SPEC 13.1)`);
|
|
263
|
+
return raw;
|
|
264
|
+
}
|
|
265
|
+
/** Build the effective-inventory-closed executor settlement seam (§13.9): the returned function
|
|
266
|
+
* runs under the barrier's op-bounded authority and refuses any ref outside its effective
|
|
267
|
+
* inventory (the barrier-discovered `spec.pools`, the accepted `oblig.<uid>.>` routes) or
|
|
268
|
+
* this retirement's lifecycle — a foreign endpoint or a pool outside its discovered spec, an
|
|
269
|
+
* unaccepted or non-pool decision, expiring a live item, or retiring an item accepted for a
|
|
270
|
+
* target outside this intent's lifecycle. The cleaner chooses refs; it can never borrow this
|
|
271
|
+
* authority beyond its discovered inventory or this retirement's lifecycle (the confused-deputy
|
|
272
|
+
* closure). */
|
|
273
|
+
export function settlementForIntent(work, intent, spec, space, opId, now) {
|
|
274
|
+
const allowedPools = new Set(spec.pools);
|
|
275
|
+
return async ({ ref, itemBytes, disposition }) => {
|
|
276
|
+
if (ref.endpoint !== spec.endpoint || !allowedPools.has(ref.pool))
|
|
277
|
+
throw new EpEnvelopeError("permission-denied", `the retirement executor for ${spec.endpoint} refuses item ${ref.endpoint}/${ref.pool}; this operation's barrier-discovered cleaner inventory grants only its endpoint's pools (SPEC 13.1/13.9)`);
|
|
278
|
+
const decSubject = epfSubject(space, ref.endpoint, ["dec", ref.acceptance.owner, ref.acceptance.actor, ref.acceptance.uid, ref.acceptance.id]);
|
|
279
|
+
const decRaw = await readLastFact(work.jsm, epfStreamName(space), decSubject);
|
|
280
|
+
if (decRaw === undefined)
|
|
281
|
+
throw new EpEnvelopeError("internal", `the retirement executor found no acceptance decision for ${decSubject}; cleaner-supplied coordinates never authorize settlement (SPEC 13.8)`);
|
|
282
|
+
const fact = parseDecisionFact(decRaw, decSubject);
|
|
283
|
+
if (fact.decision !== "accepted" || fact.route !== `pool.${ref.pool}` || fact.workExpiry === undefined)
|
|
284
|
+
throw new EpEnvelopeError("permission-denied", `the retirement executor refuses ${decSubject}; it is not an accepted item for intent pool ${ref.pool} (SPEC 13.8/13.9)`);
|
|
285
|
+
const clock = now();
|
|
286
|
+
if (disposition === "expired") {
|
|
287
|
+
if (clock < fact.workExpiry)
|
|
288
|
+
throw new EpEnvelopeError("permission-denied", `the retirement executor refuses to expire live item ${decSubject}; its acceptance horizon is ${fact.workExpiry} (executor clock ${clock})`);
|
|
289
|
+
const verdict = await reconcileWorkItem(work, { ref, itemBytes, workExpiry: fact.workExpiry, now: clock });
|
|
290
|
+
if (!("fact" in verdict))
|
|
291
|
+
throw new EpEnvelopeError("internal", `expired retirement settlement for ${decSubject} returned nonterminal state ${verdict.state}; the executor refuses to authorize an ACK`);
|
|
292
|
+
return verdict.fact;
|
|
293
|
+
}
|
|
294
|
+
const target = fact.target;
|
|
295
|
+
if (target === undefined || target.owner !== intent.owner || target.actor !== intent.actor || target.lifecycleUid !== intent.lifecycleUid)
|
|
296
|
+
throw new EpEnvelopeError("permission-denied", `the retirement executor refuses to retire ${decSubject}; its accepted target is outside this durable retirement intent (SPEC 13.1/13.9)`);
|
|
297
|
+
return (await retireWorkItem(work, {
|
|
298
|
+
ref, workExpiry: fact.workExpiry, opId, targetUid: intent.lifecycleUid, now: clock,
|
|
299
|
+
})).fact;
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
/** The per-op credential fence (§13.1): run every credential's `retire` (the deny-new half, a
|
|
303
|
+
* no-op for the no-row repair bearers) AND the KILL-LIVE half — a cluster-verified eviction of
|
|
304
|
+
* every principal — on the success AND the failure path. A `retire` that THROWS must NOT skip any
|
|
305
|
+
* live eviction, and one principal's eviction failure must NOT skip the other's, or a live
|
|
306
|
+
* connection survives the failed barrier: every containment action runs, every failure is
|
|
307
|
+
* captured, THEN the barrier fails loud on the first. The VERIFIED EVICTION is the load-bearing
|
|
308
|
+
* half — for the repair bearers it is the ONLY half; the guarantee is that no LIVE connection
|
|
309
|
+
* under the principal survives when the frontier closes (a still-unexpired bearer could open a
|
|
310
|
+
* FRESH connect after the point-in-time scan — the named seed-dominated residual on
|
|
311
|
+
* {@link drainRepairPrincipals}). A `retire` failure still fails the barrier afterward so no
|
|
312
|
+
* frontier records while a credential may live, and the resume re-runs the whole fence. */
|
|
313
|
+
async function fenceOpCredentials(deps, creds) {
|
|
314
|
+
const retireFailures = [];
|
|
315
|
+
for (const c of creds) {
|
|
316
|
+
try {
|
|
317
|
+
await c.retire();
|
|
318
|
+
}
|
|
319
|
+
catch (e) {
|
|
320
|
+
retireFailures.push({ c, e });
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
const evictFailures = [];
|
|
324
|
+
for (const c of creds) {
|
|
325
|
+
try {
|
|
326
|
+
const res = await deps.evictPrincipal(c.principal);
|
|
327
|
+
if (res.verifiedGone !== true)
|
|
328
|
+
evictFailures.push(new EpEnvelopeError("unavailable", `the retirement barrier could not VERIFY eviction of the ${c.what} principal ${c.principal} (kicked ${res.kicked}, remaining ${res.remaining}, scanComplete ${res.scanComplete}${res.note ? `; ${res.note}` : ""}); no frontier may record over a live ${c.what} (SPEC 13.1)`));
|
|
329
|
+
}
|
|
330
|
+
catch (e) {
|
|
331
|
+
evictFailures.push(e);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (evictFailures.length > 0)
|
|
335
|
+
throw evictFailures[0];
|
|
336
|
+
if (retireFailures.length > 0) {
|
|
337
|
+
const { c, e } = retireFailures[0];
|
|
338
|
+
throw new EpEnvelopeError("unavailable", `the ${c.what} principal ${c.principal} was verified-evicted (kill-live done) but revoking its credential (deny-new) failed; the barrier fails closed so no frontier records while the credential may live, and the resume re-runs the fence (SPEC 13.1): ${e?.message ?? String(e)}`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
/** Run the cleaner step for one endpoint entry: mint BOTH per-op binds (the zero-write cleaner
|
|
342
|
+
* and the settlement executor, §13.9's split authority), build the settlement seam over the
|
|
343
|
+
* EXECUTOR's own work context, clean every listed pool, then — BEFORE anything else proceeds —
|
|
344
|
+
* fence both credentials (retire + verified-evict), on the failure path too (a wedged barrier
|
|
345
|
+
* never leaves a live cleaner or executor). */
|
|
346
|
+
async function cleanEndpointPools(intent, spec, space, opId, deps, cleaned) {
|
|
347
|
+
const bind = await deps.openCleaner({ opId, endpoint: spec.endpoint, pools: [...spec.pools] });
|
|
348
|
+
const cleanerCred = { what: "cleaner", principal: bind.principal, retire: () => deps.retireCleanerCredential(bind) };
|
|
349
|
+
let exec;
|
|
350
|
+
try {
|
|
351
|
+
exec = await deps.openExecutor({ opId, endpoint: spec.endpoint, pools: [...spec.pools] });
|
|
352
|
+
}
|
|
353
|
+
catch (e) {
|
|
354
|
+
// A failed executor mint must not leave the already-minted cleaner live: run the full
|
|
355
|
+
// cleaner fence first, then fail with the mint error (a fence failure wins by throwing).
|
|
356
|
+
await fenceOpCredentials(deps, [cleanerCred]);
|
|
357
|
+
throw e;
|
|
358
|
+
}
|
|
359
|
+
const settleItem = settlementForIntent(exec.work, intent, spec, space, opId, deps.now);
|
|
360
|
+
let cleanerError;
|
|
361
|
+
try {
|
|
362
|
+
for (const pool of spec.pools) {
|
|
363
|
+
cleaned[`${spec.endpoint}/${pool}`] = await runExactPoolCleaner(bind, {
|
|
364
|
+
space, endpoint: spec.endpoint, pool, settleItem, targetUid: intent.lifecycleUid,
|
|
365
|
+
now: deps.now, fetchExpiresMs: deps.cleaner?.fetchExpiresMs, maxStalledPasses: deps.cleaner?.maxStalledPasses,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
catch (e) {
|
|
370
|
+
cleanerError = e;
|
|
371
|
+
}
|
|
372
|
+
await fenceOpCredentials(deps, [
|
|
373
|
+
cleanerCred,
|
|
374
|
+
{ what: "executor", principal: exec.principal, retire: () => deps.retireExecutorCredential(exec) },
|
|
375
|
+
]);
|
|
376
|
+
if (cleanerError !== undefined)
|
|
377
|
+
throw cleanerError;
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Run the FULL terminal retirement barrier for a managed-agent lifecycle (SPEC 13.1, in the
|
|
381
|
+
* normative order — module header). Idempotent/crash-resumable: every step re-checks durable
|
|
382
|
+
* state, so calling it again with the SAME `opId` (directly or via
|
|
383
|
+
* {@link resumeAgentRetirement}) finishes the same operation; a different operation's freeze, a
|
|
384
|
+
* foreign generation movement, or a stranger's opId refuses before any CAS.
|
|
385
|
+
*/
|
|
386
|
+
export async function runAgentRetirementBarrier(reg, args, deps) {
|
|
387
|
+
const { space, authKv, recordsKv, jsm } = registryStores(reg);
|
|
388
|
+
const opId = assertLifecycleToken(args.opId);
|
|
389
|
+
assertLifecycleToken(args.lifecycleUid);
|
|
390
|
+
const frontierKey = recordAtomicKey(RETIREMENT_FRONTIER, [args.lifecycleUid]);
|
|
391
|
+
// 0. The durable intent: read-or-create BEFORE any gate movement. An existing intent's
|
|
392
|
+
// coordinates WIN (they are the operation; the caller's args merely re-address it).
|
|
393
|
+
let intent = await readRetirementIntent(authKv, opId, space);
|
|
394
|
+
if (intent !== undefined) {
|
|
395
|
+
if (intent.lifecycleUid !== args.lifecycleUid || intent.owner !== args.owner || intent.actor !== args.actor)
|
|
396
|
+
throw new EpEnvelopeError("permission-denied", `the operation intent ${stageIntentKey(opId)} belongs to lifecycle ${intent.lifecycleUid} ("${intent.owner}/${intent.actor}"), not ${args.lifecycleUid} ("${args.owner}/${args.actor}"); an opId resumes only its OWN operation (SPEC 13.1)`);
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
assertFrontierStreams(args.frontierStreams, "frontierStreams", space);
|
|
400
|
+
const head = await readLifecycleHeadForOperation(reg, args.owner, args.actor);
|
|
401
|
+
if (head === undefined || head.mapping.state !== "active" || head.mapping.lifecycleUid !== args.lifecycleUid)
|
|
402
|
+
throw new EpEnvelopeError("failed-precondition", `retirement for "${args.owner}/${args.actor}" requires an ACTIVE head at uid ${args.lifecycleUid}; found ${head === undefined ? "no head" : `${head.mapping.state} at ${head.mapping.lifecycleUid}`} (SPEC 13.1)`);
|
|
403
|
+
const gate0 = await observeGate(reg, args.lifecycleUid);
|
|
404
|
+
if (gate0 === undefined)
|
|
405
|
+
throw new EpEnvelopeError("failed-precondition", `lifecycle ${args.lifecycleUid} has no issuance gate; nothing to retire (SPEC 13.1)`);
|
|
406
|
+
if (gate0.row.state !== "open" || gate0.row.generation < 1)
|
|
407
|
+
throw new EpEnvelopeError("failed-precondition", `the issuance gate for ${args.lifecycleUid} is "${gate0.row.state}" at generation ${gate0.row.generation}; a retirement freezes an OPEN, mintable gate (another operation owns a frozen one, SPEC 13.1)`);
|
|
408
|
+
const fresh = {
|
|
409
|
+
kind: "retirement", lifecycleUid: args.lifecycleUid, owner: args.owner, actor: args.actor,
|
|
410
|
+
fromGeneration: gate0.row.generation, frontierStreams: args.frontierStreams,
|
|
411
|
+
};
|
|
412
|
+
await createRowByteIdempotent(authKv, stageIntentKey(opId), fresh);
|
|
413
|
+
intent = await readRetirementIntent(authKv, opId, space);
|
|
414
|
+
if (intent === undefined)
|
|
415
|
+
throw new EpEnvelopeError("internal", `the operation intent ${stageIntentKey(opId)} vanished after its create (SPEC 13.12)`);
|
|
416
|
+
}
|
|
417
|
+
// 1. Freeze the gate under OUR intent — or recognize our own freeze (resume) or our own
|
|
418
|
+
// completed terminal (resume of the tail). A freeze-CAS loss to a mint's finalize touch
|
|
419
|
+
// re-observes and retries, bounded (the normative serialization on one key).
|
|
420
|
+
for (let attempt = 0;; attempt++) {
|
|
421
|
+
const gate = await observeGate(reg, intent.lifecycleUid);
|
|
422
|
+
if (gate === undefined)
|
|
423
|
+
throw new EpEnvelopeError("internal", `the issuance gate for ${intent.lifecycleUid} vanished mid-operation; a gate is never deleted (corruption, SPEC 13.12)`);
|
|
424
|
+
if (gate.row.state === "frozen") {
|
|
425
|
+
if (gate.row.op?.opId !== opId)
|
|
426
|
+
throw new EpEnvelopeError("failed-precondition", `the issuance gate for ${intent.lifecycleUid} is frozen by operation ${gate.row.op?.opId ?? "<none>"}, not ${opId}; one barrier at a time (SPEC 13.1)`);
|
|
427
|
+
break; // our freeze (fresh or resumed) — proceed into containment
|
|
428
|
+
}
|
|
429
|
+
if (gate.row.state === "retired") {
|
|
430
|
+
if (gate.row.op?.opId !== opId)
|
|
431
|
+
throw new EpEnvelopeError("permission-denied", `the issuance gate for ${intent.lifecycleUid} was terminalized by operation ${gate.row.op?.opId ?? "<none>"}, not ${opId} (SPEC 13.1)`);
|
|
432
|
+
// OUR gate terminal is durable, so steps ≤ 7 completed; only the head terminal may
|
|
433
|
+
// remain. Verify the frontier (it precedes the gate terminal in this op) and finish.
|
|
434
|
+
const fr = await recordsKv.get(frontierKey);
|
|
435
|
+
if (!fr || fr.operation !== "PUT")
|
|
436
|
+
throw new EpEnvelopeError("internal", `the gate for ${intent.lifecycleUid} is retired by ${opId} but its frontier record ${frontierKey} is ${!fr ? "absent" : `a ${fr.operation} marker`}; the frontier precedes the gate terminal (corruption, SPEC 13.1)`);
|
|
437
|
+
const frontier = parseFrontier(JSON.parse(dec.decode(fr.value)), frontierKey);
|
|
438
|
+
if (frontier.opId !== opId || frontier.lifecycleUid !== intent.lifecycleUid)
|
|
439
|
+
throw new EpEnvelopeError("internal", `the frontier record ${frontierKey} belongs to operation ${frontier.opId}, not ${opId} (corruption, SPEC 13.1)`);
|
|
440
|
+
const head = await readLifecycleHeadForOperation(reg, intent.owner, intent.actor);
|
|
441
|
+
if (head === undefined)
|
|
442
|
+
throw new EpEnvelopeError("internal", `the head for "${intent.owner}/${intent.actor}" is gone; a head is never deleted (corruption, SPEC 13.12)`);
|
|
443
|
+
if (head.mapping.lifecycleUid === intent.lifecycleUid)
|
|
444
|
+
await completeHeadRetirementWithinBarrier(reg, { owner: intent.owner, actor: intent.actor, lifecycleUid: intent.lifecycleUid, opId });
|
|
445
|
+
// else: a successor already replaced the retired head — only a retired predecessor is
|
|
446
|
+
// replaceable, so OUR terminal completed (SPEC 13.1).
|
|
447
|
+
return { opId, lifecycleUid: intent.lifecycleUid, revokedRows: 0, evictedPrincipals: [], drainedEndpoints: [], cleaned: {}, frontiers: frontier.streams };
|
|
448
|
+
}
|
|
449
|
+
// open
|
|
450
|
+
if (gate.row.generation !== intent.fromGeneration)
|
|
451
|
+
throw new EpEnvelopeError("conflict", `the issuance gate for ${intent.lifecycleUid} is open at generation ${gate.row.generation}, not this retirement's captured generation ${intent.fromGeneration}; a foreign operation moved it; the intent is stale, nothing was moved, retry with a fresh operation (SPEC 13.1)`);
|
|
452
|
+
// HEAD GUARD immediately before the freeze CAS (the takeover discipline): a stale intent
|
|
453
|
+
// must refuse WITHOUT moving the gate.
|
|
454
|
+
const headNow = await readLifecycleHeadForOperation(reg, intent.owner, intent.actor);
|
|
455
|
+
if (headNow === undefined || headNow.mapping.state !== "active" || headNow.mapping.lifecycleUid !== intent.lifecycleUid)
|
|
456
|
+
throw new EpEnvelopeError("conflict", `the head for "${intent.owner}/${intent.actor}" is ${headNow === undefined ? "gone" : `${headNow.mapping.state} (uid ${headNow.mapping.lifecycleUid})`}, not active at this retirement's uid; the intent is stale and the gate was not moved (SPEC 13.1)`);
|
|
457
|
+
try {
|
|
458
|
+
await freezeGate(reg, { lifecycleUid: intent.lifecycleUid, revision: gate.revision, op: { opId, kind: "retirement" } });
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
catch (e) {
|
|
462
|
+
if (e instanceof EpEnvelopeError && e.code === "conflict" && attempt < 4)
|
|
463
|
+
continue;
|
|
464
|
+
throw e;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
// 2. The head containment CAS `active → retiring`, bound to this op (idempotent on resume).
|
|
468
|
+
await beginHeadRetirementWithinBarrier(reg, { owner: intent.owner, actor: intent.actor, lifecycleUid: intent.lifecycleUid, opId });
|
|
469
|
+
// 3. The shared containment core: revoke every family row, reconcile session pairs, VERIFIED
|
|
470
|
+
// eviction of every holder + the alias principal.
|
|
471
|
+
const { revokedRows, evictedPrincipals } = await containLifecycleFamily(reg, { owner: intent.owner, actor: intent.actor, lifecycleUid: intent.lifecycleUid, barrier: "retirement" }, deps);
|
|
472
|
+
// 4. Drain the target's acceptance obligations to quiescence (§13.8/§13.1). The barrier owns
|
|
473
|
+
// BOTH the endpoint discovery and the final quiescence check; the injected per-endpoint drain
|
|
474
|
+
// is mechanics. Every writer that observed the pre-retiring mapping is settled HERE, before
|
|
475
|
+
// the cleaner runs and before any frontier closes.
|
|
476
|
+
// Discovery counts EVERY non-terminal row: a provisional to settle, an accepted SELF to drive
|
|
477
|
+
// terminal, AND an accepted EPF whose route reconciliation must be VERIFIED before frontiers
|
|
478
|
+
// close (an accepted-before-enqueue crash leaves an accepted EPF row with no provisional or
|
|
479
|
+
// self beside it; skipping its endpoint would close frontiers over never-enqueued accepted
|
|
480
|
+
// work the cleaner cannot repair). drainTargetForEndpoint drains its whole target prefix to
|
|
481
|
+
// quiescence (settling provisionals, driving accepted-self terminal, and running
|
|
482
|
+
// verifyAcceptedEpfRoute for every accepted-EPF row), so once every endpoint that carries a
|
|
483
|
+
// non-terminal row has had its drain RUN, the target is quiescent. `rejected`/`terminal` rows
|
|
484
|
+
// are already settled. New rows cannot appear (the head is `retiring`, so every obtain fails
|
|
485
|
+
// its currency read), which is why re-enumeration converges.
|
|
486
|
+
const drainedEndpoints = [];
|
|
487
|
+
const verifiedAcceptedEpf = new Set();
|
|
488
|
+
for (let pass = 1;; pass++) {
|
|
489
|
+
const rows = await enumerateObligationRows(registryRecordsScanner(reg), `oblig.${intent.lifecycleUid}.>`);
|
|
490
|
+
const nonTerminal = rows.filter((r) => r.row.state === "provisional" || r.row.state === "accepted");
|
|
491
|
+
const needingDrain = nonTerminal.filter((r) => r.row.state !== "accepted" || r.row.decision !== "epf" || !verifiedAcceptedEpf.has(`${r.key}@${r.revision}`));
|
|
492
|
+
if (needingDrain.length === 0)
|
|
493
|
+
break; // all accepted EPF rows seen here were route-verified at this exact revision
|
|
494
|
+
const endpoints = [...new Set(needingDrain.map((r) => r.key.split(".")[2]))].sort();
|
|
495
|
+
if (pass > 4)
|
|
496
|
+
throw new EpEnvelopeError("unavailable", `the obligation drain for ${intent.lifecycleUid} did not reach quiescence in ${pass - 1} passes (${needingDrain.length} row(s) still needing drain, first ${needingDrain[0]?.key}); investigate before retiring (SPEC 13.8)`);
|
|
497
|
+
for (const ep of endpoints) {
|
|
498
|
+
await deps.drainTargetObligations(ep, intent.lifecycleUid, opId);
|
|
499
|
+
if (!drainedEndpoints.includes(ep))
|
|
500
|
+
drainedEndpoints.push(ep);
|
|
501
|
+
for (const r of nonTerminal)
|
|
502
|
+
if (r.key.split(".")[2] === ep && r.row.state === "accepted" && r.row.decision === "epf")
|
|
503
|
+
verifiedAcceptedEpf.add(`${r.key}@${r.revision}`);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
// 4.5 THE DRAIN-REPAIR FENCE (§13.1, #4). The drain's confined repair executors — the commit
|
|
507
|
+
// APPLIER, the pool-route reconciler, and the effects canceller — mint short-lived per-op
|
|
508
|
+
// credentials INSIDE drainTargetObligations (drain-repair.ts) and close each after its one
|
|
509
|
+
// write. Each is a self-minted data-account bearer with NO ledger row (so there is no
|
|
510
|
+
// connect-time deny-new to revoke) and only a bounded JWT life. The GUARANTEE the fence adds is
|
|
511
|
+
// KILL-LIVE: a cluster-verified eviction confirms no LIVE connection under the principal survives
|
|
512
|
+
// when the frontier closes — an in-flight publish, or a close() whose socket error was swallowed,
|
|
513
|
+
// is KICKed and confirmed gone (the connections are minted `noReconnect`, so the KICK is durable
|
|
514
|
+
// in one round). The APPLIER is the priority: its records-KV LAST-VALUE write (last_by_subj on
|
|
515
|
+
// goal/cp) is returned to a normal reader REGARDLESS of the per-stream cutoff, so a LIVE
|
|
516
|
+
// post-frontier applier write would be an observable overwrite/DEL (the reconciler/canceller
|
|
517
|
+
// epw/epf appends only land past the interval and are excluded). Join all three into the SAME
|
|
518
|
+
// fence as the cleaner/executor BEFORE any frontier records, exactly where the drain that spawned
|
|
519
|
+
// them just returned. `retire` is a documented no-op (no ledger row + per-call self-closing
|
|
520
|
+
// connections = nothing to revoke). NOT covered, by design: a fresh malicious connect with a
|
|
521
|
+
// still-unexpired bearer AFTER this point-in-time scan — the seed-dominated named residual on
|
|
522
|
+
// {@link drainRepairPrincipals}. Op-derived and idempotent, so a crash-resume re-drains then
|
|
523
|
+
// re-runs this same fence.
|
|
524
|
+
await fenceOpCredentials(deps, drainRepairPrincipals(opId).map((r) => ({
|
|
525
|
+
what: r.what, principal: r.principal, retire: async () => { },
|
|
526
|
+
})));
|
|
527
|
+
// 4b. Build this operation's EFFECTIVE INVENTORY (#F) by DISCOVERY ALONE: every (endpoint, pool)
|
|
528
|
+
// the retiring lifecycle has ACCEPTED pool work on, read from the target's own obligation rows.
|
|
529
|
+
// The barrier takes NO caller-supplied pool hint. Discovery is the whole inventory because the only
|
|
530
|
+
// pools a retirement must clean are the ones the target itself accepted work on, and a hint that
|
|
531
|
+
// put a pool into the cleaner/executor grant without a backing obligation would be grant-widening
|
|
532
|
+
// authority with no production caller (the despawn rail never carried one). Without discovery the
|
|
533
|
+
// cleaner loop below would run zero times and the frontier would close over un-cleaned items: a
|
|
534
|
+
// bare live EPW is route-MATERIALIZED (the drain's `established`) but NOT retirement-terminal until
|
|
535
|
+
// the cleaner settles it (§13.1/13.9). Discover from the SAME stable, never-deleted `oblig.<uid>.>`
|
|
536
|
+
// set the drain just drove to quiescence: the head is `retiring`, so no new row can appear and the
|
|
537
|
+
// enumeration is deterministic across resumes. Every per-op cleaner/executor grant is thus scoped
|
|
538
|
+
// to EXACTLY the pools the target holds accepted work on, and settlementForIntent binds honest
|
|
539
|
+
// execution to that same discovered spec.pools + this intent's owner/actor/uid + the
|
|
540
|
+
// decision/horizon/retire-target checks. No granted pool can lack this lifecycle's accepted work.
|
|
541
|
+
const cleanerPools = new Map();
|
|
542
|
+
const addPool = (endpoint, pool) => {
|
|
543
|
+
const e = endpointToken(endpoint);
|
|
544
|
+
(cleanerPools.get(e) ?? cleanerPools.set(e, new Set()).get(e)).add(assertPoolToken(pool));
|
|
545
|
+
};
|
|
546
|
+
for (const r of await enumerateObligationRows(registryRecordsScanner(reg), `oblig.${intent.lifecycleUid}.>`)) {
|
|
547
|
+
if (r.row.state !== "accepted" || r.row.decision !== "epf" || r.row.route === undefined || !r.row.route.startsWith("pool."))
|
|
548
|
+
continue;
|
|
549
|
+
addPool(r.key.split(".")[2], r.row.route.slice("pool.".length));
|
|
550
|
+
}
|
|
551
|
+
const cleanerInventory = [...cleanerPools]
|
|
552
|
+
.map(([endpoint, pools]) => ({ endpoint, pools: [...pools].sort() }))
|
|
553
|
+
.sort((a, b) => a.endpoint.localeCompare(b.endpoint));
|
|
554
|
+
// 5. The exact-pool terminal cleaner, per (op × endpoint): the zero-write cleaner bind plus
|
|
555
|
+
// the settlement-executor bind (§13.9 split), BOTH revoked and verified-evicted BEFORE any
|
|
556
|
+
// frontier records (§13.1 fence). Runs over the DISCOVERED inventory, so every endpoint/pool the
|
|
557
|
+
// retiring lifecycle has accepted pool work on is settled before the frontier closes (#F).
|
|
558
|
+
const cleaned = {};
|
|
559
|
+
for (const spec of cleanerInventory) {
|
|
560
|
+
await cleanEndpointPools(intent, spec, space, opId, deps, cleaned);
|
|
561
|
+
}
|
|
562
|
+
// 6. Record the per-stream retirement frontiers (create-only; an existing record is THIS
|
|
563
|
+
// op's completed step — a foreign one refuses; a marker is corruption).
|
|
564
|
+
let frontiers;
|
|
565
|
+
const existingFrontier = await recordsKv.get(frontierKey);
|
|
566
|
+
if (existingFrontier !== undefined && existingFrontier !== null) {
|
|
567
|
+
if (existingFrontier.operation !== "PUT")
|
|
568
|
+
throw new EpEnvelopeError("failed-precondition", `the frontier record ${frontierKey} carries a ${existingFrontier.operation} marker; a frontier is never deleted (corruption, SPEC 13.12)`);
|
|
569
|
+
const parsed = parseFrontier(JSON.parse(dec.decode(existingFrontier.value)), frontierKey);
|
|
570
|
+
if (parsed.opId !== opId || parsed.lifecycleUid !== intent.lifecycleUid)
|
|
571
|
+
throw new EpEnvelopeError("permission-denied", `the frontier record ${frontierKey} belongs to operation ${parsed.opId}; a frontier records once, under its own retirement (SPEC 13.1)`);
|
|
572
|
+
frontiers = parsed.streams;
|
|
573
|
+
}
|
|
574
|
+
else {
|
|
575
|
+
frontiers = {};
|
|
576
|
+
for (const stream of intent.frontierStreams) {
|
|
577
|
+
let last;
|
|
578
|
+
try {
|
|
579
|
+
last = (await jsm.streams.info(stream)).state.last_seq;
|
|
580
|
+
}
|
|
581
|
+
catch (e) {
|
|
582
|
+
throw new EpEnvelopeError("failed-precondition", `the frontier step cannot read the stream ${stream} named by the intent; a missing cutoff never closes a lifecycle interval (SPEC 13.1): ${e?.message ?? String(e)}`);
|
|
583
|
+
}
|
|
584
|
+
frontiers[stream] = last;
|
|
585
|
+
}
|
|
586
|
+
const record = { lifecycleUid: intent.lifecycleUid, opId, streams: frontiers };
|
|
587
|
+
try {
|
|
588
|
+
await createRecordEntry(recordsKv, frontierKey, record);
|
|
589
|
+
}
|
|
590
|
+
catch (e) {
|
|
591
|
+
// A concurrent resume of the SAME op may have recorded first: observe and verify.
|
|
592
|
+
const after = await recordsKv.get(frontierKey);
|
|
593
|
+
if (!after || after.operation !== "PUT")
|
|
594
|
+
throw e;
|
|
595
|
+
const parsed = parseFrontier(JSON.parse(dec.decode(after.value)), frontierKey);
|
|
596
|
+
if (parsed.opId !== opId || parsed.lifecycleUid !== intent.lifecycleUid)
|
|
597
|
+
throw new EpEnvelopeError("permission-denied", `the frontier record ${frontierKey} was created by operation ${parsed.opId}; a frontier records once, under its own retirement (SPEC 13.1)`);
|
|
598
|
+
frontiers = parsed.streams;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
// 7. The gate terminal `frozen → retired` (op-pinned; a retirement freeze never reopens).
|
|
602
|
+
const gateNow = await observeGate(reg, intent.lifecycleUid);
|
|
603
|
+
if (gateNow === undefined)
|
|
604
|
+
throw new EpEnvelopeError("internal", `the issuance gate for ${intent.lifecycleUid} vanished before its terminal (corruption, SPEC 13.12)`);
|
|
605
|
+
await retireGate(reg, { lifecycleUid: intent.lifecycleUid, revision: gateNow.revision, opId });
|
|
606
|
+
// 8. The head terminal `retiring → retired` — the barrier's last step; only now is the alias
|
|
607
|
+
// replaceable, and `retired` asserts the completed cleanup.
|
|
608
|
+
await completeHeadRetirementWithinBarrier(reg, { owner: intent.owner, actor: intent.actor, lifecycleUid: intent.lifecycleUid, opId });
|
|
609
|
+
return { opId, lifecycleUid: intent.lifecycleUid, revokedRows, evictedPrincipals, drainedEndpoints, cleaned, frontiers };
|
|
610
|
+
}
|
|
611
|
+
/** Resume a crashed retirement from its durable intent alone (`{ opId }` — SPEC 13.1). Re-runs
|
|
612
|
+
* {@link runAgentRetirementBarrier} with the intent's own coordinates; a stranger's opId (no
|
|
613
|
+
* intent) is `not-found`. */
|
|
614
|
+
export async function resumeAgentRetirement(reg, opId, deps) {
|
|
615
|
+
const { space, authKv } = registryStores(reg);
|
|
616
|
+
const intent = await readRetirementIntent(authKv, assertLifecycleToken(opId), space);
|
|
617
|
+
if (intent === undefined)
|
|
618
|
+
throw new EpEnvelopeError("not-found", `no operation intent exists at ${stageIntentKey(opId)}; there is nothing to resume (SPEC 13.1)`);
|
|
619
|
+
return runAgentRetirementBarrier(reg, {
|
|
620
|
+
owner: intent.owner, actor: intent.actor, lifecycleUid: intent.lifecycleUid, opId,
|
|
621
|
+
frontierStreams: intent.frontierStreams,
|
|
622
|
+
}, deps);
|
|
623
|
+
}
|
|
624
|
+
//# sourceMappingURL=retirement-barrier.js.map
|