@cotal-ai/auth 0.16.0 → 0.18.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/auth-admin.d.ts +6 -3
- package/dist/auth-admin.d.ts.map +1 -1
- package/dist/auth-admin.js +164 -106
- package/dist/auth-admin.js.map +1 -1
- package/dist/commands.js +10 -9
- package/dist/commands.js.map +1 -1
- package/dist/credential-ledger.d.ts +3 -116
- package/dist/credential-ledger.d.ts.map +1 -1
- package/dist/credential-ledger.js +16 -156
- package/dist/credential-ledger.js.map +1 -1
- package/dist/lifecycle-registry.d.ts +5 -57
- package/dist/lifecycle-registry.d.ts.map +1 -1
- package/dist/lifecycle-registry.js +46 -459
- package/dist/lifecycle-registry.js.map +1 -1
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +9 -5
- package/dist/permissions.js.map +1 -1
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +16 -9
- package/dist/provider.js.map +1 -1
- package/dist/session-ledger.d.ts +3 -27
- package/dist/session-ledger.d.ts.map +1 -1
- package/dist/session-ledger.js +18 -119
- package/dist/session-ledger.js.map +1 -1
- package/package.json +3 -3
|
@@ -47,14 +47,10 @@
|
|
|
47
47
|
*/
|
|
48
48
|
import { jetstream, jetstreamManager } from "@nats-io/jetstream";
|
|
49
49
|
import { Kvm } from "@nats-io/kv";
|
|
50
|
-
import { EpEnvelopeError,
|
|
50
|
+
import { EpEnvelopeError, recordsBucket as coreRecordsBucket, lifecycleHeadKey as headKey, parseLifecycleHead as parseMapping, headCandidate, gateObserve, gateCreateFrozen, gateFreeze, gateReopen, gateRetire, uidTryReserve, uidReserveFresh, uidReadReservation, headAdvanceEpochWithinTakeover, headSetCurrentRootCredential, headBeginRetirement, headCompleteRetirement, runActivationSaga, runActivationSagaAtUid, resumeActivationSaga, createRecordEntry, updateRecordEntry, readRecordLeader, epAuthBucket, workPoolContext, } from "@cotal-ai/core";
|
|
51
51
|
import { assertScannerSpace } from "./ledger-scanner.js";
|
|
52
52
|
import { assertRecordsScannerSpace } from "./records-scanner.js";
|
|
53
53
|
const enc = new TextEncoder();
|
|
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
|
-
const isCasLoss = (e) => e instanceof EpEnvelopeError && e.code === "conflict";
|
|
58
54
|
const REGISTRIES = new WeakMap();
|
|
59
55
|
const READERS = new WeakMap();
|
|
60
56
|
function internals(reg) {
|
|
@@ -110,7 +106,10 @@ export function assertAuthorityStreamShape(cfg, bucket) {
|
|
|
110
106
|
export async function openLifecycleRegistry(nc, space, scanner, recordsScanner) {
|
|
111
107
|
const jsm = await jetstreamManager(nc);
|
|
112
108
|
const kvm = new Kvm(nc);
|
|
113
|
-
|
|
109
|
+
// ONE encoder for the records-bucket name (core `recordsBucket`): the previous inline
|
|
110
|
+
// `cotal_records_${space}` skipped `token(space)` — identical for every legal space name, but
|
|
111
|
+
// a second encoder of a load-bearing name is exactly the drift class the shared grammar bans.
|
|
112
|
+
const recordsBucket = coreRecordsBucket(space);
|
|
114
113
|
const authBucket = epAuthBucket(space);
|
|
115
114
|
let recordsKv, authKv;
|
|
116
115
|
let recordsCfg;
|
|
@@ -154,7 +153,10 @@ export async function openLifecycleRegistry(nc, space, scanner, recordsScanner)
|
|
|
154
153
|
* records store refuses to serve authority reads (it could otherwise leader-read a MIRROR's
|
|
155
154
|
* leader and call that the mapping). */
|
|
156
155
|
export async function openLifecycleMappingReader(nc, space) {
|
|
157
|
-
|
|
156
|
+
// ONE encoder for the records-bucket name (core `recordsBucket`): the previous inline
|
|
157
|
+
// `cotal_records_${space}` skipped `token(space)` — identical for every legal space name, but
|
|
158
|
+
// a second encoder of a load-bearing name is exactly the drift class the shared grammar bans.
|
|
159
|
+
const recordsBucket = coreRecordsBucket(space);
|
|
158
160
|
let jsm;
|
|
159
161
|
let recordsCfg;
|
|
160
162
|
try {
|
|
@@ -169,77 +171,20 @@ export async function openLifecycleMappingReader(nc, space) {
|
|
|
169
171
|
READERS.set(rd, { space, jsm });
|
|
170
172
|
return rd;
|
|
171
173
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
function
|
|
177
|
-
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
}
|
|
186
|
-
catch {
|
|
187
|
-
throw new EpEnvelopeError("internal", `the lifecycle head ${key} is not JSON; garbled trusted-path state never authorizes (SPEC 13.1)`);
|
|
188
|
-
}
|
|
189
|
-
if (!isRec(o))
|
|
190
|
-
throw new EpEnvelopeError("internal", `the lifecycle head ${key} is not an object`);
|
|
191
|
-
const allowed = new Set(["owner", "actor", "lifecycleUid", "managerInstance", "processEpoch", "state", "currentCredentialId", "lastTakeoverOpId", "op"]);
|
|
192
|
-
for (const k of Object.keys(o))
|
|
193
|
-
if (!allowed.has(k))
|
|
194
|
-
throw new EpEnvelopeError("internal", `the lifecycle head ${key} carries the unknown field "${k}" (closed schema, SPEC 13.1)`);
|
|
195
|
-
if (o.owner !== owner || o.actor !== actor ||
|
|
196
|
-
typeof o.lifecycleUid !== "string" || typeof o.managerInstance !== "string" || o.managerInstance.length === 0 ||
|
|
197
|
-
!uint(o.processEpoch) || o.processEpoch < 1 || typeof o.state !== "string" || !HEAD_STATES.has(o.state) ||
|
|
198
|
-
(o.currentCredentialId !== undefined && (typeof o.currentCredentialId !== "string" || o.currentCredentialId.length === 0)) ||
|
|
199
|
-
(o.lastTakeoverOpId !== undefined && typeof o.lastTakeoverOpId !== "string"))
|
|
200
|
-
throw new EpEnvelopeError("internal", `the lifecycle head ${key} does not validate (owner/actor/uid/epoch/state); a garbled or key-mismatched head never authorizes (SPEC 13.1/13.3)`);
|
|
201
|
-
try {
|
|
202
|
-
assertLifecycleToken(o.lifecycleUid);
|
|
203
|
-
if (o.lastTakeoverOpId !== undefined)
|
|
204
|
-
assertLifecycleToken(o.lastTakeoverOpId);
|
|
205
|
-
}
|
|
206
|
-
catch {
|
|
207
|
-
throw new EpEnvelopeError("internal", `the lifecycle head ${key} carries a malformed lifecycleUid/lastTakeoverOpId (SPEC 13.1)`);
|
|
208
|
-
}
|
|
209
|
-
// The retirement op intent: REQUIRED at `retiring`, forbidden elsewhere; itself closed.
|
|
210
|
-
if (o.state === "retiring") {
|
|
211
|
-
if (!isRec(o.op))
|
|
212
|
-
throw new EpEnvelopeError("internal", `the lifecycle head ${key} is retiring without its durable op intent (SPEC 13.1: retiring is op-bound)`);
|
|
213
|
-
}
|
|
214
|
-
else if (o.op !== undefined) {
|
|
215
|
-
throw new EpEnvelopeError("internal", `the lifecycle head ${key} carries an op intent in state "${o.state}" (SPEC 13.1: only retiring is op-bound)`);
|
|
216
|
-
}
|
|
217
|
-
if (o.op !== undefined) {
|
|
218
|
-
const op = o.op;
|
|
219
|
-
for (const k of Object.keys(op))
|
|
220
|
-
if (k !== "opId" && k !== "kind")
|
|
221
|
-
throw new EpEnvelopeError("internal", `the lifecycle head ${key} op intent carries the unknown field "${k}" (closed schema)`);
|
|
222
|
-
if (typeof op.opId !== "string" || op.kind !== "retirement")
|
|
223
|
-
throw new EpEnvelopeError("internal", `the lifecycle head ${key} op intent does not validate (SPEC 13.1)`);
|
|
224
|
-
try {
|
|
225
|
-
assertLifecycleToken(op.opId);
|
|
226
|
-
}
|
|
227
|
-
catch {
|
|
228
|
-
throw new EpEnvelopeError("internal", `the lifecycle head ${key} op intent carries a malformed opId (SPEC 13.1)`);
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
return o;
|
|
232
|
-
}
|
|
233
|
-
/** Candidate read for a CAS-fenced mutation (raw `kv.get`; the auth decision is the CAS itself,
|
|
234
|
-
* §13.1: a read is never a fence). A DEL/PURGE marker is CORRUPTION, never absence. */
|
|
235
|
-
async function readHeadCandidate(kv, owner, actor) {
|
|
236
|
-
const key = headKey(owner, actor);
|
|
237
|
-
const entry = await kv.get(key);
|
|
238
|
-
if (!entry)
|
|
239
|
-
return undefined;
|
|
240
|
-
if (entry.operation !== "PUT")
|
|
241
|
-
throw new EpEnvelopeError("failed-precondition", `the lifecycle head ${key} carries a ${entry.operation} marker; an authority head is never deleted (a deletion is corruption, not absence, SPEC 13.12)`);
|
|
242
|
-
return { mapping: parseMapping(entry.value, key, owner, actor), revision: entry.revision };
|
|
174
|
+
/** The sealed registry's write transport for the shared §13.1 saga sequencing
|
|
175
|
+
* (@cotal-ai/core `lifecycle-saga.ts`): constructed ONLY from this module's sealed internals,
|
|
176
|
+
* so the WeakMap brand still gates every write; the transport carries no sequencing decisions
|
|
177
|
+
* of its own (the three-way split: adapters are TRANSPORT, never a second saga). */
|
|
178
|
+
function transportOf(reg) {
|
|
179
|
+
const { recordsKv, authKv } = internals(reg);
|
|
180
|
+
const entryOf = (e) => e === null ? undefined : { value: e.value, revision: e.revision, operation: e.operation };
|
|
181
|
+
return {
|
|
182
|
+
getRecord: async (key) => entryOf(await recordsKv.get(key)),
|
|
183
|
+
createRecord: (key, value) => createRecordEntry(recordsKv, key, value),
|
|
184
|
+
updateRecord: (key, value, rev) => updateRecordEntry(recordsKv, key, value, rev),
|
|
185
|
+
getAuth: async (key) => entryOf(await authKv.get(key)),
|
|
186
|
+
putAuth: (key, payload, rev) => authKv.put(key, payload, { previousSeq: rev }),
|
|
187
|
+
};
|
|
243
188
|
}
|
|
244
189
|
/** PACKAGE-INTERNAL accessor for the trusted auth path's sibling modules (the credential
|
|
245
190
|
* ledger + issuance barrier, which are the SAME authority over the SAME stores). Deliberately
|
|
@@ -272,43 +217,14 @@ export function registryRecordsScanner(reg) {
|
|
|
272
217
|
* through {@link advanceEpochWithinTakeover}). Same never-deleted discipline as every head
|
|
273
218
|
* read; never re-exported from the package index. */
|
|
274
219
|
export async function readLifecycleHeadForOperation(reg, owner, actor) {
|
|
275
|
-
return
|
|
220
|
+
return headCandidate(transportOf(reg), owner, actor);
|
|
276
221
|
}
|
|
277
222
|
/** The takeover barrier's epoch-advance head CAS (SPEC 13.1: NO public epoch advance exists;
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
223
|
+
* module-internal for the credential ledger's barrier only) — delegates to the shared §13.1
|
|
224
|
+
* sequencing (core `headAdvanceEpochWithinTakeover`, which also carries the C1 same-CAS
|
|
225
|
+
* root-stamp clear) over this registry's sealed transport. */
|
|
281
226
|
export async function advanceEpochWithinTakeover(reg, args) {
|
|
282
|
-
|
|
283
|
-
const cur = await readHeadCandidate(recordsKv, args.owner, args.actor);
|
|
284
|
-
if (cur === undefined || cur.mapping.state !== "active" || cur.mapping.lifecycleUid !== args.lifecycleUid)
|
|
285
|
-
throw new EpEnvelopeError("failed-precondition", `the takeover epoch advance for "${args.owner}/${args.actor}" requires an ACTIVE head at uid ${args.lifecycleUid}; found ${cur === undefined ? "no head" : `${cur.mapping.state} at ${cur.mapping.lifecycleUid}`} (SPEC 13.1)`);
|
|
286
|
-
if (cur.mapping.processEpoch === args.fromEpoch + 1) {
|
|
287
|
-
// Idempotent ONLY for our OWN completed advance: the epoch stamp binds the completion to one
|
|
288
|
-
// op, so a LOSING concurrent takeover that captured the same fromEpoch finds a foreign opId
|
|
289
|
-
// and refuses, never claiming the winner's advance (SPEC 13.1).
|
|
290
|
-
if (cur.mapping.lastTakeoverOpId !== args.opId)
|
|
291
|
-
throw new EpEnvelopeError("conflict", `the head for "${args.owner}/${args.actor}" is at epoch ${args.fromEpoch + 1} advanced by operation ${cur.mapping.lastTakeoverOpId ?? "<none>"}, not ${args.opId}; a concurrent takeover won and this operation lost (SPEC 13.1)`);
|
|
292
|
-
// C1 (panel HIGH): our own completed advance MUST have cleared the revoked root stamp too. If
|
|
293
|
-
// the epoch advanced under our opId but `currentCredentialId` is still set, that is IMPOSSIBLE
|
|
294
|
-
// persisted state (a partial write or an old binary) — fail loud rather than report success
|
|
295
|
-
// over a head that still wedges the successor mint.
|
|
296
|
-
if (cur.mapping.currentCredentialId !== undefined)
|
|
297
|
-
throw new EpEnvelopeError("failed-precondition", `the head for "${args.owner}/${args.actor}" advanced under takeover ${args.opId} but still names root credential ${cur.mapping.currentCredentialId}; the epoch CAS clears it atomically, so a residual stamp is impossible persisted state (SPEC 13.1)`);
|
|
298
|
-
return "already-advanced";
|
|
299
|
-
}
|
|
300
|
-
if (cur.mapping.processEpoch !== args.fromEpoch)
|
|
301
|
-
throw new EpEnvelopeError("failed-precondition", `the head for "${args.owner}/${args.actor}" is at epoch ${cur.mapping.processEpoch}, not the takeover's captured epoch ${args.fromEpoch} (or its +1); a foreign operation moved it (SPEC 13.1)`);
|
|
302
|
-
// C1 (panel HIGH, all lanes): advance the epoch AND clear `currentCredentialId` in the SAME CAS.
|
|
303
|
-
// The takeover's family revoke marked the incarnation's root row `revoked`; leaving the head
|
|
304
|
-
// still naming that revoked root permanently wedges the successor mint (`ensureRootCredential`'s
|
|
305
|
-
// fast path reads the stamped id, refuses its revoked state, and `setCurrentRootCredential`
|
|
306
|
-
// refuses a value flip). Clearing the stamp makes the head's root slot ABSENT, so the successor's
|
|
307
|
-
// release-last stamp can win. Root rotation stays a barrier's job — this CAS IS that barrier step.
|
|
308
|
-
const { currentCredentialId: _revoked, ...rest } = cur.mapping;
|
|
309
|
-
void _revoked;
|
|
310
|
-
await updateRecordEntry(recordsKv, headKey(args.owner, args.actor), { ...rest, processEpoch: args.fromEpoch + 1, lastTakeoverOpId: assertLifecycleToken(args.opId) }, cur.revision);
|
|
311
|
-
return "advanced";
|
|
227
|
+
return headAdvanceEpochWithinTakeover(transportOf(reg), args);
|
|
312
228
|
}
|
|
313
229
|
/**
|
|
314
230
|
* The issuance path's head CAS stamping the incarnation's ROOT credential (SPEC 13.1: the head's
|
|
@@ -324,36 +240,14 @@ export async function advanceEpochWithinTakeover(reg, args) {
|
|
|
324
240
|
* only; a foreign head movement between the caller's read and this CAS loses fail-closed.
|
|
325
241
|
*/
|
|
326
242
|
export async function setCurrentRootCredential(reg, args) {
|
|
327
|
-
|
|
328
|
-
if (typeof args.credentialId !== "string" || args.credentialId.length === 0)
|
|
329
|
-
throw new EpEnvelopeError("failed-precondition", "setCurrentRootCredential requires a credentialId");
|
|
330
|
-
const cur = await readHeadCandidate(recordsKv, args.owner, args.actor);
|
|
331
|
-
if (cur === undefined || cur.mapping.state !== "active" || cur.mapping.lifecycleUid !== args.lifecycleUid)
|
|
332
|
-
throw new EpEnvelopeError("failed-precondition", `stamping the root credential for "${args.owner}/${args.actor}" requires an ACTIVE head at uid ${args.lifecycleUid}; found ${cur === undefined ? "no head" : `${cur.mapping.state} at ${cur.mapping.lifecycleUid}`} (SPEC 13.1)`);
|
|
333
|
-
if (cur.mapping.currentCredentialId === args.credentialId)
|
|
334
|
-
return; // our own completed stamp
|
|
335
|
-
if (cur.mapping.currentCredentialId !== undefined)
|
|
336
|
-
throw new EpEnvelopeError("permission-denied", `the head for "${args.owner}/${args.actor}" already names root credential ${cur.mapping.currentCredentialId}; rotating it takes the full family-revoke barrier, never a bare head flip (the old root's descendants would stay connectable under the leaf check, SPEC 13.1)`);
|
|
337
|
-
await updateRecordEntry(recordsKv, headKey(args.owner, args.actor), { ...cur.mapping, currentCredentialId: args.credentialId }, cur.revision);
|
|
243
|
+
return headSetCurrentRootCredential(transportOf(reg), args);
|
|
338
244
|
}
|
|
339
245
|
/** The retirement barrier's head CONTAINMENT CAS (SPEC 13.1: `active → retiring`, bound to the
|
|
340
246
|
* retirement operation's durable intent — from this point every currency seam yields no current
|
|
341
247
|
* mapping and no current epoch, and the alias is NOT replaceable). PACKAGE-INTERNAL for the
|
|
342
248
|
* barrier only (no public retire seam exists); idempotent for the barrier's crash-resume. */
|
|
343
249
|
export async function beginHeadRetirementWithinBarrier(reg, args) {
|
|
344
|
-
|
|
345
|
-
const cur = await readHeadCandidate(recordsKv, args.owner, args.actor);
|
|
346
|
-
if (cur === undefined || cur.mapping.lifecycleUid !== args.lifecycleUid)
|
|
347
|
-
throw new EpEnvelopeError("failed-precondition", `the retirement of uid ${args.lifecycleUid} requires the head for "${args.owner}/${args.actor}" to name it; found ${cur === undefined ? "no head" : `uid ${cur.mapping.lifecycleUid}`} (SPEC 13.1)`);
|
|
348
|
-
if (cur.mapping.state === "retiring") {
|
|
349
|
-
if (cur.mapping.op?.opId !== args.opId)
|
|
350
|
-
throw new EpEnvelopeError("permission-denied", `the head for "${args.owner}/${args.actor}" is retiring under operation ${cur.mapping.op?.opId ?? "<none>"}, not ${args.opId}; one retirement at a time, and a stranger never advances it (SPEC 13.1)`);
|
|
351
|
-
return "already-retiring";
|
|
352
|
-
}
|
|
353
|
-
if (cur.mapping.state !== "active")
|
|
354
|
-
throw new EpEnvelopeError("failed-precondition", `the head for "${args.owner}/${args.actor}" is "${cur.mapping.state}", not active; only an active head enters retirement containment (a completed terminal is decided at the gate, never re-entered here, SPEC 13.1)`);
|
|
355
|
-
await updateRecordEntry(recordsKv, headKey(args.owner, args.actor), { ...cur.mapping, state: "retiring", op: { opId: assertLifecycleToken(args.opId), kind: "retirement" } }, cur.revision);
|
|
356
|
-
return "retiring";
|
|
250
|
+
return headBeginRetirement(transportOf(reg), args);
|
|
357
251
|
}
|
|
358
252
|
/** The retirement barrier's TERMINAL head CAS (`retiring → retired`, op-pinned) — the barrier's
|
|
359
253
|
* LAST step (SPEC 13.1: `retired` ASSERTS completed cleanup, which is what makes the alias
|
|
@@ -361,39 +255,14 @@ export async function beginHeadRetirementWithinBarrier(reg, args) {
|
|
|
361
255
|
* `retired` is decided by the CALLER against the gate's terminal op (the retired head itself
|
|
362
256
|
* carries no retirement stamp). PACKAGE-INTERNAL for the barrier only. */
|
|
363
257
|
export async function completeHeadRetirementWithinBarrier(reg, args) {
|
|
364
|
-
|
|
365
|
-
const cur = await readHeadCandidate(recordsKv, args.owner, args.actor);
|
|
366
|
-
if (cur === undefined || cur.mapping.lifecycleUid !== args.lifecycleUid)
|
|
367
|
-
throw new EpEnvelopeError("failed-precondition", `the retirement terminal for uid ${args.lifecycleUid} requires the head for "${args.owner}/${args.actor}" to name it; found ${cur === undefined ? "no head" : `uid ${cur.mapping.lifecycleUid}`}; a replaced head is settled at the gate, never here (SPEC 13.1)`);
|
|
368
|
-
if (cur.mapping.state === "retired")
|
|
369
|
-
return "already-retired";
|
|
370
|
-
if (cur.mapping.state !== "retiring" || cur.mapping.op?.opId !== args.opId)
|
|
371
|
-
throw new EpEnvelopeError("permission-denied", `the head for "${args.owner}/${args.actor}" is ${cur.mapping.state === "retiring" ? `retiring under operation ${cur.mapping.op?.opId ?? "<none>"}` : `"${cur.mapping.state}"`}, not retiring under ${args.opId}; only the containing operation terminalizes its own retirement (SPEC 13.1)`);
|
|
372
|
-
const { op: _op, ...rest } = cur.mapping;
|
|
373
|
-
await updateRecordEntry(recordsKv, headKey(args.owner, args.actor), { ...rest, state: "retired" }, cur.revision);
|
|
374
|
-
return "retired";
|
|
258
|
+
return headCompleteRetirement(transportOf(reg), args);
|
|
375
259
|
}
|
|
376
260
|
/** PACKAGE-INTERNAL: read a UID reservation's audit `{ owner, actor }` (the minting authority
|
|
377
261
|
* recorded it at {@link tryReserveUid}). The credential ledger uses it to BIND a mint's
|
|
378
262
|
* `holderPrincipal` to the reserved identity, so a trusted caller cannot ledger a row that
|
|
379
263
|
* names a foreign principal for the barrier to evict. A DEL/PURGE marker refuses loudly. */
|
|
380
264
|
export async function readUidReservation(reg, lifecycleUid) {
|
|
381
|
-
|
|
382
|
-
const entry = await recordsKv.get(uidKey(assertLifecycleToken(lifecycleUid)));
|
|
383
|
-
if (!entry)
|
|
384
|
-
return undefined;
|
|
385
|
-
if (entry.operation !== "PUT")
|
|
386
|
-
throw new EpEnvelopeError("failed-precondition", `the uid reservation for ${lifecycleUid} carries a ${entry.operation} marker; a reservation is never deleted (corruption, SPEC 13.12)`);
|
|
387
|
-
let o;
|
|
388
|
-
try {
|
|
389
|
-
o = JSON.parse(dec.decode(entry.value));
|
|
390
|
-
}
|
|
391
|
-
catch {
|
|
392
|
-
throw new EpEnvelopeError("internal", `the uid reservation for ${lifecycleUid} is not JSON (SPEC 13.1)`);
|
|
393
|
-
}
|
|
394
|
-
if (!isRec(o) || typeof o.owner !== "string" || typeof o.actor !== "string" || o.owner.length === 0 || o.actor.length === 0)
|
|
395
|
-
throw new EpEnvelopeError("internal", `the uid reservation for ${lifecycleUid} does not carry a valid owner/actor audit (SPEC 13.1)`);
|
|
396
|
-
return { owner: o.owner, actor: o.actor };
|
|
265
|
+
return uidReadReservation(transportOf(reg), lifecycleUid);
|
|
397
266
|
}
|
|
398
267
|
// ---- the space-global UID reservation (records store) ----------------------------------------
|
|
399
268
|
/** Try to reserve ONE explicit candidate UID (test/provisioning-internal; production paths use
|
|
@@ -402,99 +271,23 @@ export async function readUidReservation(reg, lifecycleUid) {
|
|
|
402
271
|
* never-reuse rule. NOT exported from the package index: an explicit candidate is only for
|
|
403
272
|
* probes and migration tooling, never a caller-chosen identity. */
|
|
404
273
|
export async function tryReserveUid(reg, lifecycleUid, audit) {
|
|
405
|
-
|
|
406
|
-
assertLifecycleToken(lifecycleUid);
|
|
407
|
-
try {
|
|
408
|
-
await createRecordEntry(recordsKv, uidKey(lifecycleUid), { owner: audit.owner, actor: audit.actor, mintedBy: audit.mintedBy });
|
|
409
|
-
return "won";
|
|
410
|
-
}
|
|
411
|
-
catch (e) {
|
|
412
|
-
if (isCasLoss(e))
|
|
413
|
-
return "burned";
|
|
414
|
-
throw e;
|
|
415
|
-
}
|
|
274
|
+
return uidTryReserve(transportOf(reg), lifecycleUid, audit);
|
|
416
275
|
}
|
|
417
276
|
/** Reserve a fresh lifecycle UID space-globally (§13.1): mint a CSPRNG candidate, win its
|
|
418
277
|
* create-only reservation, and on a collision burn the candidate and draw another. At ≥128
|
|
419
278
|
* bits a collision is effectively adversarial, so a handful of retries is a correctness
|
|
420
279
|
* formality, not a capacity plan; exhausting them refuses loudly. */
|
|
421
280
|
export async function reserveLifecycleUid(reg, audit) {
|
|
422
|
-
|
|
423
|
-
const candidate = mintLifecycleUid();
|
|
424
|
-
if ((await tryReserveUid(reg, candidate, audit)) === "won")
|
|
425
|
-
return candidate;
|
|
426
|
-
}
|
|
427
|
-
throw new EpEnvelopeError("internal", "four fresh 128-bit UID candidates collided with existing reservations; that is not chance; inspect the uid.> family (SPEC 13.1)");
|
|
428
|
-
}
|
|
429
|
-
const GATE_STATES = new Set(["open", "frozen", "retired"]);
|
|
430
|
-
const GATE_OP_KINDS = new Set(["activation", "takeover", "registration", "retirement"]);
|
|
431
|
-
function gateKey(lifecycleUid) {
|
|
432
|
-
return `gate.${assertLifecycleToken(lifecycleUid)}`;
|
|
433
|
-
}
|
|
434
|
-
function parseGate(raw, key, lifecycleUid) {
|
|
435
|
-
let o;
|
|
436
|
-
try {
|
|
437
|
-
o = JSON.parse(dec.decode(raw));
|
|
438
|
-
}
|
|
439
|
-
catch {
|
|
440
|
-
throw new EpEnvelopeError("internal", `the issuance gate ${key} is not JSON; garbled trusted-path state never authorizes (SPEC 13.1)`);
|
|
441
|
-
}
|
|
442
|
-
if (!isRec(o))
|
|
443
|
-
throw new EpEnvelopeError("internal", `the issuance gate ${key} is not an object`);
|
|
444
|
-
for (const k of Object.keys(o))
|
|
445
|
-
if (k !== "lifecycleUid" && k !== "state" && k !== "generation" && k !== "op")
|
|
446
|
-
throw new EpEnvelopeError("internal", `the issuance gate ${key} carries the unknown field "${k}" (closed schema, SPEC 13.1)`);
|
|
447
|
-
if (o.lifecycleUid !== lifecycleUid || typeof o.state !== "string" || !GATE_STATES.has(o.state) || !uint(o.generation))
|
|
448
|
-
throw new EpEnvelopeError("internal", `the issuance gate ${key} does not validate (uid/state/generation); a garbled or key-mismatched gate never authorizes (SPEC 13.1)`);
|
|
449
|
-
if ((o.state === "frozen" || o.state === "retired") && !isRec(o.op))
|
|
450
|
-
throw new EpEnvelopeError("internal", `the issuance gate ${key} is ${o.state} without its durable op intent (SPEC 13.1: a frozen gate is op-bound, and a retired gate retains its terminalizing op)`);
|
|
451
|
-
if (o.state === "open" && o.op !== undefined)
|
|
452
|
-
throw new EpEnvelopeError("internal", `the issuance gate ${key} is open but carries an op intent (SPEC 13.1: open gates are not op-bound)`);
|
|
453
|
-
if (o.op !== undefined) {
|
|
454
|
-
const op = o.op;
|
|
455
|
-
for (const k of Object.keys(op))
|
|
456
|
-
if (k !== "opId" && k !== "kind" && k !== "successor")
|
|
457
|
-
throw new EpEnvelopeError("internal", `the issuance gate ${key} op intent carries the unknown field "${k}" (closed schema)`);
|
|
458
|
-
if (typeof op.opId !== "string" || typeof op.kind !== "string" || !GATE_OP_KINDS.has(op.kind))
|
|
459
|
-
throw new EpEnvelopeError("internal", `the issuance gate ${key} op intent does not validate (SPEC 13.1)`);
|
|
460
|
-
// STATE x KIND invariant (SPEC 13.1 per-kind transition sets): only an activation orphan or
|
|
461
|
-
// a retirement produces a `retired` gate, so a persisted `retired` gate carrying a
|
|
462
|
-
// takeover/registration kind is IMPOSSIBLE state — refuse it at parse, never let the terminal
|
|
463
|
-
// idempotence path return it as a settled success (fail-closed on corruption, not open).
|
|
464
|
-
if (o.state === "retired" && op.kind !== "activation" && op.kind !== "retirement")
|
|
465
|
-
throw new EpEnvelopeError("internal", `the issuance gate ${key} is retired under a ${op.kind} op; only an activation orphan or a retirement terminalizes (SPEC 13.1); impossible persisted state, refused`);
|
|
466
|
-
if (op.successor !== undefined && (typeof op.successor !== "string" || op.successor.length === 0 || (op.kind !== "takeover" && op.kind !== "registration")))
|
|
467
|
-
throw new EpEnvelopeError("internal", `the issuance gate ${key} op intent carries an invalid successor (SPEC 13.1: only takeover/registration stage successors, and the summary is a non-empty token)`);
|
|
468
|
-
try {
|
|
469
|
-
assertLifecycleToken(op.opId);
|
|
470
|
-
}
|
|
471
|
-
catch {
|
|
472
|
-
throw new EpEnvelopeError("internal", `the issuance gate ${key} op intent carries a malformed opId (SPEC 13.1)`);
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
return o;
|
|
281
|
+
return uidReserveFresh(transportOf(reg), audit);
|
|
476
282
|
}
|
|
283
|
+
// ---- the issuance-gate CAS primitives (auth store, agent family `gate.<lifecycleUid>`) -------
|
|
284
|
+
// The gate row schema (`EpGateRow`), key builder, and boundary parser live in the shared §13.1
|
|
285
|
+
// STATE GRAMMAR in @cotal-ai/core (`lifecycle-state.ts`), imported above as
|
|
286
|
+
// `gateKey`/`parseGate`; the type is re-exported beside `LifecycleMapping`.
|
|
477
287
|
/** Observe the gate (the candidate read feeding a revision-pinned CAS; the auth store is
|
|
478
288
|
* leader-only by shape, `allow_direct=false`). A DEL/PURGE marker refuses loudly. */
|
|
479
289
|
export async function observeGate(reg, lifecycleUid) {
|
|
480
|
-
|
|
481
|
-
const key = gateKey(lifecycleUid);
|
|
482
|
-
const entry = await authKv.get(key);
|
|
483
|
-
if (!entry)
|
|
484
|
-
return undefined;
|
|
485
|
-
if (entry.operation !== "PUT")
|
|
486
|
-
throw new EpEnvelopeError("failed-precondition", `the issuance gate ${key} carries a ${entry.operation} marker; a gate is never deleted (a deletion is corruption, not absence, SPEC 13.12)`);
|
|
487
|
-
return { row: parseGate(entry.value, key, lifecycleUid), revision: entry.revision };
|
|
488
|
-
}
|
|
489
|
-
async function putGate(authKv, lifecycleUid, row, expectedRevision) {
|
|
490
|
-
try {
|
|
491
|
-
return await authKv.put(gateKey(lifecycleUid), enc.encode(JSON.stringify(row)), { previousSeq: expectedRevision });
|
|
492
|
-
}
|
|
493
|
-
catch (e) {
|
|
494
|
-
if (isRawCasLoss(e))
|
|
495
|
-
throw new EpEnvelopeError("conflict", `the issuance gate CAS for ${gateKey(lifecycleUid)} lost (expected revision ${expectedRevision}); re-read and re-decide (SPEC 13.8)`);
|
|
496
|
-
throw e;
|
|
497
|
-
}
|
|
290
|
+
return gateObserve(transportOf(reg), lifecycleUid);
|
|
498
291
|
}
|
|
499
292
|
/** Create the gate FROZEN under its operation's durable intent (create-only: conflicts on an
|
|
500
293
|
* existing gate or a deletion marker). A gate is BORN only under an ACTIVATION intent (SPEC
|
|
@@ -503,37 +296,12 @@ async function putGate(authKv, lifecycleUid, row, expectedRevision) {
|
|
|
503
296
|
* unreserved UID would mint outside the never-reuse fence. Born unmintable at generation 0:
|
|
504
297
|
* no credential can be released until the operation's own reopen (§13.1 activation saga). */
|
|
505
298
|
export async function createGateFrozen(reg, args) {
|
|
506
|
-
|
|
507
|
-
if (args.op.kind !== "activation")
|
|
508
|
-
throw new EpEnvelopeError("failed-precondition", `an issuance gate is born only under an activation intent, not "${String(args.op.kind)}" (SPEC 13.1: other operations freeze an existing open gate)`);
|
|
509
|
-
const reservation = await recordsKv.get(uidKey(assertLifecycleToken(args.lifecycleUid)));
|
|
510
|
-
if (!reservation)
|
|
511
|
-
throw new EpEnvelopeError("failed-precondition", `no uid reservation exists for ${args.lifecycleUid}; the reservation is won BEFORE any gate or head write (SPEC 13.1)`);
|
|
512
|
-
if (reservation.operation !== "PUT")
|
|
513
|
-
throw new EpEnvelopeError("failed-precondition", `the uid reservation for ${args.lifecycleUid} carries a ${reservation.operation} marker; a reservation is never deleted (corruption, not absence, SPEC 13.12)`);
|
|
514
|
-
const row = { lifecycleUid: args.lifecycleUid, state: "frozen", generation: 0, op: { opId: assertLifecycleToken(args.op.opId), kind: "activation" } };
|
|
515
|
-
const revision = await putGate(authKv, args.lifecycleUid, row, 0);
|
|
516
|
-
return { row, revision };
|
|
299
|
+
return gateCreateFrozen(transportOf(reg), args);
|
|
517
300
|
}
|
|
518
301
|
/** CAS the gate `open → frozen` carrying the freezing operation's durable intent, at the
|
|
519
302
|
* observed revision. The bar of every barrier: a staged mint's own finalize CAS loses. */
|
|
520
303
|
export async function freezeGate(reg, args) {
|
|
521
|
-
|
|
522
|
-
if (args.op.successor !== undefined && args.op.kind === "retirement")
|
|
523
|
-
throw new EpEnvelopeError("failed-precondition", "a retirement freeze carries no successor (SPEC 13.1: a retirement has none)");
|
|
524
|
-
if (args.op.successor !== undefined && args.op.successor.length === 0)
|
|
525
|
-
throw new EpEnvelopeError("failed-precondition", "the freeze carries an empty successor token; a summary token is a non-empty stage.<opId> reference or absent (SPEC 13.1); validate before the CAS, never persist corruption");
|
|
526
|
-
const current = await observeGate(reg, args.lifecycleUid);
|
|
527
|
-
if (current === undefined)
|
|
528
|
-
throw new EpEnvelopeError("not-found", `the issuance gate for ${args.lifecycleUid} does not exist (SPEC 13.1)`);
|
|
529
|
-
if (current.row.state !== "open")
|
|
530
|
-
throw new EpEnvelopeError("failed-precondition", `the issuance gate for ${args.lifecycleUid} is "${current.row.state}", not open; only an open gate freezes (a frozen/retired gate belongs to its own operation, SPEC 13.1)`);
|
|
531
|
-
const op = { opId: assertLifecycleToken(args.op.opId), kind: args.op.kind };
|
|
532
|
-
if (args.op.successor !== undefined)
|
|
533
|
-
op.successor = args.op.successor;
|
|
534
|
-
const row = { lifecycleUid: current.row.lifecycleUid, state: "frozen", generation: current.row.generation, op };
|
|
535
|
-
const revision = await putGate(authKv, args.lifecycleUid, row, args.revision);
|
|
536
|
-
return { row, revision };
|
|
304
|
+
return gateFreeze(transportOf(reg), args);
|
|
537
305
|
}
|
|
538
306
|
/** CAS the gate `frozen → open` at the NEXT generation — op-pinned: only the freeze's own
|
|
539
307
|
* operation (the same `opId`) reopens, as its barrier's final step; a stranger or a stale
|
|
@@ -541,19 +309,7 @@ export async function freezeGate(reg, args) {
|
|
|
541
309
|
* belongs to activation, takeover, and a registration abort — NEVER retirement (a
|
|
542
310
|
* retirement freeze never reopens; its only exit is the terminal). */
|
|
543
311
|
export async function reopenGate(reg, args) {
|
|
544
|
-
|
|
545
|
-
const current = await observeGate(reg, args.lifecycleUid);
|
|
546
|
-
if (current === undefined)
|
|
547
|
-
throw new EpEnvelopeError("not-found", `the issuance gate for ${args.lifecycleUid} does not exist (SPEC 13.1)`);
|
|
548
|
-
if (current.row.state !== "frozen")
|
|
549
|
-
throw new EpEnvelopeError("failed-precondition", `the issuance gate for ${args.lifecycleUid} is "${current.row.state}", not frozen; there is no freeze to reopen (SPEC 13.1)`);
|
|
550
|
-
if (current.row.op?.opId !== args.opId)
|
|
551
|
-
throw new EpEnvelopeError("permission-denied", `the issuance gate for ${args.lifecycleUid} is frozen by operation ${current.row.op?.opId ?? "<none>"}, not ${args.opId}; only the completing operation reopens its own freeze (SPEC 13.1)`);
|
|
552
|
-
if (current.row.op.kind === "retirement")
|
|
553
|
-
throw new EpEnvelopeError("failed-precondition", `the issuance gate for ${args.lifecycleUid} is frozen by a RETIREMENT; a retirement freeze never reopens (SPEC 13.1: its only exit is the terminal)`);
|
|
554
|
-
const row = { lifecycleUid: current.row.lifecycleUid, state: "open", generation: current.row.generation + 1 };
|
|
555
|
-
const revision = await putGate(authKv, args.lifecycleUid, row, args.revision);
|
|
556
|
-
return { row, revision };
|
|
312
|
+
return gateReopen(transportOf(reg), args);
|
|
557
313
|
}
|
|
558
314
|
/** CAS the gate `frozen → retired` (terminal; never reopened) — op-pinned like the reopen. The
|
|
559
315
|
* activation saga uses it to terminalize a head-CAS loser's orphan gate; the retirement
|
|
@@ -562,24 +318,7 @@ export async function reopenGate(reg, args) {
|
|
|
562
318
|
* reopening, never by the terminal. Idempotence at `retired` is SAME-OP idempotence: a
|
|
563
319
|
* stranger's retry on a terminal gate refuses, it does not "succeed". */
|
|
564
320
|
export async function retireGate(reg, args) {
|
|
565
|
-
|
|
566
|
-
const current = await observeGate(reg, args.lifecycleUid);
|
|
567
|
-
if (current === undefined)
|
|
568
|
-
throw new EpEnvelopeError("not-found", `the issuance gate for ${args.lifecycleUid} does not exist (SPEC 13.1)`);
|
|
569
|
-
if (current.row.state === "retired") {
|
|
570
|
-
if (current.row.op?.opId !== args.opId)
|
|
571
|
-
throw new EpEnvelopeError("permission-denied", `the issuance gate for ${args.lifecycleUid} was terminalized by operation ${current.row.op?.opId ?? "<none>"}, not ${args.opId}; terminal idempotence is same-op idempotence (SPEC 13.1)`);
|
|
572
|
-
return current; // idempotent terminal, same op
|
|
573
|
-
}
|
|
574
|
-
if (current.row.state !== "frozen")
|
|
575
|
-
throw new EpEnvelopeError("failed-precondition", `the issuance gate for ${args.lifecycleUid} is "${current.row.state}"; only a frozen gate terminalizes (freeze first; the bar precedes the terminal, SPEC 13.1)`);
|
|
576
|
-
if (current.row.op?.opId !== args.opId)
|
|
577
|
-
throw new EpEnvelopeError("permission-denied", `the issuance gate for ${args.lifecycleUid} is frozen by operation ${current.row.op?.opId ?? "<none>"}, not ${args.opId}; only the owning operation terminalizes its freeze (SPEC 13.1)`);
|
|
578
|
-
if (current.row.op.kind !== "activation" && current.row.op.kind !== "retirement")
|
|
579
|
-
throw new EpEnvelopeError("failed-precondition", `the issuance gate for ${args.lifecycleUid} is frozen by a ${current.row.op.kind}; only an activation orphan or a retirement terminalizes (a ${current.row.op.kind} aborts by reopening, SPEC 13.1)`);
|
|
580
|
-
const row = { lifecycleUid: current.row.lifecycleUid, state: "retired", generation: current.row.generation, op: current.row.op };
|
|
581
|
-
const revision = await putGate(authKv, args.lifecycleUid, row, args.revision);
|
|
582
|
-
return { row, revision };
|
|
321
|
+
return gateRetire(transportOf(reg), args);
|
|
583
322
|
}
|
|
584
323
|
// ---- the activation saga (§13.1: reserve → gate frozen → head CAS → reopen LAST) -------------
|
|
585
324
|
/**
|
|
@@ -596,48 +335,7 @@ export async function retireGate(reg, args) {
|
|
|
596
335
|
* minting under the reopened gate before this becomes reachable.
|
|
597
336
|
*/
|
|
598
337
|
export async function activateLifecycle(reg, args) {
|
|
599
|
-
|
|
600
|
-
const { owner, actor } = args;
|
|
601
|
-
if (typeof args.managerInstance !== "string" || args.managerInstance.length === 0)
|
|
602
|
-
throw new EpEnvelopeError("failed-precondition", "activateLifecycle requires a managerInstance (the minting authority)");
|
|
603
|
-
const current = await readHeadCandidate(recordsKv, owner, actor);
|
|
604
|
-
if (current !== undefined && current.mapping.state === "active")
|
|
605
|
-
throw new EpEnvelopeError("already-exists", `lifecycle "${owner}/${actor}" is already active (uid ${current.mapping.lifecycleUid}); a takeover advances the epoch through its barrier, it does not re-activate (SPEC 13.1)`);
|
|
606
|
-
if (current !== undefined && current.mapping.state === "retiring")
|
|
607
|
-
throw new EpEnvelopeError("failed-precondition", `lifecycle "${owner}/${actor}" is retiring (op ${current.mapping.op?.opId}); a retiring alias is not replaceable until its barrier completes (SPEC 13.1)`);
|
|
608
|
-
const opId = mintLifecycleUid();
|
|
609
|
-
// 1. Win the space-global UID reservation.
|
|
610
|
-
const lifecycleUid = await reserveLifecycleUid(reg, { owner, actor, mintedBy: args.managerInstance });
|
|
611
|
-
// 2. Create the gate FROZEN under this activation's durable intent (unmintable from birth).
|
|
612
|
-
const gate = await createGateFrozen(reg, { lifecycleUid, op: { opId, kind: "activation" } });
|
|
613
|
-
// 3. CAS the alias head (create-only for virgin; revision-pinned over the retired predecessor).
|
|
614
|
-
const mapping = { owner, actor, lifecycleUid, managerInstance: args.managerInstance, processEpoch: 1, state: "active" };
|
|
615
|
-
let revision;
|
|
616
|
-
try {
|
|
617
|
-
revision = current === undefined
|
|
618
|
-
? await createRecordEntry(recordsKv, headKey(owner, actor), mapping)
|
|
619
|
-
: await updateRecordEntry(recordsKv, headKey(owner, actor), mapping, current.revision);
|
|
620
|
-
}
|
|
621
|
-
catch (e) {
|
|
622
|
-
if (isCasLoss(e)) {
|
|
623
|
-
// The loser terminalizes ITS OWN orphan gate; its UID stays burned (never deleted, never
|
|
624
|
-
// reused). A cleanup failure is NEVER swallowed as success: the caller gets the durable
|
|
625
|
-
// coordinates and resumes the SAME op (resumeActivation) to finish the terminalization.
|
|
626
|
-
try {
|
|
627
|
-
await retireGate(reg, { lifecycleUid, revision: gate.revision, opId });
|
|
628
|
-
}
|
|
629
|
-
catch (cleanup) {
|
|
630
|
-
// The resume coordinates ride STRUCTURED details (never only the prose message): a
|
|
631
|
-
// recovery path reads {uid, opId} from `details`, it does not parse a sentence.
|
|
632
|
-
throw new EpEnvelopeError("unavailable", `lifecycle activation for "${owner}/${actor}" lost the head CAS AND terminalizing its orphan gate failed; the uid ${lifecycleUid} is burned but its gate is still frozen by op ${opId}; resume the same op with resumeActivation: ${cleanup?.message ?? String(cleanup)}`, [{ kind: "resume-activation", owner, actor, lifecycleUid, opId }]);
|
|
633
|
-
}
|
|
634
|
-
throw new EpEnvelopeError("conflict", `lifecycle activation for "${owner}/${actor}" lost the head CAS (a concurrent activation won); this saga's uid ${lifecycleUid} is burned and its gate terminalized (SPEC 13.1)`);
|
|
635
|
-
}
|
|
636
|
-
throw e;
|
|
637
|
-
}
|
|
638
|
-
// 4. Reopen the gate at its first mintable generation — the saga's LAST step.
|
|
639
|
-
await reopenGate(reg, { lifecycleUid, revision: gate.revision, opId });
|
|
640
|
-
return { mapping, revision, opId };
|
|
338
|
+
return runActivationSaga(transportOf(reg), args);
|
|
641
339
|
}
|
|
642
340
|
/**
|
|
643
341
|
* Activate the alias `(owner, actor)` AT THE CALLER'S uid — the production ISSUANCE activation
|
|
@@ -655,90 +353,7 @@ export async function activateLifecycle(reg, args) {
|
|
|
655
353
|
* held by a FOREIGN alias, a foreign-operation freeze, and a terminally retired gate.
|
|
656
354
|
*/
|
|
657
355
|
export async function activateLifecycleAtUid(reg, args) {
|
|
658
|
-
|
|
659
|
-
const { owner, actor, lifecycleUid } = args;
|
|
660
|
-
assertLifecycleToken(lifecycleUid);
|
|
661
|
-
if (typeof args.managerInstance !== "string" || args.managerInstance.length === 0)
|
|
662
|
-
throw new EpEnvelopeError("failed-precondition", "activateLifecycleAtUid requires a managerInstance (the minting authority)");
|
|
663
|
-
const current = await readHeadCandidate(recordsKv, owner, actor);
|
|
664
|
-
if (current !== undefined && current.mapping.state === "active" && current.mapping.lifecycleUid !== lifecycleUid)
|
|
665
|
-
throw new EpEnvelopeError("already-exists", `lifecycle "${owner}/${actor}" is active at uid ${current.mapping.lifecycleUid}, not this grant's ${lifecycleUid}; retiring a live predecessor is the takeover barrier's job and production issuance does not run it (R1) - despawn/retire the predecessor first, or grant a fresh actor name (SPEC 13.1)`);
|
|
666
|
-
if (current !== undefined && current.mapping.state === "retiring")
|
|
667
|
-
throw new EpEnvelopeError("failed-precondition", `lifecycle "${owner}/${actor}" is retiring (op ${current.mapping.op?.opId}); a retiring alias is not replaceable until its barrier completes (SPEC 13.1)`);
|
|
668
|
-
const headIsOurs = current !== undefined && current.mapping.state === "active"; // same uid, by the guard above
|
|
669
|
-
// 1. The uid reservation: win it, or adopt a prior attempt's — SAME alias only.
|
|
670
|
-
if (!headIsOurs && (await tryReserveUid(reg, lifecycleUid, { owner, actor, mintedBy: args.managerInstance })) === "burned") {
|
|
671
|
-
const res = await readUidReservation(reg, lifecycleUid);
|
|
672
|
-
if (res === undefined || res.owner !== owner || res.actor !== actor)
|
|
673
|
-
throw new EpEnvelopeError("permission-denied", `uid ${lifecycleUid} is reserved by ${res ? `"${res.owner}/${res.actor}"` : "an unreadable reservation"}, not "${owner}/${actor}"; a grant's uid is never adopted across aliases (SPEC 13.1)`);
|
|
674
|
-
}
|
|
675
|
-
// 2. The activation gate: create frozen, or adopt OUR prior attempt's frozen activation gate
|
|
676
|
-
// (the reservation above already binds this uid to this alias, so any activation freeze on
|
|
677
|
-
// it is this alias's own activation). A retry loop absorbs the sibling-race CAS losses.
|
|
678
|
-
for (let attempt = 0;; attempt++) {
|
|
679
|
-
if (attempt > 4)
|
|
680
|
-
throw new EpEnvelopeError("unavailable", `activation for "${owner}/${actor}" at uid ${lifecycleUid} keeps losing its gate/head CASes to concurrent movement; re-read and re-decide (SPEC 13.1)`);
|
|
681
|
-
let gate = await observeGate(reg, lifecycleUid);
|
|
682
|
-
let opId;
|
|
683
|
-
if (gate === undefined) {
|
|
684
|
-
try {
|
|
685
|
-
gate = await createGateFrozen(reg, { lifecycleUid, op: { opId: mintLifecycleUid(), kind: "activation" } });
|
|
686
|
-
}
|
|
687
|
-
catch (e) {
|
|
688
|
-
if (isCasLoss(e))
|
|
689
|
-
continue; // a sibling created it; re-observe and adopt
|
|
690
|
-
throw e;
|
|
691
|
-
}
|
|
692
|
-
opId = gate.row.op.opId;
|
|
693
|
-
}
|
|
694
|
-
else if (gate.row.state === "frozen") {
|
|
695
|
-
if (gate.row.op?.kind !== "activation")
|
|
696
|
-
throw new EpEnvelopeError("failed-precondition", `the issuance gate for ${lifecycleUid} is frozen by a ${gate.row.op?.kind ?? "<unknown>"} (op ${gate.row.op?.opId ?? "<none>"}); a barrier is in flight - issuance activation neither adopts nor overrides it (SPEC 13.1)`);
|
|
697
|
-
opId = gate.row.op.opId;
|
|
698
|
-
}
|
|
699
|
-
else if (gate.row.state === "retired") {
|
|
700
|
-
throw new EpEnvelopeError("permission-denied", `uid ${lifecycleUid} has a terminally retired issuance gate; a burned uid never re-activates - re-grant the actor for a fresh incarnation (SPEC 13.1)`);
|
|
701
|
-
}
|
|
702
|
-
else {
|
|
703
|
-
// Open gate: the saga writes the head BEFORE its reopen, so an open gate with the head
|
|
704
|
-
// active at our uid is a COMPLETED activation; anything else is foreign movement.
|
|
705
|
-
const head = await readHeadCandidate(recordsKv, owner, actor);
|
|
706
|
-
if (head !== undefined && head.mapping.state === "active" && head.mapping.lifecycleUid === lifecycleUid)
|
|
707
|
-
return;
|
|
708
|
-
throw new EpEnvelopeError("failed-precondition", `the issuance gate for ${lifecycleUid} is open but the head for "${owner}/${actor}" is ${head === undefined ? "absent" : `${head.mapping.state} at ${head.mapping.lifecycleUid}`}; an activation reopens only AFTER its head CAS - this is foreign movement or corruption, refuse (SPEC 13.1/13.12)`);
|
|
709
|
-
}
|
|
710
|
-
// 3. The head CAS (create-only for virgin; revision-pinned over a retired predecessor). A
|
|
711
|
-
// loss converges if the sibling won for the SAME uid, refuses on a foreign winner.
|
|
712
|
-
if (!headIsOurs) {
|
|
713
|
-
const mapping = { owner, actor, lifecycleUid, managerInstance: args.managerInstance, processEpoch: 1, state: "active" };
|
|
714
|
-
try {
|
|
715
|
-
if (current === undefined)
|
|
716
|
-
await createRecordEntry(recordsKv, headKey(owner, actor), mapping);
|
|
717
|
-
else
|
|
718
|
-
await updateRecordEntry(recordsKv, headKey(owner, actor), mapping, current.revision);
|
|
719
|
-
}
|
|
720
|
-
catch (e) {
|
|
721
|
-
if (!isCasLoss(e))
|
|
722
|
-
throw e;
|
|
723
|
-
const head = await readHeadCandidate(recordsKv, owner, actor);
|
|
724
|
-
if (!(head !== undefined && head.mapping.state === "active" && head.mapping.lifecycleUid === lifecycleUid))
|
|
725
|
-
throw new EpEnvelopeError("conflict", `activation for "${owner}/${actor}" at uid ${lifecycleUid} lost the head CAS to a foreign movement (now ${head === undefined ? "absent" : `${head.mapping.state} at ${head.mapping.lifecycleUid}`}); re-grant raced this exchange - re-exchange (SPEC 13.1)`);
|
|
726
|
-
}
|
|
727
|
-
}
|
|
728
|
-
// 4. Reopen the gate — the saga's LAST step. A loss to the sibling's reopen is convergence.
|
|
729
|
-
try {
|
|
730
|
-
await reopenGate(reg, { lifecycleUid, revision: gate.revision, opId });
|
|
731
|
-
return;
|
|
732
|
-
}
|
|
733
|
-
catch (e) {
|
|
734
|
-
const g = await observeGate(reg, lifecycleUid);
|
|
735
|
-
if (g !== undefined && g.row.state === "open")
|
|
736
|
-
return; // the sibling finished it
|
|
737
|
-
if (e instanceof EpEnvelopeError && e.code === "conflict")
|
|
738
|
-
continue; // revision moved; re-observe
|
|
739
|
-
throw e;
|
|
740
|
-
}
|
|
741
|
-
}
|
|
356
|
+
return runActivationSagaAtUid(transportOf(reg), args);
|
|
742
357
|
}
|
|
743
358
|
/**
|
|
744
359
|
* Resume a crashed activation saga from its durable coordinates (`{alias, lifecycleUid, opId}`,
|
|
@@ -751,35 +366,7 @@ export async function activateLifecycleAtUid(reg, args) {
|
|
|
751
366
|
* what it did.
|
|
752
367
|
*/
|
|
753
368
|
export async function resumeActivation(reg, args) {
|
|
754
|
-
|
|
755
|
-
const head = await readHeadCandidate(recordsKv, args.owner, args.actor);
|
|
756
|
-
const gate = await observeGate(reg, args.lifecycleUid);
|
|
757
|
-
const won = head !== undefined && head.mapping.state === "active" && head.mapping.lifecycleUid === args.lifecycleUid;
|
|
758
|
-
if (gate === undefined) {
|
|
759
|
-
// Crash before step 2: nothing durable beyond the reservation; the uid stays burned.
|
|
760
|
-
if (won)
|
|
761
|
-
throw new EpEnvelopeError("internal", `the head names uid ${args.lifecycleUid} but its gate does not exist; an active head without a gate is corruption (SPEC 13.1)`);
|
|
762
|
-
return "already-settled";
|
|
763
|
-
}
|
|
764
|
-
if (gate.row.state === "retired") {
|
|
765
|
-
// Terminal idempotence is SAME-OP idempotence: a stranger cannot claim another
|
|
766
|
-
// operation's terminal as its own settlement.
|
|
767
|
-
if (gate.row.op?.opId !== args.opId)
|
|
768
|
-
throw new EpEnvelopeError("permission-denied", `the gate for uid ${args.lifecycleUid} was terminalized by operation ${gate.row.op?.opId ?? "<none>"}, not ${args.opId} (SPEC 13.1)`);
|
|
769
|
-
return "already-settled";
|
|
770
|
-
}
|
|
771
|
-
if (gate.row.state === "open") {
|
|
772
|
-
if (!won)
|
|
773
|
-
throw new EpEnvelopeError("internal", `the gate for uid ${args.lifecycleUid} is open but the head does not name it; an open gate without its active head is corruption (SPEC 13.1)`);
|
|
774
|
-
return "already-settled";
|
|
775
|
-
}
|
|
776
|
-
// frozen: only OUR op may advance it (reopen/retire are op-pinned and will refuse a stranger).
|
|
777
|
-
if (won) {
|
|
778
|
-
await reopenGate(reg, { lifecycleUid: args.lifecycleUid, revision: gate.revision, opId: args.opId });
|
|
779
|
-
return "completed";
|
|
780
|
-
}
|
|
781
|
-
await retireGate(reg, { lifecycleUid: args.lifecycleUid, revision: gate.revision, opId: args.opId });
|
|
782
|
-
return "terminalized";
|
|
369
|
+
return resumeActivationSaga(transportOf(reg), args);
|
|
783
370
|
}
|
|
784
371
|
// ---- the leader-served mapping reader (the currency seam) ------------------------------------
|
|
785
372
|
/**
|