@cotal-ai/auth 0.12.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/admission-mediator.d.ts +319 -0
- package/dist/admission-mediator.d.ts.map +1 -0
- package/dist/admission-mediator.js +1033 -0
- package/dist/admission-mediator.js.map +1 -0
- package/dist/auth-admin.d.ts +29 -0
- package/dist/auth-admin.d.ts.map +1 -0
- package/dist/auth-admin.js +224 -0
- package/dist/auth-admin.js.map +1 -0
- package/dist/authority-client.d.ts +215 -0
- package/dist/authority-client.d.ts.map +1 -0
- package/dist/authority-client.js +346 -0
- package/dist/authority-client.js.map +1 -0
- package/dist/barrier-evict.d.ts +16 -0
- package/dist/barrier-evict.d.ts.map +1 -0
- package/dist/barrier-evict.js +100 -0
- package/dist/barrier-evict.js.map +1 -0
- package/dist/connect-reader.d.ts +72 -0
- package/dist/connect-reader.d.ts.map +1 -0
- package/dist/connect-reader.js +154 -0
- package/dist/connect-reader.js.map +1 -0
- package/dist/continuity.d.ts.map +1 -1
- package/dist/continuity.js +3 -0
- package/dist/continuity.js.map +1 -1
- package/dist/credential-ledger.d.ts +343 -0
- package/dist/credential-ledger.d.ts.map +1 -0
- package/dist/credential-ledger.js +732 -0
- package/dist/credential-ledger.js.map +1 -0
- package/dist/drain-repair.d.ts +95 -0
- package/dist/drain-repair.d.ts.map +1 -0
- package/dist/drain-repair.js +305 -0
- package/dist/drain-repair.js.map +1 -0
- package/dist/idp.d.ts +19 -0
- package/dist/idp.d.ts.map +1 -1
- package/dist/idp.js +17 -0
- package/dist/idp.js.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +29 -2
- package/dist/index.js.map +1 -1
- package/dist/issuer.d.ts +8 -0
- package/dist/issuer.d.ts.map +1 -1
- package/dist/issuer.js +4 -2
- package/dist/issuer.js.map +1 -1
- package/dist/ledger-scanner.d.ts +98 -0
- package/dist/ledger-scanner.d.ts.map +1 -0
- package/dist/ledger-scanner.js +352 -0
- package/dist/ledger-scanner.js.map +1 -0
- package/dist/ledger.d.ts +7 -0
- package/dist/ledger.d.ts.map +1 -1
- package/dist/ledger.js +53 -5
- package/dist/ledger.js.map +1 -1
- package/dist/lifecycle-registry.d.ts +397 -0
- package/dist/lifecycle-registry.d.ts.map +1 -0
- package/dist/lifecycle-registry.js +819 -0
- package/dist/lifecycle-registry.js.map +1 -0
- package/dist/permissions.d.ts +5 -1
- package/dist/permissions.d.ts.map +1 -1
- package/dist/permissions.js +29 -6
- package/dist/permissions.js.map +1 -1
- package/dist/plane-claim.d.ts +83 -0
- package/dist/plane-claim.d.ts.map +1 -0
- package/dist/plane-claim.js +290 -0
- package/dist/plane-claim.js.map +1 -0
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +2 -1
- package/dist/provider.js.map +1 -1
- package/dist/records-scanner.d.ts +78 -0
- package/dist/records-scanner.d.ts.map +1 -0
- package/dist/records-scanner.js +320 -0
- package/dist/records-scanner.js.map +1 -0
- package/dist/retirement-barrier.d.ts +217 -0
- package/dist/retirement-barrier.d.ts.map +1 -0
- package/dist/retirement-barrier.js +624 -0
- package/dist/retirement-barrier.js.map +1 -0
- package/dist/retirement-cleaner.d.ts +54 -0
- package/dist/retirement-cleaner.d.ts.map +1 -0
- package/dist/retirement-cleaner.js +158 -0
- package/dist/retirement-cleaner.js.map +1 -0
- package/dist/root-credential.d.ts +26 -0
- package/dist/root-credential.d.ts.map +1 -0
- package/dist/root-credential.js +116 -0
- package/dist/root-credential.js.map +1 -0
- package/dist/service.d.ts +60 -0
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +317 -10
- package/dist/service.js.map +1 -1
- package/dist/session-ledger.d.ts +224 -0
- package/dist/session-ledger.d.ts.map +1 -0
- package/dist/session-ledger.js +713 -0
- package/dist/session-ledger.js.map +1 -0
- package/dist/token.d.ts +17 -0
- package/dist/token.d.ts.map +1 -1
- package/dist/token.js +30 -2
- package/dist/token.js.map +1 -1
- package/package.json +5 -3
|
@@ -0,0 +1,1033 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The D13 (4) ADMISSION MEDIATOR + admission-policy coordinate (SPEC §13.6/§13.8/§13.9, as
|
|
3
|
+
* amended): the per-endpoint mediated writer that owns the `oblig.` prefix — the ONE durable
|
|
4
|
+
* serialization coordinate on which a durable acceptance/start contends with its authority
|
|
5
|
+
* head's movement — plus the govern-head policy selector's stage/drain/promote protocol and
|
|
6
|
+
* the immutable `policy.<endpoint>.<digest-hex>` version publication.
|
|
7
|
+
*
|
|
8
|
+
* The §13.8 protocol, implemented here end to end:
|
|
9
|
+
* 1. CREATE-FENCE: immediately before the create, the mediator performs the FENCING currency
|
|
10
|
+
* reads it will pin — a leader-served read of the target's lifecycle head (REFUSING unless
|
|
11
|
+
* `active` at the presented uid) for a target-bound admission, and a leader-served read of
|
|
12
|
+
* the enforced policy under the govern head for a policy-admitted decision (REFUSING while
|
|
13
|
+
* a `pendingPolicy…` is staged: the endpoint is inside its drain window). It then creates
|
|
14
|
+
* the obligation row create-only at the deterministic acceptance-identity key; a create
|
|
15
|
+
* loser leader-reads the winner and JOINS only on the FULL pinned identity (an `epf` row on
|
|
16
|
+
* fingerprint + route; a `self` row on the ENTIRE commit intent), else `conflict`.
|
|
17
|
+
* 2. PROOF-GATED ADMISSION: after winning or joining, the mediator leader-reads the SAME
|
|
18
|
+
* coordinates AGAIN and only then returns the opaque admission proof (BRANDED, bounded-
|
|
19
|
+
* lived, bound to `{ space, endpoint, obligation key, opId }`); on any movement it settles
|
|
20
|
+
* its own provisional through the row's per-class decision coordinate and refuses. An
|
|
21
|
+
* obligation created in the movement window exists durably but can never admit.
|
|
22
|
+
* 3. PER-CLASS DECISION COORDINATES: an `epf` row settles through the EPF decision subject's
|
|
23
|
+
* create-only CAS (the mediator holds the terminal-REJECTION publish, §13.9, with the
|
|
24
|
+
* explicit D32 residual); a `self` row settles on ITSELF (`provisional → accepted` by the
|
|
25
|
+
* writer under an unexpired proof vs `provisional → rejected` by the drain, one CAS wins),
|
|
26
|
+
* and an `accepted` `self` row is deterministically finishable from its pinned commit
|
|
27
|
+
* intent alone ({@link recoverSelfObligation}: landed / re-apply / superseded).
|
|
28
|
+
* 4. DRAIN TO QUIESCENCE: enumerate the prefix (per-run throwaway LastPerSubject PULL
|
|
29
|
+
* consumer, fail-loud on markers and parse failures), settle every provisional through its
|
|
30
|
+
* decision coordinate, drive every accepted `self` row to terminal, RE-ENUMERATE until an
|
|
31
|
+
* enumeration finds no unsettled row. Quiescence = no `provisional` and no un-driven
|
|
32
|
+
* accepted `self` rows (§13.8; accepted `epf` work is tracked by its route facts).
|
|
33
|
+
*
|
|
34
|
+
* AUTHORITY BOUNDARIES (§13.9): the mediator's own grant is the `oblig.` create + CAS, the
|
|
35
|
+
* FENCING leader reads, and the EPF terminal-rejection publish. The guarded commit's WRITE
|
|
36
|
+
* stays with the writer principal — recovery takes an injected `applyCommit` and the mediator
|
|
37
|
+
* never holds the record-write grant itself. The policy-version publish and the govern-head
|
|
38
|
+
* selector CAS belong to the provisioner-registration principal, so those functions take the
|
|
39
|
+
* sealed {@link LifecycleRegistry} (the reference implementation's provisioner authority),
|
|
40
|
+
* not the mediator. The endpoint identity bind (§13.8's confined policy reader) is structural
|
|
41
|
+
* here: the mediator is SEALED per endpoint at open, every obligation key derives its endpoint
|
|
42
|
+
* token from the sealed identity, and a proof never validates for another endpoint's mediator.
|
|
43
|
+
*
|
|
44
|
+
* NOT HERE (later slices): the exact-pool terminal cleaner and the retirement barrier
|
|
45
|
+
* orchestration (D13 (5)) consume {@link drainTargetForEndpoint}; the production canonicalizer
|
|
46
|
+
* wiring rides the EPF slice.
|
|
47
|
+
*/
|
|
48
|
+
import { jetstream, jetstreamManager } from "@nats-io/jetstream";
|
|
49
|
+
import { Kvm } from "@nats-io/kv";
|
|
50
|
+
import { EpEnvelopeError, OBLIGATION, OBLIGATION_EP_SENTINEL, POLICY_VERSION, GOVERN_HEAD, recordAtomicKey, createRecordEntry, updateRecordEntry, readRecordLeader, recordsBucket, epfEffectSubject, epfSubject, epfStreamName, epwSubject, epwStreamName, goalResultSubject, parseGoalResultFact, publishFactCreateOnly, readLastFact, parseDecisionFact, parseEffectFact, readGoalStatusByRefLeader, workTerminalSubject, parseWorkTerminalFact, workItemBytesOf, contractDigest, parseEpSubject, mintLifecycleUid, assertLifecycleToken, assertIdToken, endpointToken, assertPoolToken, } from "@cotal-ai/core";
|
|
51
|
+
import { assertAuthorityStreamShape, registryStores, openLifecycleMappingReader, readLifecycleMappingLeader, } from "./lifecycle-registry.js";
|
|
52
|
+
import { assertRecordsScannerSpace } from "./records-scanner.js";
|
|
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
|
+
/** Every `*Digest` scalar on the wire is `sha256:<hex>` (§13.7/§13.8), the shape core's own fact
|
|
58
|
+
* validators and canonicalizer emit; the mediator stores and compares that shape end to end and
|
|
59
|
+
* strips the prefix only at a raw-hash boundary. */
|
|
60
|
+
const DIGEST_SCALAR_RE = /^sha256:[0-9a-f]{64}$/;
|
|
61
|
+
/** The §13.4 error-detail bound: `parseDecisionFact` rejects a longer detail, so a rejection the
|
|
62
|
+
* mediator publishes must fit it or core would refuse the mediator's own fact. */
|
|
63
|
+
const MAX_ERROR_DETAIL = 256;
|
|
64
|
+
/** The proof TTL ceiling (§13.8: a proof is bounded-lived; 60 s is 4x the reference call
|
|
65
|
+
* deadline, headroom for a slow admission without letting a proof outlive its state). */
|
|
66
|
+
const MAX_PROOF_TTL_MS = 60_000;
|
|
67
|
+
/** Truncate a string to at most `maxBytes` UTF-8 bytes without splitting a multibyte character
|
|
68
|
+
* (the §13.4 error-detail bound is a BYTE limit; a UTF-16 `.slice` can overshoot it). */
|
|
69
|
+
function truncateUtf8(s, maxBytes) {
|
|
70
|
+
if (Buffer.byteLength(s, "utf8") <= maxBytes)
|
|
71
|
+
return s;
|
|
72
|
+
let out = s;
|
|
73
|
+
while (Buffer.byteLength(out, "utf8") > maxBytes)
|
|
74
|
+
out = out.slice(0, -1);
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
const isCasLoss = (e) => e instanceof EpEnvelopeError && e.code === "conflict";
|
|
78
|
+
/** The CANONICAL content digest of a JSON value, `<hex>` (no prefix) for a `policy` key segment.
|
|
79
|
+
* Uses core's RFC-8785 canonicalizer (`contractDigest` = `sha256:<hex over I-JSON>`), so
|
|
80
|
+
* publication is insensitive to property order and a second implementation that content-
|
|
81
|
+
* addresses the same policy lands on the SAME key; also validates the value is I-JSON (throws
|
|
82
|
+
* otherwise), so a non-canonicalizable policy is refused before any create. */
|
|
83
|
+
const canonicalDigestHex = (value) => contractDigest(value).slice("sha256:".length);
|
|
84
|
+
const MEDIATORS = new WeakMap();
|
|
85
|
+
function internals(med) {
|
|
86
|
+
const i = MEDIATORS.get(med);
|
|
87
|
+
if (!i)
|
|
88
|
+
throw new EpEnvelopeError("failed-precondition", "the admission mediator was not constructed by openAdmissionMediator(); a hand-assembled context never authorizes (SPEC 13.12)");
|
|
89
|
+
return i;
|
|
90
|
+
}
|
|
91
|
+
/** Open the sealed per-endpoint admission mediator over the space's PRIMARY records store —
|
|
92
|
+
* shape-proved at bind exactly like every trusted consumer of that store (SPEC 13.12).
|
|
93
|
+
* `now`/`proofTtlMs` are probe seams; the proof TTL defaults to the §13.8 reference call
|
|
94
|
+
* deadline (15 s). `recordsScanner` is the SEALED records-obligation scanner the mediator's
|
|
95
|
+
* drain-to-quiescence enumeration runs on (SPEC 13.9, site 3): the mediator's own credential holds
|
|
96
|
+
* NO `CONSUMER.CREATE` on the records stream (a create-request body is not subject-ACL confinable —
|
|
97
|
+
* nats-server#8274), so it can never build a durable+PUSH exporter of its `oblig.` subtree; the
|
|
98
|
+
* CREATE lives only inside {@link ./records-scanner.ts}. Required: a mediator that cannot enumerate
|
|
99
|
+
* cannot drain (SPEC 13.8), which is a composition bug, not a silent degrade. */
|
|
100
|
+
export async function openAdmissionMediator(nc, space, endpoint, opts) {
|
|
101
|
+
assertRecordsScannerSpace(opts.recordsScanner, space);
|
|
102
|
+
const ep = endpointToken(endpoint);
|
|
103
|
+
const bucket = recordsBucket(space);
|
|
104
|
+
const jsm = await jetstreamManager(nc);
|
|
105
|
+
let recordsKv;
|
|
106
|
+
let cfg;
|
|
107
|
+
try {
|
|
108
|
+
recordsKv = await new Kvm(nc).open(bucket);
|
|
109
|
+
cfg = (await jsm.streams.info(`KV_${bucket}`)).config;
|
|
110
|
+
}
|
|
111
|
+
catch (e) {
|
|
112
|
+
throw new EpEnvelopeError("failed-precondition", `the records store ${bucket} is not provisioned (run space setup; SPEC 13.12): ${e?.message ?? String(e)}`);
|
|
113
|
+
}
|
|
114
|
+
assertAuthorityStreamShape(cfg, bucket);
|
|
115
|
+
const reader = await openLifecycleMappingReader(nc, space);
|
|
116
|
+
// The proof TTL is CAPPED to the reference call-deadline ceiling (§13.8: a proof is
|
|
117
|
+
// bounded-lived, and an effectively-unbounded TTL would let a stale proof outlive the state
|
|
118
|
+
// it was issued against). A larger value fails loud rather than being silently clamped.
|
|
119
|
+
if (opts.proofTtlMs !== undefined && (!Number.isSafeInteger(opts.proofTtlMs) || opts.proofTtlMs <= 0 || opts.proofTtlMs > MAX_PROOF_TTL_MS))
|
|
120
|
+
throw new EpEnvelopeError("failed-precondition", `proofTtlMs ${JSON.stringify(opts.proofTtlMs)} is not a positive safe integer within the ${MAX_PROOF_TTL_MS} ms bound (a proof is bounded-lived, SPEC 13.8)`);
|
|
121
|
+
const med = Object.freeze({ space, endpoint: ep });
|
|
122
|
+
MEDIATORS.set(med, {
|
|
123
|
+
space, endpoint: ep, recordsKv, jsm, js: jetstream(nc), reader,
|
|
124
|
+
now: opts.now ?? Date.now, proofTtlMs: opts.proofTtlMs ?? 15_000,
|
|
125
|
+
recordsScanner: opts.recordsScanner,
|
|
126
|
+
});
|
|
127
|
+
return med;
|
|
128
|
+
}
|
|
129
|
+
const MEDIATED_REQUESTS = new WeakSet();
|
|
130
|
+
/** Build the branded mediated request from the RAW authenticated request subject the broker
|
|
131
|
+
* delivered (an `ep`-plane request or an `epj`-plane journal submission). Core's
|
|
132
|
+
* {@link parseEpSubject} extracts the endpoint + caller triple STRUCTURALLY from the subject;
|
|
133
|
+
* a malformed subject, or one that is not a request/journal, refuses. The `id` (the
|
|
134
|
+
* caller-chosen request id, §13.4) is NOT in the subject and stays an explicit operation
|
|
135
|
+
* argument; everything that identifies WHO the caller is comes from here. */
|
|
136
|
+
export function mediatedRequestFromSubject(subject) {
|
|
137
|
+
const parsed = parseEpSubject(subject);
|
|
138
|
+
if (parsed === null || (parsed.plane !== "request" && parsed.plane !== "journal"))
|
|
139
|
+
throw new EpEnvelopeError("failed-precondition", `the admission request subject ${JSON.stringify(subject)} is not an authenticated request/journal subject; the obligation identity derives from the subject, never a body field (SPEC 13.8)`);
|
|
140
|
+
const c = parsed.caller;
|
|
141
|
+
const req = Object.freeze({ endpoint: parsed.endpoint, caller: Object.freeze({ owner: c.owner, actor: c.actor, uid: c.uid }) });
|
|
142
|
+
MEDIATED_REQUESTS.add(req);
|
|
143
|
+
return req;
|
|
144
|
+
}
|
|
145
|
+
function assertMediatedRequest(med, request) {
|
|
146
|
+
if (!MEDIATED_REQUESTS.has(request))
|
|
147
|
+
throw new EpEnvelopeError("permission-denied", "the admission request was not derived from an authenticated subject via mediatedRequestFromSubject(); a hand-assembled caller identity never authorizes (SPEC 13.8/13.12)");
|
|
148
|
+
if (request.endpoint !== med.endpoint)
|
|
149
|
+
throw new EpEnvelopeError("permission-denied", `the admission request is for endpoint "${request.endpoint}", not this mediator's "${med.endpoint}"; a mediator admits only its own endpoint's requests (SPEC 13.8/13.9)`);
|
|
150
|
+
}
|
|
151
|
+
function obligationKey(med, request, op) {
|
|
152
|
+
const target = op.target === undefined ? OBLIGATION_EP_SENTINEL : assertLifecycleToken(op.target.lifecycleUid, "target lifecycleUid");
|
|
153
|
+
return recordAtomicKey(OBLIGATION, [target, med.endpoint, request.caller.owner, request.caller.actor, request.caller.uid, op.id]);
|
|
154
|
+
}
|
|
155
|
+
const OBLIGATION_STATES = new Set(["provisional", "accepted", "rejected", "terminal"]);
|
|
156
|
+
const ROUTE_RE = /^(effects|pool\.[a-z0-9_-]{1,64})$/;
|
|
157
|
+
const DIGEST_HEX_RE = /^[0-9a-f]{64}$/; // the `policy.<endpoint>.<digest-hex>` KEY segment is bare hex (§13.7)
|
|
158
|
+
/** The submission fingerprint core's `parseDecisionFact` accepts — a `sha256:<hex>` scalar (the
|
|
159
|
+
* same shape `submissionFingerprint` emits). A row whose fingerprint is not this cannot settle
|
|
160
|
+
* through a core-conformant rejection fact, so it is refused at obtain, never persisted. */
|
|
161
|
+
const FINGERPRINT_RE = DIGEST_SCALAR_RE;
|
|
162
|
+
function parseCommitValue(v, key) {
|
|
163
|
+
if (!isRec(v))
|
|
164
|
+
throw new EpEnvelopeError("internal", `the obligation ${key} carries a non-object commitValue (SPEC 13.8)`);
|
|
165
|
+
const keys = Object.keys(v).sort();
|
|
166
|
+
if (v.enc === "b64u" && keys.join(",") === "bytes,enc" && typeof v.bytes === "string" && /^[A-Za-z0-9_-]*$/.test(v.bytes))
|
|
167
|
+
return v;
|
|
168
|
+
if (v.enc === "ref" && keys.join(",") === "enc,key" && typeof v.key === "string" && v.key.length > 0)
|
|
169
|
+
return v;
|
|
170
|
+
throw new EpEnvelopeError("internal", `the obligation ${key} carries a malformed commitValue (the CLOSED b64u|ref union, SPEC 13.8)`);
|
|
171
|
+
}
|
|
172
|
+
/** Validate an obligation row at the consuming boundary: CLOSED per-class schema, at least one
|
|
173
|
+
* currency pin, class-required fields present and class-foreign fields absent (SPEC 13.8). */
|
|
174
|
+
export function parseObligationRow(raw, key) {
|
|
175
|
+
let o;
|
|
176
|
+
try {
|
|
177
|
+
o = JSON.parse(dec.decode(raw));
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
throw new EpEnvelopeError("internal", `the obligation ${key} is not JSON; garbled trusted-path state never authorizes (SPEC 13.8)`);
|
|
181
|
+
}
|
|
182
|
+
if (!isRec(o))
|
|
183
|
+
throw new EpEnvelopeError("internal", `the obligation ${key} is not an object`);
|
|
184
|
+
const allowed = new Set(["state", "decision", "opId", "mappingRevision", "policyRevision", "fingerprint", "sourceSeq", "route", "commit"]);
|
|
185
|
+
for (const k of Object.keys(o))
|
|
186
|
+
if (!allowed.has(k))
|
|
187
|
+
throw new EpEnvelopeError("internal", `the obligation ${key} carries the unknown field "${k}" (closed schema, SPEC 13.8)`);
|
|
188
|
+
if (typeof o.state !== "string" || !OBLIGATION_STATES.has(o.state) || (o.decision !== "epf" && o.decision !== "self") || typeof o.opId !== "string")
|
|
189
|
+
throw new EpEnvelopeError("internal", `the obligation ${key} does not validate (state/decision/opId, SPEC 13.8)`);
|
|
190
|
+
try {
|
|
191
|
+
assertLifecycleToken(o.opId);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
throw new EpEnvelopeError("internal", `the obligation ${key} carries a malformed opId (SPEC 13.8)`);
|
|
195
|
+
}
|
|
196
|
+
if (o.mappingRevision !== undefined && !uint(o.mappingRevision))
|
|
197
|
+
throw new EpEnvelopeError("internal", `the obligation ${key} carries a non-integer mappingRevision (SPEC 13.8)`);
|
|
198
|
+
if (o.policyRevision !== undefined && !uint(o.policyRevision))
|
|
199
|
+
throw new EpEnvelopeError("internal", `the obligation ${key} carries a non-integer policyRevision (SPEC 13.8)`);
|
|
200
|
+
if (o.mappingRevision === undefined && o.policyRevision === undefined)
|
|
201
|
+
throw new EpEnvelopeError("internal", `the obligation ${key} pins NO currency coordinate (at least one of mappingRevision/policyRevision, SPEC 13.8)`);
|
|
202
|
+
if (o.decision === "epf") {
|
|
203
|
+
if (o.commit !== undefined)
|
|
204
|
+
throw new EpEnvelopeError("internal", `the obligation ${key} is epf-class but carries a self-class commit intent (SPEC 13.8)`);
|
|
205
|
+
if (typeof o.fingerprint !== "string" || !FINGERPRINT_RE.test(o.fingerprint) || !uint(o.sourceSeq) || o.sourceSeq < 1 || typeof o.route !== "string" || !ROUTE_RE.test(o.route))
|
|
206
|
+
throw new EpEnvelopeError("internal", `the obligation ${key} is epf-class without a valid { fingerprint (sha256:<hex>), sourceSeq (>=1), route } (SPEC 13.4/13.8)`);
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
if (o.fingerprint !== undefined || o.sourceSeq !== undefined || o.route !== undefined)
|
|
210
|
+
throw new EpEnvelopeError("internal", `the obligation ${key} is self-class but carries epf-class fields (SPEC 13.8)`);
|
|
211
|
+
if (!isRec(o.commit))
|
|
212
|
+
throw new EpEnvelopeError("internal", `the obligation ${key} is self-class without its commit intent (SPEC 13.8)`);
|
|
213
|
+
const cm = o.commit;
|
|
214
|
+
for (const k of Object.keys(cm))
|
|
215
|
+
if (!["commitKey", "commitBaseRevision", "commitValue", "commitDigest"].includes(k))
|
|
216
|
+
throw new EpEnvelopeError("internal", `the obligation ${key} commit intent carries the unknown field "${k}" (closed schema, SPEC 13.8)`);
|
|
217
|
+
if (typeof cm.commitKey !== "string" || cm.commitKey.length === 0 || !uint(cm.commitBaseRevision) || typeof cm.commitDigest !== "string" || !DIGEST_SCALAR_RE.test(cm.commitDigest))
|
|
218
|
+
throw new EpEnvelopeError("internal", `the obligation ${key} commit intent does not validate (commitDigest is sha256:<hex>, SPEC 13.8)`);
|
|
219
|
+
parseCommitValue(cm.commitValue, key);
|
|
220
|
+
}
|
|
221
|
+
return o;
|
|
222
|
+
}
|
|
223
|
+
async function readObligationLeader(med, key) {
|
|
224
|
+
const read = await readRecordLeader(med.jsm, med.space, key);
|
|
225
|
+
if (read === undefined)
|
|
226
|
+
return undefined;
|
|
227
|
+
return { row: parseObligationRow(enc.encode(JSON.stringify(read.value)), key), revision: read.revision };
|
|
228
|
+
}
|
|
229
|
+
function parseSelector(head, key) {
|
|
230
|
+
const sel = {};
|
|
231
|
+
const pair = (kk, rk) => {
|
|
232
|
+
const k = head[kk], r = head[rk];
|
|
233
|
+
if ((k === undefined) !== (r === undefined))
|
|
234
|
+
throw new EpEnvelopeError("internal", `the govern head ${key} carries ${kk}/${rk} unpaired; the selector is atomic (SPEC 13.6)`);
|
|
235
|
+
if (k === undefined)
|
|
236
|
+
return;
|
|
237
|
+
if (typeof k !== "string" || k.length === 0 || !uint(r))
|
|
238
|
+
throw new EpEnvelopeError("internal", `the govern head ${key} carries a malformed ${kk}/${rk} (SPEC 13.6)`);
|
|
239
|
+
sel[kk] = k;
|
|
240
|
+
sel[rk] = r;
|
|
241
|
+
};
|
|
242
|
+
pair("enforcedPolicyKey", "enforcedPolicyRevision");
|
|
243
|
+
pair("pendingPolicyKey", "pendingPolicyRevision");
|
|
244
|
+
return sel;
|
|
245
|
+
}
|
|
246
|
+
/** Publish ONE immutable policy version (§13.7 `policy.<endpoint>.<digest-hex>`): the key is the
|
|
247
|
+
* RFC-8785 CANONICAL content digest of the value (property-order-insensitive, so a conforming
|
|
248
|
+
* second implementation content-addresses the same policy to the same key), so publication is
|
|
249
|
+
* content-addressed and idempotent; an existing key whose value does not re-digest to it is
|
|
250
|
+
* corruption. Provisioner-registration authority (§13.9): takes the sealed registry, not the
|
|
251
|
+
* mediator. */
|
|
252
|
+
export async function publishPolicyVersion(reg, endpoint, value) {
|
|
253
|
+
const { recordsKv } = registryStores(reg);
|
|
254
|
+
const digestHex = canonicalDigestHex(value); // throws on non-I-JSON before any create
|
|
255
|
+
const key = recordAtomicKey(POLICY_VERSION, [endpoint, digestHex]);
|
|
256
|
+
try {
|
|
257
|
+
const revision = await createRecordEntry(recordsKv, key, value);
|
|
258
|
+
return { key, revision, digestHex };
|
|
259
|
+
}
|
|
260
|
+
catch (e) {
|
|
261
|
+
if (!isCasLoss(e))
|
|
262
|
+
throw e;
|
|
263
|
+
const existing = await recordsKv.get(key);
|
|
264
|
+
if (!existing || existing.operation !== "PUT")
|
|
265
|
+
throw new EpEnvelopeError("failed-precondition", `the policy version ${key} carries a ${existing?.operation ?? "missing"} marker; policy versions are never deleted (corruption, SPEC 13.12)`);
|
|
266
|
+
if (canonicalDigestHex(JSON.parse(dec.decode(existing.value))) !== digestHex)
|
|
267
|
+
throw new EpEnvelopeError("internal", `the policy version ${key} exists with a value that does not canonically digest to its own key; a self-certifying key never lies (corruption, SPEC 13.7)`);
|
|
268
|
+
return { key, revision: existing.revision, digestHex };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/** Leader-read + SELF-CERTIFY one immutable policy version: the stored bytes must digest to
|
|
272
|
+
* the key's own `<digest-hex>`, and the key must belong to THIS endpoint (the §13.8 identity
|
|
273
|
+
* bind: endpoint A can never be governed by a key smuggled from endpoint B's family). */
|
|
274
|
+
async function readPolicyVersionCertified(jsm, space, endpoint, key) {
|
|
275
|
+
const expectedPrefix = `policy.${endpoint}.`;
|
|
276
|
+
if (!key.startsWith(expectedPrefix))
|
|
277
|
+
throw new EpEnvelopeError("failed-precondition", `the policy key ${key} does not belong to endpoint "${endpoint}" (the selector names only this endpoint's own policy family, SPEC 13.6)`);
|
|
278
|
+
const digestHex = key.slice(expectedPrefix.length);
|
|
279
|
+
if (!DIGEST_HEX_RE.test(digestHex))
|
|
280
|
+
throw new EpEnvelopeError("failed-precondition", `the policy key ${key} is not digest-addressed (SPEC 13.7)`);
|
|
281
|
+
const read = await readRecordLeader(jsm, space, key);
|
|
282
|
+
if (read === undefined)
|
|
283
|
+
throw new EpEnvelopeError("failed-precondition", `the policy version ${key} does not exist; the selector names a published immutable version only (SPEC 13.6)`);
|
|
284
|
+
if (canonicalDigestHex(read.value) !== digestHex)
|
|
285
|
+
throw new EpEnvelopeError("internal", `the policy version ${key} does not canonically digest to its own key; a self-certifying key never lies (corruption, SPEC 13.7)`);
|
|
286
|
+
return read;
|
|
287
|
+
}
|
|
288
|
+
/** LEADER-SERVED govern-head read (§13.9: every govern read that fences a stage/promote/drain
|
|
289
|
+
* decision is leader-served `STREAM.MSG.GET`, never a follower Direct Get, or a multi-server
|
|
290
|
+
* stage/promote could act on a stale selector). `readRecordLeader` refuses a DEL/PURGE marker
|
|
291
|
+
* as corruption. Returns the head object + its store revision (the stage/promote CAS pins it). */
|
|
292
|
+
async function readGovernHeadRaw(jsm, space, endpoint) {
|
|
293
|
+
const key = recordAtomicKey(GOVERN_HEAD, [endpoint]);
|
|
294
|
+
const read = await readRecordLeader(jsm, space, key);
|
|
295
|
+
if (read === undefined)
|
|
296
|
+
return undefined;
|
|
297
|
+
if (!isRec(read.value))
|
|
298
|
+
throw new EpEnvelopeError("internal", `the govern head ${key} is not an object`);
|
|
299
|
+
return { head: read.value, revision: read.revision };
|
|
300
|
+
}
|
|
301
|
+
/** STAGE a policy mutation (§13.6 step 1, provisioner authority): verify the NEW immutable
|
|
302
|
+
* version exists and self-certifies, then CAS `pendingPolicy{Key,Revision}` onto the govern
|
|
303
|
+
* head, preserving every other head field verbatim (the binding map and registration slot are
|
|
304
|
+
* the registration path's). A virgin head is created with an empty binding map. A pending
|
|
305
|
+
* already staged refuses (`conflict`: one mutation at a time; promote or abandon it first). */
|
|
306
|
+
export async function stagePolicySelector(reg, endpoint, policyKey) {
|
|
307
|
+
const { recordsKv, jsm, space } = registryStores(reg);
|
|
308
|
+
const ep = endpointToken(endpoint);
|
|
309
|
+
const version = await readPolicyVersionCertified(jsm, space, ep, policyKey);
|
|
310
|
+
const govKey = recordAtomicKey(GOVERN_HEAD, [ep]);
|
|
311
|
+
const cur = await readGovernHeadRaw(jsm, space, ep);
|
|
312
|
+
const head = cur === undefined ? { commands: {} } : { ...cur.head };
|
|
313
|
+
parseSelector(head, govKey);
|
|
314
|
+
if (head.pendingPolicyKey !== undefined)
|
|
315
|
+
throw new EpEnvelopeError("conflict", `the govern head ${govKey} already stages pending policy ${String(head.pendingPolicyKey)}; one mutation at a time; promote or abandon it first (SPEC 13.6)`);
|
|
316
|
+
// A UNIQUE mutation opId stamped on the head binds this exact stage: a drain witness carries it,
|
|
317
|
+
// and a later re-stage (a NEW opId) invalidates any witness from an earlier stage, even for the
|
|
318
|
+
// same content-addressed policy key (the freelance's stage→drain→promote→re-stage→reuse attack).
|
|
319
|
+
const mutationOpId = mintLifecycleUid();
|
|
320
|
+
head.pendingPolicyKey = policyKey;
|
|
321
|
+
head.pendingPolicyRevision = version.revision;
|
|
322
|
+
head.pendingMutationOpId = mutationOpId;
|
|
323
|
+
const governRevision = cur === undefined
|
|
324
|
+
? await createRecordEntry(recordsKv, govKey, head)
|
|
325
|
+
: await updateRecordEntry(recordsKv, govKey, head, cur.revision);
|
|
326
|
+
return { pendingPolicyKey: policyKey, pendingPolicyRevision: version.revision, mutationOpId, governRevision };
|
|
327
|
+
}
|
|
328
|
+
const QUIESCENCE = new WeakSet();
|
|
329
|
+
/** PROMOTE a staged policy mutation (§13.6 step 2, provisioner authority): only under the
|
|
330
|
+
* branded drain witness for the SAME endpoint and pending key. Moves `pendingPolicy…` into
|
|
331
|
+
* `enforcedPolicy…` and clears the pending slot, preserving every other head field. */
|
|
332
|
+
export async function promotePolicySelector(reg, endpoint, quiescence) {
|
|
333
|
+
const { recordsKv, jsm, space } = registryStores(reg);
|
|
334
|
+
const ep = endpointToken(endpoint);
|
|
335
|
+
if (!QUIESCENCE.has(quiescence))
|
|
336
|
+
throw new EpEnvelopeError("failed-precondition", "the promote was not given a drain-minted quiescence witness; a hand-assembled witness never authorizes (SPEC 13.6/13.12)");
|
|
337
|
+
if (quiescence.space !== space)
|
|
338
|
+
throw new EpEnvelopeError("permission-denied", `the quiescence witness belongs to space "${quiescence.space}", not "${space}"; a witness never crosses spaces (SPEC 13.6)`);
|
|
339
|
+
if (quiescence.endpoint !== ep)
|
|
340
|
+
throw new EpEnvelopeError("permission-denied", `the quiescence witness belongs to endpoint "${quiescence.endpoint}", not "${ep}" (SPEC 13.6)`);
|
|
341
|
+
const govKey = recordAtomicKey(GOVERN_HEAD, [ep]);
|
|
342
|
+
const cur = await readGovernHeadRaw(jsm, space, ep); // leader-served fence
|
|
343
|
+
if (cur === undefined)
|
|
344
|
+
throw new EpEnvelopeError("failed-precondition", `the govern head ${govKey} does not exist; nothing is staged (SPEC 13.6)`);
|
|
345
|
+
// Bind to the EXACT staged mutation: the head must still carry the witness's pending key AND
|
|
346
|
+
// the witness's mutation opId AND be at the witness's captured store revision. A re-stage
|
|
347
|
+
// (new opId, moved revision), a clear/restage, or a foreign promote in between all fail here,
|
|
348
|
+
// and the CAS from that exact revision is the final serialization.
|
|
349
|
+
const sel = parseSelector(cur.head, govKey);
|
|
350
|
+
if (sel.pendingPolicyKey === undefined)
|
|
351
|
+
throw new EpEnvelopeError("failed-precondition", `the govern head ${govKey} stages no pending policy; nothing to promote (SPEC 13.6)`);
|
|
352
|
+
if (sel.pendingPolicyKey !== quiescence.pendingPolicyKey || sel.pendingPolicyRevision !== quiescence.pendingPolicyRevision || cur.head.pendingMutationOpId !== quiescence.mutationOpId || cur.revision !== quiescence.governStageRevision)
|
|
353
|
+
throw new EpEnvelopeError("conflict", `the govern head ${govKey} moved since the witness was minted (staged ${String(sel.pendingPolicyKey)}@${String(sel.pendingPolicyRevision)} op ${String(cur.head.pendingMutationOpId)} rev ${cur.revision}, witness ${quiescence.pendingPolicyKey}@${quiescence.pendingPolicyRevision} op ${quiescence.mutationOpId} rev ${quiescence.governStageRevision}); drain again under the current stage (SPEC 13.6)`);
|
|
354
|
+
const head = { ...cur.head };
|
|
355
|
+
head.enforcedPolicyKey = sel.pendingPolicyKey;
|
|
356
|
+
head.enforcedPolicyRevision = sel.pendingPolicyRevision;
|
|
357
|
+
delete head.pendingPolicyKey;
|
|
358
|
+
delete head.pendingPolicyRevision;
|
|
359
|
+
delete head.pendingMutationOpId;
|
|
360
|
+
await updateRecordEntry(recordsKv, govKey, head, cur.revision);
|
|
361
|
+
QUIESCENCE.delete(quiescence); // consume: a witness authorizes exactly ONE promote
|
|
362
|
+
return { enforcedPolicyKey: sel.pendingPolicyKey, enforcedPolicyRevision: sel.pendingPolicyRevision };
|
|
363
|
+
}
|
|
364
|
+
/** The policy currency read (§13.6): leader-read the govern head, REFUSE while a pending
|
|
365
|
+
* policy is staged (the drain-window pause), follow `enforcedPolicyKey`, self-certify it, and
|
|
366
|
+
* RE-PROVE it is still at `enforcedPolicyRevision`. Public as {@link readEnforcedPolicy}. */
|
|
367
|
+
async function readPolicyCurrency(med) {
|
|
368
|
+
const govKey = recordAtomicKey(GOVERN_HEAD, [med.endpoint]);
|
|
369
|
+
const read = await readRecordLeader(med.jsm, med.space, govKey);
|
|
370
|
+
if (read === undefined || !isRec(read.value))
|
|
371
|
+
throw new EpEnvelopeError("failed-precondition", `endpoint "${med.endpoint}" has no govern head; no admission policy was ever enforced (SPEC 13.6)`);
|
|
372
|
+
const sel = parseSelector(read.value, govKey);
|
|
373
|
+
if (sel.pendingPolicyKey !== undefined)
|
|
374
|
+
throw new EpEnvelopeError("failed-precondition", `endpoint "${med.endpoint}" stages a pending policy (${sel.pendingPolicyKey}); proof issuance for policy-admitted decisions PAUSES inside the drain window (SPEC 13.6)`);
|
|
375
|
+
if (sel.enforcedPolicyKey === undefined)
|
|
376
|
+
throw new EpEnvelopeError("failed-precondition", `endpoint "${med.endpoint}" enforces no admission policy; a policy-admitted decision has nothing to pin (SPEC 13.6)`);
|
|
377
|
+
const version = await readPolicyVersionCertified(med.jsm, med.space, med.endpoint, sel.enforcedPolicyKey);
|
|
378
|
+
if (version.revision !== sel.enforcedPolicyRevision)
|
|
379
|
+
throw new EpEnvelopeError("failed-precondition", `the enforced policy ${sel.enforcedPolicyKey} reads at revision ${version.revision}, not the selector's ${sel.enforcedPolicyRevision}; an immutable version never moves (corruption); admission pauses rather than guessing (SPEC 13.6)`);
|
|
380
|
+
return { policy: version.value, revision: version.revision, key: sel.enforcedPolicyKey };
|
|
381
|
+
}
|
|
382
|
+
/** The confined policy read for a canonicalizer's capacity decision (§13.6/§13.8): the sealed
|
|
383
|
+
* mediator IS the identity bind (it reads only its own endpoint's policy). Returns
|
|
384
|
+
* `{ policy, revision }`; revision is what an admission pins. */
|
|
385
|
+
export async function readEnforcedPolicy(med) {
|
|
386
|
+
return readPolicyCurrency(internals(med));
|
|
387
|
+
}
|
|
388
|
+
/** The target currency read (§13.1/§13.8): leader-served head, `active` at the PRESENTED uid
|
|
389
|
+
* only (a retiring or retired target admits nothing). */
|
|
390
|
+
async function readTargetCurrency(med, target) {
|
|
391
|
+
const head = await readLifecycleMappingLeader(med.reader, target.owner, target.actor);
|
|
392
|
+
if (head === undefined || head.mapping.state !== "active" || head.mapping.lifecycleUid !== target.lifecycleUid)
|
|
393
|
+
throw new EpEnvelopeError("failed-precondition", `admission target "${target.owner}/${target.actor}" is ${head === undefined ? "unknown" : `${head.mapping.state} at uid ${head.mapping.lifecycleUid}`}, not ACTIVE at uid ${target.lifecycleUid}; a non-current target admits nothing (SPEC 13.8)`);
|
|
394
|
+
return { mappingRevision: head.revision };
|
|
395
|
+
}
|
|
396
|
+
async function readPins(med, op) {
|
|
397
|
+
if (op.target === undefined && op.policy !== true)
|
|
398
|
+
throw new EpEnvelopeError("failed-precondition", "an admission pins at least one currency coordinate: a target lifecycle or the enforced policy (SPEC 13.8)");
|
|
399
|
+
const pins = {};
|
|
400
|
+
if (op.target !== undefined)
|
|
401
|
+
pins.mappingRevision = (await readTargetCurrency(med, op.target)).mappingRevision;
|
|
402
|
+
if (op.policy === true)
|
|
403
|
+
pins.policyRevision = (await readPolicyCurrency(med)).revision;
|
|
404
|
+
return pins;
|
|
405
|
+
}
|
|
406
|
+
/** The post-create RECHECK (§13.8 step 2): the SAME coordinates, and the row's own pins must
|
|
407
|
+
* still be current. Movement between the create and here leaves the row as inert debt. */
|
|
408
|
+
async function recheckPins(med, op, row) {
|
|
409
|
+
if (row.mappingRevision !== undefined) {
|
|
410
|
+
if (op.target === undefined)
|
|
411
|
+
throw new EpEnvelopeError("failed-precondition", "the winning obligation is target-bound but this join presents no target; the full pinned identity must match (SPEC 13.8)");
|
|
412
|
+
const cur = await readTargetCurrency(med, op.target);
|
|
413
|
+
if (cur.mappingRevision !== row.mappingRevision)
|
|
414
|
+
throw new EpEnvelopeError("failed-precondition", `the target head moved (revision ${cur.mappingRevision} vs pinned ${row.mappingRevision}); the proof can never issue and the obligation is inert debt for the drain (SPEC 13.8)`);
|
|
415
|
+
}
|
|
416
|
+
if (row.policyRevision !== undefined) {
|
|
417
|
+
const cur = await readPolicyCurrency(med);
|
|
418
|
+
if (cur.revision !== row.policyRevision)
|
|
419
|
+
throw new EpEnvelopeError("failed-precondition", `the enforced policy moved (revision ${cur.revision} vs pinned ${row.policyRevision}); the proof can never issue and the obligation is inert debt for the drain (SPEC 13.8)`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const PROOFS = new WeakSet();
|
|
423
|
+
function mintProof(med, key, opId) {
|
|
424
|
+
const proof = Object.freeze({ space: med.space, endpoint: med.endpoint, obligationKey: key, opId, exp: med.now() + med.proofTtlMs });
|
|
425
|
+
PROOFS.add(proof);
|
|
426
|
+
return proof;
|
|
427
|
+
}
|
|
428
|
+
/** The STRUCTURAL proof check: branded, unexpired, same space/endpoint/key (§13.8: endpoint A
|
|
429
|
+
* can never replay endpoint B's proof). This is the fast pre-check; the AUTHORITATIVE gate that
|
|
430
|
+
* admission requires is {@link verifyAdmissionProof}, which ALSO re-reads the CURRENT obligation
|
|
431
|
+
* state (§13.8: a drained or settled row leaves a locally-valid-looking proof inert). */
|
|
432
|
+
export function assertAdmissionProof(med, proof, obligationKey) {
|
|
433
|
+
const i = internals(med);
|
|
434
|
+
if (!PROOFS.has(proof))
|
|
435
|
+
throw new EpEnvelopeError("permission-denied", "the presented admission proof was not issued by a mediator; a hand-assembled proof never authorizes (SPEC 13.8/13.12)");
|
|
436
|
+
if (proof.space !== i.space || proof.endpoint !== i.endpoint)
|
|
437
|
+
throw new EpEnvelopeError("permission-denied", `the admission proof binds ${proof.space}/${proof.endpoint}, not ${i.space}/${i.endpoint}; a proof never crosses endpoints (SPEC 13.8)`);
|
|
438
|
+
if (proof.obligationKey !== obligationKey)
|
|
439
|
+
throw new EpEnvelopeError("permission-denied", `the admission proof binds ${proof.obligationKey}, not ${obligationKey} (SPEC 13.8)`);
|
|
440
|
+
if (i.now() >= proof.exp)
|
|
441
|
+
throw new EpEnvelopeError("deadline-exceeded", `the admission proof for ${obligationKey} expired; re-obtain through the mediator (the CURRENT obligation state is re-checked, SPEC 13.8)`);
|
|
442
|
+
}
|
|
443
|
+
/** The COMPLETE proof gate (§13.8: "checked against the CURRENT obligation state"): the
|
|
444
|
+
* structural check PLUS a leader-read of the row confirming it still exists, carries the
|
|
445
|
+
* proof's opId, and is NOT settled (rejected/terminal). A proof whose row a drain settled after
|
|
446
|
+
* issuance is refused here even though its brand/bind/expiry still look valid, so admission is
|
|
447
|
+
* proof-gated on live state, never on stale possession. Every effect the proof authorizes (the
|
|
448
|
+
* EPF acceptance publish, the self-class accept) gates on THIS, not the structural check alone. */
|
|
449
|
+
export async function verifyAdmissionProof(med, proof, obligationKey) {
|
|
450
|
+
const i = internals(med);
|
|
451
|
+
assertAdmissionProof(med, proof, obligationKey);
|
|
452
|
+
const cur = await readObligationLeader(i, obligationKey);
|
|
453
|
+
if (cur === undefined)
|
|
454
|
+
throw new EpEnvelopeError("failed-precondition", `the obligation ${obligationKey} no longer exists; a proof never authorizes over a vanished row (SPEC 13.8)`);
|
|
455
|
+
if (cur.row.opId !== proof.opId)
|
|
456
|
+
throw new EpEnvelopeError("permission-denied", `the obligation ${obligationKey} is held by operation ${cur.row.opId}, not the proof's ${proof.opId}; a superseded operation's proof never admits (SPEC 13.8)`);
|
|
457
|
+
if (cur.row.state === "rejected" || cur.row.state === "terminal")
|
|
458
|
+
throw new EpEnvelopeError("failed-precondition", `the obligation ${obligationKey} is ${cur.row.state}; its proof is stale (a drain or a foreign settle resolved it) and never admits (SPEC 13.8)`);
|
|
459
|
+
return cur.row;
|
|
460
|
+
}
|
|
461
|
+
async function obtainObligation(med, request, op, build, join) {
|
|
462
|
+
assertMediatedRequest(med, request); // the caller identity is broker-authenticated, not a body field
|
|
463
|
+
assertIdToken(op.id, "id");
|
|
464
|
+
const key = obligationKey(med, request, op);
|
|
465
|
+
// Step 1: the create-fence currency reads, IMMEDIATELY before the create.
|
|
466
|
+
const pins = await readPins(med, op);
|
|
467
|
+
const opId = mintLifecycleUid();
|
|
468
|
+
const fresh = build(pins, opId);
|
|
469
|
+
let row;
|
|
470
|
+
let revision;
|
|
471
|
+
let joined = false;
|
|
472
|
+
try {
|
|
473
|
+
revision = await createRecordEntry(med.recordsKv, key, fresh);
|
|
474
|
+
row = fresh;
|
|
475
|
+
}
|
|
476
|
+
catch (e) {
|
|
477
|
+
if (!isCasLoss(e))
|
|
478
|
+
throw e;
|
|
479
|
+
// A create loser leader-reads the winner and joins ONLY on the full pinned identity.
|
|
480
|
+
const winner = await readObligationLeader(med, key);
|
|
481
|
+
if (winner === undefined)
|
|
482
|
+
throw new EpEnvelopeError("failed-precondition", `the obligation ${key} lost its create but has no readable winner (a deletion marker or torn state); rows are never deleted (corruption, SPEC 13.12)`);
|
|
483
|
+
if (winner.row.decision !== fresh.decision)
|
|
484
|
+
throw new EpEnvelopeError("conflict", `the obligation ${key} is held by a ${winner.row.decision}-class winner; the full pinned identity must match to join (SPEC 13.8)`);
|
|
485
|
+
join(winner.row);
|
|
486
|
+
if (winner.row.state === "rejected" || winner.row.state === "terminal")
|
|
487
|
+
throw new EpEnvelopeError("failed-precondition", `the obligation ${key} is already ${winner.row.state}; a settled acceptance identity never re-admits (SPEC 13.8)`);
|
|
488
|
+
row = winner.row;
|
|
489
|
+
revision = winner.revision;
|
|
490
|
+
joined = true;
|
|
491
|
+
}
|
|
492
|
+
// Step 2: proof issuance is a post-create currency recheck. Movement settles our OWN
|
|
493
|
+
// provisional through its decision coordinate and refuses.
|
|
494
|
+
try {
|
|
495
|
+
await recheckPins(med, op, row);
|
|
496
|
+
}
|
|
497
|
+
catch (e) {
|
|
498
|
+
if (row.state === "provisional") {
|
|
499
|
+
try {
|
|
500
|
+
await settleObligation(med, key, row, revision, "the post-create recheck found the pinned coordinate moved");
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
/* the drain settles it; the refusal below is the authoritative outcome */
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
throw e;
|
|
507
|
+
}
|
|
508
|
+
return { key, row, revision, proof: mintProof(med, key, row.opId), joined };
|
|
509
|
+
}
|
|
510
|
+
/** Obtain an EPF-class obligation (a canonical acceptance's reservation, §13.8). The class is
|
|
511
|
+
* fixed HERE, by the trusted operation kind. */
|
|
512
|
+
export async function obtainEpfObligation(med, request, args) {
|
|
513
|
+
const i = internals(med);
|
|
514
|
+
// The fingerprint and sourceSeq must satisfy CORE's own fact validators up front (§13.4): the
|
|
515
|
+
// mediator settles an unresolved row by publishing a terminal RejectionFact carrying exactly
|
|
516
|
+
// these, and `parseDecisionFact` requires a `sha256:<hex>` fingerprint and a positive sourceSeq
|
|
517
|
+
// — a row that could not settle through a core-conformant fact is refused before it exists.
|
|
518
|
+
if (typeof args.fingerprint !== "string" || !FINGERPRINT_RE.test(args.fingerprint) || !uint(args.sourceSeq) || args.sourceSeq < 1 || typeof args.route !== "string" || !ROUTE_RE.test(args.route))
|
|
519
|
+
throw new EpEnvelopeError("failed-precondition", "an epf-class obtain requires { fingerprint (sha256:<hex>), sourceSeq (>=1), route (effects | pool.<pool>) } (SPEC 13.4/13.8)");
|
|
520
|
+
if (args.route.startsWith("pool."))
|
|
521
|
+
assertPoolToken(args.route.slice("pool.".length));
|
|
522
|
+
return obtainObligation(i, request, args, (pins, opId) => ({ state: "provisional", decision: "epf", opId, ...pins, fingerprint: args.fingerprint, sourceSeq: args.sourceSeq, route: args.route }), (winner) => {
|
|
523
|
+
// The join identity: coordinate (the key) + fingerprint + route. sourceSeq is the
|
|
524
|
+
// winner's own (whichever delivery is processing publishes with the WINNER's, §13.8).
|
|
525
|
+
if (winner.fingerprint !== args.fingerprint || winner.route !== args.route)
|
|
526
|
+
throw new EpEnvelopeError("conflict", `the obligation winner pins fingerprint ${winner.fingerprint}/route ${winner.route}, not ${args.fingerprint}/${args.route}; a mismatched join is a conflict, never a second obligation (SPEC 13.8)`);
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
/** Obtain a SELF-class obligation (a guarded record commit's reservation, e.g. the
|
|
530
|
+
* restart-status CAS, §13.6/§13.8). The COMPLETE commit intent is pinned at obtain. */
|
|
531
|
+
export async function obtainSelfObligation(med, request, args) {
|
|
532
|
+
const i = internals(med);
|
|
533
|
+
const cm = args.commit;
|
|
534
|
+
if (!isRec(cm) || typeof cm.commitKey !== "string" || cm.commitKey.length === 0 || !uint(cm.commitBaseRevision) || typeof cm.commitDigest !== "string" || !DIGEST_SCALAR_RE.test(cm.commitDigest))
|
|
535
|
+
throw new EpEnvelopeError("failed-precondition", "a self-class obtain requires the complete commit intent { commitKey, commitBaseRevision, commitValue, commitDigest (sha256:<hex>) } (SPEC 13.8)");
|
|
536
|
+
parseCommitValue(cm.commitValue, "<obtain>");
|
|
537
|
+
// RESOLVE + verify the commit value canonically digests to commitDigest BEFORE the row can be
|
|
538
|
+
// created/accepted (§13.8: deterministic finishability). A mismatched value/digest, a
|
|
539
|
+
// non-canonical b64u, or a mutable/absent ref is refused here, never reaching `accepted` where
|
|
540
|
+
// recovery would wedge every drain forever.
|
|
541
|
+
await resolveCommitValue(i, { commitKey: cm.commitKey, commitBaseRevision: cm.commitBaseRevision, commitValue: cm.commitValue, commitDigest: cm.commitDigest });
|
|
542
|
+
return obtainObligation(i, request, args, (pins, opId) => ({ state: "provisional", decision: "self", opId, ...pins, commit: { commitKey: cm.commitKey, commitBaseRevision: cm.commitBaseRevision, commitValue: cm.commitValue, commitDigest: cm.commitDigest } }), (winner) => {
|
|
543
|
+
// The join identity: the ENTIRE commit intent (key + base revision + digest), so two
|
|
544
|
+
// different desired values or base revisions never join under one commitKey (§13.8).
|
|
545
|
+
const w = winner.commit;
|
|
546
|
+
if (w.commitKey !== cm.commitKey || w.commitBaseRevision !== cm.commitBaseRevision || w.commitDigest !== cm.commitDigest)
|
|
547
|
+
throw new EpEnvelopeError("conflict", `the obligation winner pins commit ${w.commitKey}@${w.commitBaseRevision} digest ${w.commitDigest.slice(0, 12)}…, not ${cm.commitKey}@${cm.commitBaseRevision} digest ${cm.commitDigest.slice(0, 12)}…; a mismatched join is a conflict (SPEC 13.8)`);
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
// ---- the per-class decision coordinates (§13.8 step 3) ----------------------------------------
|
|
551
|
+
function epfDecisionSubjectFor(med, key) {
|
|
552
|
+
// The obligation key IS the acceptance identity: oblig.<target>.<endpoint>.<cO>.<cA>.<cUid>.<id>
|
|
553
|
+
// (toks: [oblig, target, endpoint, cOwner, cActor, cUid, id]) — the caller triple + id start
|
|
554
|
+
// at index 3, AFTER the endpoint at index 2.
|
|
555
|
+
const toks = key.split(".");
|
|
556
|
+
const [cOwner, cActor, cUid, id] = toks.slice(3);
|
|
557
|
+
return epfSubject(med.space, med.endpoint, ["dec", cOwner, cActor, cUid, id]);
|
|
558
|
+
}
|
|
559
|
+
/** Settle ONE unresolved (provisional) obligation through its class's decision coordinate
|
|
560
|
+
* (§13.8): an `epf` row reads the decision subject — an existing acceptance advances the row
|
|
561
|
+
* (the writer crashed between the decision CAS and the row advance), otherwise the mediator
|
|
562
|
+
* publishes the create-only TERMINAL REJECTION (its §13.9 grant; a delayed acceptance CAS
|
|
563
|
+
* loses) and rejects the row; a `self` row settles on ITSELF (`provisional → rejected`,
|
|
564
|
+
* contending with the writer's own `provisional → accepted` on the one row). */
|
|
565
|
+
async function settleObligation(med, key, row, revision, why) {
|
|
566
|
+
if (row.state !== "provisional")
|
|
567
|
+
throw new EpEnvelopeError("failed-precondition", `the obligation ${key} is ${row.state}, not provisional; only an unresolved row settles here (SPEC 13.8)`);
|
|
568
|
+
if (row.decision === "self") {
|
|
569
|
+
try {
|
|
570
|
+
await updateRecordEntry(med.recordsKv, key, { ...row, state: "rejected" }, revision);
|
|
571
|
+
return "rejected";
|
|
572
|
+
}
|
|
573
|
+
catch (e) {
|
|
574
|
+
if (!isCasLoss(e))
|
|
575
|
+
throw e;
|
|
576
|
+
// Exactly one of { writer's accept, our reject } wins the one-row CAS. Re-read and report.
|
|
577
|
+
const after = await readObligationLeader(med, key);
|
|
578
|
+
if (after === undefined)
|
|
579
|
+
throw new EpEnvelopeError("internal", `the obligation ${key} vanished mid-settle; rows are never deleted (corruption, SPEC 13.12)`);
|
|
580
|
+
if (after.row.state === "accepted")
|
|
581
|
+
return "accepted";
|
|
582
|
+
if (after.row.state === "rejected" || after.row.state === "terminal")
|
|
583
|
+
return "rejected";
|
|
584
|
+
throw new EpEnvelopeError("conflict", `the obligation ${key} moved during its settle (still ${after.row.state}); re-read and re-decide (SPEC 13.8)`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
// epf: the EPF decision subject is the coordinate.
|
|
588
|
+
const subject = epfDecisionSubjectFor(med, key);
|
|
589
|
+
const stream = epfStreamName(med.space);
|
|
590
|
+
const existing = await readLastFact(med.jsm, stream, subject);
|
|
591
|
+
if (existing !== undefined) {
|
|
592
|
+
const fact = parseDecisionFact(existing, subject);
|
|
593
|
+
assertDecisionMatchesRow(fact, row, key); // the fact on this coordinate must be THIS obligation's identity
|
|
594
|
+
const to = fact.decision === "accepted" ? "accepted" : "rejected";
|
|
595
|
+
await advanceRowSettled(med, key, row, revision, to);
|
|
596
|
+
return to;
|
|
597
|
+
}
|
|
598
|
+
const toks = key.split(".");
|
|
599
|
+
const [cOwner, cActor, cUid, id] = toks.slice(3); // [oblig, target, endpoint, cOwner, cActor, cUid, id]
|
|
600
|
+
// The detail is BOUNDED to the §13.4 error-detail limit, which counts UTF-8 BYTES (what
|
|
601
|
+
// `parseDecisionFact` enforces), not UTF-16 code units — a multibyte `why` truncated by
|
|
602
|
+
// `.slice` could still exceed the byte limit and make core refuse the mediator's own fact.
|
|
603
|
+
const detail = truncateUtf8(`settled by the admission mediator: ${why}`, MAX_ERROR_DETAIL);
|
|
604
|
+
const rejection = {
|
|
605
|
+
v: 1, id, decision: "rejected", fingerprint: row.fingerprint,
|
|
606
|
+
error: { code: "failed-precondition", detail },
|
|
607
|
+
caller: { id: `${cOwner}.${cActor}`, lifecycleUid: cUid },
|
|
608
|
+
sourceSeq: row.sourceSeq, ts: med.now(),
|
|
609
|
+
};
|
|
610
|
+
const published = await publishFactCreateOnly(med.js, subject, enc.encode(JSON.stringify(rejection)));
|
|
611
|
+
if (!published.won) {
|
|
612
|
+
// A decision landed between our read and our publish: the loser reads the winner (§13.4).
|
|
613
|
+
const winner = await readLastFact(med.jsm, stream, subject);
|
|
614
|
+
if (winner === undefined)
|
|
615
|
+
throw new EpEnvelopeError("internal", `the decision subject ${subject} rejected our create but reads empty; fail closed (SPEC 13.4)`);
|
|
616
|
+
const fact = parseDecisionFact(winner, subject);
|
|
617
|
+
assertDecisionMatchesRow(fact, row, key);
|
|
618
|
+
const to = fact.decision === "accepted" ? "accepted" : "rejected";
|
|
619
|
+
await advanceRowSettled(med, key, row, revision, to);
|
|
620
|
+
return to;
|
|
621
|
+
}
|
|
622
|
+
await advanceRowSettled(med, key, row, revision, "rejected");
|
|
623
|
+
return "rejected";
|
|
624
|
+
}
|
|
625
|
+
/** A decision fact found on an obligation's coordinate MUST be THIS obligation's acceptance
|
|
626
|
+
* identity (§13.4/§13.8): the caller-scoped subject can only bear one first-wins decision, so a
|
|
627
|
+
* fact whose fingerprint (or, for an acceptance, route) differs from the row is a foreign
|
|
628
|
+
* identity on the same coordinate — never allowed to silently advance this row. Fail loud. */
|
|
629
|
+
function assertDecisionMatchesRow(fact, row, key) {
|
|
630
|
+
if (fact.fingerprint !== row.fingerprint)
|
|
631
|
+
throw new EpEnvelopeError("internal", `the decision fact on ${key}'s coordinate pins fingerprint ${fact.fingerprint}, not this obligation's ${row.fingerprint}; a foreign acceptance identity never settles this obligation (SPEC 13.4/13.8)`);
|
|
632
|
+
if (fact.sourceSeq !== row.sourceSeq)
|
|
633
|
+
throw new EpEnvelopeError("internal", `the decision fact on ${key}'s coordinate pins sourceSeq ${fact.sourceSeq}, not this obligation's ${row.sourceSeq}; the winner's full acceptance identity (fingerprint + sourceSeq + route) must match (SPEC 13.4/13.8)`);
|
|
634
|
+
if (fact.decision === "accepted" && fact.route !== row.route)
|
|
635
|
+
throw new EpEnvelopeError("internal", `the acceptance fact on ${key}'s coordinate pins route ${fact.route}, not this obligation's ${row.route} (SPEC 13.4/13.8)`);
|
|
636
|
+
}
|
|
637
|
+
async function advanceRowSettled(med, key, row, revision, to) {
|
|
638
|
+
try {
|
|
639
|
+
await updateRecordEntry(med.recordsKv, key, { ...row, state: to }, revision);
|
|
640
|
+
}
|
|
641
|
+
catch (e) {
|
|
642
|
+
if (!isCasLoss(e))
|
|
643
|
+
throw e;
|
|
644
|
+
const after = await readObligationLeader(med, key);
|
|
645
|
+
if (after === undefined)
|
|
646
|
+
throw new EpEnvelopeError("internal", `the obligation ${key} vanished mid-advance (corruption, SPEC 13.12)`);
|
|
647
|
+
if (after.row.state !== to && !(to === "rejected" && after.row.state === "terminal"))
|
|
648
|
+
throw new EpEnvelopeError("conflict", `the obligation ${key} advanced to ${after.row.state} while settling to ${to}; re-read and re-decide (SPEC 13.8)`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
/** Public settle of one unresolved obligation (the drains call it; the recheck refusals call
|
|
652
|
+
* it internally). Leader-reads the current row first. */
|
|
653
|
+
export async function settleEpfOrSelfObligation(med, obligationKey, why) {
|
|
654
|
+
const i = internals(med);
|
|
655
|
+
assertObligationOfEndpoint(i, obligationKey);
|
|
656
|
+
const cur = await readObligationLeader(i, obligationKey);
|
|
657
|
+
if (cur === undefined)
|
|
658
|
+
throw new EpEnvelopeError("not-found", `no obligation exists at ${obligationKey} (SPEC 13.8)`);
|
|
659
|
+
if (cur.row.state !== "provisional")
|
|
660
|
+
return "already-settled";
|
|
661
|
+
return settleObligation(i, obligationKey, cur.row, cur.revision, why);
|
|
662
|
+
}
|
|
663
|
+
function assertObligationOfEndpoint(med, key) {
|
|
664
|
+
const toks = key.split(".");
|
|
665
|
+
if (toks[0] !== "oblig" || toks.length !== 7 || toks[2] !== med.endpoint)
|
|
666
|
+
throw new EpEnvelopeError("permission-denied", `the obligation ${key} does not belong to endpoint "${med.endpoint}"; a mediator settles only its own endpoint's rows (SPEC 13.9)`);
|
|
667
|
+
}
|
|
668
|
+
// ---- the self-class writer protocol (accept → guarded commit → terminal) ----------------------
|
|
669
|
+
/** Advance a `self`-class obligation `provisional → accepted` under an unexpired proof
|
|
670
|
+
* (§13.8 step 3: the guarded commit is authorized only while the row is `accepted`). */
|
|
671
|
+
export async function acceptSelfObligation(med, proof) {
|
|
672
|
+
const i = internals(med);
|
|
673
|
+
// The state-aware proof gate (§13.8): re-reads the CURRENT row (opId + not-settled), so a
|
|
674
|
+
// drain that settled the row after the proof issued refuses the accept here.
|
|
675
|
+
const row = await verifyAdmissionProof(med, proof, proof.obligationKey);
|
|
676
|
+
if (row.decision !== "self")
|
|
677
|
+
throw new EpEnvelopeError("failed-precondition", `the obligation ${proof.obligationKey} is epf-class; only a self-class row advances on itself (SPEC 13.8)`);
|
|
678
|
+
const cur = await readObligationLeader(i, proof.obligationKey);
|
|
679
|
+
if (cur === undefined)
|
|
680
|
+
throw new EpEnvelopeError("not-found", `no obligation exists at ${proof.obligationKey} (SPEC 13.8)`);
|
|
681
|
+
if (cur.row.state === "accepted")
|
|
682
|
+
return { revision: cur.revision }; // the writer's own resume
|
|
683
|
+
if (cur.row.state !== "provisional")
|
|
684
|
+
throw new EpEnvelopeError("failed-precondition", `the obligation ${proof.obligationKey} is ${cur.row.state}; a settled row never re-accepts (the drain won the one-row CAS, SPEC 13.8)`);
|
|
685
|
+
try {
|
|
686
|
+
const revision = await updateRecordEntry(i.recordsKv, proof.obligationKey, { ...cur.row, state: "accepted" }, cur.revision);
|
|
687
|
+
return { revision };
|
|
688
|
+
}
|
|
689
|
+
catch (e) {
|
|
690
|
+
if (!isCasLoss(e))
|
|
691
|
+
throw e;
|
|
692
|
+
throw new EpEnvelopeError("permission-denied", `the obligation ${proof.obligationKey} moved during the accept (a drain's rejection contends on the one row and exactly one wins); the delayed commit's authority is gone (SPEC 13.8)`);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
/** Strictly decode a `b64u` commit value: CANONICAL base64url (a round-trip re-encode must
|
|
696
|
+
* reproduce the input exactly, rejecting non-zero pad bits that Node's decoder tolerates) and
|
|
697
|
+
* FATAL UTF-8 (no replacement chars), then parse JSON. A strict second implementation refuses
|
|
698
|
+
* exactly what a lax one would silently accept, so the closed union stays interoperable (§13.8). */
|
|
699
|
+
function strictB64uToJson(b64u, commitKey) {
|
|
700
|
+
if (!/^[A-Za-z0-9_-]*$/.test(b64u))
|
|
701
|
+
throw new EpEnvelopeError("failed-precondition", `the b64u commit value for ${commitKey} is not base64url (RFC 4648 §5, no pad); a non-canonical encoding never resolves (SPEC 13.8)`);
|
|
702
|
+
const bytes = new Uint8Array(Buffer.from(b64u, "base64url"));
|
|
703
|
+
if (Buffer.from(bytes).toString("base64url") !== b64u)
|
|
704
|
+
throw new EpEnvelopeError("failed-precondition", `the b64u commit value for ${commitKey} is not canonical base64url (its bytes re-encode differently); a lax decoder would tolerate stray pad bits (SPEC 13.8)`);
|
|
705
|
+
let text;
|
|
706
|
+
try {
|
|
707
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
708
|
+
}
|
|
709
|
+
catch {
|
|
710
|
+
throw new EpEnvelopeError("failed-precondition", `the b64u commit value for ${commitKey} is not valid UTF-8; a recovery never writes replacement-decoded bytes (SPEC 13.8)`);
|
|
711
|
+
}
|
|
712
|
+
let value;
|
|
713
|
+
try {
|
|
714
|
+
value = JSON.parse(text);
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
throw new EpEnvelopeError("failed-precondition", `the b64u commit value for ${commitKey} is not JSON; a recovery never writes non-JSON (SPEC 13.8)`);
|
|
718
|
+
}
|
|
719
|
+
return { value, bytes };
|
|
720
|
+
}
|
|
721
|
+
/** Resolve a commit intent's value + bytes (§13.8) and VERIFY its canonical digest equals
|
|
722
|
+
* `commitDigest` — the SINGLE integrity gate used at BOTH obtain (before the row can accept)
|
|
723
|
+
* and recovery, so a mismatched value/digest never reaches `accepted` to wedge a drain. A
|
|
724
|
+
* `b64u` value is strictly decoded (canonical base64url + fatal UTF-8); a `ref` MUST name an
|
|
725
|
+
* IMMUTABLE `policy`-kind key (a mutable ref could move after acceptance and permanently wedge
|
|
726
|
+
* recovery), leader-read and self-certified. The digest is over the canonical VALUE, so a
|
|
727
|
+
* non-canonical storage stringify never breaks the comparison (RFC-8785). */
|
|
728
|
+
async function resolveCommitValue(med, intent) {
|
|
729
|
+
let value;
|
|
730
|
+
let bytes;
|
|
731
|
+
if (intent.commitValue.enc === "b64u") {
|
|
732
|
+
({ value, bytes } = strictB64uToJson(intent.commitValue.bytes, intent.commitKey));
|
|
733
|
+
}
|
|
734
|
+
else {
|
|
735
|
+
const refKey = intent.commitValue.key;
|
|
736
|
+
if (!/^policy\.[^.]+\.[0-9a-f]{64}$/.test(refKey))
|
|
737
|
+
throw new EpEnvelopeError("failed-precondition", `the commit-value ref ${refKey} is not an immutable policy.<endpoint>.<digest> key; only an immutable create-only kind may be referenced (a mutable ref could move after acceptance and wedge recovery, SPEC 13.8)`);
|
|
738
|
+
const read = await readRecordLeader(med.jsm, med.space, refKey);
|
|
739
|
+
if (read === undefined)
|
|
740
|
+
throw new EpEnvelopeError("failed-precondition", `the commit-value ref ${refKey} does not exist; a recovery cannot resolve the promised value (SPEC 13.8)`);
|
|
741
|
+
// SELF-CERTIFY the referenced version against ITS OWN key digest (§13.7: every policy
|
|
742
|
+
// reader refuses a key/content mismatch), not only against the caller-pinned commitDigest
|
|
743
|
+
// below — a corrupted row whose content matches the caller's pin but not its key would
|
|
744
|
+
// otherwise launder a non-canonical version through the ref form.
|
|
745
|
+
const keyHex = refKey.split(".").pop();
|
|
746
|
+
const valueHex = contractDigest(read.value).slice("sha256:".length);
|
|
747
|
+
if (valueHex !== keyHex)
|
|
748
|
+
throw new EpEnvelopeError("failed-precondition", `the commit-value ref ${refKey} resolves to content whose canonical digest is ${valueHex}, not the key's own digest; a policy version is self-certifying and a mismatch is corruption, refused (SPEC 13.7)`);
|
|
749
|
+
value = read.value;
|
|
750
|
+
bytes = enc.encode(JSON.stringify(read.value));
|
|
751
|
+
}
|
|
752
|
+
if (contractDigest(value) !== intent.commitDigest)
|
|
753
|
+
throw new EpEnvelopeError("failed-precondition", `the resolved commit value does not canonically digest to the pinned commitDigest; the intent is refused before it can accept (SPEC 13.8)`);
|
|
754
|
+
return { value, bytes };
|
|
755
|
+
}
|
|
756
|
+
/** Drive an `accepted` `self`-class obligation to `terminal` deterministically from its pinned
|
|
757
|
+
* intent alone (§13.8): landed (digest matches) / re-apply (still at base; via the injected
|
|
758
|
+
* {@link ApplyCommit}) / superseded (moved past to a foreign value). The writer's own resume
|
|
759
|
+
* AND the drain reconciler both use this — an accepted commit is never an unrecoverable
|
|
760
|
+
* orphan. Returns what it found. */
|
|
761
|
+
export async function recoverSelfObligation(med, obligationKey, deps) {
|
|
762
|
+
return recoverSelfCore(internals(med), obligationKey, deps);
|
|
763
|
+
}
|
|
764
|
+
async function recoverSelfCore(i, obligationKey, deps) {
|
|
765
|
+
assertObligationOfEndpoint(i, obligationKey);
|
|
766
|
+
const cur = await readObligationLeader(i, obligationKey);
|
|
767
|
+
if (cur === undefined)
|
|
768
|
+
throw new EpEnvelopeError("not-found", `no obligation exists at ${obligationKey} (SPEC 13.8)`);
|
|
769
|
+
if (cur.row.decision !== "self" || cur.row.state !== "accepted")
|
|
770
|
+
throw new EpEnvelopeError("failed-precondition", `the obligation ${obligationKey} is ${cur.row.decision}/${cur.row.state}; recovery drives ACCEPTED self-class rows only (a provisional settles through the drain, SPEC 13.8)`);
|
|
771
|
+
const intent = cur.row.commit;
|
|
772
|
+
const record = await readRecordLeader(i.jsm, i.space, intent.commitKey);
|
|
773
|
+
// The terminal CAS is idempotent under concurrency (distsys H3): two recoveries, or a drain
|
|
774
|
+
// reconciler racing the writer's own resume, both drive `accepted → terminal`. On a lost CAS,
|
|
775
|
+
// re-read: already terminal is SUCCESS, still accepted (a foreign advance bumped the revision)
|
|
776
|
+
// retries once at the fresh revision, anything else is a real conflict.
|
|
777
|
+
const terminal = async () => {
|
|
778
|
+
try {
|
|
779
|
+
await updateRecordEntry(i.recordsKv, obligationKey, { ...cur.row, state: "terminal" }, cur.revision);
|
|
780
|
+
}
|
|
781
|
+
catch (e) {
|
|
782
|
+
if (!isCasLoss(e))
|
|
783
|
+
throw e;
|
|
784
|
+
const after = await readObligationLeader(i, obligationKey);
|
|
785
|
+
if (after === undefined)
|
|
786
|
+
throw new EpEnvelopeError("internal", `the obligation ${obligationKey} vanished mid-terminalize (corruption, SPEC 13.12)`);
|
|
787
|
+
if (after.row.state === "terminal")
|
|
788
|
+
return;
|
|
789
|
+
if (after.row.state === "accepted") {
|
|
790
|
+
await updateRecordEntry(i.recordsKv, obligationKey, { ...after.row, state: "terminal" }, after.revision);
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
throw new EpEnvelopeError("conflict", `the obligation ${obligationKey} is ${after.row.state} while terminalizing; re-read and re-decide (SPEC 13.8)`);
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
if (record !== undefined && contractDigest(record.value) === intent.commitDigest) {
|
|
797
|
+
await terminal();
|
|
798
|
+
return "landed";
|
|
799
|
+
}
|
|
800
|
+
const atBase = (record === undefined && intent.commitBaseRevision === 0) || (record !== undefined && record.revision === intent.commitBaseRevision);
|
|
801
|
+
if (atBase) {
|
|
802
|
+
const { bytes } = await resolveCommitValue(i, intent);
|
|
803
|
+
await deps.applyCommit(intent.commitKey, bytes, intent.commitBaseRevision);
|
|
804
|
+
await terminal();
|
|
805
|
+
return "re-applied";
|
|
806
|
+
}
|
|
807
|
+
// Moved past the base to a foreign value: the guarded CAS could never land. Terminal as
|
|
808
|
+
// superseded (§13.8) — the intended commit is dead, not pending.
|
|
809
|
+
await terminal();
|
|
810
|
+
return "superseded";
|
|
811
|
+
}
|
|
812
|
+
/** Enumerate an obligation filter through the SEALED records scanner (§13.9, site 3), turning each
|
|
813
|
+
* raw entry into a parsed, closed-schema obligation row. Markers and parse failures abort LOUD: a
|
|
814
|
+
* drain that skipped either would declare quiescence over rows it never read. The scanner holds the
|
|
815
|
+
* ONLY `CONSUMER.CREATE` on the records stream (fence-free LastPerSubject; the caller — mediator or
|
|
816
|
+
* §13.1 retirement barrier — holds none, so a compromise can never durable-export the `oblig.`
|
|
817
|
+
* subtree, nats-server#8274). The barrier enumerates the target-wide `oblig.<targetUid>.>` (its
|
|
818
|
+
* endpoint discovery + quiescence re-check) through the registry's records scanner; each mediator
|
|
819
|
+
* enumerates its own endpoint's subtree through the scanner injected at open. INVARIANT: the
|
|
820
|
+
* `scanner` argument MUST come from one of those brand-asserted seams (`med.recordsScanner`,
|
|
821
|
+
* `registryRecordsScanner(reg)`), never a hand-passed instance; the brand assert lives at
|
|
822
|
+
* INJECTION (openAdmissionMediator / openLifecycleRegistry), not here, so a new caller that
|
|
823
|
+
* hand-passes a scanner bypasses the space bond. */
|
|
824
|
+
export async function enumerateObligationRows(scanner, filter) {
|
|
825
|
+
const out = [];
|
|
826
|
+
for (const e of await scanner.scanObligations(filter)) {
|
|
827
|
+
if (e.op === "DEL" || e.op === "PURGE")
|
|
828
|
+
throw new EpEnvelopeError("failed-precondition", `the obligation ${e.key} carries a ${e.op} marker; obligation rows are never deleted (corruption, SPEC 13.12)`);
|
|
829
|
+
out.push({ key: e.key, row: parseObligationRow(e.data, e.key), revision: e.seq });
|
|
830
|
+
}
|
|
831
|
+
return out;
|
|
832
|
+
}
|
|
833
|
+
function enumerateObligations(med, filter) {
|
|
834
|
+
return enumerateObligationRows(med.recordsScanner, filter);
|
|
835
|
+
}
|
|
836
|
+
/** Verify an ACCEPTED epf row's route reached its durable postcondition before the drain counts
|
|
837
|
+
* it quiescent (§13.8 accept-side reconciliation). The drain OWNS the check so a presence-only
|
|
838
|
+
* reconciler cannot fake it: an `effects` route is established by its own acceptance decision
|
|
839
|
+
* fact (the row is accepted, so that fact exists); a `pool.<pool>` route is established by the
|
|
840
|
+
* EXACT §13.6 reconciliation predicate: a VALIDATED terminal `wrk` fact (the item is SETTLED —
|
|
841
|
+
* EPW is a WorkQueue, so a terminally-acked item is normally ABSENT, and settled work is never
|
|
842
|
+
* re-enqueued) OR a live EPW entry at the acceptance identity (the enqueue landed and is in
|
|
843
|
+
* flight). Only BOTH absent is the repairable crash-before-enqueue state: the injected
|
|
844
|
+
* reconciler repairs it and the drain RE-READS both coordinates; still unestablished fails
|
|
845
|
+
* closed. */
|
|
846
|
+
async function verifyAcceptedEpfRoute(med, key, row, deps) {
|
|
847
|
+
const [cOwner, cActor, cUid, id] = key.split(".").slice(3); // [oblig, target, endpoint, cO, cA, cUid, id]
|
|
848
|
+
if (row.route === "effects") {
|
|
849
|
+
const decSubject = epfSubject(med.space, med.endpoint, ["dec", cOwner, cActor, cUid, id]);
|
|
850
|
+
const decRaw = await readLastFact(med.jsm, epfStreamName(med.space), decSubject);
|
|
851
|
+
if (decRaw === undefined)
|
|
852
|
+
throw new EpEnvelopeError("internal", `accepted effects obligation ${key} has no decision fact; accepted rows derive from a durable decision (corruption, SPEC 13.8)`);
|
|
853
|
+
const fact = parseDecisionFact(decRaw, decSubject);
|
|
854
|
+
if (fact.decision !== "accepted")
|
|
855
|
+
throw new EpEnvelopeError("internal", `accepted effects obligation ${key} points at a rejected decision fact; obligation/decision state diverged (corruption, SPEC 13.8)`);
|
|
856
|
+
assertDecisionMatchesRow(fact, row, key);
|
|
857
|
+
const goalId = typeof fact.request.goalId === "string" ? fact.request.goalId : undefined;
|
|
858
|
+
const goalRef = goalId !== undefined
|
|
859
|
+
? { endpoint: med.endpoint, caller: { owner: cOwner, actor: cActor, uid: cUid }, goalId }
|
|
860
|
+
: undefined;
|
|
861
|
+
const doneSubject = goalRef !== undefined
|
|
862
|
+
? goalResultSubject(med.space, goalRef)
|
|
863
|
+
: epfEffectSubject(med.space, med.endpoint, { owner: cOwner, actor: cActor, uid: cUid }, id);
|
|
864
|
+
const established = async () => {
|
|
865
|
+
const doneRaw = await readLastFact(med.jsm, epfStreamName(med.space), doneSubject);
|
|
866
|
+
if (doneRaw === undefined)
|
|
867
|
+
return false;
|
|
868
|
+
if (goalRef !== undefined) {
|
|
869
|
+
const done = parseGoalResultFact(doneRaw, doneSubject, goalRef);
|
|
870
|
+
if (done.fingerprint !== fact.fingerprint)
|
|
871
|
+
throw new EpEnvelopeError("internal", `goal completion ${doneSubject} does not match accepted decision ${decSubject}; a mismatched terminal never proves quiescence (SPEC 13.6/13.9)`);
|
|
872
|
+
return true;
|
|
873
|
+
}
|
|
874
|
+
const done = parseEffectFact(doneRaw, doneSubject);
|
|
875
|
+
if (done.fingerprint !== fact.fingerprint || done.sourceSeq !== fact.sourceSeq)
|
|
876
|
+
throw new EpEnvelopeError("internal", `effect completion ${doneSubject} does not match accepted decision ${decSubject}; a mismatched marker never proves quiescence (SPEC 13.9)`);
|
|
877
|
+
return true;
|
|
878
|
+
};
|
|
879
|
+
if (await established())
|
|
880
|
+
return;
|
|
881
|
+
if (deps.cancelEffectsRoute === undefined)
|
|
882
|
+
throw new EpEnvelopeError("failed-precondition", `accepted effects obligation ${key} has no durable completion marker ${doneSubject} and no cancelEffectsRoute was given; a drain never declares quiescence over executable accepted effects work (SPEC 13.8/13.9)`);
|
|
883
|
+
// CANCEL-FIRST for ACTIONS (fact#2): a retirement's create-only `cancelled` terminal asserts
|
|
884
|
+
// the effect did NOT run (SPEC 13.6: "a reader that sees cancelled KNOWS the effect did not
|
|
885
|
+
// run"). For an ACTION goal that is proof only while the goal is still `accepted` — the
|
|
886
|
+
// `running` edge is where it effects, and it is guard/currency-fenced. Once the goal is
|
|
887
|
+
// running/waiting/cancelling the external action MAY have effected, so a create-only cancelled
|
|
888
|
+
// could beat the executor's real completion and lie. The action's own `accepted → cancelling →
|
|
889
|
+
// terminal` machine (executor epoch/currency-fenced) is the ONLY correct cancel there; the
|
|
890
|
+
// retirement drives no create-only over it. So: leader-read the goal status and, if it is past
|
|
891
|
+
// `accepted`, REFUSE the drain loud + resumable — the goal terminalizes through its own machine
|
|
892
|
+
// (a racing completion, fenced out once the target is retired, or its readiness → uncertain
|
|
893
|
+
// settle) and the drain re-reads on resume. An `accepted` (or never-created) goal never
|
|
894
|
+
// effected, so its create-only cancelled below is truthful (SPEC 13.8 option (i), the SPEC's
|
|
895
|
+
// first-terminal-wins model). The NON-action effect variant is untouched (goalRef undefined):
|
|
896
|
+
// it has no goal machine and its create-only cancel is a held SPEC decision.
|
|
897
|
+
if (goalRef !== undefined) {
|
|
898
|
+
const goalStatus = await readGoalStatusByRefLeader(med.jsm, med.space, goalRef);
|
|
899
|
+
if (goalStatus !== undefined && goalStatus.state !== "accepted")
|
|
900
|
+
throw new EpEnvelopeError("unavailable", `accepted action goal "${goalRef.goalId}" for ${key} is "${goalStatus.state}", not "accepted"; a retirement never writes a create-only cancelled over an action that may have effected - it terminalizes through its own accepted->cancelling machine (executor epoch-fenced) or its readiness settle, and the drain re-reads on resume (SPEC 13.6/13.8)`);
|
|
901
|
+
}
|
|
902
|
+
// The MEDIATOR derives the repair coordinates (the closed-command boundary): the parsed,
|
|
903
|
+
// row-bound acceptance + the exact completion subject. The executor stamps its own retirement
|
|
904
|
+
// identity through the core validated builders and publishes create-only (first-terminal-wins).
|
|
905
|
+
await deps.cancelEffectsRoute({ kind: "effects-cancel", subject: doneSubject, key, goal: goalRef !== undefined, acceptance: fact });
|
|
906
|
+
if (!(await established()))
|
|
907
|
+
throw new EpEnvelopeError("unavailable", `the effects hook for accepted obligation ${key} did not establish a bound completion terminal at ${doneSubject}; effects work is still executable and quiescence fails closed (SPEC 13.8/13.9)`);
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const pool = row.route.slice("pool.".length);
|
|
911
|
+
const ref = { endpoint: med.endpoint, pool, acceptance: { owner: cOwner, actor: cActor, uid: cUid, id } };
|
|
912
|
+
const wrkSubject = workTerminalSubject(med.space, ref);
|
|
913
|
+
const itemSubject = epwSubject(med.space, med.endpoint, pool, ref.acceptance);
|
|
914
|
+
const established = async () => {
|
|
915
|
+
const wrk = await readLastFact(med.jsm, epfStreamName(med.space), wrkSubject);
|
|
916
|
+
if (wrk !== undefined) {
|
|
917
|
+
parseWorkTerminalFact(wrk, wrkSubject, ref); // a garbled terminal never counts as settled
|
|
918
|
+
return true;
|
|
919
|
+
}
|
|
920
|
+
return (await readLastFact(med.jsm, epwStreamName(med.space), itemSubject)) !== undefined;
|
|
921
|
+
};
|
|
922
|
+
if (await established())
|
|
923
|
+
return;
|
|
924
|
+
if (deps.reconcilePoolRoute === undefined)
|
|
925
|
+
throw new EpEnvelopeError("failed-precondition", `accepted pool obligation ${key} has no terminal wrk fact and no live EPW item (its enqueue did not land) and no reconcilePoolRoute was given; a drain never declares quiescence over unmaterialized accepted work (SPEC 13.8)`);
|
|
926
|
+
// The MEDIATOR derives the repair (fact pin 3): read + validate the durable acceptance decision
|
|
927
|
+
// itself, then hand the executor a closed command — never row-supplied coordinates or bytes.
|
|
928
|
+
const decSubject = epfSubject(med.space, med.endpoint, ["dec", cOwner, cActor, cUid, id]);
|
|
929
|
+
const decRaw = await readLastFact(med.jsm, epfStreamName(med.space), decSubject);
|
|
930
|
+
if (decRaw === undefined)
|
|
931
|
+
throw new EpEnvelopeError("internal", `accepted pool obligation ${key} has no decision fact; accepted rows derive from a durable decision (corruption, SPEC 13.8)`);
|
|
932
|
+
const fact = parseDecisionFact(decRaw, decSubject);
|
|
933
|
+
if (fact.decision !== "accepted")
|
|
934
|
+
throw new EpEnvelopeError("internal", `accepted pool obligation ${key} points at a rejected decision fact; obligation/decision state diverged (corruption, SPEC 13.8)`);
|
|
935
|
+
assertDecisionMatchesRow(fact, row, key);
|
|
936
|
+
if (fact.route !== row.route)
|
|
937
|
+
throw new EpEnvelopeError("internal", `the decision fact for ${key} routes "${fact.route}" but the obligation row routes "${row.route}"; state diverged (corruption, SPEC 13.8)`);
|
|
938
|
+
if (typeof fact.workExpiry !== "number")
|
|
939
|
+
throw new EpEnvelopeError("internal", `the accepted pool decision for ${key} pins no workExpiry; a pool item carries its absolute horizon (corruption, SPEC 13.8)`);
|
|
940
|
+
await deps.reconcilePoolRoute({ kind: "pool", subject: itemSubject, bytes: workItemBytesOf(fact), workExpiry: fact.workExpiry });
|
|
941
|
+
if (!(await established()))
|
|
942
|
+
throw new EpEnvelopeError("unavailable", `the reconciler for accepted pool obligation ${key} established neither a terminal wrk fact nor a live EPW item; the route is still unmaterialized and quiescence fails closed (SPEC 13.8)`);
|
|
943
|
+
}
|
|
944
|
+
/** Drain the rows a `counts` predicate SELECTS under `filter` to quiescence (§13.8): settle
|
|
945
|
+
* every counted provisional through its decision coordinate, drive every counted accepted
|
|
946
|
+
* self-class row to terminal, and run the injected route reconciler for every counted accepted
|
|
947
|
+
* EPF row; re-enumerate until a pass finds no counted provisional or accepted-self left. A row
|
|
948
|
+
* the predicate does NOT count is skipped (a policy drain must not settle a target-only row it
|
|
949
|
+
* does not govern). */
|
|
950
|
+
async function drainFilter(med, filter, why, counts, deps = {}) {
|
|
951
|
+
let settledProvisional = 0, recoveredSelf = 0, reconciledAcceptedEpf = 0;
|
|
952
|
+
for (let pass = 1; pass <= 8; pass++) {
|
|
953
|
+
const rows = await enumerateObligations(med, filter);
|
|
954
|
+
let unsettled = 0;
|
|
955
|
+
for (const item of rows) {
|
|
956
|
+
if (!counts(item.row))
|
|
957
|
+
continue; // not this drain's concern (e.g. a target-only row in a policy drain)
|
|
958
|
+
if (item.row.state === "provisional") {
|
|
959
|
+
unsettled++;
|
|
960
|
+
assertObligationOfEndpoint(med, item.key);
|
|
961
|
+
await settleObligation(med, item.key, item.row, item.revision, why);
|
|
962
|
+
settledProvisional++;
|
|
963
|
+
}
|
|
964
|
+
else if (item.row.state === "accepted" && item.row.decision === "self") {
|
|
965
|
+
unsettled++;
|
|
966
|
+
if (deps.applyCommit === undefined)
|
|
967
|
+
throw new EpEnvelopeError("failed-precondition", `the drain found an ACCEPTED self-class obligation ${item.key} but was given no applyCommit; an accepted commit is drivable to terminal and a drain must drive it, never skip it (SPEC 13.8)`);
|
|
968
|
+
await recoverSelfCore(med, item.key, { applyCommit: deps.applyCommit });
|
|
969
|
+
recoveredSelf++;
|
|
970
|
+
}
|
|
971
|
+
else if (item.row.state === "accepted") {
|
|
972
|
+
// Accepted EPF: the drain VERIFIES the route's durable postcondition BEFORE declaring
|
|
973
|
+
// quiescence (an accepted decision whose enqueue never landed must not be silently
|
|
974
|
+
// declared quiescent). The drain OWNS the check (it reads the route marker itself), so a
|
|
975
|
+
// no-op reconciler cannot fake it — for a pool route a missing EPW item calls the
|
|
976
|
+
// injected reconciler and then RE-READS, failing closed if still unmaterialized.
|
|
977
|
+
await verifyAcceptedEpfRoute(med, item.key, item.row, deps);
|
|
978
|
+
reconciledAcceptedEpf++;
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
// RE-ENUMERATE (§13.8): quiescence is declared only by an enumeration that finds no counted
|
|
982
|
+
// provisional or un-driven accepted-self row — never by having settled a PREVIOUS enumeration.
|
|
983
|
+
if (unsettled === 0)
|
|
984
|
+
return { passes: pass, settledProvisional, recoveredSelf, reconciledAcceptedEpf };
|
|
985
|
+
}
|
|
986
|
+
throw new EpEnvelopeError("unavailable", `the drain under ${filter} did not reach quiescence in 8 passes; admission traffic is outrunning it; investigate before promoting or retiring (SPEC 13.8)`);
|
|
987
|
+
}
|
|
988
|
+
/** The POLICY drain (§13.6): settles ONLY the obligations pinned to the OLD enforced policy
|
|
989
|
+
* revision (the one being replaced), across the endpoint's whole prefix. Target-only rows and
|
|
990
|
+
* rows pinned to a different revision are NOT this drain's concern (settling them would burn a
|
|
991
|
+
* target-bound acceptance the policy movement does not govern, and target-only traffic would
|
|
992
|
+
* block convergence). Run AFTER {@link stagePolicySelector} (new policy-admitted proofs are
|
|
993
|
+
* paused, so no new old-revision row appears and the drain converges); RE-READS the govern head
|
|
994
|
+
* after draining to confirm the stage did not move, then mints the exact-revision-bound
|
|
995
|
+
* quiescence witness {@link promotePolicySelector} consumes. */
|
|
996
|
+
export async function drainEndpointPolicy(med, deps = {}) {
|
|
997
|
+
const i = internals(med);
|
|
998
|
+
const govKey = recordAtomicKey(GOVERN_HEAD, [i.endpoint]);
|
|
999
|
+
const gov = await readGovernHeadRaw(i.jsm, i.space, i.endpoint);
|
|
1000
|
+
if (gov === undefined)
|
|
1001
|
+
throw new EpEnvelopeError("failed-precondition", `endpoint "${i.endpoint}" has no govern head; the policy drain runs inside a stage → drain → promote mutation (SPEC 13.6)`);
|
|
1002
|
+
const sel = parseSelector(gov.head, govKey);
|
|
1003
|
+
if (sel.pendingPolicyKey === undefined || typeof gov.head.pendingMutationOpId !== "string")
|
|
1004
|
+
throw new EpEnvelopeError("failed-precondition", `endpoint "${i.endpoint}" stages no pending policy; the policy drain runs inside a stage → drain → promote mutation (SPEC 13.6)`);
|
|
1005
|
+
if (sel.enforcedPolicyRevision === undefined)
|
|
1006
|
+
throw new EpEnvelopeError("failed-precondition", `endpoint "${i.endpoint}" enforces no policy to drain from; a first policy has nothing to migrate (SPEC 13.6)`);
|
|
1007
|
+
const retiringRevision = sel.enforcedPolicyRevision;
|
|
1008
|
+
const mutationOpId = gov.head.pendingMutationOpId;
|
|
1009
|
+
const result = await drainFilter(i, `oblig.*.${i.endpoint}.>`, `the enforced policy for "${i.endpoint}" is moving (stage → drain → promote)`, (row) => row.policyRevision === retiringRevision, // ONLY rows pinned to the OLD enforced revision
|
|
1010
|
+
deps);
|
|
1011
|
+
// Confirm the stage did not move under us (a concurrent clear/re-stage): the witness binds the
|
|
1012
|
+
// EXACT current govern revision + mutation opId; the promote CASes from it.
|
|
1013
|
+
const after = await readGovernHeadRaw(i.jsm, i.space, i.endpoint);
|
|
1014
|
+
const afterSel = after === undefined ? {} : parseSelector(after.head, govKey);
|
|
1015
|
+
if (after === undefined || afterSel.pendingPolicyKey !== sel.pendingPolicyKey || afterSel.pendingPolicyRevision !== sel.pendingPolicyRevision || after.head.pendingMutationOpId !== mutationOpId)
|
|
1016
|
+
throw new EpEnvelopeError("conflict", `the pending policy for "${i.endpoint}" moved during its drain (op ${mutationOpId}); re-stage and drain again (SPEC 13.6)`);
|
|
1017
|
+
const quiescence = Object.freeze({
|
|
1018
|
+
space: i.space, endpoint: i.endpoint, pendingPolicyKey: sel.pendingPolicyKey,
|
|
1019
|
+
pendingPolicyRevision: sel.pendingPolicyRevision, mutationOpId, governStageRevision: after.revision,
|
|
1020
|
+
});
|
|
1021
|
+
QUIESCENCE.add(quiescence);
|
|
1022
|
+
return { ...result, quiescence };
|
|
1023
|
+
}
|
|
1024
|
+
/** The RETIREMENT-side drain for THIS endpoint's rows under one target
|
|
1025
|
+
* (`oblig.<targetUid>.<endpoint>.>`): the §13.1 terminal barrier (D13 (5)) runs one per
|
|
1026
|
+
* endpoint found under the target. Both coordinates stay inside this mediator's own grant. */
|
|
1027
|
+
export async function drainTargetForEndpoint(med, targetUid, deps = {}) {
|
|
1028
|
+
const i = internals(med);
|
|
1029
|
+
// A retirement drains EVERY row bound to the target (the prefix is target-scoped and excludes
|
|
1030
|
+
// the `ep` sentinel), regardless of policy pin — a retiring target admits nothing new either way.
|
|
1031
|
+
return drainFilter(i, `oblig.${assertLifecycleToken(targetUid, "targetUid")}.${i.endpoint}.>`, `the target lifecycle ${targetUid} is retiring`, () => true, deps);
|
|
1032
|
+
}
|
|
1033
|
+
//# sourceMappingURL=admission-mediator.js.map
|