@cotal-ai/core 0.12.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acls.d.ts +31 -7
- package/dist/acls.d.ts.map +1 -1
- package/dist/acls.js +105 -17
- package/dist/acls.js.map +1 -1
- package/dist/agent-file.js +1 -1
- package/dist/agent-file.js.map +1 -1
- package/dist/auth-provider.d.ts +8 -0
- package/dist/auth-provider.d.ts.map +1 -1
- package/dist/auth-provider.js.map +1 -1
- package/dist/backup.d.ts.map +1 -1
- package/dist/backup.js +6 -4
- package/dist/backup.js.map +1 -1
- package/dist/broker-floor.d.ts +34 -0
- package/dist/broker-floor.d.ts.map +1 -0
- package/dist/broker-floor.js +39 -0
- package/dist/broker-floor.js.map +1 -0
- package/dist/canonical.d.ts +24 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +159 -0
- package/dist/canonical.js.map +1 -0
- package/dist/connector.d.ts +4 -0
- package/dist/connector.d.ts.map +1 -1
- package/dist/endpoint-action.d.ts +424 -0
- package/dist/endpoint-action.d.ts.map +1 -0
- package/dist/endpoint-action.js +910 -0
- package/dist/endpoint-action.js.map +1 -0
- package/dist/endpoint-binding.d.ts +344 -0
- package/dist/endpoint-binding.d.ts.map +1 -0
- package/dist/endpoint-binding.js +753 -0
- package/dist/endpoint-binding.js.map +1 -0
- package/dist/endpoint-checkpoint.d.ts +292 -0
- package/dist/endpoint-checkpoint.d.ts.map +1 -0
- package/dist/endpoint-checkpoint.js +818 -0
- package/dist/endpoint-checkpoint.js.map +1 -0
- package/dist/endpoint-cluster.d.ts +95 -0
- package/dist/endpoint-cluster.d.ts.map +1 -0
- package/dist/endpoint-cluster.js +194 -0
- package/dist/endpoint-cluster.js.map +1 -0
- package/dist/endpoint-contract-store.d.ts +90 -0
- package/dist/endpoint-contract-store.d.ts.map +1 -0
- package/dist/endpoint-contract-store.js +348 -0
- package/dist/endpoint-contract-store.js.map +1 -0
- package/dist/endpoint-envelope.d.ts +165 -0
- package/dist/endpoint-envelope.d.ts.map +1 -0
- package/dist/endpoint-envelope.js +346 -0
- package/dist/endpoint-envelope.js.map +1 -0
- package/dist/endpoint-grants.d.ts +123 -0
- package/dist/endpoint-grants.d.ts.map +1 -0
- package/dist/endpoint-grants.js +215 -0
- package/dist/endpoint-grants.js.map +1 -0
- package/dist/endpoint-guard.d.ts +222 -0
- package/dist/endpoint-guard.d.ts.map +1 -0
- package/dist/endpoint-guard.js +468 -0
- package/dist/endpoint-guard.js.map +1 -0
- package/dist/endpoint-handle.d.ts +176 -0
- package/dist/endpoint-handle.d.ts.map +1 -0
- package/dist/endpoint-handle.js +608 -0
- package/dist/endpoint-handle.js.map +1 -0
- package/dist/endpoint-journal.d.ts +225 -0
- package/dist/endpoint-journal.d.ts.map +1 -0
- package/dist/endpoint-journal.js +431 -0
- package/dist/endpoint-journal.js.map +1 -0
- package/dist/endpoint-receipt.d.ts +225 -0
- package/dist/endpoint-receipt.d.ts.map +1 -0
- package/dist/endpoint-receipt.js +417 -0
- package/dist/endpoint-receipt.js.map +1 -0
- package/dist/endpoint-records.d.ts +243 -0
- package/dist/endpoint-records.d.ts.map +1 -0
- package/dist/endpoint-records.js +591 -0
- package/dist/endpoint-records.js.map +1 -0
- package/dist/endpoint-serve.d.ts +176 -0
- package/dist/endpoint-serve.d.ts.map +1 -0
- package/dist/endpoint-serve.js +446 -0
- package/dist/endpoint-serve.js.map +1 -0
- package/dist/endpoint-service.d.ts +518 -0
- package/dist/endpoint-service.d.ts.map +1 -0
- package/dist/endpoint-service.js +973 -0
- package/dist/endpoint-service.js.map +1 -0
- package/dist/endpoint-session.d.ts +398 -0
- package/dist/endpoint-session.d.ts.map +1 -0
- package/dist/endpoint-session.js +834 -0
- package/dist/endpoint-session.js.map +1 -0
- package/dist/endpoint-signing.d.ts +105 -0
- package/dist/endpoint-signing.d.ts.map +1 -0
- package/dist/endpoint-signing.js +190 -0
- package/dist/endpoint-signing.js.map +1 -0
- package/dist/endpoint-subjects.d.ts +222 -0
- package/dist/endpoint-subjects.d.ts.map +1 -0
- package/dist/endpoint-subjects.js +381 -0
- package/dist/endpoint-subjects.js.map +1 -0
- package/dist/endpoint-supervisor.d.ts +19 -0
- package/dist/endpoint-supervisor.d.ts.map +1 -0
- package/dist/endpoint-supervisor.js +12 -0
- package/dist/endpoint-supervisor.js.map +1 -0
- package/dist/endpoint-traits.d.ts +225 -0
- package/dist/endpoint-traits.d.ts.map +1 -0
- package/dist/endpoint-traits.js +469 -0
- package/dist/endpoint-traits.js.map +1 -0
- package/dist/endpoint-verbs.d.ts +277 -0
- package/dist/endpoint-verbs.d.ts.map +1 -0
- package/dist/endpoint-verbs.js +576 -0
- package/dist/endpoint-verbs.js.map +1 -0
- package/dist/endpoint-virtual.d.ts +308 -0
- package/dist/endpoint-virtual.d.ts.map +1 -0
- package/dist/endpoint-virtual.js +440 -0
- package/dist/endpoint-virtual.js.map +1 -0
- package/dist/endpoint-work.d.ts +304 -0
- package/dist/endpoint-work.d.ts.map +1 -0
- package/dist/endpoint-work.js +728 -0
- package/dist/endpoint-work.js.map +1 -0
- package/dist/endpoint.d.ts +66 -22
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +289 -89
- package/dist/endpoint.js.map +1 -1
- package/dist/evict.d.ts +95 -32
- package/dist/evict.d.ts.map +1 -1
- package/dist/evict.js +193 -3
- package/dist/evict.js.map +1 -1
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -1
- package/dist/lease.d.ts +4 -0
- package/dist/lease.d.ts.map +1 -1
- package/dist/lease.js.map +1 -1
- package/dist/members.d.ts +4 -4
- package/dist/members.d.ts.map +1 -1
- package/dist/members.js +12 -12
- package/dist/members.js.map +1 -1
- package/dist/provision.d.ts +70 -18
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +194 -33
- package/dist/provision.js.map +1 -1
- package/dist/safe-pattern.d.ts +33 -0
- package/dist/safe-pattern.d.ts.map +1 -0
- package/dist/safe-pattern.js +486 -0
- package/dist/safe-pattern.js.map +1 -0
- package/dist/schema-profile.d.ts +93 -0
- package/dist/schema-profile.d.ts.map +1 -0
- package/dist/schema-profile.js +284 -0
- package/dist/schema-profile.js.map +1 -0
- package/dist/streams.d.ts +13 -8
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +35 -18
- package/dist/streams.js.map +1 -1
- package/dist/subjects.d.ts +100 -40
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +136 -60
- package/dist/subjects.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -1
|
@@ -0,0 +1,973 @@
|
|
|
1
|
+
import { spacePrefix, principalKey } from "./subjects.js";
|
|
2
|
+
import { endpointToken, assertBoundedOwner, assertLifecycleToken, assertCommandToken, assertPoolToken, } from "./endpoint-subjects.js";
|
|
3
|
+
import { EpEnvelopeError } from "./endpoint-envelope.js";
|
|
4
|
+
import { RECORD_KINDS, GOVERN_HEAD, recordSpecKey, recordStatusKey, recordAtomicKey, readRecord, recordsBucket, createRecordEntry, updateRecordEntry, assertStatusValue, } from "./endpoint-records.js";
|
|
5
|
+
import { verifyClusterManifest, verifyClusterRoot, deriveDescriptor, GOVERNED_TRAIT_URNS } from "./endpoint-cluster.js";
|
|
6
|
+
import { isSupervisorWrite } from "./endpoint-supervisor.js";
|
|
7
|
+
/** The convergence states the SPEC keys on (§13.6 item 6). */
|
|
8
|
+
export const SERVICE_READY = "ready";
|
|
9
|
+
export const SERVICE_EXITED = "exited";
|
|
10
|
+
/** Restart-intensity escalation (§13.6 virtual endpoints): the instance stops restarting and
|
|
11
|
+
* the lifecycle retires terminally; readers treat it as permanently not-startable. The state
|
|
12
|
+
* is IRREVERSIBLE at {@link writeServiceStatus}: no later status write (any epoch) replaces
|
|
13
|
+
* it — the only touch a stored escalated row admits is the supervisor's own revision-pinned
|
|
14
|
+
* retirement mark, written directly by the reconciler, never through this writer. */
|
|
15
|
+
export const SERVICE_ESCALATED = "escalated";
|
|
16
|
+
/** The SUPERVISOR-OWNED status fields (§13.6 restart intensity): the durable restart history
|
|
17
|
+
* and the retirement-complete mark. {@link writeServiceStatus} carries them forward on every
|
|
18
|
+
* INSTANCE-side write and strips whatever the caller supplied; ONLY a holder of the branded
|
|
19
|
+
* {@link SupervisorWriteGrant} may originate them or the `escalated` state. */
|
|
20
|
+
export const SERVICE_RESTART_HISTORY_FIELD = "restarts";
|
|
21
|
+
export const SERVICE_RETIRED_MARK_FIELD = "retiredAt";
|
|
22
|
+
export function parseActivationPolicy(raw) {
|
|
23
|
+
const o = isRec(raw) ? raw : svcFail("activation policy is not an object");
|
|
24
|
+
for (const k of Object.keys(o))
|
|
25
|
+
if (!["mode", "capacity", "maxRestarts", "restartWindowMs"].includes(k))
|
|
26
|
+
svcFail(`activation policy carries unknown field "${k}" (closed schema)`);
|
|
27
|
+
if (o.mode !== "on-demand")
|
|
28
|
+
svcFail(`activation.mode "${String(o.mode)}" is not "on-demand"`);
|
|
29
|
+
if (typeof o.capacity !== "number" || !Number.isSafeInteger(o.capacity) || o.capacity <= 0)
|
|
30
|
+
svcFail("activation.capacity must be a positive integer (a virtual pool is bounded by policy, never open-ended)");
|
|
31
|
+
if (o.maxRestarts !== undefined && (typeof o.maxRestarts !== "number" || !Number.isSafeInteger(o.maxRestarts) || o.maxRestarts <= 0))
|
|
32
|
+
svcFail("activation.maxRestarts must be a positive integer");
|
|
33
|
+
if (o.restartWindowMs !== undefined && (typeof o.restartWindowMs !== "number" || !Number.isSafeInteger(o.restartWindowMs) || o.restartWindowMs <= 0))
|
|
34
|
+
svcFail("activation.restartWindowMs must be a positive integer");
|
|
35
|
+
return o;
|
|
36
|
+
}
|
|
37
|
+
const STATE_TOKEN = /^[a-z][a-z0-9-]{0,31}$/;
|
|
38
|
+
const isRec = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
39
|
+
const wireInt = (v) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0;
|
|
40
|
+
const isDigest = (v) => typeof v === "string" && /^sha256:[0-9a-f]{64}$/.test(v);
|
|
41
|
+
function svcFail(what) {
|
|
42
|
+
throw new EpEnvelopeError("internal", `service record does not validate: ${what}`);
|
|
43
|
+
}
|
|
44
|
+
/** Validate a `svc….spec` value at its consuming boundary (§13.3: every plane is
|
|
45
|
+
* runtime-validated; mediated-writer state that does not validate is a writer bug, never a
|
|
46
|
+
* data error). The body's endpoint must AGREE with the key's endpoint qualifier. */
|
|
47
|
+
export function parseServiceSpec(raw, key) {
|
|
48
|
+
const o = isRec(raw) ? raw : svcFail("not an object");
|
|
49
|
+
if (typeof o.endpoint !== "string")
|
|
50
|
+
svcFail("endpoint");
|
|
51
|
+
if (endpointToken(o.endpoint) !== endpointToken(key.endpoint))
|
|
52
|
+
svcFail("endpoint disagrees with the record key");
|
|
53
|
+
if (typeof o.owner !== "string")
|
|
54
|
+
svcFail("owner");
|
|
55
|
+
try {
|
|
56
|
+
assertBoundedOwner(o.owner, "service owner");
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
svcFail(`owner: ${e.message}`);
|
|
60
|
+
}
|
|
61
|
+
if (o.endpointType !== undefined && typeof o.endpointType !== "string")
|
|
62
|
+
svcFail("endpointType");
|
|
63
|
+
if (!Array.isArray(o.clusterDigests) || o.clusterDigests.length === 0 || !o.clusterDigests.every(isDigest))
|
|
64
|
+
svcFail("clusterDigests must be a non-empty array of sha256 digests");
|
|
65
|
+
if (!isRec(o.protocol) || o.protocol.v !== 1)
|
|
66
|
+
svcFail("protocol.v");
|
|
67
|
+
if (o.activation !== undefined)
|
|
68
|
+
parseActivationPolicy(o.activation); // closed schema, capacity REQUIRED (§13.6)
|
|
69
|
+
return o;
|
|
70
|
+
}
|
|
71
|
+
/** Validate a `svc….status` value at its consuming boundary. */
|
|
72
|
+
export function parseServiceStatus(raw) {
|
|
73
|
+
const o = isRec(raw) ? raw : svcFail("status not an object");
|
|
74
|
+
if (!wireInt(o.epoch))
|
|
75
|
+
svcFail("status.epoch");
|
|
76
|
+
if (typeof o.state !== "string" || !STATE_TOKEN.test(o.state))
|
|
77
|
+
svcFail("status.state");
|
|
78
|
+
if (!wireInt(o.observedSpecRevision))
|
|
79
|
+
svcFail("status.observedSpecRevision");
|
|
80
|
+
// The SUPERVISOR-OWNED fields are validated at the consuming boundary too, so corrupt/legacy
|
|
81
|
+
// state never rides through the escalation barrier (§13.6): the restart history is an array
|
|
82
|
+
// of {t, epoch} with UNIQUE epochs (a real restart advances the epoch; a duplicate is a
|
|
83
|
+
// fabricated count), and the retirement mark is a non-negative integer that appears ONLY on an
|
|
84
|
+
// escalated row (a mark on a live row would let a forged `retiredAt` fake completion).
|
|
85
|
+
if (o[SERVICE_RESTART_HISTORY_FIELD] !== undefined) {
|
|
86
|
+
const h = o[SERVICE_RESTART_HISTORY_FIELD];
|
|
87
|
+
if (!Array.isArray(h) || !h.every((e) => isRec(e) && wireInt(e.t) && wireInt(e.epoch)))
|
|
88
|
+
svcFail(`status.${SERVICE_RESTART_HISTORY_FIELD} is not an array of {t, epoch}`);
|
|
89
|
+
const epochs = new Set(h.map((e) => e.epoch));
|
|
90
|
+
if (epochs.size !== h.length)
|
|
91
|
+
svcFail(`status.${SERVICE_RESTART_HISTORY_FIELD} has duplicate epochs (a fabricated count)`);
|
|
92
|
+
}
|
|
93
|
+
if (o[SERVICE_RETIRED_MARK_FIELD] !== undefined) {
|
|
94
|
+
if (!wireInt(o[SERVICE_RETIRED_MARK_FIELD]))
|
|
95
|
+
svcFail(`status.${SERVICE_RETIRED_MARK_FIELD} is not a non-negative integer`);
|
|
96
|
+
if (o.state !== SERVICE_ESCALATED)
|
|
97
|
+
svcFail(`status.${SERVICE_RETIRED_MARK_FIELD} is present on a "${String(o.state)}" row; a retirement mark appears only on an escalated row (§13.6)`);
|
|
98
|
+
}
|
|
99
|
+
return o;
|
|
100
|
+
}
|
|
101
|
+
/** Enforce §13.9 name authority before a registration/serve grant is minted, from ONE atomic
|
|
102
|
+
* snapshot: an endpoint name binds to exactly ONE owner (§13.2), so a registration claiming a
|
|
103
|
+
* name its owner does not hold fails `permission-denied`, and an UNREGISTERED reverse-DNS name
|
|
104
|
+
* fails closed. Returns the name-authority binding REVISION read atomically WITH the decision —
|
|
105
|
+
* the caller binds it into the issuance gate so a transfer between decision and mint is fenced,
|
|
106
|
+
* never a torn owner-vs-revision read. */
|
|
107
|
+
export async function assertServiceNameAuthority(endpoint, owner, authority) {
|
|
108
|
+
endpointToken(endpoint); // grammar first: a malformed name is refused before any authority answer
|
|
109
|
+
assertBoundedOwner(owner, "service owner");
|
|
110
|
+
const snapshot = await authority.authorize(endpoint, owner);
|
|
111
|
+
if (!snapshot.authorized)
|
|
112
|
+
throw new EpEnvelopeError("permission-denied", `service name "${endpoint}" does not authorize owner "${owner}" (SPEC 13.9: a core name needs operator authority; a reverse-DNS name binds to its registered owner and an unregistered one is never adopted first-come)`);
|
|
113
|
+
if (!Number.isSafeInteger(snapshot.revision) || snapshot.revision < 0)
|
|
114
|
+
throw new EpEnvelopeError("internal", `the name-authority revision for "${endpoint}" is ${JSON.stringify(snapshot.revision)}, not an unsigned integer`);
|
|
115
|
+
return snapshot.revision;
|
|
116
|
+
}
|
|
117
|
+
// ---- registration (spec writes, the `provisioner-registration` principal) ---------------------
|
|
118
|
+
/** Register (or re-register) a service instance: authenticated-registrant binding, name
|
|
119
|
+
* authority, then the spec-key CAS. The returned `registrationRevision` is the spec key's
|
|
120
|
+
* store revision (§13.7) — a re-registration advances it, which is exactly what invalidates a
|
|
121
|
+
* frozen scatter slot (§13.5 `churn`). A concurrent registration race is a loud `conflict`
|
|
122
|
+
* (§13.8: re-read and re-decide).
|
|
123
|
+
*
|
|
124
|
+
* `registrant` is the BROKER-AUTHENTICATED caller of the registration request (its subject
|
|
125
|
+
* principal, §13.9 — never a payload claim): the descriptor owner must BE that caller, so a
|
|
126
|
+
* privileged owner's descriptor cannot be registered by anyone else, and a re-registration can
|
|
127
|
+
* never change an instance's ownership. `instanceId` MUST be provisioner-minted and never
|
|
128
|
+
* reused (§13.1); the allocator that enforces non-reuse is the lifecycle registry (D13) — this
|
|
129
|
+
* seam enforces what is checkable at the record: grammar, ownership stability, and CAS.
|
|
130
|
+
*
|
|
131
|
+
* ISSUANCE-GATE BARRIER (§13.1). A registration is a WRITER on the instance's issuance gate: to
|
|
132
|
+
* be linearizable against an in-flight serve mint it MUST run the barrier protocol on the SAME
|
|
133
|
+
* `gate.<lifecycleUid>` key, in order: freeze the gate (so a fresh mint observes `frozen` and
|
|
134
|
+
* refuses, and a staged-but-uncommitted mint loses its revision-pinned CAS), authorize the owner
|
|
135
|
+
* under the frozen gate, revoke + VERIFIED-evict the superseded credential family, THEN advance
|
|
136
|
+
* the spec, then reopen at the successor `registrationRevision`. Old authority dies before new
|
|
137
|
+
* authority is published. This is REQUIRED, not documented: core exports no bare spec-key advance
|
|
138
|
+
* that could leave a mint's observed `registrationRevision` permanently equal to its snapshot,
|
|
139
|
+
* win a never-frozen CAS, and silently release a superseded-surface credential. The gate is
|
|
140
|
+
* created by the provisioner at instance mint (D13); a missing gate is `failed-precondition`. The
|
|
141
|
+
* production `barrier` wires to the durable KV CAS (D13/D14); the D4 seam is the typed protocol
|
|
142
|
+
* and its faithful in-memory model, so the barrier's writes serialize with the mint's on one key. */
|
|
143
|
+
/** Reconstruct a registered spec's command surface from trusted registry + content-addressed
|
|
144
|
+
* store state (§13.7): EVERY command name -> the set of governed URNs its verified cluster
|
|
145
|
+
* document declares (an empty set for an un-governed command; the full command set is needed so
|
|
146
|
+
* continuity can tell a STRIPPED-but-surviving command from a REMOVED one). The registrar drives
|
|
147
|
+
* this, so the OWNER never supplies the prior state that continuity compares against - it is read
|
|
148
|
+
* from the mediated spec + the digest-verified cluster bytes. The governed set is the canonical
|
|
149
|
+
* {@link GOVERNED_TRAIT_URNS}, pinned STRUCTURALLY - a caller-supplied set was the
|
|
150
|
+
* subset-narrowing escape (pass guarded-only and a priced imposition is never recorded), the
|
|
151
|
+
* same class as the required-but-empty policy the panel already rejected. */
|
|
152
|
+
async function readGovernedDeclarations(readArtifact, clusterDigests) {
|
|
153
|
+
const out = new Map();
|
|
154
|
+
const classes = new Map();
|
|
155
|
+
const read = async (digest) => {
|
|
156
|
+
const raw = await readArtifact(digest);
|
|
157
|
+
if (raw === undefined)
|
|
158
|
+
throw new EpEnvelopeError("failed-precondition", `governed-continuity: cluster artifact ${digest} is not readable; an unverifiable prior/next surface cannot authorize a governance change (SPEC 13.7)`);
|
|
159
|
+
return raw;
|
|
160
|
+
};
|
|
161
|
+
for (const closureDigest of clusterDigests) {
|
|
162
|
+
const { root } = verifyClusterManifest(closureDigest, await read(closureDigest));
|
|
163
|
+
const document = verifyClusterRoot(root, await read(root));
|
|
164
|
+
for (const cmd of document.commands) {
|
|
165
|
+
const set = out.get(cmd.name) ?? new Set();
|
|
166
|
+
for (const t of (cmd.traits ?? []))
|
|
167
|
+
if (GOVERNED_TRAIT_URNS.includes(t))
|
|
168
|
+
set.add(t);
|
|
169
|
+
// A command name is DECLARED ONCE across the whole closure: a cross-cluster duplicate is
|
|
170
|
+
// an ambiguous surface (serve authorization rejects it later as internal-ambiguous), so
|
|
171
|
+
// registration refuses it up front rather than publishing a surface that cannot serve.
|
|
172
|
+
if (out.has(cmd.name))
|
|
173
|
+
throw new EpEnvelopeError("failed-precondition", `command "${cmd.name}" is declared in more than one cluster of the closure; a duplicate command name is an ambiguous surface (SPEC 13.7)`);
|
|
174
|
+
out.set(cmd.name, set); // present for EVERY command, governed or not
|
|
175
|
+
classes.set(cmd.name, cmd.class);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return { governed: out, classes };
|
|
179
|
+
}
|
|
180
|
+
/** Governed-continuity at the mediated registration write (§13.7: removal/downgrade is an
|
|
181
|
+
* AUTHORIZED contract revision). For every command the NEW spec DECLARES, its governed-trait
|
|
182
|
+
* set must be a SUPERSET of the endpoint's recorded governance for that command: a
|
|
183
|
+
* self-published descriptor cannot strip an authority-imposed annotation. A command the new
|
|
184
|
+
* spec does NOT declare (removed) keeps its recorded governance as a TOMBSTONE (so a later
|
|
185
|
+
* re-add ungoverned still refuses) but does not itself refuse here; a NEW command and an ADDED
|
|
186
|
+
* trait are fine.
|
|
187
|
+
*
|
|
188
|
+
* `prior` is the ENDPOINT-WIDE governance head ({@link readEndpointGovernance}), NOT a single
|
|
189
|
+
* instance's prior spec: a per-instance head compare is defeated by three launder paths a
|
|
190
|
+
* history-bearing endpoint record closes — a FRESH instanceId (no prior head of its own), a
|
|
191
|
+
* REMOVE→RE-ADD across two revisions (the intermediate head carries no governance), and the
|
|
192
|
+
* optional-policy omission. (The "authority AUTHORIZES stopping governance" path needs the
|
|
193
|
+
* authority's own consent artifact, the D18 governance-consent record; until then a governed
|
|
194
|
+
* trait can be lifted by no owner-driven path at all, fail-closed.) */
|
|
195
|
+
function assertGovernedDeclarationContinuity(prior, next) {
|
|
196
|
+
for (const [command, priorTraits] of prior) {
|
|
197
|
+
if (priorTraits.size === 0)
|
|
198
|
+
continue; // was un-governed - nothing to carry forward
|
|
199
|
+
const nextTraits = next.get(command);
|
|
200
|
+
if (nextTraits === undefined)
|
|
201
|
+
continue; // the command is not declared by the new spec (removed) - tombstone persists, not a strip
|
|
202
|
+
for (const urn of priorTraits)
|
|
203
|
+
if (!nextTraits.has(urn))
|
|
204
|
+
throw new EpEnvelopeError("permission-denied", `registration drops governed trait "${urn}" from command "${command}", which the endpoint's governance record still imposes; a self-published descriptor cannot strip an authority-imposed annotation via re-registration, a fresh instance, or a remove-then-re-add - land an authority-authorized revision (SPEC 13.7)`);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
function parseGovernanceCommands(commands, key, what) {
|
|
208
|
+
if (commands === null || typeof commands !== "object" || Array.isArray(commands))
|
|
209
|
+
throw new EpEnvelopeError("internal", `the endpoint governance head ${key} has a non-object ${what} map; garbled mediated governance state never authorizes (SPEC 13.7)`);
|
|
210
|
+
const out = new Map();
|
|
211
|
+
for (const [command, urns] of Object.entries(commands)) {
|
|
212
|
+
if (!Array.isArray(urns) || !urns.every((u) => typeof u === "string" && u.length > 0))
|
|
213
|
+
throw new EpEnvelopeError("internal", `the endpoint governance head ${key} maps ${what} command "${command}" to a non-string-array; garbled state never authorizes (SPEC 13.7)`);
|
|
214
|
+
out.set(command, new Set(urns));
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
function parseEndpointGovernance(raw, key) {
|
|
219
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw) || !("commands" in raw))
|
|
220
|
+
throw new EpEnvelopeError("internal", `the endpoint governance head ${key} is not a { commands } record; garbled mediated governance state never authorizes (SPEC 13.7)`);
|
|
221
|
+
const commands = parseGovernanceCommands(raw.commands, key, "binding");
|
|
222
|
+
const p = raw.provisional;
|
|
223
|
+
if (p === undefined)
|
|
224
|
+
return { commands, provisional: null };
|
|
225
|
+
if (p === null || typeof p !== "object" || Array.isArray(p))
|
|
226
|
+
throw new EpEnvelopeError("internal", `the endpoint governance head ${key} has a non-object provisional slot; garbled state never authorizes (SPEC 13.7)`);
|
|
227
|
+
const slot = p;
|
|
228
|
+
if (typeof slot.instanceId !== "string" || slot.instanceId.length === 0)
|
|
229
|
+
throw new EpEnvelopeError("internal", `the endpoint governance head ${key} provisional slot has no holder instanceId; garbled state never authorizes (SPEC 13.7)`);
|
|
230
|
+
if (typeof slot.generation !== "number" || !Number.isSafeInteger(slot.generation) || slot.generation < 0)
|
|
231
|
+
throw new EpEnvelopeError("internal", `the endpoint governance head ${key} provisional slot has a non-integer generation; garbled state never authorizes (SPEC 13.7)`);
|
|
232
|
+
return {
|
|
233
|
+
commands,
|
|
234
|
+
provisional: { instanceId: slot.instanceId, generation: slot.generation, commands: parseGovernanceCommands(slot.commands, key, "provisional") },
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/** Read the endpoint-wide governance head fresh under the frozen gate (a KV get, not a fence -
|
|
238
|
+
* the fence is the slot-take CAS that follows): binding impositions + the in-flight provisional
|
|
239
|
+
* slot, plus the store `revision` the slot-take must CAS against (`null` = the head does not
|
|
240
|
+
* exist yet). Fail-closed on anything but a clean read: a garbled head is `internal`, and a
|
|
241
|
+
* DEL/PURGE marker is REFUSED, never treated as a virgin head - the KV client's get() returns
|
|
242
|
+
* deletion markers and its create() recreates over them, so mapping non-PUT to "no history"
|
|
243
|
+
* would let whoever can delete the key erase every tombstone and register a stripped surface
|
|
244
|
+
* against a reset record. Only TRUE ABSENCE is virgin; a deletion marker on a monotonic
|
|
245
|
+
* history-bearing authority record is tampering or a storage fault to reconcile, never
|
|
246
|
+
* authorization to forget (SPEC 13.7). */
|
|
247
|
+
async function readEndpointGovernance(kv, endpoint) {
|
|
248
|
+
const key = recordAtomicKey(GOVERN_HEAD, [endpoint]);
|
|
249
|
+
const entry = await kv.get(key);
|
|
250
|
+
if (!entry)
|
|
251
|
+
return { commands: new Map(), provisional: null, revision: null };
|
|
252
|
+
if (entry.operation !== "PUT")
|
|
253
|
+
throw new EpEnvelopeError("failed-precondition", `the endpoint governance head ${key} carries a ${entry.operation} marker; a monotonic history-bearing record is never deleted, so this is tampering or a storage fault - reconcile the head before registering, a deletion never resets governance history (SPEC 13.7)`);
|
|
254
|
+
return { ...parseEndpointGovernance(decodeJson(entry.value, key), key), revision: entry.revision };
|
|
255
|
+
}
|
|
256
|
+
/** Union the new spec's governed declarations into the binding governance (monotonic: a governed
|
|
257
|
+
* trait is only ever ADDED, never dropped; un-governed commands are not recorded). This is the
|
|
258
|
+
* PROMOTE content - written only after the spec publish commits. */
|
|
259
|
+
function mergeEndpointGovernance(prior, next) {
|
|
260
|
+
const merged = new Map();
|
|
261
|
+
for (const [command, urns] of prior)
|
|
262
|
+
merged.set(command, new Set(urns));
|
|
263
|
+
for (const [command, urns] of next) {
|
|
264
|
+
if (urns.size === 0)
|
|
265
|
+
continue; // un-governed commands are not recorded (only impositions)
|
|
266
|
+
const into = merged.get(command) ?? new Set();
|
|
267
|
+
for (const urn of urns)
|
|
268
|
+
into.add(urn);
|
|
269
|
+
merged.set(command, into);
|
|
270
|
+
}
|
|
271
|
+
return merged;
|
|
272
|
+
}
|
|
273
|
+
function serializeGovernanceCommands(commands) {
|
|
274
|
+
const out = {};
|
|
275
|
+
for (const [command, urns] of commands)
|
|
276
|
+
if (urns.size > 0)
|
|
277
|
+
out[command] = [...urns].sort();
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
export async function registerServiceInstance(kv, args) {
|
|
281
|
+
if (typeof args.readClusterArtifact !== "function")
|
|
282
|
+
throw new EpEnvelopeError("failed-precondition", "registerServiceInstance requires a content-store reader (readClusterArtifact); governed-continuity is not an optional seam (SPEC 13.7)");
|
|
283
|
+
spacePrefix(args.space); // up-front boundary guard on the space arg (mirrors authorizeServeGrant): usable as a subject token, throws on an absent/non-string space at an untyped caller. This is NOT the cross-space authority fence - that is the observed-gate `(space, endpoint, instanceId)` identity check below (trusted-context equality against the per-space KV bucket).
|
|
284
|
+
const spec = parseServiceSpec(args.spec, { endpoint: args.spec.endpoint });
|
|
285
|
+
assertBoundedOwner(args.registrant.owner, "registrant owner");
|
|
286
|
+
if (args.registrant.owner !== spec.owner)
|
|
287
|
+
throw new EpEnvelopeError("permission-denied", `the registration's authenticated caller "${args.registrant.owner}" is not the descriptor owner "${spec.owner}" (SPEC 13.9: authenticated caller binding, never a payload claim)`);
|
|
288
|
+
// The NAME-AUTHORITY decision is deferred until UNDER the frozen gate (phase 1): a transfer must
|
|
289
|
+
// freeze this same gate, so authorizing while we hold the freeze serializes the decision with the
|
|
290
|
+
// transfer — checking here (pre-freeze) would repeat the torn owner-vs-revision read the atomic
|
|
291
|
+
// authorize() closed for authorizeServeGrant.
|
|
292
|
+
const key = recordSpecKey(RECORD_KINDS.svc, [spec.endpoint, assertLifecycleToken(args.instanceId, "instanceId")]);
|
|
293
|
+
// §13.1 barrier: freeze the instance's gate FIRST so no serve mint can win against the surface
|
|
294
|
+
// this registration is about to supersede. A non-open gate or a lost freeze is another barrier
|
|
295
|
+
// holding the key — a loud `conflict` (§13.8: re-read and re-decide), never a bare write.
|
|
296
|
+
const obs = await args.barrier.observe();
|
|
297
|
+
if (obs === null)
|
|
298
|
+
throw new EpEnvelopeError("failed-precondition", `no issuance gate for instance "${args.instanceId}"; a registration writes only behind the provisioner-created gate (SPEC 13.1)`);
|
|
299
|
+
if (obs.space !== args.space || obs.endpoint !== spec.endpoint || obs.lifecycleUid !== args.instanceId)
|
|
300
|
+
throw new EpEnvelopeError("internal", `the issuance gate is for "${obs.space}/${obs.endpoint}/${obs.lifecycleUid}", not "${args.space}/${spec.endpoint}/${args.instanceId}"; a registration drives only its OWN instance's gate, and the instance token is unique only within (space, endpoint) (SPEC 13.1)`);
|
|
301
|
+
if (obs.state === "retired")
|
|
302
|
+
throw new EpEnvelopeError("failed-precondition", `the issuance gate for "${args.instanceId}" is retired; the lifecycle is permanently closed and its id is never reused, so a re-read cannot help (SPEC 13.1)`);
|
|
303
|
+
if (obs.state !== "open")
|
|
304
|
+
throw new EpEnvelopeError("conflict", `the issuance gate for "${args.instanceId}" is ${obs.state}; another barrier holds it; re-read and re-decide (SPEC 13.8)`);
|
|
305
|
+
const token = await args.barrier.freeze(obs.revision);
|
|
306
|
+
if (token === null)
|
|
307
|
+
throw new EpEnvelopeError("conflict", `a concurrent barrier froze the issuance gate for "${args.instanceId}" first; re-read and re-decide (SPEC 13.1/13.8)`);
|
|
308
|
+
// The gate is frozen; every exit below reopens it (token-pinned, at the original coordinate) or
|
|
309
|
+
// deliberately leaves it FROZEN for reconciliation. The successor the completing reopen targets.
|
|
310
|
+
const successorAt = (registrationRevision) => ({
|
|
311
|
+
generation: obs.generation + 1, processEpoch: obs.processEpoch, registrationRevision, nameAuthorityRevision: obs.nameAuthorityRevision,
|
|
312
|
+
});
|
|
313
|
+
// PHASE 1 — authorize UNDER the frozen gate, then ownership stability. Both are authority /
|
|
314
|
+
// local reads with NO write side-effect, so any failure (owner not authorized, name-authority
|
|
315
|
+
// drift, a garbled stored spec, an ownership change) is a DEFINITE no-write and no revoke has
|
|
316
|
+
// run → reopen the ORIGINAL coordinate and rethrow.
|
|
317
|
+
// - the name-authority decision is made HERE (holding the freeze), and the authorized revision
|
|
318
|
+
// MUST equal the frozen gate's `nameAuthorityRevision`: a transfer that raced would have to
|
|
319
|
+
// freeze this same gate (it can't) or would leave the gate at a different coordinate, so a
|
|
320
|
+
// mismatch is a raced transfer — a loud `conflict`, never a stale-owner registration.
|
|
321
|
+
let current;
|
|
322
|
+
const govKey = recordAtomicKey(GOVERN_HEAD, [spec.endpoint]);
|
|
323
|
+
// Assigned in PHASE 1 on every non-throwing path (the slot-take is PHASE 1's last act).
|
|
324
|
+
let governSlotRevision;
|
|
325
|
+
let governPromote;
|
|
326
|
+
try {
|
|
327
|
+
const authorizedNameRevision = await assertServiceNameAuthority(spec.endpoint, spec.owner, args.authority);
|
|
328
|
+
if (authorizedNameRevision !== obs.nameAuthorityRevision)
|
|
329
|
+
throw new EpEnvelopeError("conflict", `a name-authority transfer raced this registration: owner "${spec.owner}" is authorized at nameAuthorityRevision ${authorizedNameRevision} but the frozen gate is at ${obs.nameAuthorityRevision}; re-read and re-decide (SPEC 13.9)`);
|
|
330
|
+
current = await kv.get(key);
|
|
331
|
+
if (current && current.operation === "PUT") {
|
|
332
|
+
const stored = parseServiceSpec(decodeJson(current.value, key), { endpoint: spec.endpoint });
|
|
333
|
+
if (stored.owner !== spec.owner)
|
|
334
|
+
throw new EpEnvelopeError("permission-denied", `instance "${args.instanceId}" is registered to owner "${stored.owner}"; a re-registration can never change ownership (SPEC 13.1: instance ids are never reused across identities)`);
|
|
335
|
+
}
|
|
336
|
+
// §13.7 ENDPOINT-WIDE governed-continuity, run on EVERY registration (a first registration
|
|
337
|
+
// included — that is where a fresh-instance strip is caught). The governance head is BOTH the
|
|
338
|
+
// history-bearing imposition record AND the endpoint's registration linearization point:
|
|
339
|
+
// - read it fresh under the frozen gate; refuse if a FOREIGN registration holds its
|
|
340
|
+
// provisional slot (`conflict`, re-read and re-decide — per-instance gates do not mutually
|
|
341
|
+
// exclude across instances, the slot does);
|
|
342
|
+
// - validate the new spec's digest-verified declarations against the BINDING impositions
|
|
343
|
+
// (fresh-instance, remove→re-add, and stripped-but-surviving all refuse here);
|
|
344
|
+
// - CAS-TAKE the slot — every registration takes it, an ungoverned/`changed:false` one
|
|
345
|
+
// included, or a pure head READER could decide against one governance state and publish
|
|
346
|
+
// under another (the cross-instance launder). The slot is stamped with this gate's frozen
|
|
347
|
+
// `generation`; it is HELD through the spec publish and PROMOTED to binding only after the
|
|
348
|
+
// publish commits, so no imposition ever binds for a descriptor that never published (the
|
|
349
|
+
// phantom-obligation orphan) and no publish ever slips past a concurrent imposition.
|
|
350
|
+
// Every failure in this block is a DEFINITE no-spec-write, so the outer catch reopens the
|
|
351
|
+
// ORIGINAL coordinate: a slot-take CAS loss is a raced endpoint registration (`conflict`);
|
|
352
|
+
// an AMBIGUOUS slot-take is also safe to reopen because a committed-but-unacked slot is
|
|
353
|
+
// stamped with the generation this reopen advances PAST — the stale stamp marks it orphaned,
|
|
354
|
+
// this instance's own retry replaces it, and a foreign registration refuses on it until then
|
|
355
|
+
// (fail-closed, reclaimed by retry or by the D13 reconciler; predicate: the stamped gate
|
|
356
|
+
// coordinate is not frozen at that generation).
|
|
357
|
+
const gov = await readEndpointGovernance(kv, spec.endpoint);
|
|
358
|
+
if (gov.provisional) {
|
|
359
|
+
if (gov.provisional.instanceId !== args.instanceId)
|
|
360
|
+
throw new EpEnvelopeError("conflict", `a concurrent registration for endpoint "${spec.endpoint}" (instance "${gov.provisional.instanceId}") holds the governance slot through its spec publication; re-read and re-decide; if its holder aborted pre-publish its stale-generation slot is reclaimed by that instance's retry or by reconciliation (SPEC 13.7/13.8)`);
|
|
361
|
+
if (gov.provisional.generation >= obs.generation)
|
|
362
|
+
throw new EpEnvelopeError("internal", `the governance slot for endpoint "${spec.endpoint}" is held by this very instance at generation ${gov.provisional.generation} while its gate is frozen at ${obs.generation}; a live slot under a re-frozen gate cannot exist (every retry freezes at an advanced generation); reconcile the head before registering (SPEC 13.7)`);
|
|
363
|
+
// else: this instance's OWN orphan from an aborted earlier attempt (the gate has reopened
|
|
364
|
+
// past its stamp since) — the slot-take below replaces it.
|
|
365
|
+
}
|
|
366
|
+
const { governed: next, classes } = await readGovernedDeclarations(args.readClusterArtifact, spec.clusterDigests);
|
|
367
|
+
// §13.6: a VIRTUAL endpoint's commands MUST be journal-class — an ephemeral call to an
|
|
368
|
+
// endpoint with no live instance is an honest `unavailable`, so registering an on-demand
|
|
369
|
+
// activation policy over an ephemeral command would advertise a surface that cannot exist.
|
|
370
|
+
if (spec.activation !== undefined) {
|
|
371
|
+
for (const [name, cls] of classes) {
|
|
372
|
+
if (cls !== "journal")
|
|
373
|
+
throw new EpEnvelopeError("failed-precondition", `endpoint "${spec.endpoint}" registers on-demand activation but declares the ${cls}-class command "${name}"; a virtual endpoint's commands MUST be journal-class (SPEC 13.6)`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
assertGovernedDeclarationContinuity(gov.commands, next);
|
|
377
|
+
governPromote = { commands: serializeGovernanceCommands(mergeEndpointGovernance(gov.commands, next)) };
|
|
378
|
+
const slotValue = {
|
|
379
|
+
commands: serializeGovernanceCommands(gov.commands),
|
|
380
|
+
provisional: { instanceId: args.instanceId, generation: obs.generation, commands: serializeGovernanceCommands(next) },
|
|
381
|
+
};
|
|
382
|
+
try {
|
|
383
|
+
governSlotRevision = gov.revision === null
|
|
384
|
+
? await createRecordEntry(kv, govKey, slotValue)
|
|
385
|
+
: await updateRecordEntry(kv, govKey, slotValue, gov.revision);
|
|
386
|
+
}
|
|
387
|
+
catch (e) {
|
|
388
|
+
// createRecordEntry/updateRecordEntry translate the broker CAS loss (err_code 10071/10164)
|
|
389
|
+
// into EpEnvelopeError("conflict") — classify on THAT, the numeric code never reaches here.
|
|
390
|
+
if (e instanceof EpEnvelopeError && e.code === "conflict")
|
|
391
|
+
throw new EpEnvelopeError("conflict", `a concurrent registration for endpoint "${spec.endpoint}" took the governance slot first (a definite no-write CAS loss); re-read and re-decide (SPEC 13.7/13.8)`);
|
|
392
|
+
throw new EpEnvelopeError("unavailable", `the governance slot-take for endpoint "${spec.endpoint}" is ambiguous; the registration aborts before any spec write and the gate reopens; a committed-but-unacked slot self-orphans at the reopened generation and this instance's retry replaces it (SPEC 13.7): ${e?.message ?? String(e)}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
catch (err) {
|
|
396
|
+
await reopenGateAfterAbort(args.barrier, token, successorAt(obs.registrationRevision), err);
|
|
397
|
+
throw err;
|
|
398
|
+
}
|
|
399
|
+
// PHASE 2 — revoke + VERIFIED eviction of the superseded family BEFORE publishing the new spec
|
|
400
|
+
// (§13.1 order: old authority must die before new authority is visible). Fail-closed: if any
|
|
401
|
+
// revoke/eviction cannot be verified, leave the gate FROZEN for reconciliation — never reopen,
|
|
402
|
+
// or old credentials could come back to life against a pending re-registration.
|
|
403
|
+
// - revoke every ACTIVE row (an already-`revoked` row was flipped by an earlier barrier);
|
|
404
|
+
// - but verified-evict the distinct holder principals of the ENTIRE enumerated family: an
|
|
405
|
+
// already-revoked row from a PARTIALLY FAILED prior barrier may still have a live connection
|
|
406
|
+
// that was never verified gone, so eviction must not skip it (§13.1).
|
|
407
|
+
try {
|
|
408
|
+
const family = await args.barrier.enumerate();
|
|
409
|
+
for (const row of family)
|
|
410
|
+
if (row.state === "active")
|
|
411
|
+
await args.barrier.revoke(row);
|
|
412
|
+
for (const holderPrincipal of new Set(family.map((row) => row.holderPrincipal)))
|
|
413
|
+
if (!(await args.barrier.evict(holderPrincipal)))
|
|
414
|
+
throw new Error(`principal "${holderPrincipal}" is not verified evicted`);
|
|
415
|
+
}
|
|
416
|
+
catch (err) {
|
|
417
|
+
throw new EpEnvelopeError("unavailable", `re-registration could not revoke + verify-evict the superseded serve family; the gate is left frozen for reconciliation, no new spec published (SPEC 13.1): ${err?.message ?? String(err)}`);
|
|
418
|
+
}
|
|
419
|
+
// PHASE 3 — publish the new spec. ANY write error stays FROZEN for reconciliation, never
|
|
420
|
+
// reopening the old coordinate: the KV may have committed while the ack was lost (an ambiguous
|
|
421
|
+
// outcome), and reopening old would release stale-surface credentials against a spec that
|
|
422
|
+
// advanced. Under the frozen gate THIS barrier is the sole spec-key writer, so a write error is
|
|
423
|
+
// genuinely infra/ambiguous — never a concurrent-CAS loss we could treat as a definite no-write.
|
|
424
|
+
let newRev;
|
|
425
|
+
try {
|
|
426
|
+
newRev = current && current.operation === "PUT"
|
|
427
|
+
? await updateRecordEntry(kv, key, spec, current.revision)
|
|
428
|
+
: await createRecordEntry(kv, key, spec);
|
|
429
|
+
}
|
|
430
|
+
catch (err) {
|
|
431
|
+
throw new EpEnvelopeError("unavailable", `the re-registration spec-write outcome is ambiguous (it may have committed); the gate is left frozen for reconciliation, never reopened at the old coordinate (SPEC 13.1): ${err?.message ?? String(err)}`);
|
|
432
|
+
}
|
|
433
|
+
// PHASE 3b — PROMOTE the governance slot to binding, now that the spec publish committed: the
|
|
434
|
+
// held provisional impositions merge into the binding map and the slot clears. Only HERE does
|
|
435
|
+
// an imposition become permanent, so a registration that failed in PHASE 2/3 never binds
|
|
436
|
+
// governance for a descriptor that never published (the phantom-obligation orphan), and the
|
|
437
|
+
// slot's hold from decision through publish is what serializes every concurrent registration
|
|
438
|
+
// of this endpoint. Nothing else can have CAS'd the head while we held the slot (a foreign
|
|
439
|
+
// registration refuses on it), so ANY failure — a lost ack, or a CAS loss to a reconciler that
|
|
440
|
+
// stole the slot — leaves the gate FROZEN for reconciliation, consistent with PHASE 3: the
|
|
441
|
+
// spec is published, so reopening without the promote would activate a surface whose
|
|
442
|
+
// imposition never bound. The promote is idempotent for the reconciler (re-CAS the same merge).
|
|
443
|
+
try {
|
|
444
|
+
await updateRecordEntry(kv, govKey, governPromote, governSlotRevision);
|
|
445
|
+
}
|
|
446
|
+
catch (err) {
|
|
447
|
+
throw new EpEnvelopeError("unavailable", `the spec for "${args.instanceId}" is published at revision ${newRev} but the governance promote did not complete; the gate is left frozen for reconciliation (the promote is an idempotent re-CAS of the held slot to binding, SPEC 13.7/13.1): ${err?.message ?? String(err)}`);
|
|
448
|
+
}
|
|
449
|
+
// PHASE 4 — reopen at the successor, TOKEN-pinned: only this barrier (still holding its freeze)
|
|
450
|
+
// may reopen; a lost CAS means a reconciler/newer barrier superseded us → leave frozen.
|
|
451
|
+
try {
|
|
452
|
+
if (!(await args.barrier.reopen(token, successorAt(newRev))))
|
|
453
|
+
throw new Error("the reopen CAS lost its freeze token (a reconciler or newer barrier superseded this one)");
|
|
454
|
+
}
|
|
455
|
+
catch (err) {
|
|
456
|
+
throw new EpEnvelopeError("unavailable", `re-registration wrote the spec at revision ${newRev} but the reopen did not complete; the gate is left frozen for reconciliation (SPEC 13.1): ${err?.message ?? String(err)}`);
|
|
457
|
+
}
|
|
458
|
+
return { registrationRevision: newRev };
|
|
459
|
+
}
|
|
460
|
+
/** Reopen a barrier-frozen gate (token-pinned) after a registration aborted before any spec write
|
|
461
|
+
* or revoke — the gate returns to `open` at the given successor. A lost or failed reopen leaves
|
|
462
|
+
* the gate frozen for reconciliation and is surfaced with the aborting cause attached (§13.1:
|
|
463
|
+
* fail closed, never a silently stuck gate). */
|
|
464
|
+
async function reopenGateAfterAbort(barrier, token, successor, cause) {
|
|
465
|
+
try {
|
|
466
|
+
if (await barrier.reopen(token, successor))
|
|
467
|
+
return;
|
|
468
|
+
throw new Error("the reopen CAS lost its freeze token");
|
|
469
|
+
}
|
|
470
|
+
catch (err) {
|
|
471
|
+
const e = new EpEnvelopeError("unavailable", `registration aborted and the issuance gate could not be reopened; it is left frozen for reconciliation (SPEC 13.1): ${err?.message ?? String(err)}`);
|
|
472
|
+
e.cause = cause;
|
|
473
|
+
throw e;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
function decodeJson(value, key) {
|
|
477
|
+
try {
|
|
478
|
+
return JSON.parse(new TextDecoder().decode(value));
|
|
479
|
+
}
|
|
480
|
+
catch (e) {
|
|
481
|
+
throw new EpEnvelopeError("internal", `record ${key} does not decode as JSON: ${e.message}`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
/** Write an instance's status with the FULL §13.9 writer fence. `epoch` is the
|
|
485
|
+
* WRITER-AUTHENTICATED epoch — in production the record writer reads it from the
|
|
486
|
+
* broker-authenticated `epr` subject (§13.9), never from the payload; this helper trusts its
|
|
487
|
+
* caller to be that seam and additionally requires the payload to agree. The fence is
|
|
488
|
+
* THREE-part, in order:
|
|
489
|
+
* 1. a registered spec must exist and `observedSpecRevision` must not run AHEAD of it — a
|
|
490
|
+
* spec-less status is the torn record state readers reject (§13.4), never written;
|
|
491
|
+
* 2. the epoch must equal a FRESH read of the authoritative lifecycle mapping's
|
|
492
|
+
* `processEpoch` (`expired` otherwise) — monotonicity against the stored status alone is
|
|
493
|
+
* NOT sufficient: between the takeover CAS (N→N+1) and the completed revoke/evict barrier
|
|
494
|
+
* the superseded N still equals the stored epoch (§13.9);
|
|
495
|
+
* 3. a below-stored epoch is `conflict` (§13.9), distinct from the mapping fence.
|
|
496
|
+
* `readProcessEpoch` is the trusted mapping-reader seam (leader-served, §13.9; the D13
|
|
497
|
+
* lifecycle registry provides the production reader). The racing CAS loss is a loud `conflict`.
|
|
498
|
+
* `expectedStatusRevision` pins the CAS to the CALLER's observed status revision (0 = observed
|
|
499
|
+
* ABSENT) for read-modify-write callers whose new value derives from the stored one (the §13.6
|
|
500
|
+
* restart-intensity history): without the pin, this function's own fresh internal read would
|
|
501
|
+
* let two concurrent derivations silently merge-lose each other's contribution. It is PURELY a
|
|
502
|
+
* CAS pin — the AUTHORITY to originate supervisor-owned state is the separate branded
|
|
503
|
+
* {@link SupervisorWriteGrant} (`supervisor`), never revision presence. Without the grant this
|
|
504
|
+
* is an instance-side write: it may not carry the restart history, the retirement mark, or the
|
|
505
|
+
* `escalated` state (they are stripped on both create and update, and `escalated` refuses),
|
|
506
|
+
* and the stored supervisor fields ride forward untouched. */
|
|
507
|
+
export async function writeServiceStatus(kv, args) {
|
|
508
|
+
// SNAPSHOT the raw input FIRST (a JSON round-trip reads each getter exactly once, so nothing a
|
|
509
|
+
// caller controls flips between here and the awaits below — validate-then-clone would validate
|
|
510
|
+
// the caller object then re-read it during the clone, a getter/Proxy TOCTOU). An instance-side
|
|
511
|
+
// (ungranted) write cannot ORIGINATE the supervisor-owned state: its reserved fields are
|
|
512
|
+
// stripped from the RAW snapshot BEFORE validation (a forged garbage `restarts`/`retiredAt` is
|
|
513
|
+
// dropped, not rejected — the instance's ordinary `ready`/`exited` write still goes through),
|
|
514
|
+
// and it may not originate `escalated`. Only THEN validate the (possibly stripped) snapshot.
|
|
515
|
+
const raw = JSON.parse(JSON.stringify(args.status));
|
|
516
|
+
const bySupervisor = isSupervisorWrite(args.supervisor);
|
|
517
|
+
if (!bySupervisor) {
|
|
518
|
+
if (raw.state === SERVICE_ESCALATED)
|
|
519
|
+
throw new EpEnvelopeError("failed-precondition", `an instance-side status write cannot ORIGINATE "${SERVICE_ESCALATED}" for "${args.endpoint}/${args.instanceId}"; escalation is the supervisor's branded authority (SPEC 13.6)`);
|
|
520
|
+
delete raw[SERVICE_RESTART_HISTORY_FIELD];
|
|
521
|
+
delete raw[SERVICE_RETIRED_MARK_FIELD];
|
|
522
|
+
}
|
|
523
|
+
const status = parseServiceStatus(raw);
|
|
524
|
+
if (status.epoch !== args.epoch)
|
|
525
|
+
throw new EpEnvelopeError("internal", `status.epoch ${status.epoch} disagrees with the writer-authenticated epoch ${args.epoch} (SPEC 13.9: the epoch rides the subject)`);
|
|
526
|
+
assertStatusValue(status);
|
|
527
|
+
// The endpoint NAME rides through: the kind's own qualifier assert tokenizes it exactly once.
|
|
528
|
+
const iId = assertLifecycleToken(args.instanceId, "instanceId");
|
|
529
|
+
const specEntry = await kv.get(recordSpecKey(RECORD_KINDS.svc, [args.endpoint, iId]));
|
|
530
|
+
if (!specEntry || specEntry.operation !== "PUT")
|
|
531
|
+
throw new EpEnvelopeError("failed-precondition", `status write for "${args.endpoint}/${args.instanceId}" has no registered spec; writing it would create the torn record state readers reject (SPEC 13.4)`);
|
|
532
|
+
if (status.observedSpecRevision > specEntry.revision)
|
|
533
|
+
throw new EpEnvelopeError("failed-precondition", `observedSpecRevision ${status.observedSpecRevision} runs AHEAD of the spec revision ${specEntry.revision}; a status can only observe a registration that exists (SPEC 13.4)`);
|
|
534
|
+
const current = await args.readProcessEpoch();
|
|
535
|
+
if (!Number.isSafeInteger(current) || current < 0)
|
|
536
|
+
throw new EpEnvelopeError("internal", `the authoritative mapping read returned ${JSON.stringify(current)}, not an unsigned processEpoch`);
|
|
537
|
+
if (args.epoch !== current)
|
|
538
|
+
throw new EpEnvelopeError("expired", `status write from epoch ${args.epoch} is not the authoritative mapping's current processEpoch ${current}; stored-status monotonicity alone is insufficient during takeover (SPEC 13.9)`);
|
|
539
|
+
const key = recordStatusKey(RECORD_KINDS.svc, [args.endpoint, iId]);
|
|
540
|
+
const stored = await kv.get(key);
|
|
541
|
+
if (args.expectedStatusRevision !== undefined) {
|
|
542
|
+
if (!Number.isSafeInteger(args.expectedStatusRevision) || args.expectedStatusRevision < 0)
|
|
543
|
+
throw new EpEnvelopeError("internal", `expectedStatusRevision ${String(args.expectedStatusRevision)} is not an unsigned integer`);
|
|
544
|
+
const current = stored && stored.operation === "PUT" ? stored.revision : 0;
|
|
545
|
+
if (current !== args.expectedStatusRevision)
|
|
546
|
+
throw new EpEnvelopeError("conflict", `the status for "${args.endpoint}/${args.instanceId}" moved (observed revision ${args.expectedStatusRevision}, stored ${current}); a derived write against a moved base would merge-lose the concurrent write, re-read and re-derive (SPEC 13.6)`);
|
|
547
|
+
}
|
|
548
|
+
if (stored && stored.operation === "PUT") {
|
|
549
|
+
const recorded = parseServiceStatus(decodeJson(stored.value, key));
|
|
550
|
+
// ESCALATED is IRREVERSIBLE here (§13.6): no later write, any epoch, replaces it. The only
|
|
551
|
+
// permitted touch on an escalated row is the supervisor's retirement mark, which the
|
|
552
|
+
// escalation reconciler writes DIRECTLY (never through this writer).
|
|
553
|
+
if (recorded.state === SERVICE_ESCALATED)
|
|
554
|
+
throw new EpEnvelopeError("failed-precondition", `"${args.endpoint}/${args.instanceId}" is escalated; the state is terminal and a status write cannot clear it (SPEC 13.6)`);
|
|
555
|
+
if (args.epoch < recorded.epoch)
|
|
556
|
+
throw new EpEnvelopeError("conflict", `status write from epoch ${args.epoch} is below the stored status epoch ${recorded.epoch} (SPEC 13.9)`);
|
|
557
|
+
if (!bySupervisor) {
|
|
558
|
+
// The supervisor-owned fields ride FORWARD through this instance-side write: they were
|
|
559
|
+
// stripped above, so copy the stored values back (a successor's `ready` convergence
|
|
560
|
+
// survives the history, §13.6).
|
|
561
|
+
const s = status;
|
|
562
|
+
const r = recorded;
|
|
563
|
+
if (r[SERVICE_RESTART_HISTORY_FIELD] !== undefined)
|
|
564
|
+
s[SERVICE_RESTART_HISTORY_FIELD] = r[SERVICE_RESTART_HISTORY_FIELD];
|
|
565
|
+
if (r[SERVICE_RETIRED_MARK_FIELD] !== undefined)
|
|
566
|
+
s[SERVICE_RETIRED_MARK_FIELD] = r[SERVICE_RETIRED_MARK_FIELD];
|
|
567
|
+
}
|
|
568
|
+
return updateRecordEntry(kv, key, status, stored.revision);
|
|
569
|
+
}
|
|
570
|
+
return createRecordEntry(kv, key, status);
|
|
571
|
+
}
|
|
572
|
+
/** Freeze the request-scoped expected set (§13.5): the LIVE instances of a class from the
|
|
573
|
+
* service registry at send time — VALIDATED registered spec, status present and caught up to
|
|
574
|
+
* the current registration (a stale projection is an instance not yet live under it, so
|
|
575
|
+
* freezing `(new registrationRevision, pre-registration epoch)` would combine a registration
|
|
576
|
+
* with liveness it never had), and not {@link SERVICE_EXITED}. An EMPTY or UNREADABLE registry
|
|
577
|
+
* is `failed-precondition`, never an empty success (§13.5); a MALFORMED registry record fails
|
|
578
|
+
* loud (`internal`, §13.9: readers fail loud on invalid mediated-writer state). The read grant
|
|
579
|
+
* this runs under is a §13.9 matrix row. */
|
|
580
|
+
export async function freezeExpectedSet(jsm, kv, space, endpoint) {
|
|
581
|
+
const e = endpointToken(endpoint);
|
|
582
|
+
const frozen = [];
|
|
583
|
+
const instanceIds = [];
|
|
584
|
+
try {
|
|
585
|
+
// Enumeration is a bounded-lag `kv.keys` LIST (which instances exist): a just-registered
|
|
586
|
+
// instance missed here simply is not frozen this round (it falls to the gather/reconcile), so
|
|
587
|
+
// the list read need not be leader-served — but each frozen slot's coordinate reads below ARE.
|
|
588
|
+
const iter = await kv.keys(`svc.${e}.*.spec`);
|
|
589
|
+
for await (const key of iter)
|
|
590
|
+
instanceIds.push(key.split(".")[2]);
|
|
591
|
+
}
|
|
592
|
+
catch (err) {
|
|
593
|
+
const wrapped = new EpEnvelopeError("failed-precondition", `the service registry for "${endpoint}" is unreadable; an unreadable registry is failed-precondition, never an empty success (SPEC 13.5): ${err?.message ?? String(err)}`);
|
|
594
|
+
wrapped.cause = err;
|
|
595
|
+
throw wrapped;
|
|
596
|
+
}
|
|
597
|
+
for (const instanceId of instanceIds) {
|
|
598
|
+
// Leader-served spec + status reads, so the FROZEN registrationRevision comes from the same
|
|
599
|
+
// consistency level as the reconcile's — a follower-stale freeze paired with a leader reconcile
|
|
600
|
+
// would otherwise fabricate registration churn from pure read-skew.
|
|
601
|
+
const spec = await readSvcRecordLeader(jsm, space, recordSpecKey(RECORD_KINDS.svc, [endpoint, instanceId]));
|
|
602
|
+
if (!spec || "deleted" in spec)
|
|
603
|
+
continue; // gone since the enumeration list: not a live member
|
|
604
|
+
parseServiceSpec(spec.value, { endpoint }); // malformed registry state fails LOUD (§13.9)
|
|
605
|
+
const statusRec = await readSvcRecordLeader(jsm, space, recordStatusKey(RECORD_KINDS.svc, [endpoint, instanceId]));
|
|
606
|
+
if (!statusRec || "deleted" in statusRec)
|
|
607
|
+
continue; // registered but never converged: not a live class member
|
|
608
|
+
const status = parseServiceStatus(statusRec.value);
|
|
609
|
+
if (status.state === SERVICE_EXITED)
|
|
610
|
+
continue;
|
|
611
|
+
if (status.state === SERVICE_ESCALATED)
|
|
612
|
+
continue; // terminally not-startable (§13.6): never a live scatter member
|
|
613
|
+
if (status.observedSpecRevision < spec.revision)
|
|
614
|
+
continue; // staleProjection: liveness predates the CURRENT registration
|
|
615
|
+
frozen.push({ instanceId, registrationRevision: spec.revision, epoch: status.epoch });
|
|
616
|
+
}
|
|
617
|
+
if (frozen.length === 0)
|
|
618
|
+
throw new EpEnvelopeError("failed-precondition", `service "${endpoint}" has no live registered instances; an empty registry is never an empty scatter success (SPEC 13.5)`);
|
|
619
|
+
return frozen;
|
|
620
|
+
}
|
|
621
|
+
/** A LEADER-SERVED read of one `svc` record key (§13.1: every authority currency read is a
|
|
622
|
+
* leader-served `STREAM.MSG.GET`, never a follower/mirror Direct Get — independent of how a KV
|
|
623
|
+
* handle was opened, so the public reader seams do not depend on a caller passing a bind-only KV).
|
|
624
|
+
* `svc` records, unlike the never-deleted lifecycle families, MAY be deleted on deregistration
|
|
625
|
+
* (§13.5: a deleted spec is an explicit deregistration), so a DELETE/PURGE marker is reported as
|
|
626
|
+
* `{ deleted: true }`, never a corruption throw. Absent → `undefined`; malformed → loud. */
|
|
627
|
+
async function readSvcRecordLeader(jsm, space, key) {
|
|
628
|
+
const bucket = recordsBucket(space);
|
|
629
|
+
let m;
|
|
630
|
+
try {
|
|
631
|
+
m = await jsm.streams.getMessage(`KV_${bucket}`, { last_by_subj: `$KV.${bucket}.${key}` });
|
|
632
|
+
}
|
|
633
|
+
catch (e) {
|
|
634
|
+
if (e.code === 10037)
|
|
635
|
+
return undefined; // no message on the subject: never registered
|
|
636
|
+
const wrapped = new EpEnvelopeError("failed-precondition", `the service registry key ${key} is unreadable (leader read); an unreadable registry is failed-precondition, never a fabricated verdict (SPEC 13.5): ${e?.message ?? String(e)}`);
|
|
637
|
+
wrapped.cause = e;
|
|
638
|
+
throw wrapped;
|
|
639
|
+
}
|
|
640
|
+
if (!m)
|
|
641
|
+
return undefined;
|
|
642
|
+
if (m.header?.get("KV-Operation"))
|
|
643
|
+
return { deleted: true }; // a deregistration, not corruption (§13.5)
|
|
644
|
+
return { value: decodeJson(m.data, key), revision: m.seq };
|
|
645
|
+
}
|
|
646
|
+
/** The PRODUCTION `reconcileRegistration` hook for {@link epScatter} (§13.5): a bounded post-T
|
|
647
|
+
* LEADER-SERVED read of every frozen slot's CURRENT `svc….spec` key (the same §13.9 read class as
|
|
648
|
+
* the freeze). Per slot: a live spec is `{ registered: true, registrationRevision }` (the key's
|
|
649
|
+
* CURRENT store revision — an advance past the frozen value is what the gather classifies as
|
|
650
|
+
* `registration` churn); an absent OR deleted spec is the EXPLICIT `{ registered: false }` verdict
|
|
651
|
+
* (a mid-scatter deregistration, §13.5: not churn). Only the spec KEY is read — the reconcile
|
|
652
|
+
* compares registration currency, not liveness. A malformed spec fails loud (`internal`, §13.9);
|
|
653
|
+
* an unreadable registry normalizes to `failed-precondition` (§13.5: never a fabricated verdict).
|
|
654
|
+
* Leader-served so a follower-stale read can never miss an advanced revision and falsely retain a
|
|
655
|
+
* counted reply (engineer/distsys). */
|
|
656
|
+
export function registrationReconciler(jsm, space, endpoint, frozen) {
|
|
657
|
+
endpointToken(endpoint); // grammar up front: a malformed endpoint never reaches the read
|
|
658
|
+
return async () => {
|
|
659
|
+
const verdicts = new Map();
|
|
660
|
+
for (const slot of frozen) {
|
|
661
|
+
const rec = await readSvcRecordLeader(jsm, space, recordSpecKey(RECORD_KINDS.svc, [endpoint, slot.instanceId]));
|
|
662
|
+
if (!rec || "deleted" in rec) {
|
|
663
|
+
verdicts.set(slot.instanceId, { registered: false });
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
parseServiceSpec(rec.value, { endpoint }); // malformed registry state fails LOUD (§13.9)
|
|
667
|
+
verdicts.set(slot.instanceId, { registered: true, registrationRevision: rec.revision });
|
|
668
|
+
}
|
|
669
|
+
return verdicts;
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
/** The PRODUCTION `currentEpoch` hook for {@link epCall} on the `one` rail (§13.2): a LEADER-SERVED
|
|
673
|
+
* read of the answering instance's CURRENT `svc….status` epoch. An unregistered instance or one
|
|
674
|
+
* that never converged (no status) has no current epoch to verify a queue winner against and
|
|
675
|
+
* refuses `failed-precondition` — the read's OWN failure, which {@link epCall} never mislabels as
|
|
676
|
+
* responder staleness. Leader-served so a follower-stale status read can never accept a
|
|
677
|
+
* just-superseded queue winner at an old epoch (engineer/distsys). A stale projection does not
|
|
678
|
+
* refuse: `epoch` advances only through a takeover's status write (§13.5). */
|
|
679
|
+
export function serviceEpochReader(jsm, space, endpoint) {
|
|
680
|
+
endpointToken(endpoint);
|
|
681
|
+
return async (instanceId) => {
|
|
682
|
+
const rec = await readSvcRecordLeader(jsm, space, recordStatusKey(RECORD_KINDS.svc, [endpoint, instanceId]));
|
|
683
|
+
if (!rec || "deleted" in rec)
|
|
684
|
+
throw new EpEnvelopeError("failed-precondition", `"${endpoint}/${instanceId}" has no live status; a \`one\` responder with no current epoch cannot be verified (SPEC 13.2)`);
|
|
685
|
+
return parseServiceStatus(rec.value).epoch;
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
const AUTHORIZED_SERVE = new WeakMap();
|
|
689
|
+
/**
|
|
690
|
+
* Authorize a serve credential against the REGISTERED service (§13.9: serving is granted
|
|
691
|
+
* authority, dual to calling — the registry is discovery, the serve grant is the authority).
|
|
692
|
+
* Runs inside the provisioner. The fence, in order:
|
|
693
|
+
* 1. the instance must be REGISTERED (its `svc….spec` record exists) — `failed-precondition`;
|
|
694
|
+
* 2. the credential's holder must BE the registered owner (`permission-denied`), and the name
|
|
695
|
+
* authority is re-checked FRESH (`permission-denied` on drift);
|
|
696
|
+
* 3. every registered cluster is read through the two-stage §13.7 content-address protocol:
|
|
697
|
+
* the MANIFEST is fetched at the registered CLOSURE digest and verified, `members` must be
|
|
698
|
+
* empty (P1 single-document clusters; a non-empty closure is the D8 loader's, refused loud
|
|
699
|
+
* until then), then the ROOT cluster document is fetched at `manifest.root` and verified.
|
|
700
|
+
* The verified documents are the ONLY command source — the FULL union of their declared
|
|
701
|
+
* commands is the surface (no caller subset; caller scoping is response-time describe).
|
|
702
|
+
* `describe` is derived by the row builder, never a registered command;
|
|
703
|
+
* 4. the epoch must EQUAL a fresh read of the authoritative mapping's `processEpoch`
|
|
704
|
+
* (`expired`): a serve credential binds the CURRENT incarnation.
|
|
705
|
+
* The returned artifact carries the verified surface, the derived descriptor, and the
|
|
706
|
+
* registration revision. The MINT's fence is the durable issuance gate ({@link
|
|
707
|
+
* finalizeServeIssuance}), NOT this authorization (a read is never a fence, §13.1): this seam
|
|
708
|
+
* produces the surface, the gate serializes its release against takeover and re-registration.
|
|
709
|
+
*/
|
|
710
|
+
export async function authorizeServeGrant(kv, args) {
|
|
711
|
+
spacePrefix(args.space); // grammar: a malformed space token never becomes credential rows
|
|
712
|
+
const iId = assertLifecycleToken(args.instanceId, "instanceId");
|
|
713
|
+
if (!Number.isSafeInteger(args.epoch) || args.epoch < 0)
|
|
714
|
+
throw new EpEnvelopeError("internal", `epoch ${args.epoch} is not an unsigned integer`);
|
|
715
|
+
const specKey = recordSpecKey(RECORD_KINDS.svc, [args.endpoint, iId]);
|
|
716
|
+
const specEntry = await kv.get(specKey);
|
|
717
|
+
if (!specEntry || specEntry.operation !== "PUT")
|
|
718
|
+
throw new EpEnvelopeError("failed-precondition", `no registered spec for "${args.endpoint}/${args.instanceId}"; a serve credential is minted only for a REGISTERED instance (SPEC 13.9)`);
|
|
719
|
+
const spec = parseServiceSpec(decodeJson(specEntry.value, specKey), { endpoint: args.endpoint });
|
|
720
|
+
assertBoundedOwner(args.holder.owner, "serve credential holder");
|
|
721
|
+
if (args.holder.owner !== spec.owner)
|
|
722
|
+
throw new EpEnvelopeError("permission-denied", `the serve credential holder "${args.holder.owner}" is not the registered owner "${spec.owner}" of "${args.endpoint}" (SPEC 13.9: serving is the registered owner's authority)`);
|
|
723
|
+
// §13.9 name authority: ONE atomic snapshot binds the owner DECISION and the binding REVISION
|
|
724
|
+
// together (never a torn owner-vs-revision read, engineer/distsys/security). The revision is
|
|
725
|
+
// RECORDED (not fenced here — a read is never a fence, §13.1); the issuance gate carries it and
|
|
726
|
+
// the mint refuses on drift, so a name transfer after this authorization can never release an
|
|
727
|
+
// old-owner credential.
|
|
728
|
+
const nameAuthorityRevision = await assertServiceNameAuthority(spec.endpoint, spec.owner, args.authority);
|
|
729
|
+
// §13.7 two-stage content-address read: the registered digest is a CLOSURE digest naming a
|
|
730
|
+
// MANIFEST; the manifest's `root` names the cluster DOCUMENT. Both fetched, both verified —
|
|
731
|
+
// a raw root document presented at a closure-digest key would conflate the two identities.
|
|
732
|
+
const read = async (digest, what) => {
|
|
733
|
+
let raw;
|
|
734
|
+
try {
|
|
735
|
+
raw = await args.readClusterArtifact(digest);
|
|
736
|
+
}
|
|
737
|
+
catch (err) {
|
|
738
|
+
throw new EpEnvelopeError("unavailable", `the contract-store read seam failed for ${what} ${digest}; serve authorization fails closed (SPEC 13.7): ${err?.message ?? String(err)}`);
|
|
739
|
+
}
|
|
740
|
+
if (raw === undefined)
|
|
741
|
+
throw new EpEnvelopeError("failed-precondition", `${what} ${digest} is not readable from the contract store; an unverifiable registered surface never authorizes (SPEC 13.7)`);
|
|
742
|
+
return raw;
|
|
743
|
+
};
|
|
744
|
+
// Null-prototype: "constructor" is a valid command token, and on a plain `{}` the duplicate
|
|
745
|
+
// check below would resolve the inherited `Object.prototype.constructor` (falsely refusing a
|
|
746
|
+
// legitimate command as "declared twice") while every downstream `surface[command]` lookup
|
|
747
|
+
// (serve construction, governed-surface verification) would leak through the prototype.
|
|
748
|
+
const surface = Object.create(null);
|
|
749
|
+
const commands = [];
|
|
750
|
+
const clusters = [];
|
|
751
|
+
for (const closureDigest of spec.clusterDigests) {
|
|
752
|
+
const manifestRaw = await read(closureDigest, "registered cluster manifest");
|
|
753
|
+
let root;
|
|
754
|
+
try {
|
|
755
|
+
({ root } = verifyClusterManifest(closureDigest, manifestRaw));
|
|
756
|
+
}
|
|
757
|
+
catch (err) {
|
|
758
|
+
throw new EpEnvelopeError("internal", `registered cluster manifest ${closureDigest} of "${args.endpoint}/${args.instanceId}" fails verification; mediated registered state that does not verify is a writer/store bug, never authority (SPEC 13.7): ${err.message}`);
|
|
759
|
+
}
|
|
760
|
+
const rootRaw = await read(root, "registered cluster document");
|
|
761
|
+
let document;
|
|
762
|
+
try {
|
|
763
|
+
document = verifyClusterRoot(root, rootRaw);
|
|
764
|
+
}
|
|
765
|
+
catch (err) {
|
|
766
|
+
throw new EpEnvelopeError("internal", `registered cluster document ${root} (closure ${closureDigest}) fails verification; mediated registered state that does not verify is never authority (SPEC 13.7): ${err.message}`);
|
|
767
|
+
}
|
|
768
|
+
for (const cmd of document.commands) {
|
|
769
|
+
if (surface[cmd.name] !== undefined)
|
|
770
|
+
throw new EpEnvelopeError("internal", `the registered clusters of "${args.endpoint}" declare command "${cmd.name}" twice; an ambiguous registered surface never authorizes (SPEC 13.7)`);
|
|
771
|
+
surface[cmd.name] = Object.freeze({
|
|
772
|
+
clusterDigest: closureDigest,
|
|
773
|
+
class: cmd.class,
|
|
774
|
+
targeted: cmd.targeted,
|
|
775
|
+
modes: Object.freeze([...(cmd.modes ?? [])]),
|
|
776
|
+
capability: cmd.capability,
|
|
777
|
+
inputDigest: cmd.inputDigest,
|
|
778
|
+
outputDigest: cmd.outputDigest,
|
|
779
|
+
traits: Object.freeze([...(cmd.traits ?? [])]),
|
|
780
|
+
});
|
|
781
|
+
commands.push(cmd.name);
|
|
782
|
+
}
|
|
783
|
+
// The inline copy for describe is the verified ROOT cluster DOCUMENT (its command
|
|
784
|
+
// declarations), never the manifest: a consumer verifies it against the advertised closure
|
|
785
|
+
// digest by reconstructing the single-member manifest `{v:1, root: digest(document),
|
|
786
|
+
// members:[]}` (§13.7 two-digest read). Inlining the manifest would ship bytes whose
|
|
787
|
+
// `commands` disagree with the sibling command list.
|
|
788
|
+
clusters.push({ digest: closureDigest, document, raw: rootRaw });
|
|
789
|
+
}
|
|
790
|
+
commands.sort(); // deterministic full surface
|
|
791
|
+
// §13.9 bind-row inputs: journal class is REGISTERED truth (derived from the verified
|
|
792
|
+
// surface, never caller-asserted); pools are PROVISIONING truth (the authorizing provisioner
|
|
793
|
+
// asserts exactly the pool durables it pre-created — no registered record enumerates pool
|
|
794
|
+
// names, routes are per-acceptance policy decisions, §13.6). Pools without a journal-class
|
|
795
|
+
// surface are refused: only journal acceptances route to pools, so the combination is a
|
|
796
|
+
// caller bug, never a silent no-op.
|
|
797
|
+
const journalClass = commands.some((cmd) => surface[cmd].class === "journal");
|
|
798
|
+
const pools = [...(args.pools ?? [])].map((p) => assertPoolToken(p)).sort();
|
|
799
|
+
if (new Set(pools).size !== pools.length)
|
|
800
|
+
throw new EpEnvelopeError("internal", `the pools list for "${args.endpoint}/${args.instanceId}" carries duplicates; the provisioner enumerates each pre-created pool once (SPEC 13.9)`);
|
|
801
|
+
if (pools.length > 0 && !journalClass)
|
|
802
|
+
throw new EpEnvelopeError("failed-precondition", `"${args.endpoint}" registers no journal-class command but the provisioner asserts pools [${pools.join(", ")}]; only journal acceptances route to work pools (SPEC 13.6/13.9)`);
|
|
803
|
+
const current = await args.readProcessEpoch();
|
|
804
|
+
if (!Number.isSafeInteger(current) || current < 0)
|
|
805
|
+
throw new EpEnvelopeError("internal", `the authoritative mapping read returned ${JSON.stringify(current)}, not an unsigned processEpoch`);
|
|
806
|
+
if (args.epoch !== current)
|
|
807
|
+
throw new EpEnvelopeError("expired", `serve grant for epoch ${args.epoch} but the authoritative mapping's current processEpoch is ${current}; a serve credential binds the CURRENT incarnation only (SPEC 13.1/13.9)`);
|
|
808
|
+
const grant = Object.freeze({
|
|
809
|
+
space: args.space,
|
|
810
|
+
endpoint: spec.endpoint,
|
|
811
|
+
instanceId: iId,
|
|
812
|
+
epoch: args.epoch,
|
|
813
|
+
owner: spec.owner,
|
|
814
|
+
registrationRevision: specEntry.revision,
|
|
815
|
+
nameAuthorityRevision,
|
|
816
|
+
commands: Object.freeze([...commands]),
|
|
817
|
+
surface: Object.freeze(surface),
|
|
818
|
+
journalClass,
|
|
819
|
+
pools: Object.freeze([...pools]),
|
|
820
|
+
descriptor: deriveDescriptor({ endpoint: spec.endpoint, owner: spec.owner, ...(spec.endpointType !== undefined ? { endpointType: spec.endpointType } : {}) }, clusters),
|
|
821
|
+
});
|
|
822
|
+
AUTHORIZED_SERVE.set(grant, {
|
|
823
|
+
space: args.space, endpoint: spec.endpoint, instanceId: iId, epoch: args.epoch,
|
|
824
|
+
owner: spec.owner, registrationRevision: specEntry.revision, nameAuthorityRevision, commands: [...commands],
|
|
825
|
+
journalClass, pools: [...pools],
|
|
826
|
+
});
|
|
827
|
+
return grant;
|
|
828
|
+
}
|
|
829
|
+
/** The brand check every consuming seam runs: `serve` must be the ARTIFACT
|
|
830
|
+
* {@link authorizeServeGrant} returned, field-for-field equal to its authorized snapshot. A
|
|
831
|
+
* structural copy, a raw literal, or a diverging value refuses — serve authority flows only
|
|
832
|
+
* THROUGH the registry authorization. Returns the immutable snapshot (space/owner/epoch/
|
|
833
|
+
* registrationRevision the release fence checks against). */
|
|
834
|
+
export function assertServeGrantAuthorized(serve) {
|
|
835
|
+
const snap = AUTHORIZED_SERVE.get(serve);
|
|
836
|
+
if (!snap)
|
|
837
|
+
throw new EpEnvelopeError("permission-denied", "the serve artifact was not authorized against the registered service (authorizeServeGrant); a raw or copied value never carries serve authority (SPEC 13.9)");
|
|
838
|
+
if (snap.space !== serve.space || snap.endpoint !== serve.endpoint || snap.instanceId !== serve.instanceId
|
|
839
|
+
|| snap.epoch !== serve.epoch || snap.owner !== serve.owner || snap.registrationRevision !== serve.registrationRevision
|
|
840
|
+
|| snap.nameAuthorityRevision !== serve.nameAuthorityRevision
|
|
841
|
+
|| snap.commands.length !== serve.commands.length || snap.commands.some((cmd, i) => serve.commands[i] !== cmd)
|
|
842
|
+
|| snap.journalClass !== serve.journalClass
|
|
843
|
+
|| snap.pools.length !== serve.pools.length || snap.pools.some((p, i) => serve.pools[i] !== p))
|
|
844
|
+
throw new EpEnvelopeError("permission-denied", "the serve artifact diverges from its authorized snapshot; refusing mutated serve authority (SPEC 13.9)");
|
|
845
|
+
return snap;
|
|
846
|
+
}
|
|
847
|
+
/** The mint-side CONTEXT binding (`permissionsFor`, profile `endpoint-serve`): brand + snapshot
|
|
848
|
+
* equality plus the mint context bound to the artifact (same space, and the minted principal
|
|
849
|
+
* IS the registered owner — an authorized artifact for space A/owner X emits rows for no other
|
|
850
|
+
* space or principal). This is NOT the freshness fence: {@link finalizeServeIssuance} is, and
|
|
851
|
+
* `mintCreds` runs it before releasing the credential. */
|
|
852
|
+
export function assertServeGrantMintable(serve, mint) {
|
|
853
|
+
const snap = assertServeGrantAuthorized(serve);
|
|
854
|
+
if (mint.space !== snap.space)
|
|
855
|
+
throw new EpEnvelopeError("permission-denied", `the serve artifact was authorized for space "${snap.space}", not "${mint.space}"; serve authority never crosses spaces (SPEC 13.9)`);
|
|
856
|
+
if (mint.holderOwner !== snap.owner)
|
|
857
|
+
throw new EpEnvelopeError("permission-denied", `the serve artifact belongs to the registered owner "${snap.owner}"; principal "${mint.holderOwner}" cannot mint from it (SPEC 13.9)`);
|
|
858
|
+
return snap;
|
|
859
|
+
}
|
|
860
|
+
/** A §13.1 source-chain element, EXACT grammar: the `root` anchor, a handle-redemption step
|
|
861
|
+
* `handle.<issuerKeyId>.<id>` (exactly two record-grammar id segments), or a session step
|
|
862
|
+
* `session.<sessionId>` (exactly one). Owner/actor principal components are NOT a lineage; the
|
|
863
|
+
* mint records `["root"]` for a serve credential minted directly by the provisioner authority.
|
|
864
|
+
* Ids are the record grammar `[A-Za-z0-9_-]` (uppercase admitted), bounded, and every segment is
|
|
865
|
+
* non-empty — so `handle.x`, `handle.x.`, and `session.x.y` all refuse. */
|
|
866
|
+
const SOURCE_CHAIN_ID = "[A-Za-z0-9_-]{1,64}"; // the §13.2:1248 / assertIdToken id bound
|
|
867
|
+
const SOURCE_CHAIN_ELEMENT = new RegExp(`^(root|handle\\.${SOURCE_CHAIN_ID}\\.${SOURCE_CHAIN_ID}|session\\.${SOURCE_CHAIN_ID})$`);
|
|
868
|
+
/**
|
|
869
|
+
* The serve-credential release fence (§13.1 "observe gate → write rows → CAS the gate →
|
|
870
|
+
* release"). `mintCreds` calls this AFTER building the credential and BEFORE returning it, so a
|
|
871
|
+
* credential is released only when its ledger row is durably written and its winning CAS proves
|
|
872
|
+
* the gate was still `open` at the SAME `(processEpoch, registrationRevision, nameAuthorityRevision)`
|
|
873
|
+
* the artifact was verified against:
|
|
874
|
+
* - observe the gate; a missing gate or a `frozen`/`retired` state refuses (`expired`);
|
|
875
|
+
* - the observed `processEpoch`, `registrationRevision`, and `nameAuthorityRevision` MUST each
|
|
876
|
+
* equal the artifact's — a takeover (epoch), a re-registration (revision), or a name transfer
|
|
877
|
+
* (name authority) that already froze+reopened advanced one of them, and this mint's surface
|
|
878
|
+
* or its owner is superseded (`expired`);
|
|
879
|
+
* - stage the NORMATIVE ledger row (`holderPrincipal`/`lifecycleUid`/`sourceChain`/`state`/`exp`
|
|
880
|
+
* plus the three currency coordinates), then revision-pinned CAS the gate; a LOSS (a
|
|
881
|
+
* concurrent barrier's freeze CAS won the single key) revokes the staged row and releases
|
|
882
|
+
* nothing (`expired`).
|
|
883
|
+
* The race is closed by serialization on ONE key: a mint that wins wrote its row before its
|
|
884
|
+
* winning CAS, so a later barrier enumerates and revokes/evicts it by `holderPrincipal`; a mint
|
|
885
|
+
* that loses never released.
|
|
886
|
+
*/
|
|
887
|
+
export async function finalizeServeIssuance(gate, serve, credential) {
|
|
888
|
+
const snap = assertServeGrantAuthorized(serve);
|
|
889
|
+
const boundedId = (v) => typeof v === "string" && v.length > 0 && v.length <= 128;
|
|
890
|
+
if (!boundedId(credential.credentialId))
|
|
891
|
+
throw new EpEnvelopeError("internal", "credentialId must be a bounded non-empty per-JWT identifier");
|
|
892
|
+
if (!boundedId(credential.credentialKey))
|
|
893
|
+
throw new EpEnvelopeError("internal", "credentialKey must be a bounded non-empty identifier (the minted credential's nkey)");
|
|
894
|
+
assertBoundedOwner(credential.holderActor, "serve credential holder actor");
|
|
895
|
+
if (!Array.isArray(credential.sourceChain) || credential.sourceChain.length === 0
|
|
896
|
+
|| !credential.sourceChain.every((p) => typeof p === "string" && SOURCE_CHAIN_ELEMENT.test(p)))
|
|
897
|
+
throw new EpEnvelopeError("internal", "the serve credential sourceChain must be a non-empty §13.1 issuance lineage (root | handle.<issuer>.<id> | session.<id>), never principal components");
|
|
898
|
+
if (credential.exp !== undefined && (!Number.isSafeInteger(credential.exp) || credential.exp < 0))
|
|
899
|
+
throw new EpEnvelopeError("internal", `the serve credential exp ${JSON.stringify(credential.exp)} is not an unsigned unix timestamp`);
|
|
900
|
+
const obs = await gate.observe();
|
|
901
|
+
if (obs === null)
|
|
902
|
+
throw new EpEnvelopeError("expired", `no issuance gate for "${snap.endpoint}/${snap.instanceId}"; a serve credential never mints against a missing gate (SPEC 13.1)`);
|
|
903
|
+
// Gate IDENTITY `(space, endpoint, lifecycleUid)`: the instance token is unique only within
|
|
904
|
+
// `(space, endpoint)`, so ALL must match — a caller that handed a foreign gate (another space's,
|
|
905
|
+
// a different endpoint sharing the instance token, or any wrong gate) with coincidentally
|
|
906
|
+
// matching coordinates is refused (the per-space auth bucket is the production space fence; this
|
|
907
|
+
// is the seam's defense-in-depth).
|
|
908
|
+
if (obs.space !== snap.space || obs.endpoint !== snap.endpoint || obs.lifecycleUid !== snap.instanceId)
|
|
909
|
+
throw new EpEnvelopeError("internal", `the issuance gate is for "${obs.space}/${obs.endpoint}/${obs.lifecycleUid}", not the authorized instance "${snap.space}/${snap.endpoint}/${snap.instanceId}"; a serve credential mints only against its OWN gate (SPEC 13.1)`);
|
|
910
|
+
if (obs.state !== "open")
|
|
911
|
+
throw new EpEnvelopeError("expired", `the issuance gate for "${snap.endpoint}/${snap.instanceId}" is ${obs.state}; minting is closed (SPEC 13.1)`);
|
|
912
|
+
// JOINT currency on ONE key: a takeover advances processEpoch, a re-registration advances
|
|
913
|
+
// registrationRevision, a name transfer advances nameAuthorityRevision; any one that has
|
|
914
|
+
// already frozen+reopened the gate supersedes the branded surface or its owner, and the read
|
|
915
|
+
// below is safe only because the CAS re-checks the same key.
|
|
916
|
+
if (obs.processEpoch !== snap.epoch)
|
|
917
|
+
throw new EpEnvelopeError("expired", `the issuance gate is at processEpoch ${obs.processEpoch}, not the authorized ${snap.epoch}; a takeover superseded this incarnation (SPEC 13.1)`);
|
|
918
|
+
if (obs.registrationRevision !== snap.registrationRevision)
|
|
919
|
+
throw new EpEnvelopeError("expired", `the issuance gate is at registrationRevision ${obs.registrationRevision}, not the authorized ${snap.registrationRevision}; a re-registration superseded the branded surface (SPEC 13.5/13.9)`);
|
|
920
|
+
if (obs.nameAuthorityRevision !== snap.nameAuthorityRevision)
|
|
921
|
+
throw new EpEnvelopeError("expired", `the issuance gate is at nameAuthorityRevision ${obs.nameAuthorityRevision}, not the authorized ${snap.nameAuthorityRevision}; a name transfer superseded the serving owner (SPEC 13.9)`);
|
|
922
|
+
// SERVING-PRINCIPAL BINDING (§13.1:1056-1069): the mint is bound to the gate's REGISTERED serving
|
|
923
|
+
// principal, not merely the registered owner. authorizeServeGrant proves owner == registered
|
|
924
|
+
// owner, but a SIBLING ACTOR under that owner would otherwise win the real gate and be
|
|
925
|
+
// ledgered/evicted in place of the registered serving instance. The minted `owner.actor` MUST
|
|
926
|
+
// equal `epgate.principal`; on any mismatch the mint releases nothing and writes no active row.
|
|
927
|
+
const mintedPrincipal = principalKey(snap.owner, credential.holderActor).key;
|
|
928
|
+
if (mintedPrincipal !== obs.principal)
|
|
929
|
+
throw new EpEnvelopeError("permission-denied", `the serve mint's principal "${mintedPrincipal}" is not the gate's registered serving principal "${obs.principal}" for "${snap.endpoint}/${snap.instanceId}"; a sibling actor under the registered owner cannot win the endpoint gate (SPEC 13.1)`);
|
|
930
|
+
const row = {
|
|
931
|
+
credentialId: credential.credentialId,
|
|
932
|
+
credentialKey: credential.credentialKey,
|
|
933
|
+
// The eviction target is the OBSERVED gate principal (== the minted principal, checked above),
|
|
934
|
+
// serialized through the ONE principal serializer the eviction feed keys on (subjects.ts
|
|
935
|
+
// principalKey invariant), so the barrier's enumeration key can never drift from the
|
|
936
|
+
// credential's and always names the registered serving principal (§13.1).
|
|
937
|
+
holderPrincipal: obs.principal,
|
|
938
|
+
endpoint: snap.endpoint,
|
|
939
|
+
lifecycleUid: snap.instanceId,
|
|
940
|
+
sourceChain: Object.freeze([...credential.sourceChain]),
|
|
941
|
+
state: "active",
|
|
942
|
+
...(credential.exp !== undefined ? { exp: credential.exp } : {}),
|
|
943
|
+
generation: obs.generation,
|
|
944
|
+
processEpoch: obs.processEpoch,
|
|
945
|
+
registrationRevision: obs.registrationRevision,
|
|
946
|
+
nameAuthorityRevision: obs.nameAuthorityRevision,
|
|
947
|
+
};
|
|
948
|
+
await gate.stage(row);
|
|
949
|
+
// Best-effort revoke of the staged row on any non-win, ALWAYS surfacing a revoke failure (never
|
|
950
|
+
// swallowed) so the reconciliation debt is visible — the credential is released only on a win.
|
|
951
|
+
const revokeStaged = async () => {
|
|
952
|
+
try {
|
|
953
|
+
await gate.revoke(row);
|
|
954
|
+
return undefined;
|
|
955
|
+
}
|
|
956
|
+
catch (err) {
|
|
957
|
+
return err?.message ?? String(err);
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
let won;
|
|
961
|
+
try {
|
|
962
|
+
won = await gate.commit(obs.revision);
|
|
963
|
+
}
|
|
964
|
+
catch (err) {
|
|
965
|
+
const revokeFailed = await revokeStaged();
|
|
966
|
+
throw new EpEnvelopeError("unavailable", `the issuance-gate CAS failed; refusing to release a serve credential (SPEC 13.1): ${err?.message ?? String(err)}${revokeFailed ? `; ALSO the staged-row revoke failed and the row needs barrier reconciliation: ${revokeFailed}` : ""}`);
|
|
967
|
+
}
|
|
968
|
+
if (!won) {
|
|
969
|
+
const revokeFailed = await revokeStaged();
|
|
970
|
+
throw new EpEnvelopeError("expired", `the issuance gate advanced during mint (a takeover, re-registration, or name transfer won the serialization on ${snap.endpoint}/${snap.instanceId}); this mint released nothing (SPEC 13.1)${revokeFailed ? `; ALSO the staged-row revoke failed and the row needs barrier reconciliation: ${revokeFailed}` : ""}`);
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
//# sourceMappingURL=endpoint-service.js.map
|