@cotal-ai/auth 0.12.0 → 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.
Files changed (95) hide show
  1. package/dist/admission-mediator.d.ts +319 -0
  2. package/dist/admission-mediator.d.ts.map +1 -0
  3. package/dist/admission-mediator.js +1033 -0
  4. package/dist/admission-mediator.js.map +1 -0
  5. package/dist/auth-admin.d.ts +29 -0
  6. package/dist/auth-admin.d.ts.map +1 -0
  7. package/dist/auth-admin.js +224 -0
  8. package/dist/auth-admin.js.map +1 -0
  9. package/dist/authority-client.d.ts +215 -0
  10. package/dist/authority-client.d.ts.map +1 -0
  11. package/dist/authority-client.js +346 -0
  12. package/dist/authority-client.js.map +1 -0
  13. package/dist/barrier-evict.d.ts +16 -0
  14. package/dist/barrier-evict.d.ts.map +1 -0
  15. package/dist/barrier-evict.js +100 -0
  16. package/dist/barrier-evict.js.map +1 -0
  17. package/dist/connect-reader.d.ts +72 -0
  18. package/dist/connect-reader.d.ts.map +1 -0
  19. package/dist/connect-reader.js +154 -0
  20. package/dist/connect-reader.js.map +1 -0
  21. package/dist/continuity.d.ts.map +1 -1
  22. package/dist/continuity.js +3 -0
  23. package/dist/continuity.js.map +1 -1
  24. package/dist/credential-ledger.d.ts +343 -0
  25. package/dist/credential-ledger.d.ts.map +1 -0
  26. package/dist/credential-ledger.js +732 -0
  27. package/dist/credential-ledger.js.map +1 -0
  28. package/dist/drain-repair.d.ts +95 -0
  29. package/dist/drain-repair.d.ts.map +1 -0
  30. package/dist/drain-repair.js +305 -0
  31. package/dist/drain-repair.js.map +1 -0
  32. package/dist/idp.d.ts +19 -0
  33. package/dist/idp.d.ts.map +1 -1
  34. package/dist/idp.js +17 -0
  35. package/dist/idp.js.map +1 -1
  36. package/dist/index.d.ts +5 -2
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +29 -2
  39. package/dist/index.js.map +1 -1
  40. package/dist/issuer.d.ts +8 -0
  41. package/dist/issuer.d.ts.map +1 -1
  42. package/dist/issuer.js +4 -2
  43. package/dist/issuer.js.map +1 -1
  44. package/dist/ledger-scanner.d.ts +98 -0
  45. package/dist/ledger-scanner.d.ts.map +1 -0
  46. package/dist/ledger-scanner.js +352 -0
  47. package/dist/ledger-scanner.js.map +1 -0
  48. package/dist/ledger.d.ts +7 -0
  49. package/dist/ledger.d.ts.map +1 -1
  50. package/dist/ledger.js +53 -5
  51. package/dist/ledger.js.map +1 -1
  52. package/dist/lifecycle-registry.d.ts +397 -0
  53. package/dist/lifecycle-registry.d.ts.map +1 -0
  54. package/dist/lifecycle-registry.js +819 -0
  55. package/dist/lifecycle-registry.js.map +1 -0
  56. package/dist/permissions.d.ts +5 -1
  57. package/dist/permissions.d.ts.map +1 -1
  58. package/dist/permissions.js +29 -6
  59. package/dist/permissions.js.map +1 -1
  60. package/dist/plane-claim.d.ts +83 -0
  61. package/dist/plane-claim.d.ts.map +1 -0
  62. package/dist/plane-claim.js +290 -0
  63. package/dist/plane-claim.js.map +1 -0
  64. package/dist/provider.d.ts.map +1 -1
  65. package/dist/provider.js +2 -1
  66. package/dist/provider.js.map +1 -1
  67. package/dist/records-scanner.d.ts +78 -0
  68. package/dist/records-scanner.d.ts.map +1 -0
  69. package/dist/records-scanner.js +320 -0
  70. package/dist/records-scanner.js.map +1 -0
  71. package/dist/retirement-barrier.d.ts +217 -0
  72. package/dist/retirement-barrier.d.ts.map +1 -0
  73. package/dist/retirement-barrier.js +624 -0
  74. package/dist/retirement-barrier.js.map +1 -0
  75. package/dist/retirement-cleaner.d.ts +54 -0
  76. package/dist/retirement-cleaner.d.ts.map +1 -0
  77. package/dist/retirement-cleaner.js +158 -0
  78. package/dist/retirement-cleaner.js.map +1 -0
  79. package/dist/root-credential.d.ts +26 -0
  80. package/dist/root-credential.d.ts.map +1 -0
  81. package/dist/root-credential.js +116 -0
  82. package/dist/root-credential.js.map +1 -0
  83. package/dist/service.d.ts +60 -0
  84. package/dist/service.d.ts.map +1 -1
  85. package/dist/service.js +317 -10
  86. package/dist/service.js.map +1 -1
  87. package/dist/session-ledger.d.ts +224 -0
  88. package/dist/session-ledger.d.ts.map +1 -0
  89. package/dist/session-ledger.js +713 -0
  90. package/dist/session-ledger.js.map +1 -0
  91. package/dist/token.d.ts +17 -0
  92. package/dist/token.d.ts.map +1 -1
  93. package/dist/token.js +30 -2
  94. package/dist/token.js.map +1 -1
  95. package/package.json +5 -3
@@ -0,0 +1,732 @@
1
+ import { EpEnvelopeError, assertLifecycleToken, endpointToken, parsePrincipalKey, isPrincipalOwnerToken, isCasLoss as isRawCasLoss, } from "@cotal-ai/core";
2
+ import { registryStores, registryScanner, readLifecycleHeadForOperation, readUidReservation, observeGate, freezeGate, reopenGate, advanceEpochWithinTakeover, } from "./lifecycle-registry.js";
3
+ const enc = new TextEncoder();
4
+ const dec = new TextDecoder();
5
+ const isRec = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
6
+ const uint = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
7
+ // ---- key grammar ------------------------------------------------------------------------------
8
+ /** One KV key segment: no dots (a segment separator), no wildcards, KV-safe. */
9
+ const KEY_SEGMENT = /^[A-Za-z0-9_-]+$/;
10
+ function assertKeySegment(v, what) {
11
+ if (typeof v !== "string" || !KEY_SEGMENT.test(v))
12
+ throw new EpEnvelopeError("failed-precondition", `${what} ${JSON.stringify(v)} is not a KV-safe key segment (SPEC 13.1)`);
13
+ return v;
14
+ }
15
+ /** A credential id: one or more KV-safe segments (dots allowed BETWEEN segments — the session
16
+ * families use `<sessionId>.c` / `<sessionId>.s` — but never wildcards or empty segments). */
17
+ function assertCredentialIdTail(v, what) {
18
+ if (typeof v !== "string" || v.length === 0 || v.length > 256 || !v.split(".").every((s) => KEY_SEGMENT.test(s)))
19
+ throw new EpEnvelopeError("failed-precondition", `${what} ${JSON.stringify(v)} is not a bounded dotted credential id (SPEC 13.1)`);
20
+ return v;
21
+ }
22
+ /** A holder principal `<owner>.<actor>` with a REAL owner (derived `u_…` or the dev owner) —
23
+ * eviction is BY PRINCIPAL, so a row that cannot name an evictable principal never ledgers. */
24
+ function assertHolderPrincipal(v, what) {
25
+ const p = typeof v === "string" ? parsePrincipalKey(v) : null;
26
+ if (!p || !isPrincipalOwnerToken(p.owner))
27
+ throw new EpEnvelopeError("failed-precondition", `${what} ${JSON.stringify(v)} is not a principal dot-form the barrier can evict (SPEC 13.1)`);
28
+ return v;
29
+ }
30
+ export const SOURCE_ROOT = "root";
31
+ /** Validate ONE sourceChain member — `root`, `handle.<issuerKeyId>.<id>`, or
32
+ * `session.<sessionId>` (SPEC 13.1) — and return its parsed shape. */
33
+ export function parseSourceMember(member) {
34
+ if (member === SOURCE_ROOT)
35
+ return { kind: "root" };
36
+ if (typeof member === "string" && member.startsWith("handle.")) {
37
+ const rest = member.slice("handle.".length).split(".");
38
+ if (rest.length === 2 && KEY_SEGMENT.test(rest[0]) && KEY_SEGMENT.test(rest[1]))
39
+ return { kind: "handle", issuerKeyId: rest[0], id: rest[1] };
40
+ }
41
+ if (typeof member === "string" && member.startsWith("session.")) {
42
+ const sid = member.slice("session.".length);
43
+ if (KEY_SEGMENT.test(sid))
44
+ return { kind: "session", sessionId: sid };
45
+ }
46
+ throw new EpEnvelopeError("failed-precondition", `sourceChain member ${JSON.stringify(member)} is not root | handle.<issuerKeyId>.<id> | session.<sessionId> (SPEC 13.1)`);
47
+ }
48
+ function assertSourceChain(v, what) {
49
+ if (!Array.isArray(v) || v.length === 0)
50
+ throw new EpEnvelopeError("failed-precondition", `${what} must be a non-empty sourceChain (SPEC 13.1: the FULL verified lineage, never absent)`);
51
+ for (const m of v)
52
+ parseSourceMember(m);
53
+ return v;
54
+ }
55
+ /** The agent-family ledger key `cred.<lifecycleUid>.<credentialId>`. */
56
+ export function credRowKey(lifecycleUid, credentialId) {
57
+ return `cred.${assertLifecycleToken(lifecycleUid)}.${assertCredentialIdTail(credentialId, "credentialId")}`;
58
+ }
59
+ /** The endpoint-family ledger key `epcred.<endpoint>.<instanceId>.<credentialId>` (disjoint by
60
+ * explicit prefix, never arity, SPEC 13.1). */
61
+ export function epcredRowKey(endpoint, instanceId, credentialId) {
62
+ return `epcred.${endpointToken(endpoint)}.${assertLifecycleToken(instanceId, "instanceId")}.${assertCredentialIdTail(credentialId, "credentialId")}`;
63
+ }
64
+ /** The per-ancestor lineage index key `bysrc.<issuerKeyId>.<id>.<lifecycleUid>.<credentialId>`. */
65
+ export function bysrcKey(issuerKeyId, id, lifecycleUid, credentialId) {
66
+ return `bysrc.${assertKeySegment(issuerKeyId, "issuerKeyId")}.${assertKeySegment(id, "handle id")}.${assertLifecycleToken(lifecycleUid)}.${assertCredentialIdTail(credentialId, "credentialId")}`;
67
+ }
68
+ /** The per-handle source gate key `srcgate.<issuerKeyId>.<id>`. */
69
+ export function srcgateKey(issuerKeyId, id) {
70
+ return `srcgate.${assertKeySegment(issuerKeyId, "issuerKeyId")}.${assertKeySegment(id, "handle id")}`;
71
+ }
72
+ /** A takeover/registration operation's durable intent key `stage.<opId>` (the `stage.<opId>.`
73
+ * staging family root — NEVER under a ledger prefix a barrier enumerates, SPEC 13.1). */
74
+ export function stageIntentKey(opId) {
75
+ return `stage.${assertLifecycleToken(opId)}`;
76
+ }
77
+ /** Parse + validate a ledger row at its consuming boundary — closed schema, and the embedded
78
+ * identity MUST rebuild the row's own key, so a key-mismatched or family-swapped poison row
79
+ * never authorizes (SPEC 13.1/13.3). */
80
+ export function parseLedgerRow(raw, key) {
81
+ let o;
82
+ try {
83
+ o = JSON.parse(dec.decode(raw));
84
+ }
85
+ catch {
86
+ throw new EpEnvelopeError("internal", `the credential-ledger row ${key} is not JSON; garbled trusted-path state never authorizes (SPEC 13.1)`);
87
+ }
88
+ if (!isRec(o))
89
+ throw new EpEnvelopeError("internal", `the credential-ledger row ${key} is not an object`);
90
+ const allowed = new Set(["credentialId", "holderPrincipal", "lifecycleUid", "endpoint", "sourceChain", "state", "exp"]);
91
+ for (const k of Object.keys(o))
92
+ if (!allowed.has(k))
93
+ throw new EpEnvelopeError("internal", `the credential-ledger row ${key} carries the unknown field "${k}" (closed schema, SPEC 13.1)`);
94
+ if (typeof o.credentialId !== "string" || typeof o.holderPrincipal !== "string" || typeof o.lifecycleUid !== "string" ||
95
+ (o.state !== "active" && o.state !== "revoked") || !uint(o.exp))
96
+ throw new EpEnvelopeError("internal", `the credential-ledger row ${key} does not validate (id/holder/uid/state/exp); a garbled row never authorizes (SPEC 13.1)`);
97
+ try {
98
+ assertSourceChain(o.sourceChain, `row ${key} sourceChain`);
99
+ assertCredentialIdTail(o.credentialId, `row ${key} credentialId`);
100
+ // holderPrincipal is ALWAYS a CONNZ-attributable principal, in BOTH families (the barrier
101
+ // KICKs it; the endpoint name is NOT attributable and never sits here).
102
+ assertHolderPrincipal(o.holderPrincipal, `row ${key} holderPrincipal`);
103
+ }
104
+ catch (e) {
105
+ throw new EpEnvelopeError("internal", `the credential-ledger row ${key} carries a malformed lineage/id/holder: ${e.message}`);
106
+ }
107
+ // KEY BINDING, per family: the row's own identity must rebuild its key exactly. The endpoint
108
+ // family keys on its own `endpoint` field (NOT holderPrincipal), so the key identity and the
109
+ // eviction target stay disjoint.
110
+ let expected;
111
+ if (key.startsWith("cred.")) {
112
+ if (o.endpoint !== undefined)
113
+ throw new EpEnvelopeError("internal", `the agent-family row ${key} carries an endpoint field (that belongs to the epcred family, SPEC 13.1)`);
114
+ expected = credRowKey(o.lifecycleUid, o.credentialId);
115
+ }
116
+ else if (key.startsWith("epcred.")) {
117
+ if (typeof o.endpoint !== "string" || o.endpoint.length === 0)
118
+ throw new EpEnvelopeError("internal", `the endpoint-family row ${key} is missing its endpoint field (it forms the key, SPEC 13.1)`);
119
+ try {
120
+ expected = epcredRowKey(o.endpoint, o.lifecycleUid, o.credentialId);
121
+ }
122
+ catch (e) {
123
+ throw new EpEnvelopeError("internal", `the credential-ledger row ${key} does not validate for the endpoint family: ${e.message}`);
124
+ }
125
+ }
126
+ else {
127
+ throw new EpEnvelopeError("internal", `the credential-ledger row key ${key} is under neither ledger family prefix (SPEC 13.1)`);
128
+ }
129
+ if (expected !== key)
130
+ throw new EpEnvelopeError("internal", `the credential-ledger row at ${key} embeds an identity that rebuilds ${expected}; a key-mismatched row never authorizes (SPEC 13.1)`);
131
+ return o;
132
+ }
133
+ /** Create-only write of a ledger/index row with the BYTE-IDENTICAL retry (a crashed writer's
134
+ * retry of its OWN deterministic row proceeds; foreign content under a staged name refuses —
135
+ * a name never silently re-binds, SPEC 13.6 discipline). PACKAGE-INTERNAL: the session
136
+ * adapter writes its `epcred.` rows through this. */
137
+ export async function createRowByteIdempotent(kv, key, value) {
138
+ const bytes = JSON.stringify(value);
139
+ try {
140
+ await kv.create(key, enc.encode(bytes));
141
+ }
142
+ catch (e) {
143
+ if (!isRawCasLoss(e))
144
+ throw new EpEnvelopeError("unavailable", `creating the row ${key} is ambiguous; the mint fails closed (SPEC 13.1): ${e?.message ?? String(e)}`);
145
+ const existing = await kv.get(key);
146
+ if (!existing || existing.operation !== "PUT" || dec.decode(existing.value) !== bytes)
147
+ throw new EpEnvelopeError("conflict", `the row ${key} exists with FOREIGN content; a staged name never silently re-binds (SPEC 13.1)`);
148
+ }
149
+ }
150
+ /** Idempotent monotonic revocation mark on a ledger row (revision-pinned CAS; a lost pin
151
+ * re-reads — terminal-now returns, still-active retries). An ABSENT key refuses: the ledger
152
+ * is never-deleted, so "revoke a row that does not exist" is a caller bug or corruption,
153
+ * never a silent success. PACKAGE-INTERNAL (the session adapter routes through this too). */
154
+ export async function markLedgerRowRevoked(kv, key) {
155
+ for (let attempt = 0; attempt < 4; attempt++) {
156
+ const entry = await kv.get(key);
157
+ if (!entry)
158
+ throw new EpEnvelopeError("failed-precondition", `no credential-ledger row exists at ${key}; a revocation mark needs its row (SPEC 13.1)`);
159
+ if (entry.operation !== "PUT")
160
+ throw new EpEnvelopeError("failed-precondition", `the credential-ledger row ${key} carries a ${entry.operation} marker; ledger rows are never deleted (corruption, not absence, SPEC 13.12)`);
161
+ const row = parseLedgerRow(entry.value, key);
162
+ if (row.state === "revoked")
163
+ return "already-revoked";
164
+ try {
165
+ await kv.update(key, enc.encode(JSON.stringify({ ...row, state: "revoked" })), entry.revision);
166
+ return "revoked";
167
+ }
168
+ catch (e) {
169
+ if (isRawCasLoss(e))
170
+ continue;
171
+ throw new EpEnvelopeError("unavailable", `revoking the row ${key} is ambiguous; the barrier retries (SPEC 13.1): ${e?.message ?? String(e)}`);
172
+ }
173
+ }
174
+ throw new EpEnvelopeError("unavailable", `revoking the row ${key} kept losing its pin; retry the barrier (SPEC 13.1)`);
175
+ }
176
+ function parseSourceGate(raw, key, issuerKeyId, id) {
177
+ let o;
178
+ try {
179
+ o = JSON.parse(dec.decode(raw));
180
+ }
181
+ catch {
182
+ throw new EpEnvelopeError("internal", `the source gate ${key} is not JSON (SPEC 13.1)`);
183
+ }
184
+ if (!isRec(o))
185
+ throw new EpEnvelopeError("internal", `the source gate ${key} is not an object`);
186
+ for (const k of Object.keys(o))
187
+ if (k !== "issuerKeyId" && k !== "id" && k !== "state")
188
+ throw new EpEnvelopeError("internal", `the source gate ${key} carries the unknown field "${k}" (closed schema, SPEC 13.1)`);
189
+ if (o.issuerKeyId !== issuerKeyId || o.id !== id || (o.state !== "open" && o.state !== "frozen"))
190
+ throw new EpEnvelopeError("internal", `the source gate ${key} does not validate (key binding/state); a garbled gate never authorizes (SPEC 13.1)`);
191
+ return o;
192
+ }
193
+ /** Observe a source gate (the candidate read feeding a pinned CAS/touch). A DEL/PURGE marker
194
+ * refuses loudly — a gate is never deleted. */
195
+ export async function observeSourceGate(reg, args) {
196
+ const { authKv } = registryStores(reg);
197
+ const key = srcgateKey(args.issuerKeyId, args.id);
198
+ const entry = await authKv.get(key);
199
+ if (!entry)
200
+ return undefined;
201
+ if (entry.operation !== "PUT")
202
+ throw new EpEnvelopeError("failed-precondition", `the source gate ${key} carries a ${entry.operation} marker; a gate is never deleted (corruption, not absence, SPEC 13.12)`);
203
+ return { row: parseSourceGate(entry.value, key, args.issuerKeyId, args.id), revision: entry.revision };
204
+ }
205
+ /** Create a handle's source gate OPEN (at the handle's own provisioning). Create-only:
206
+ * idempotent for the identical row; anything else (including a deletion marker) conflicts. */
207
+ export async function createSourceGateOpen(reg, args) {
208
+ const { authKv } = registryStores(reg);
209
+ const row = { issuerKeyId: assertKeySegment(args.issuerKeyId, "issuerKeyId"), id: assertKeySegment(args.id, "handle id"), state: "open" };
210
+ await createRowByteIdempotent(authKv, srcgateKey(args.issuerKeyId, args.id), row);
211
+ }
212
+ /** CAS a source gate `open → frozen` at the observed revision — the handle-revocation fence
213
+ * (SPEC 13.1: freeze BEFORE enumerating `bysrc.`; an in-flight redemption either finished
214
+ * before the freeze, so its rows are in the enumeration, or loses its pinned touch). */
215
+ export async function freezeSourceGate(reg, args) {
216
+ const { authKv } = registryStores(reg);
217
+ const key = srcgateKey(args.issuerKeyId, args.id);
218
+ const row = { issuerKeyId: args.issuerKeyId, id: args.id, state: "frozen" };
219
+ try {
220
+ await authKv.update(key, enc.encode(JSON.stringify(row)), args.revision);
221
+ }
222
+ catch (e) {
223
+ if (isRawCasLoss(e))
224
+ throw new EpEnvelopeError("conflict", `the source-gate freeze for ${key} lost (expected revision ${args.revision}); re-observe and re-decide (SPEC 13.1)`);
225
+ throw e;
226
+ }
227
+ }
228
+ function parseBysrcRow(raw, key) {
229
+ let o;
230
+ try {
231
+ o = JSON.parse(dec.decode(raw));
232
+ }
233
+ catch {
234
+ throw new EpEnvelopeError("internal", `the lineage index row ${key} is not JSON (SPEC 13.1)`);
235
+ }
236
+ if (!isRec(o) || Object.keys(o).some((k) => k !== "ref") || typeof o.ref !== "string" || !(o.ref.startsWith("cred.") || o.ref.startsWith("epcred.")))
237
+ throw new EpEnvelopeError("internal", `the lineage index row ${key} does not validate (a bare { ref: <ledger row key> }, SPEC 13.1)`);
238
+ // KEY BINDING: the index key's own `<lifecycleUid>.<credentialId>` tail must be the tail of
239
+ // the row it names, so a poisoned index can never walk revocation onto a DIFFERENT credential.
240
+ const tail = key.split(".").slice(3).join(".");
241
+ if (!(o.ref === `cred.${tail}` || (o.ref.startsWith("epcred.") && o.ref.endsWith(`.${tail}`))))
242
+ throw new EpEnvelopeError("internal", `the lineage index row ${key} names ${o.ref}, which does not carry the index's own identity tail; a mismatched index never authorizes (SPEC 13.1)`);
243
+ return o;
244
+ }
245
+ const STAGED_MINTS = new WeakMap();
246
+ /**
247
+ * Stage an AGENT-family mint (SPEC 13.1 mint protocol, steps observe + write-rows): observe
248
+ * the lifecycle issuance gate (must exist and be `open` — a frozen gate is a barrier in
249
+ * flight, a retired one is terminal) and EVERY presented handle's source gate (must exist and
250
+ * be `open`), recording each revision; then create the ledger row (`state: "active"` — the
251
+ * row is authority only after finalize releases, and the barrier enumerates it either way)
252
+ * and one `bysrc.` index row per handle chain member. Returns the staged coordinates for
253
+ * {@link finalizeAgentMint}. Nothing is released here.
254
+ */
255
+ export async function stageAgentMint(reg, args) {
256
+ const { authKv } = registryStores(reg);
257
+ assertHolderPrincipal(args.holderPrincipal, "holderPrincipal");
258
+ if (!uint(args.exp))
259
+ throw new EpEnvelopeError("failed-precondition", `exp ${JSON.stringify(args.exp)} is not a non-negative safe integer (SPEC 13.1)`);
260
+ const chain = assertSourceChain(args.sourceChain, "sourceChain");
261
+ const rowKey = credRowKey(args.lifecycleUid, args.credentialId);
262
+ // 0. BIND holderPrincipal to the UID's reserved identity (SPEC 13.1: the barrier evicts this
263
+ // principal, so a trusted caller cannot ledger a row that names a FOREIGN principal to KICK).
264
+ const reservation = await readUidReservation(reg, args.lifecycleUid);
265
+ if (reservation === undefined)
266
+ throw new EpEnvelopeError("permission-denied", `lifecycle ${args.lifecycleUid} has no uid reservation; a mint binds its holderPrincipal to the reserved identity (SPEC 13.1)`);
267
+ const boundPrincipal = `${reservation.owner}.${reservation.actor}`;
268
+ if (args.holderPrincipal !== boundPrincipal)
269
+ throw new EpEnvelopeError("permission-denied", `the mint's holderPrincipal "${args.holderPrincipal}" is not the reserved identity "${boundPrincipal}" for uid ${args.lifecycleUid}; the eviction target is derived from the reservation, never free-standing (SPEC 13.1)`);
270
+ // 1. Observe the lifecycle gate: only an OPEN gate mints.
271
+ const gate = await observeGate(reg, args.lifecycleUid);
272
+ if (gate === undefined)
273
+ throw new EpEnvelopeError("permission-denied", `lifecycle ${args.lifecycleUid} has no issuance gate; a never-activated or foreign lifecycle mints nothing (SPEC 13.1)`);
274
+ if (gate.row.state !== "open")
275
+ throw new EpEnvelopeError("permission-denied", `the issuance gate for ${args.lifecycleUid} is "${gate.row.state}"; only an open gate mints (a frozen gate is a barrier in flight, a retired one is terminal, SPEC 13.1)`);
276
+ // 2. Observe EVERY presented handle's source gate (SPEC 13.1: the same fence per issuing handle).
277
+ const pins = [{ key: `gate.${args.lifecycleUid}`, revision: gate.revision }];
278
+ const bysrcKeys = [];
279
+ for (const member of chain) {
280
+ const parsed = parseSourceMember(member);
281
+ if (parsed.kind !== "handle")
282
+ continue;
283
+ const src = await observeSourceGate(reg, parsed);
284
+ if (src === undefined)
285
+ throw new EpEnvelopeError("permission-denied", `the presented handle ${parsed.issuerKeyId}.${parsed.id} has no source gate; an unprovisioned or revoked-and-collapsed handle mints nothing (SPEC 13.1)`);
286
+ if (src.row.state !== "open")
287
+ throw new EpEnvelopeError("permission-denied", `the source gate for handle ${parsed.issuerKeyId}.${parsed.id} is frozen; a revoked handle mints nothing (SPEC 13.1)`);
288
+ pins.push({ key: srcgateKey(parsed.issuerKeyId, parsed.id), revision: src.revision });
289
+ bysrcKeys.push(bysrcKey(parsed.issuerKeyId, parsed.id, args.lifecycleUid, args.credentialId));
290
+ }
291
+ // 3. Write the ledger row + its lineage index rows (create-only, byte-identical retry).
292
+ const row = {
293
+ credentialId: args.credentialId, holderPrincipal: args.holderPrincipal, lifecycleUid: args.lifecycleUid,
294
+ sourceChain: chain, state: "active", exp: args.exp,
295
+ };
296
+ await createRowByteIdempotent(authKv, rowKey, row);
297
+ for (const k of bysrcKeys)
298
+ await createRowByteIdempotent(authKv, k, { ref: rowKey });
299
+ // The AUTHORITATIVE snapshot is module-private (finalize reads only this, never the object's
300
+ // own fields); the public object is deep-frozen as well, so a strict-mode mutation throws.
301
+ const snapshot = { pins: pins.map((p) => ({ ...p })), rowKey };
302
+ const staged = Object.freeze({
303
+ lifecycleUid: args.lifecycleUid, credentialId: args.credentialId,
304
+ pins: Object.freeze(pins.map((p) => Object.freeze({ ...p }))), rowKey,
305
+ });
306
+ STAGED_MINTS.set(staged, snapshot);
307
+ return staged;
308
+ }
309
+ /**
310
+ * Finalize an AGENT-family mint (SPEC 13.1 mint protocol, step gate-CAS): touch-CAS every
311
+ * observed gate — the lifecycle gate and each presented source gate — at its pinned revision,
312
+ * each DISTINCT key once, in CANONICAL (sorted) order (the same crossed-pair discipline as the
313
+ * session stage). The touch preserves the value and bumps only the revision: a barrier or a
314
+ * handle revocation that moved ANY of them since observation makes the pinned write LOSE, the
315
+ * mint marks its OWN row revoked, and throws `permission-denied` — it never releases. Only a
316
+ * finalize that returns cleanly permits the caller to release the credential bytes.
317
+ */
318
+ export async function finalizeAgentMint(reg, staged) {
319
+ const { authKv } = registryStores(reg);
320
+ // BRAND CHECK + AUTHORITATIVE SNAPSHOT: only stageAgentMint mints a StagedAgentMint, so a
321
+ // hand-assembled object never reaches the touch-CAS — and the pins/rowKey used below come
322
+ // from the module-private snapshot, never from fields the caller's object hands back (a
323
+ // mutated pin object could otherwise duplicate the source-gate pin and the dedup would skip
324
+ // the lifecycle gate entirely).
325
+ const snap = STAGED_MINTS.get(staged);
326
+ if (snap === undefined)
327
+ throw new EpEnvelopeError("failed-precondition", "the staged mint was not produced by stageAgentMint(); a hand-assembled StagedAgentMint never authorizes a finalize (SPEC 13.12)");
328
+ if (snap.pins.length === 0)
329
+ throw new EpEnvelopeError("internal", `the staged mint for ${snap.rowKey} carries no gate pins; a finalize proves at least the lifecycle gate (SPEC 13.1)`);
330
+ const byKey = new Map();
331
+ for (const pin of snap.pins) {
332
+ const seen = byKey.get(pin.key);
333
+ if (seen !== undefined && seen !== pin.revision)
334
+ throw new EpEnvelopeError("internal", `the gate ${pin.key} was observed at two revisions (${seen} vs ${pin.revision}); one observation feeds the pinned touch (SPEC 13.1)`);
335
+ byKey.set(pin.key, pin.revision);
336
+ }
337
+ const lose = async (key, detail) => {
338
+ await markLedgerRowRevoked(authKv, snap.rowKey);
339
+ throw new EpEnvelopeError("permission-denied", `the mint for ${snap.rowKey} lost its fence on ${key} (${detail}); its row is revoked and nothing releases (SPEC 13.1)`);
340
+ };
341
+ for (const key of [...byKey.keys()].sort()) {
342
+ const revision = byKey.get(key);
343
+ const entry = await authKv.get(key);
344
+ if (!entry || entry.operation !== "PUT" || entry.revision !== revision)
345
+ return lose(key, `revision ${entry?.revision ?? "gone"} vs pinned ${revision}`);
346
+ try {
347
+ await authKv.update(key, entry.value, revision);
348
+ }
349
+ catch (e) {
350
+ if (isRawCasLoss(e))
351
+ return lose(key, "the pinned touch lost the CAS");
352
+ throw new EpEnvelopeError("unavailable", `the gate touch for ${key} is ambiguous; the mint fails closed (SPEC 13.1): ${e?.message ?? String(e)}`);
353
+ }
354
+ }
355
+ }
356
+ /**
357
+ * Parse a sealed-scanner family read into closed {@link EnumeratedRow}s. FAIL-LOUD is the
358
+ * contract: a DEL/PURGE marker under a ledger prefix is corruption (rows are revoked, never
359
+ * deleted), and a row that does not parse aborts the enumeration — a barrier that skipped either
360
+ * would report a family it did not actually cover. The {@link RawScanEntry} seam stays here (the
361
+ * ledger's own parse layer): each entry becomes a closed parsed row BEFORE anything reaches a
362
+ * barrier dependency.
363
+ */
364
+ function parseLedgerEntries(entries) {
365
+ return entries.map((e) => {
366
+ if (e.op === "DEL" || e.op === "PURGE")
367
+ throw new EpEnvelopeError("failed-precondition", `the ledger key ${e.key} carries a ${e.op} marker; ledger rows are revoked, never deleted; the enumeration refuses (corruption, SPEC 13.12)`);
368
+ return { key: e.key, row: parseLedgerRow(e.data, e.key), revision: e.seq };
369
+ });
370
+ }
371
+ /**
372
+ * Enumerate a lifecycle's FULL descendant family `cred.<lifecycleUid>.>` (SPEC 13.1/13.9) through
373
+ * the SEALED auth-ledger scanner ({@link registryScanner}). The scan is a fence-free LastPerSubject
374
+ * read: under the normative history=1 store a same-subject `active→revoked` overwrite EVICTS the
375
+ * pre-scan revision, so the scanner delivers each subject's CURRENT last (a concurrent revoke is
376
+ * SEEN, never dropped). The scanner holds the ONLY auth-stream `CONSUMER.CREATE` on its own sealed
377
+ * credential; the barrier profile holds none (a consumer-create body is not subject-ACL confinable,
378
+ * nats-server#8274 — ledger-scanner.ts).
379
+ */
380
+ export async function enumerateAgentFamily(reg, lifecycleUid) {
381
+ return parseLedgerEntries(await registryScanner(reg).scanCredentialFamily(assertLifecycleToken(lifecycleUid)));
382
+ }
383
+ /**
384
+ * Enumerate every durable OPERATION intent `stage.<opId>` (SPEC 13.1) — the boot-resume
385
+ * discovery: a barrier executor that crashed mid-operation finds what it may owe here (whether
386
+ * an intent is actually OWED is the gate's call: only a gate still frozen by that opId is —
387
+ * completed and lost operations leave their intent behind by design). Multi-segment `stage.`
388
+ * keys (the session release pins) are not operations and are skipped; a single-segment intent
389
+ * that carries a DEL/PURGE marker, fails to parse, or names an unknown kind THROWS — an intent
390
+ * is never deleted while resumable, and garbled trusted-path state never drives a barrier.
391
+ */
392
+ export async function enumerateOperationIntents(reg) {
393
+ const entries = await registryScanner(reg).scanStageFamily();
394
+ const out = [];
395
+ for (const e of entries) {
396
+ const opId = e.key.slice("stage.".length);
397
+ if (opId.includes("."))
398
+ continue; // session release pins etc. — not operation intents
399
+ if (e.op === "DEL" || e.op === "PURGE")
400
+ throw new EpEnvelopeError("failed-precondition", `the operation intent ${e.key} carries a ${e.op} marker; an intent is never deleted while resumable (corruption, SPEC 13.12)`);
401
+ let o;
402
+ try {
403
+ o = JSON.parse(dec.decode(e.data));
404
+ }
405
+ catch {
406
+ throw new EpEnvelopeError("internal", `the operation intent ${e.key} is not JSON (SPEC 13.1)`);
407
+ }
408
+ if (!isRec(o) || (o.kind !== "takeover" && o.kind !== "retirement"))
409
+ throw new EpEnvelopeError("internal", `the operation intent ${e.key} carries an unknown kind ${isRec(o) ? JSON.stringify(o.kind ?? null) : "(not an object)"} (closed set, SPEC 13.1)`);
410
+ if (typeof o.lifecycleUid !== "string")
411
+ throw new EpEnvelopeError("internal", `the operation intent ${e.key} carries no lifecycleUid (SPEC 13.1)`);
412
+ let uid;
413
+ try {
414
+ uid = assertLifecycleToken(o.lifecycleUid);
415
+ }
416
+ catch {
417
+ throw new EpEnvelopeError("internal", `the operation intent ${e.key} carries a malformed lifecycleUid (SPEC 13.1)`);
418
+ }
419
+ out.push({ opId, kind: o.kind, lifecycleUid: uid });
420
+ }
421
+ return out;
422
+ }
423
+ function parseTakeoverIntent(raw, key) {
424
+ let o;
425
+ try {
426
+ o = JSON.parse(dec.decode(raw));
427
+ }
428
+ catch {
429
+ throw new EpEnvelopeError("internal", `the operation intent ${key} is not JSON (SPEC 13.1)`);
430
+ }
431
+ if (!isRec(o))
432
+ throw new EpEnvelopeError("internal", `the operation intent ${key} is not an object`);
433
+ const allowed = new Set(["kind", "lifecycleUid", "owner", "actor", "fromEpoch", "fromGeneration"]);
434
+ for (const k of Object.keys(o))
435
+ if (!allowed.has(k))
436
+ throw new EpEnvelopeError("internal", `the operation intent ${key} carries the unknown field "${k}" (closed schema, SPEC 13.1)`);
437
+ if (o.kind !== "takeover" || typeof o.lifecycleUid !== "string" || typeof o.owner !== "string" || o.owner.length === 0 ||
438
+ typeof o.actor !== "string" || o.actor.length === 0 || !uint(o.fromEpoch) || o.fromEpoch < 1 || !uint(o.fromGeneration) || o.fromGeneration < 1)
439
+ throw new EpEnvelopeError("internal", `the operation intent ${key} does not validate as a takeover intent (SPEC 13.1)`);
440
+ try {
441
+ assertLifecycleToken(o.lifecycleUid);
442
+ }
443
+ catch {
444
+ throw new EpEnvelopeError("internal", `the operation intent ${key} carries a malformed lifecycleUid (SPEC 13.1)`);
445
+ }
446
+ return o;
447
+ }
448
+ async function readTakeoverIntent(authKv, opId) {
449
+ const key = stageIntentKey(opId);
450
+ const entry = await authKv.get(key);
451
+ if (!entry)
452
+ return undefined;
453
+ if (entry.operation !== "PUT")
454
+ 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)`);
455
+ return { intent: parseTakeoverIntent(entry.value, key), revision: entry.revision };
456
+ }
457
+ /**
458
+ * The shared CONTAINMENT core of the lifecycle barriers (SPEC 13.1: takeover and terminal
459
+ * retirement run the SAME revoke + verified-evict discipline over the SAME family):
460
+ * point-in-time enumeration of `cred.<lifecycleUid>.>` → revoke EVERY row (idempotent — a
461
+ * resumed barrier finds some already revoked; the row CAS is the deny-new SUBSTRATE, module
462
+ * header) → reconcile BOTH halves of every session-derived credential (the reconciler
463
+ * terminalizes `session.<sessionId>` and revokes the paired serving `epcred.` row, returning
464
+ * the serving holders; a lifecycle that HAS session-derived credentials but no reconciler
465
+ * fails loud — the serving half would be left live) → VERIFIED cluster-wide eviction of every
466
+ * enumerated holder principal (revoked earlier runs included: their connections may still be
467
+ * live), the alias principal itself, and every session-serving principal. Fail-closed per
468
+ * principal: anything but `verifiedGone` throws and the calling barrier's gate stays frozen.
469
+ * PACKAGE-INTERNAL: both barriers reach it through the sealed registry only.
470
+ */
471
+ export async function containLifecycleFamily(reg, args, deps) {
472
+ const { authKv } = registryStores(reg);
473
+ const { evictPrincipal, reconcileSessionPair } = deps;
474
+ const family = await enumerateAgentFamily(reg, args.lifecycleUid);
475
+ let revokedRows = 0;
476
+ for (const item of family) {
477
+ if ((await markLedgerRowRevoked(authKv, item.key)) === "revoked")
478
+ revokedRows++;
479
+ }
480
+ const sessionIds = new Set();
481
+ for (const item of family)
482
+ for (const member of item.row.sourceChain) {
483
+ const parsed = parseSourceMember(member);
484
+ if (parsed.kind === "session")
485
+ sessionIds.add(parsed.sessionId);
486
+ }
487
+ const servingPrincipals = new Set();
488
+ if (sessionIds.size > 0) {
489
+ if (reconcileSessionPair === undefined)
490
+ throw new EpEnvelopeError("failed-precondition", `lifecycle ${args.lifecycleUid} has ${sessionIds.size} session-derived credential(s) but the ${args.barrier} barrier was given no session-pair reconciler; the serving half would be left live (SPEC 13.1)`);
491
+ for (const sid of [...sessionIds].sort())
492
+ for (const p of (await reconcileSessionPair(sid)).servingPrincipals)
493
+ servingPrincipals.add(p);
494
+ }
495
+ const principals = new Set(family.map((f) => f.row.holderPrincipal));
496
+ principals.add(`${args.owner}.${args.actor}`);
497
+ for (const p of servingPrincipals)
498
+ principals.add(p);
499
+ const evicted = [];
500
+ for (const principal of [...principals].sort()) {
501
+ const res = await evictPrincipal(principal);
502
+ if (res.verifiedGone !== true)
503
+ throw new EpEnvelopeError("unavailable", `the ${args.barrier} barrier could not VERIFY eviction of principal ${principal} (kicked ${res.kicked}, remaining ${res.remaining}, scanComplete ${res.scanComplete}${res.note ? `; ${res.note}` : ""}); the gate stays frozen (SPEC 13.1)`);
504
+ evicted.push(principal);
505
+ }
506
+ return { revokedRows, evictedPrincipals: evicted };
507
+ }
508
+ /**
509
+ * Run the FULL takeover issuance barrier for a managed-agent lifecycle (SPEC 13.1, in the
510
+ * normative order — see the module header). Idempotent/crash-resumable: every step re-checks
511
+ * durable state, so calling it again with the SAME `opId` (directly or via
512
+ * {@link resumeAgentTakeover}) finishes the same operation; a DIFFERENT operation's freeze,
513
+ * a foreign epoch/generation movement, or a stranger's opId refuses before any CAS.
514
+ *
515
+ * FAIL-CLOSED CONTRACT: any failure after the freeze leaves the gate FROZEN (nothing mints)
516
+ * and the epoch un-advanced; eviction failure (`verifiedGone !== true` for ANY principal)
517
+ * throws `unavailable` — takeover MUST fail loud rather than proceed over a live predecessor.
518
+ */
519
+ export async function runAgentTakeoverBarrier(reg, args, deps) {
520
+ const { authKv } = registryStores(reg);
521
+ const opId = assertLifecycleToken(args.opId);
522
+ assertLifecycleToken(args.lifecycleUid);
523
+ // 0. The durable intent: read-or-create BEFORE any gate movement, so every later step (and
524
+ // every resume) works from the SAME captured coordinates.
525
+ let intentRead = await readTakeoverIntent(authKv, opId);
526
+ if (intentRead !== undefined) {
527
+ const it = intentRead.intent;
528
+ if (it.lifecycleUid !== args.lifecycleUid || it.owner !== args.owner || it.actor !== args.actor)
529
+ throw new EpEnvelopeError("permission-denied", `the operation intent ${stageIntentKey(opId)} belongs to lifecycle ${it.lifecycleUid} ("${it.owner}/${it.actor}"), not ${args.lifecycleUid} ("${args.owner}/${args.actor}"); an opId resumes only its OWN operation (SPEC 13.1)`);
530
+ }
531
+ else {
532
+ const head = await readLifecycleHeadForOperation(reg, args.owner, args.actor);
533
+ if (head === undefined || head.mapping.state !== "active" || head.mapping.lifecycleUid !== args.lifecycleUid)
534
+ throw new EpEnvelopeError("failed-precondition", `takeover 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)`);
535
+ const gate0 = await observeGate(reg, args.lifecycleUid);
536
+ if (gate0 === undefined)
537
+ throw new EpEnvelopeError("failed-precondition", `lifecycle ${args.lifecycleUid} has no issuance gate; nothing to take over (SPEC 13.1)`);
538
+ if (gate0.row.state !== "open" || gate0.row.generation < 1)
539
+ throw new EpEnvelopeError("failed-precondition", `the issuance gate for ${args.lifecycleUid} is "${gate0.row.state}" at generation ${gate0.row.generation}; a takeover freezes an OPEN, mintable gate (another operation owns a frozen one, SPEC 13.1)`);
540
+ // COORDINATE-PAIR COHERENCE (SPEC 13.1): the head and gate reads above are two reads, and a
541
+ // COMPLETED foreign takeover can land between them, leaving a TORN pair (old epoch, new
542
+ // generation). An intent persisted from a torn pair freezes the winner's reopened gate,
543
+ // revokes the successor's rows, and then wedges forever on the foreign epoch stamp — so
544
+ // re-read the head and refuse any movement BEFORE the intent becomes durable.
545
+ const head2 = await readLifecycleHeadForOperation(reg, args.owner, args.actor);
546
+ if (head2 === undefined || head2.mapping.state !== "active" || head2.mapping.lifecycleUid !== args.lifecycleUid || head2.mapping.processEpoch !== head.mapping.processEpoch)
547
+ throw new EpEnvelopeError("conflict", `the head for "${args.owner}/${args.actor}" moved while this takeover captured its coordinates (epoch ${head.mapping.processEpoch} → ${head2 === undefined ? "gone" : `${head2.mapping.state}@${head2.mapping.processEpoch}`}); the captured pair is torn; re-read and re-decide with fresh coordinates (SPEC 13.1)`);
548
+ const intent = {
549
+ kind: "takeover", lifecycleUid: args.lifecycleUid, owner: args.owner, actor: args.actor,
550
+ fromEpoch: head.mapping.processEpoch, fromGeneration: gate0.row.generation,
551
+ };
552
+ await createRowByteIdempotent(authKv, stageIntentKey(opId), intent);
553
+ intentRead = await readTakeoverIntent(authKv, opId);
554
+ if (intentRead === undefined)
555
+ throw new EpEnvelopeError("internal", `the operation intent ${stageIntentKey(opId)} vanished after its create (SPEC 13.12)`);
556
+ }
557
+ const intent = intentRead.intent;
558
+ // 1. Freeze the gate under OUR intent (or recognize our own freeze / our own completed
559
+ // reopen). A freeze-CAS loss means a mint's finalize touch bumped the revision — that is the
560
+ // normative serialization on one key — so re-observe and retry the freeze, bounded.
561
+ for (let attempt = 0;; attempt++) {
562
+ const gate = await observeGate(reg, intent.lifecycleUid);
563
+ if (gate === undefined)
564
+ throw new EpEnvelopeError("internal", `the issuance gate for ${intent.lifecycleUid} vanished mid-operation; a gate is never deleted (corruption, SPEC 13.12)`);
565
+ if (gate.row.state === "frozen") {
566
+ if (gate.row.op?.opId !== opId)
567
+ 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)`);
568
+ // Our own freeze (fresh or resumed). Whether we are still the CURRENT operation or a
569
+ // stale loser whose freeze straddled a foreign completion is decided at the epoch CAS
570
+ // (step 5), NOT here: a freeze held over a foreign head may already have PARTIALLY
571
+ // revoked the family before crashing, so recovery MUST complete containment (revoke +
572
+ // reconcile + verified-evict every family holder) before it aborts — reopening early
573
+ // would leave a revoked credential's connection live. We therefore proceed into the
574
+ // containment steps unconditionally and let advanceEpochWithinTakeover detect the loss.
575
+ break;
576
+ }
577
+ if (gate.row.state === "open" && gate.row.generation === intent.fromGeneration + 1) {
578
+ // The gate reopened at OUR successor generation. Confirm the head shows a completed
579
+ // takeover AND that OUR opId is the one that advanced it: a LOSING concurrent takeover
580
+ // that captured the same (fromEpoch, fromGeneration) also lands here, and it MUST NOT
581
+ // claim the winner's completion (SPEC 13.1). The epoch stamp binds completion to one op.
582
+ const head = await readLifecycleHeadForOperation(reg, intent.owner, intent.actor);
583
+ if (head === undefined || head.mapping.state !== "active" || head.mapping.lifecycleUid !== intent.lifecycleUid || head.mapping.processEpoch !== intent.fromEpoch + 1)
584
+ throw new EpEnvelopeError("internal", `the gate for ${intent.lifecycleUid} reopened at generation ${gate.row.generation} but the head does not show the completed takeover (SPEC 13.1); inspect the operation ${opId}`);
585
+ if (head.mapping.lastTakeoverOpId !== opId)
586
+ throw new EpEnvelopeError("conflict", `the takeover of ${intent.lifecycleUid} at epoch ${intent.fromEpoch + 1} was completed by operation ${head.mapping.lastTakeoverOpId ?? "<none>"}, not ${opId}; a concurrent takeover won and this operation lost (SPEC 13.1)`);
587
+ return { opId, lifecycleUid: intent.lifecycleUid, toEpoch: intent.fromEpoch + 1, toGeneration: intent.fromGeneration + 1, revokedRows: 0, evictedPrincipals: [] };
588
+ }
589
+ if (!(gate.row.state === "open" && gate.row.generation === intent.fromGeneration))
590
+ throw new EpEnvelopeError("failed-precondition", `the issuance gate for ${intent.lifecycleUid} is "${gate.row.state}" at generation ${gate.row.generation}, not this takeover's captured generation ${intent.fromGeneration} (or its +1); a foreign operation moved it (SPEC 13.1)`);
591
+ // HEAD GUARD, immediately before the freeze CAS (SPEC 13.1): the intent's coordinates are
592
+ // durable and may be STALE — if a foreign operation advanced the head since capture, this
593
+ // operation has already lost, and it must refuse WITHOUT moving the gate (a stale intent
594
+ // that freezes anyway revokes the winner's successor and wedges on the foreign epoch
595
+ // stamp). The gate CAS then serializes the residue: a foreign takeover completing after
596
+ // this read must first freeze the gate itself, which makes OUR freeze CAS lose and re-loop.
597
+ const headNow = await readLifecycleHeadForOperation(reg, intent.owner, intent.actor);
598
+ if (headNow === undefined || headNow.mapping.state !== "active" || headNow.mapping.lifecycleUid !== intent.lifecycleUid || headNow.mapping.processEpoch !== intent.fromEpoch)
599
+ throw new EpEnvelopeError("conflict", `the head for "${intent.owner}/${intent.actor}" is ${headNow === undefined ? "gone" : `${headNow.mapping.state}@epoch ${headNow.mapping.processEpoch} (uid ${headNow.mapping.lifecycleUid})`}, not this takeover's captured epoch ${intent.fromEpoch}; the intent is stale and this operation lost; the gate was not moved (SPEC 13.1)`);
600
+ try {
601
+ await freezeGate(reg, { lifecycleUid: intent.lifecycleUid, revision: gate.revision, op: { opId, kind: "takeover" } });
602
+ break;
603
+ }
604
+ catch (e) {
605
+ if (e instanceof EpEnvelopeError && e.code === "conflict" && attempt < 4)
606
+ continue;
607
+ throw e;
608
+ }
609
+ }
610
+ // 2-4. The shared containment core (enumerate → revoke → session reconcile → verified evict):
611
+ // post-freeze, a mint that won its fence wrote its rows before the freeze, so the scan sees
612
+ // them; a mint that lost never released.
613
+ const { revokedRows, evictedPrincipals: evicted } = await containLifecycleFamily(reg, { owner: intent.owner, actor: intent.actor, lifecycleUid: intent.lifecycleUid, barrier: "takeover" }, deps);
614
+ // 5. The epoch head CAS — LAST among the containment steps (SPEC 13.1: no predecessor egress
615
+ // survives to publish under the old epoch once the successor's epoch exists). It STAMPS this
616
+ // op's id, so the completion is bound to exactly this operation. Idempotent for our own advance.
617
+ // If a FOREIGN operation already advanced the epoch (a stale/torn intent that froze the gate
618
+ // and revoked the family before crashing), this throws `conflict`. We have already completed
619
+ // CONTAINMENT above (every revoked row's holder is verified-evicted), so the state is
620
+ // consistent — no revoked credential is left live. We then ABORT SAFELY: reopen our own
621
+ // freeze WITHOUT advancing the epoch (the winner owns it) so the lifecycle is not wedged, and
622
+ // rethrow. This is the crash-boundary counterpart to the pre-freeze head guard (which stops a
623
+ // FRESH stale intent from freezing at all).
624
+ try {
625
+ await advanceEpochWithinTakeover(reg, { owner: intent.owner, actor: intent.actor, lifecycleUid: intent.lifecycleUid, fromEpoch: intent.fromEpoch, opId });
626
+ }
627
+ catch (e) {
628
+ if (e instanceof EpEnvelopeError && e.code === "conflict") {
629
+ const g = await observeGate(reg, intent.lifecycleUid);
630
+ if (g !== undefined && g.row.state === "frozen" && g.row.op?.opId === opId)
631
+ await reopenGate(reg, { lifecycleUid: intent.lifecycleUid, revision: g.revision, opId }); // abort, no epoch advance
632
+ throw new EpEnvelopeError("conflict", `the takeover ${opId} of ${intent.lifecycleUid} lost: a foreign operation already advanced the epoch (captured ${intent.fromEpoch}). Containment completed (${revokedRows} row(s) revoked and every holder verified-evicted) and the gate was reopened without an epoch advance; nothing is wedged and no revoked credential is left live (SPEC 13.1)`);
633
+ }
634
+ throw e;
635
+ }
636
+ // 6. Reopen the gate at the successor's first mintable generation — the barrier's own final
637
+ // step (SPEC 13.1: no credential of generation G is ever live when G+1 mints).
638
+ const latest = await observeGate(reg, intent.lifecycleUid);
639
+ if (latest === undefined)
640
+ throw new EpEnvelopeError("internal", `the issuance gate for ${intent.lifecycleUid} vanished before the reopen (corruption, SPEC 13.12)`);
641
+ if (latest.row.state === "frozen")
642
+ await reopenGate(reg, { lifecycleUid: intent.lifecycleUid, revision: latest.revision, opId });
643
+ else if (!(latest.row.state === "open" && latest.row.generation === intent.fromGeneration + 1))
644
+ throw new EpEnvelopeError("internal", `the issuance gate for ${intent.lifecycleUid} is "${latest.row.state}" at generation ${latest.row.generation} at the reopen step; a foreign movement inside our freeze is corruption (SPEC 13.1)`);
645
+ return { opId, lifecycleUid: intent.lifecycleUid, toEpoch: intent.fromEpoch + 1, toGeneration: intent.fromGeneration + 1, revokedRows, evictedPrincipals: evicted };
646
+ }
647
+ /** Resume a crashed takeover from its durable intent alone (`{ opId }` — SPEC 13.1: the intent
648
+ * decides WHICH operation a frozen gate belongs to; `{ opId, kind }` alone resumes
649
+ * deterministically). Re-runs {@link runAgentTakeoverBarrier} with the intent's own
650
+ * coordinates; a stranger's opId (no intent) is `not-found`. */
651
+ export async function resumeAgentTakeover(reg, opId, deps) {
652
+ const { authKv } = registryStores(reg);
653
+ const read = await readTakeoverIntent(authKv, assertLifecycleToken(opId));
654
+ if (read === undefined)
655
+ throw new EpEnvelopeError("not-found", `no operation intent exists at ${stageIntentKey(opId)}; there is nothing to resume (SPEC 13.1)`);
656
+ const it = read.intent;
657
+ return runAgentTakeoverBarrier(reg, { owner: it.owner, actor: it.actor, lifecycleUid: it.lifecycleUid, opId }, deps);
658
+ }
659
+ // ---- handle revocation (the source-gate walk) ---------------------------------------------------
660
+ /**
661
+ * Revoke a sturdy handle at its SOURCE (SPEC 13.1): CAS the source gate `open → frozen` FIRST
662
+ * (the fence — an in-flight redemption under this handle either finished before the freeze,
663
+ * so its rows are in the enumeration, or loses its pinned finalize touch and never releases),
664
+ * then enumerate `bysrc.<issuerKeyId>.<id>.>`, revoke EVERY descendant ledger row it indexes
665
+ * (credentials minted under this handle OR under any descendant handle that carried it in the
666
+ * chain), and run VERIFIED cluster-wide eviction of every revoked row's holder principal —
667
+ * fail-closed, exactly the takeover discipline. The source gate stays frozen forever (a
668
+ * revoked handle never mints again); an already-frozen gate resumes the walk idempotently.
669
+ */
670
+ export async function revokeHandleSource(reg, args, deps) {
671
+ const { authKv } = registryStores(reg);
672
+ const { evictPrincipal, reconcileSessionPair } = deps;
673
+ // 1. Freeze FIRST (idempotent resume over an already-frozen gate).
674
+ const gate = await observeSourceGate(reg, args);
675
+ if (gate === undefined)
676
+ throw new EpEnvelopeError("not-found", `no source gate exists for handle ${args.issuerKeyId}.${args.id}; nothing to revoke (SPEC 13.1)`);
677
+ if (gate.row.state === "open")
678
+ await freezeSourceGate(reg, { issuerKeyId: args.issuerKeyId, id: args.id, revision: gate.revision });
679
+ // 2. Enumerate the lineage index, point-in-time (same throwaway-consumer mechanics).
680
+ const indexRows = await enumerateBysrc(reg, args.issuerKeyId, args.id);
681
+ // 3. Revoke every indexed descendant row (the index names the row; the ROW is the authority).
682
+ let revokedRows = 0;
683
+ const principals = new Set();
684
+ const sessionIds = new Set();
685
+ for (const { key, ref } of indexRows) {
686
+ const entry = await authKv.get(ref);
687
+ if (!entry || entry.operation !== "PUT")
688
+ throw new EpEnvelopeError("failed-precondition", `the lineage index ${key} names ${ref}, which ${!entry ? "does not exist" : `carries a ${entry.operation} marker`}; ledger rows are never deleted (corruption, SPEC 13.12)`);
689
+ const row = parseLedgerRow(entry.value, ref);
690
+ principals.add(row.holderPrincipal);
691
+ for (const member of row.sourceChain) {
692
+ const parsed = parseSourceMember(member);
693
+ if (parsed.kind === "session")
694
+ sessionIds.add(parsed.sessionId);
695
+ }
696
+ if ((await markLedgerRowRevoked(authKv, ref)) === "revoked")
697
+ revokedRows++;
698
+ }
699
+ // 3b. Tear down BOTH halves of any session-derived descendant (as the takeover barrier does),
700
+ // and JOIN the serving principals into the eviction set below (SPEC 13.6).
701
+ if (sessionIds.size > 0) {
702
+ if (reconcileSessionPair === undefined)
703
+ throw new EpEnvelopeError("failed-precondition", `handle ${args.issuerKeyId}.${args.id} has ${sessionIds.size} session-derived descendant credential(s) but the revocation was given no session-pair reconciler (SPEC 13.1)`);
704
+ for (const sid of [...sessionIds].sort())
705
+ for (const p of (await reconcileSessionPair(sid)).servingPrincipals)
706
+ principals.add(p);
707
+ }
708
+ // 4. VERIFIED eviction of every descendant holder (SPEC 13.1: an already-connected descendant
709
+ // credential is never silently left with live grants; acked only after this completes).
710
+ const evicted = [];
711
+ for (const principal of [...principals].sort()) {
712
+ const res = await evictPrincipal(principal);
713
+ if (res.verifiedGone !== true)
714
+ throw new EpEnvelopeError("unavailable", `handle revocation for ${args.issuerKeyId}.${args.id} could not VERIFY eviction of principal ${principal} (kicked ${res.kicked}, remaining ${res.remaining}, scanComplete ${res.scanComplete}${res.note ? `; ${res.note}` : ""}); re-run the revocation (SPEC 13.1)`);
715
+ evicted.push(principal);
716
+ }
717
+ return { revokedRows, evictedPrincipals: evicted };
718
+ }
719
+ /** Enumerate a handle's lineage index `bysrc.<issuerKeyId>.<id>.>` (the walk's read). The
720
+ * index parse is closed and key-bound; the referenced rows are parsed by the walk itself. */
721
+ async function enumerateBysrc(reg, issuerKeyId, id) {
722
+ // The SEALED scanner's fence-free LastPerSubject read over `bysrc.<issuerKeyId>.<id>.>`
723
+ // (ledger-scanner.ts: a consumer-create grant is body-unconfinable, so the CREATE lives only in
724
+ // the scanner's own sealed credential). Index rows are never deleted, so a DEL/PURGE marker is
725
+ // corruption and refuses.
726
+ return (await registryScanner(reg).scanBysrc(assertKeySegment(issuerKeyId, "issuerKeyId"), assertKeySegment(id, "handle id"))).map((e) => {
727
+ if (e.op === "DEL" || e.op === "PURGE")
728
+ throw new EpEnvelopeError("failed-precondition", `the lineage index key ${e.key} carries a ${e.op} marker; index rows are never deleted (corruption, SPEC 13.12)`);
729
+ return { key: e.key, ref: parseBysrcRow(e.data, e.key).ref };
730
+ });
731
+ }
732
+ //# sourceMappingURL=credential-ledger.js.map