@cotal-ai/auth 0.91.0 → 0.92.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/authority-client.d.ts +70 -3
- package/dist/authority-client.d.ts.map +1 -1
- package/dist/authority-client.js +109 -8
- package/dist/authority-client.js.map +1 -1
- package/dist/commands.d.ts.map +1 -1
- package/dist/commands.js +37 -10
- package/dist/commands.js.map +1 -1
- package/dist/manager-authority.d.ts +59 -3
- package/dist/manager-authority.d.ts.map +1 -1
- package/dist/manager-authority.js +185 -4
- package/dist/manager-authority.js.map +1 -1
- package/dist/native-service-gate.d.ts +14 -3
- package/dist/native-service-gate.d.ts.map +1 -1
- package/dist/native-service-gate.js +34 -20
- package/dist/native-service-gate.js.map +1 -1
- package/dist/native-service-name.d.ts +7 -0
- package/dist/native-service-name.d.ts.map +1 -1
- package/dist/native-service-name.js +15 -6
- package/dist/native-service-name.js.map +1 -1
- package/dist/platform-control.d.ts.map +1 -1
- package/dist/platform-control.js +1 -0
- package/dist/platform-control.js.map +1 -1
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +8 -0
- package/dist/provider.js.map +1 -1
- package/dist/run-cancel-observation.d.ts +50 -0
- package/dist/run-cancel-observation.d.ts.map +1 -0
- package/dist/run-cancel-observation.js +146 -0
- package/dist/run-cancel-observation.js.map +1 -0
- package/dist/service-custody.d.ts +29 -20
- package/dist/service-custody.d.ts.map +1 -1
- package/dist/service-custody.js +37 -167
- package/dist/service-custody.js.map +1 -1
- package/dist/service-endpoint-authority.d.ts +70 -0
- package/dist/service-endpoint-authority.d.ts.map +1 -0
- package/dist/service-endpoint-authority.js +257 -0
- package/dist/service-endpoint-authority.js.map +1 -0
- package/dist/service-host-contract.d.ts +158 -16
- package/dist/service-host-contract.d.ts.map +1 -1
- package/dist/service-host-contract.js +330 -1
- package/dist/service-host-contract.js.map +1 -1
- package/dist/service-host-ops.d.ts +550 -0
- package/dist/service-host-ops.d.ts.map +1 -0
- package/dist/service-host-ops.js +2180 -0
- package/dist/service-host-ops.js.map +1 -0
- package/dist/service-write.d.ts +141 -0
- package/dist/service-write.d.ts.map +1 -0
- package/dist/service-write.js +179 -0
- package/dist/service-write.js.map +1 -0
- package/dist/service.d.ts +54 -1
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +313 -14
- package/dist/service.js.map +1 -1
- package/package.json +9 -4
|
@@ -0,0 +1,2180 @@
|
|
|
1
|
+
/** Package-internal service host operations: register admission, decide (approve or refuse),
|
|
2
|
+
* revoke, create-only name binding, the operation and proposal reads and one owner's assignment
|
|
3
|
+
* state, over closed per-key records in the auth bucket. The index does not export it; only the
|
|
4
|
+
* owner-proved public authority door calls register. It grants no serving, registration, caller
|
|
5
|
+
* policy, restore, terminal retirement or KICK.
|
|
6
|
+
*
|
|
7
|
+
* Every write is one zero-history create or one exact-revision CAS on one key, issued through the
|
|
8
|
+
* shared custody writer (service-write.ts) under the same plane-claim hold check and fatal latch as
|
|
9
|
+
* native custody, and only inside the invocation's write scope: the keys discovery located before
|
|
10
|
+
* its effect connection opened. That connection's credential publishes only those keys and expires
|
|
11
|
+
* at the invocation's original deadline. A step that needs a key outside it holds before anything is issued. No record carries a TTL and none is ever deleted or purged. There are no
|
|
12
|
+
* multi-key batches: every window between two writes is either fenced on its key or settled from
|
|
13
|
+
* what a later invocation reads. The invocation limit and the original deadline are limits, never
|
|
14
|
+
* waits, fences or proof that an earlier write cannot still land. */
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { EpEnvelopeError, GOVERN_HEAD, RECORD_KINDS, assertDerivedOwnerToken, assertLifecycleToken, canonicalJson, contractArtifactCanonicalBytes, contractDigest, contractStoreContext, endpointRegistrationBarrier, endpointToken, epgateKey, isContractDigest, mintLifecycleUid, parseEndpointCapabilities, parseNativeUidCustody, parseServiceSpec, parseServiceStatus, publishContractArtifact, recordAtomicKey, recordSpecKey, recordStatusKey, registerServiceInstance, serviceEndpointActor, serviceGateApprovalLive, serviceWitnessDigest, verifyClusterManifest, verifyClusterRoot, writeServiceStatus, } from "@cotal-ai/core";
|
|
17
|
+
import { SERVICE_HOST_INVOCATION_MS, openAuthorityClient, serviceHostEffectGrants, serviceHostEffectLabel } from "./authority-client.js";
|
|
18
|
+
import { findInteractiveActor } from "./ledger.js";
|
|
19
|
+
import { openLifecycleRegistry, registryStores } from "./lifecycle-registry.js";
|
|
20
|
+
import { activatedServiceGateRow, approvedServiceGateRow, assertApprovableServiceGate } from "./native-service-gate.js";
|
|
21
|
+
import { nativeServiceNameAuthority, readNativeServiceName, serviceNameKey, serviceNameRowBytes } from "./native-service-name.js";
|
|
22
|
+
import { encodeRoundTrip } from "./service-custody-shapes.js";
|
|
23
|
+
import { cancelServiceCustody, runServiceBootstrap, sealServiceBootstrapIntent, sealServiceCancelIntent, serviceAssignmentGate, serviceCustodyKeys, serviceReservationKeys, } from "./service-custody.js";
|
|
24
|
+
import { parseServiceHostRequest, parseServiceProposalClusters, parseServiceProposalCommands, } from "./service-host-contract.js";
|
|
25
|
+
import { readRegistryServiceRoot } from "./service-root-reader.js";
|
|
26
|
+
import { HOSTS, Held, bounded, context, endpoint, held, leader, message, openServiceCustodyHost, readyAfter, readyBefore, scopeCovers, write, } from "./service-write.js";
|
|
27
|
+
/** Original authority limit of one approve, refuse or register, from its admission. */
|
|
28
|
+
export const S2A_OPERATION_TTL_MS = 600_000;
|
|
29
|
+
/** Per-invocation limit for reads, bookkeeping, settlement and native cancellation, and the
|
|
30
|
+
* longest window an invocation's effect credential may cover. */
|
|
31
|
+
export const S2A_INVOCATION_MS = SERVICE_HOST_INVOCATION_MS;
|
|
32
|
+
/** A proposal's canonical lifetime (canon:203). */
|
|
33
|
+
export const S2A_PROPOSAL_LIFETIME_MS = 86_400_000;
|
|
34
|
+
export const S2A_MAX_PENDING = 256;
|
|
35
|
+
export const S2A_MAX_PENDING_PER_OWNER = 32;
|
|
36
|
+
export const S2A_MAX_PROPOSAL_BYTES = 256 * 1024;
|
|
37
|
+
/** Fresh decisions after a CAS loss before a step holds. */
|
|
38
|
+
export const S2A_MAX_REDECIDE = 4;
|
|
39
|
+
/** Serving request intents one instance keeps (SPEC 13.1): never pruned or reused in this cut. */
|
|
40
|
+
export const S3_MAX_INTENTS_PER_INSTANCE = 64;
|
|
41
|
+
const ADMISSION_KEY = "svchost.admission";
|
|
42
|
+
const CAPACITY_KEY = "svccap.space";
|
|
43
|
+
const opKey = (operationId) => `svcop.${operationId}`;
|
|
44
|
+
const proposalKey = (proposalId) => `svcprop.${proposalId}`;
|
|
45
|
+
const assignmentKey = (endpoint, instanceId) => `svcasg.${endpointToken(endpoint)}.${assertLifecycleToken(instanceId, "instanceId")}`;
|
|
46
|
+
const intentKey = (operationId) => `svcint.${hexN(operationId, 32)}`;
|
|
47
|
+
const intentIndexKey = (endpoint, instanceId) => `svcintx.${endpointToken(endpoint)}.${assertLifecycleToken(instanceId, "instanceId")}`;
|
|
48
|
+
const enc = new TextEncoder();
|
|
49
|
+
const seconds = (ms) => Math.floor(ms / 1000);
|
|
50
|
+
// ---- identities ---------------------------------------------------------------------------------
|
|
51
|
+
const hex32 = (tag, space, value) => createHash("sha256").update(`${tag}\0${space}\0${value}`, "utf8").digest("hex").slice(0, 32);
|
|
52
|
+
/** One id namespace for every host request in a space; a valid lifecycle token. */
|
|
53
|
+
export const serviceHostOperationId = (space, requestId) => hex32("cotal-service-host-op", space, requestId);
|
|
54
|
+
/** The native cancel identity an expiry settlement derives from recorded facts only. */
|
|
55
|
+
const expiryOperationId = (space, approveOperationId) => hex32("cotal-service-host-expiry", space, approveOperationId);
|
|
56
|
+
const expiryRequestDigest = (operationId, assignmentDigest) => contractDigest({ v: 1, kind: "service-approval-expiry", operationId, assignmentDigest });
|
|
57
|
+
const requestDigestOf = (r) => contractDigest({ v: r.v, kind: r.kind, requestId: r.requestId, operation: r.operation, input: r.input, granter: r.granter });
|
|
58
|
+
const proposalIdOf = (proposalDigest, registerRequestId) => contractDigest({ v: 1, kind: "service-proposal-id", proposalDigest, registerRequestId }).slice("sha256:".length);
|
|
59
|
+
const approvalDigestOf = (a) => contractDigest({ v: 1, kind: "service-approval", ...a });
|
|
60
|
+
class Malformed extends Error {
|
|
61
|
+
}
|
|
62
|
+
function bad(detail) { throw new Malformed(detail); }
|
|
63
|
+
function closed(raw, fields, optional = []) {
|
|
64
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
65
|
+
bad("expected an object");
|
|
66
|
+
const proto = Object.getPrototypeOf(raw);
|
|
67
|
+
if (proto !== Object.prototype && proto !== null)
|
|
68
|
+
bad("expected a plain object");
|
|
69
|
+
const o = raw;
|
|
70
|
+
if (fields.some((k) => !Object.hasOwn(o, k)) || Reflect.ownKeys(o).some((k) => typeof k !== "string" || (!fields.includes(k) && !optional.includes(k))))
|
|
71
|
+
bad("missing or unsupported fields");
|
|
72
|
+
return o;
|
|
73
|
+
}
|
|
74
|
+
const hexN = (raw, n) => { if (typeof raw !== "string" || !new RegExp(`^[0-9a-f]{${n}}$`).test(raw))
|
|
75
|
+
bad(`expected ${n} hex characters`); return raw; };
|
|
76
|
+
const digest = (raw) => { if (typeof raw !== "string" || !isContractDigest(raw))
|
|
77
|
+
bad("expected a canonical digest"); return raw; };
|
|
78
|
+
const ms = (raw) => { if (typeof raw !== "number" || !Number.isSafeInteger(raw) || raw < 0)
|
|
79
|
+
bad("expected an unsigned safe integer"); return raw; };
|
|
80
|
+
const word = (raw, max = 64) => { if (typeof raw !== "string" || !raw || raw.length > max || /[\u0000-\u001f]/.test(raw))
|
|
81
|
+
bad("expected bounded text"); return raw; };
|
|
82
|
+
const pick = (raw, values) => { if (typeof raw !== "string" || !values.includes(raw))
|
|
83
|
+
bad(`expected one of ${values.join(", ")}`); return raw; };
|
|
84
|
+
const grammar = (f) => { try {
|
|
85
|
+
return f();
|
|
86
|
+
}
|
|
87
|
+
catch (e) {
|
|
88
|
+
return bad(message(e));
|
|
89
|
+
} };
|
|
90
|
+
const owner = (raw) => grammar(() => assertDerivedOwnerToken(raw));
|
|
91
|
+
const endpointName = (raw) => { if (typeof raw !== "string")
|
|
92
|
+
bad("expected an endpoint"); grammar(() => endpointToken(raw)); return raw; };
|
|
93
|
+
const token = (raw, what) => { if (typeof raw !== "string")
|
|
94
|
+
bad(`expected ${what}`); return grammar(() => assertLifecycleToken(raw, what)); };
|
|
95
|
+
const CAUSES = ["owner-request", "membership-removal", "operator-request", "restore-quarantine", "approval-expired"];
|
|
96
|
+
const REFUSALS = ["conflict", "expired", "revoked", "superseded", "custody-refused", "admission-closed",
|
|
97
|
+
"resource-exhausted", "selector-pending", "selector-bound", "name-owned", "restore", "owner", "operator"];
|
|
98
|
+
const CLOSE_REASONS = ["owner", "operator", "revoked", "expired", "selector-bound", "restore", "custody-refused"];
|
|
99
|
+
function account(raw) {
|
|
100
|
+
const o = closed(raw, ["accountPublicKey", "space"]);
|
|
101
|
+
return { accountPublicKey: word(o.accountPublicKey, 64), space: word(o.space, 128) };
|
|
102
|
+
}
|
|
103
|
+
function granter(raw) {
|
|
104
|
+
const o = raw;
|
|
105
|
+
if (o?.kind === "owner") {
|
|
106
|
+
closed(raw, ["kind", "owner"]);
|
|
107
|
+
return { kind: "owner", owner: owner(o.owner) };
|
|
108
|
+
}
|
|
109
|
+
closed(raw, ["kind", "auditId"]);
|
|
110
|
+
pick(o.kind, ["operator"]);
|
|
111
|
+
return { kind: "operator", auditId: word(o.auditId, 128) };
|
|
112
|
+
}
|
|
113
|
+
function projection(raw) {
|
|
114
|
+
const o = closed(raw, ["subjectOwner", "endpoint", "instanceId", "proposalId"]);
|
|
115
|
+
return { subjectOwner: owner(o.subjectOwner), endpoint: endpointName(o.endpoint), instanceId: token(o.instanceId, "instanceId"),
|
|
116
|
+
proposalId: o.proposalId === null ? null : hexN(o.proposalId, 64) };
|
|
117
|
+
}
|
|
118
|
+
/** A service reservation that native custody's own parser accepts for its lifecycle UID. */
|
|
119
|
+
function reservation(raw) {
|
|
120
|
+
const r = structuredClone(raw);
|
|
121
|
+
const lifecycleUid = token(r?.native?.lifecycleUid, "lifecycleUid");
|
|
122
|
+
if (grammar(() => parseNativeUidCustody(enc.encode(canonicalJson(r)), lifecycleUid)).kind !== "service")
|
|
123
|
+
bad("expected a service reservation");
|
|
124
|
+
return r;
|
|
125
|
+
}
|
|
126
|
+
function debt(raw) {
|
|
127
|
+
if (!Array.isArray(raw) || raw.length > 2)
|
|
128
|
+
bad("invalid debt");
|
|
129
|
+
return raw.map((d) => { const o = closed(d, ["kind", "proposalId"]); return { kind: pick(o.kind, ["capacity-release", "proposal-close"]), proposalId: hexN(o.proposalId, 64) }; });
|
|
130
|
+
}
|
|
131
|
+
function cancelOf(raw) {
|
|
132
|
+
const o = closed(raw, ["operationId", "requestDigest", "cause"]);
|
|
133
|
+
return { operationId: token(o.operationId, "operationId"), requestDigest: digest(o.requestDigest), cause: pick(o.cause, CAUSES) };
|
|
134
|
+
}
|
|
135
|
+
function plan(raw) {
|
|
136
|
+
const o = raw;
|
|
137
|
+
switch (o?.kind) {
|
|
138
|
+
case "register":
|
|
139
|
+
closed(raw, ["kind", "proposalId", "proposalDigest", "createdAtMs", "expiresAtMs"]);
|
|
140
|
+
return { kind: "register", proposalId: hexN(o.proposalId, 64), proposalDigest: digest(o.proposalDigest), createdAtMs: ms(o.createdAtMs), expiresAtMs: ms(o.expiresAtMs) };
|
|
141
|
+
case "approve":
|
|
142
|
+
closed(raw, ["kind", "proposalId", "proposalDigest", "proposalRevision", "nameRevision", "reservation", "callerCeiling", "approvalExpiresAt"]);
|
|
143
|
+
return { kind: "approve", proposalId: hexN(o.proposalId, 64), proposalDigest: digest(o.proposalDigest), proposalRevision: ms(o.proposalRevision),
|
|
144
|
+
nameRevision: ms(o.nameRevision), reservation: reservation(o.reservation), callerCeiling: grammar(() => parseEndpointCapabilities(o.callerCeiling)),
|
|
145
|
+
approvalExpiresAt: o.approvalExpiresAt === null ? null : ms(o.approvalExpiresAt) };
|
|
146
|
+
case "refuse":
|
|
147
|
+
closed(raw, ["kind", "proposalId", "proposalRevision"]);
|
|
148
|
+
return { kind: "refuse", proposalId: hexN(o.proposalId, 64), proposalRevision: ms(o.proposalRevision) };
|
|
149
|
+
case "revoke":
|
|
150
|
+
closed(raw, ["kind", "endpoint", "instanceId", "expectedAssignmentDigest", "cause"]);
|
|
151
|
+
return { kind: "revoke", endpoint: endpointName(o.endpoint), instanceId: token(o.instanceId, "instanceId"), expectedAssignmentDigest: digest(o.expectedAssignmentDigest), cause: pick(o.cause, CAUSES) };
|
|
152
|
+
default: return bad("unknown plan");
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function tombstoneView(raw) {
|
|
156
|
+
const o = closed(raw, ["proposalId", "proposalDigest", "owner", "endpoint", "instanceId", "register", "state", "operationId", "approvalDigest", "reason", "at"]);
|
|
157
|
+
const r = closed(o.register, ["requestId", "requestDigest", "operationId"]);
|
|
158
|
+
return { proposalId: hexN(o.proposalId, 64), proposalDigest: digest(o.proposalDigest), owner: owner(o.owner), endpoint: endpointName(o.endpoint),
|
|
159
|
+
instanceId: token(o.instanceId, "instanceId"), register: { requestId: hexN(r.requestId, 32), requestDigest: digest(r.requestDigest), operationId: hexN(r.operationId, 32) },
|
|
160
|
+
state: pick(o.state, ["approved", "refused", "expired"]), operationId: o.operationId === null ? null : hexN(o.operationId, 32),
|
|
161
|
+
approvalDigest: o.approvalDigest === null ? null : digest(o.approvalDigest), reason: o.reason === null ? null : pick(o.reason, CLOSE_REASONS), at: ms(o.at) };
|
|
162
|
+
}
|
|
163
|
+
function approvalResult(raw) {
|
|
164
|
+
const o = closed(raw, ["proposalId", "proposalDigest", "operationId", "assignment", "approvalDigest", "approvalExpiresAt", "callerCeiling",
|
|
165
|
+
"nameAuthorityRevision", "rootCredentialId", "rootExpiresAt", "approvedGateRevision"]);
|
|
166
|
+
const a = closed(o.assignment, ["endpoint", "instanceId", "assignmentDigest", "lifecycleUid"]);
|
|
167
|
+
return { proposalId: hexN(o.proposalId, 64), proposalDigest: digest(o.proposalDigest), operationId: hexN(o.operationId, 32),
|
|
168
|
+
assignment: { endpoint: endpointName(a.endpoint), instanceId: token(a.instanceId, "instanceId"), assignmentDigest: digest(a.assignmentDigest), lifecycleUid: token(a.lifecycleUid, "lifecycleUid") },
|
|
169
|
+
approvalDigest: digest(o.approvalDigest), approvalExpiresAt: o.approvalExpiresAt === null ? null : ms(o.approvalExpiresAt),
|
|
170
|
+
callerCeiling: grammar(() => parseEndpointCapabilities(o.callerCeiling)), nameAuthorityRevision: ms(o.nameAuthorityRevision),
|
|
171
|
+
rootCredentialId: token(o.rootCredentialId, "rootCredentialId"), rootExpiresAt: ms(o.rootExpiresAt),
|
|
172
|
+
approvedGateRevision: o.approvedGateRevision === null ? null : ms(o.approvedGateRevision) };
|
|
173
|
+
}
|
|
174
|
+
function result(raw, operation, decision) {
|
|
175
|
+
if (operation === "register") {
|
|
176
|
+
const o = closed(raw, ["proposalId", "revision", "expiresAtMs"]);
|
|
177
|
+
return { proposalId: hexN(o.proposalId, 64), revision: ms(o.revision), expiresAtMs: ms(o.expiresAtMs) };
|
|
178
|
+
}
|
|
179
|
+
if (operation === "decide" && decision === "approve")
|
|
180
|
+
return approvalResult(raw);
|
|
181
|
+
if (operation === "decide" && decision === "refuse") {
|
|
182
|
+
const o = closed(raw, ["proposal", "revision"]);
|
|
183
|
+
return { proposal: tombstoneView(o.proposal), revision: ms(o.revision) };
|
|
184
|
+
}
|
|
185
|
+
return bad("this operation stores no result");
|
|
186
|
+
}
|
|
187
|
+
/** A complete outcome is parsed by the shape it stores; a terminal op has no plan, so its result is told apart by its fields. */
|
|
188
|
+
function final(raw, operation, decision) {
|
|
189
|
+
const o = raw;
|
|
190
|
+
if (o?.state === "complete") {
|
|
191
|
+
closed(raw, ["state", "result"]);
|
|
192
|
+
return { state: "complete", result: result(o.result, operation, decision) };
|
|
193
|
+
}
|
|
194
|
+
if (o?.state === "refused") {
|
|
195
|
+
closed(raw, ["state", "reason"]);
|
|
196
|
+
return { state: "refused", reason: pick(o.reason, REFUSALS) };
|
|
197
|
+
}
|
|
198
|
+
closed(raw, ["state", "boundOperationId"]);
|
|
199
|
+
pick(o.state, ["superseded"]);
|
|
200
|
+
return { state: "superseded", boundOperationId: hexN(o.boundOperationId, 32) };
|
|
201
|
+
}
|
|
202
|
+
const terminalDecision = (operation, outcome) => operation !== "decide" ? null : outcome?.result && Object.hasOwn(outcome.result, "proposal") ? "refuse" : "approve";
|
|
203
|
+
export function parseServiceHostOperationRecord(raw) {
|
|
204
|
+
const o = raw;
|
|
205
|
+
const common = (x) => ({ operationId: token(x.operationId, "operationId"), requestId: hexN(x.requestId, 32), requestDigest: digest(x.requestDigest),
|
|
206
|
+
operation: pick(x.operation, ["register", "decide", "revoke"]), account: account(x.account), granter: granter(x.granter), admittedAtMs: ms(x.admittedAtMs), projection: projection(x.projection) });
|
|
207
|
+
if (o?.kind === "service-host-operation") {
|
|
208
|
+
closed(raw, ["v", "kind", "operationId", "requestId", "requestDigest", "operation", "account", "granter", "admittedAtMs", "notAfterMs", "admissionEpoch", "projection", "plan", "progress", "debt"]);
|
|
209
|
+
if (o.v !== 1)
|
|
210
|
+
bad("unknown version");
|
|
211
|
+
const c = common(o), p = plan(o.plan);
|
|
212
|
+
if ((c.operation === "register") !== (p.kind === "register") || (c.operation === "revoke") !== (p.kind === "revoke"))
|
|
213
|
+
bad("plan does not match its operation");
|
|
214
|
+
const g = o.progress;
|
|
215
|
+
let progress;
|
|
216
|
+
if (g?.state === "admitted") {
|
|
217
|
+
closed(g, ["state"]);
|
|
218
|
+
progress = { state: "admitted" };
|
|
219
|
+
}
|
|
220
|
+
else if (g?.state === "custody") {
|
|
221
|
+
closed(g, ["state", "step"]);
|
|
222
|
+
progress = { state: "custody", step: word(g.step) };
|
|
223
|
+
}
|
|
224
|
+
else if (g?.state === "held") {
|
|
225
|
+
closed(g, ["state", "reason", "phase"]);
|
|
226
|
+
progress = { state: "held", reason: word(g.reason), phase: word(g.phase) };
|
|
227
|
+
}
|
|
228
|
+
else
|
|
229
|
+
progress = final(g, c.operation, p.kind === "approve" || p.kind === "refuse" ? p.kind : null);
|
|
230
|
+
return { v: 1, kind: "service-host-operation", ...c, notAfterMs: ms(o.notAfterMs), admissionEpoch: ms(o.admissionEpoch), plan: p, progress, debt: debt(o.debt) };
|
|
231
|
+
}
|
|
232
|
+
closed(raw, ["v", "kind", "operationId", "requestId", "requestDigest", "operation", "account", "granter", "admittedAtMs", "projection", "outcome"]);
|
|
233
|
+
if (o.v !== 1 || o.kind !== "service-host-operation-terminal")
|
|
234
|
+
bad("unknown version or kind");
|
|
235
|
+
const c = common(o);
|
|
236
|
+
return { v: 1, kind: "service-host-operation-terminal", ...c, outcome: final(o.outcome, c.operation, terminalDecision(c.operation, o.outcome)) };
|
|
237
|
+
}
|
|
238
|
+
const registerRef = (raw) => { const r = closed(raw, ["requestId", "requestDigest", "operationId"]); return { requestId: hexN(r.requestId, 32), requestDigest: digest(r.requestDigest), operationId: hexN(r.operationId, 32) }; };
|
|
239
|
+
export function parseServiceProposalRecord(raw) {
|
|
240
|
+
const o = raw;
|
|
241
|
+
if (o?.kind === "service-proposal") {
|
|
242
|
+
closed(raw, ["v", "kind", "proposalId", "proposalDigest", "account", "owner", "endpoint", "instanceId", "register", "createdAtMs", "expiresAtMs", "clusterDigests", "commands", "state"]);
|
|
243
|
+
if (o.v !== 1)
|
|
244
|
+
bad("unknown version");
|
|
245
|
+
const s = o.state;
|
|
246
|
+
const state = s?.state === "proposed" ? (closed(s, ["state"]), { state: "proposed" })
|
|
247
|
+
: (closed(s, ["state", "operationId", "assignmentDigest"]), pick(s.state, ["approving"]), { state: "approving", operationId: hexN(s.operationId, 32), assignmentDigest: digest(s.assignmentDigest) });
|
|
248
|
+
return { v: 1, kind: "service-proposal", proposalId: hexN(o.proposalId, 64), proposalDigest: digest(o.proposalDigest), account: account(o.account),
|
|
249
|
+
owner: owner(o.owner), endpoint: endpointName(o.endpoint), instanceId: token(o.instanceId, "instanceId"), register: registerRef(o.register),
|
|
250
|
+
createdAtMs: ms(o.createdAtMs), expiresAtMs: ms(o.expiresAtMs), clusterDigests: grammar(() => parseServiceProposalClusters(o.clusterDigests)),
|
|
251
|
+
commands: grammar(() => parseServiceProposalCommands(o.commands)), state };
|
|
252
|
+
}
|
|
253
|
+
closed(raw, ["v", "kind", "proposalId", "proposalDigest", "owner", "endpoint", "instanceId", "register", "state", "operationId", "approvalDigest", "reason", "atMs"]);
|
|
254
|
+
if (o.v !== 1 || o.kind !== "service-proposal-terminal")
|
|
255
|
+
bad("unknown version or kind");
|
|
256
|
+
const t = { v: 1, kind: "service-proposal-terminal", proposalId: hexN(o.proposalId, 64), proposalDigest: digest(o.proposalDigest), owner: owner(o.owner),
|
|
257
|
+
endpoint: endpointName(o.endpoint), instanceId: token(o.instanceId, "instanceId"), register: registerRef(o.register), state: pick(o.state, ["approved", "refused", "expired"]),
|
|
258
|
+
operationId: o.operationId === null ? null : hexN(o.operationId, 32), approvalDigest: o.approvalDigest === null ? null : digest(o.approvalDigest),
|
|
259
|
+
reason: o.reason === null ? null : pick(o.reason, CLOSE_REASONS), atMs: ms(o.atMs) };
|
|
260
|
+
if ((t.state === "approved") !== (t.approvalDigest !== null) || (t.state === "approved") === (t.reason !== null))
|
|
261
|
+
bad("terminal proposal fields do not match its state");
|
|
262
|
+
return t;
|
|
263
|
+
}
|
|
264
|
+
export function parseServiceCapacityRecord(raw) {
|
|
265
|
+
const o = closed(raw, ["v", "kind", "pending"]);
|
|
266
|
+
if (o.v !== 1 || o.kind !== "service-proposal-capacity" || !Array.isArray(o.pending) || o.pending.length > S2A_MAX_PENDING)
|
|
267
|
+
bad("invalid capacity row");
|
|
268
|
+
const pending = o.pending.map((e) => {
|
|
269
|
+
const x = closed(e, ["proposalId", "owner", "endpoint", "instanceId", "registerRequestId", "expiresAtMs"]);
|
|
270
|
+
return { proposalId: hexN(x.proposalId, 64), owner: owner(x.owner), endpoint: endpointName(x.endpoint), instanceId: token(x.instanceId, "instanceId"), registerRequestId: hexN(x.registerRequestId, 32), expiresAtMs: ms(x.expiresAtMs) };
|
|
271
|
+
});
|
|
272
|
+
if (pending.some((e, i) => i > 0 && !(pending[i - 1].proposalId < e.proposalId)))
|
|
273
|
+
bad("capacity entries must be unique and sorted by proposalId");
|
|
274
|
+
const perOwner = new Map();
|
|
275
|
+
for (const e of pending)
|
|
276
|
+
perOwner.set(e.owner, (perOwner.get(e.owner) ?? 0) + 1);
|
|
277
|
+
if ([...perOwner.values()].some((n) => n > S2A_MAX_PENDING_PER_OWNER))
|
|
278
|
+
bad("capacity entries exceed the per-owner bound");
|
|
279
|
+
return { v: 1, kind: "service-proposal-capacity", pending };
|
|
280
|
+
}
|
|
281
|
+
export function parseServiceAssignmentRecord(raw) {
|
|
282
|
+
const o = raw;
|
|
283
|
+
const base = (x) => ({ v: 1, kind: "service-assignment", endpoint: endpointName(x.endpoint), instanceId: token(x.instanceId, "instanceId"),
|
|
284
|
+
owner: owner(x.owner), proposalId: hexN(x.proposalId, 64), assignmentDigest: digest(x.assignmentDigest) });
|
|
285
|
+
if (o?.v !== 1 || o?.kind !== "service-assignment")
|
|
286
|
+
bad("unknown version or kind");
|
|
287
|
+
if (o.state === "bound") {
|
|
288
|
+
closed(raw, ["v", "kind", "state", "endpoint", "instanceId", "owner", "proposalId", "assignmentDigest", "reservation", "creation", "cancel"]);
|
|
289
|
+
const r = reservation(o.reservation), c = closed(o.creation, ["operationId"]);
|
|
290
|
+
if (contractDigest(r) !== o.assignmentDigest || r.assignment.endpoint !== o.endpoint || r.assignment.instanceId !== o.instanceId || r.assignment.owner !== o.owner)
|
|
291
|
+
bad("the bound reservation does not match its assignment");
|
|
292
|
+
return { ...base(o), state: "bound", reservation: r, creation: { operationId: token(c.operationId, "operationId") }, cancel: o.cancel === null ? null : cancelOf(o.cancel) };
|
|
293
|
+
}
|
|
294
|
+
closed(raw, ["v", "kind", "state", "endpoint", "instanceId", "owner", "proposalId", "assignmentDigest", "cancel"]);
|
|
295
|
+
pick(o.state, ["fenced"]);
|
|
296
|
+
return { ...base(o), state: "fenced", cancel: cancelOf(o.cancel) };
|
|
297
|
+
}
|
|
298
|
+
const SERVE_OPERATIONS = ["register", "activate", "renew", "ready"];
|
|
299
|
+
export function parseServiceRequestIntentRecord(raw) {
|
|
300
|
+
const o = closed(raw, ["v", "kind", "operationId", "operation", "account", "owner", "endpoint", "instanceId", "requestId", "requestDigest",
|
|
301
|
+
"registrationRequestId", "witnessDigest", "admittedAtMs"]);
|
|
302
|
+
if (o.v !== 1 || o.kind !== "service-request-intent")
|
|
303
|
+
bad("unknown version or kind");
|
|
304
|
+
const operation = pick(o.operation, SERVE_OPERATIONS);
|
|
305
|
+
const registrationRequestId = o.registrationRequestId === null ? null : hexN(o.registrationRequestId, 32);
|
|
306
|
+
const witnessDigest = o.witnessDigest === null ? null : digest(o.witnessDigest);
|
|
307
|
+
if ((registrationRequestId === null) !== (witnessDigest === null) || (operation !== "register" && witnessDigest === null))
|
|
308
|
+
bad("a request intent names its registration exactly when it has a witness");
|
|
309
|
+
return { v: 1, kind: "service-request-intent", operationId: hexN(o.operationId, 32), operation, account: account(o.account),
|
|
310
|
+
owner: owner(o.owner), endpoint: endpointName(o.endpoint), instanceId: token(o.instanceId, "instanceId"), requestId: hexN(o.requestId, 32),
|
|
311
|
+
requestDigest: digest(o.requestDigest), registrationRequestId, witnessDigest, admittedAtMs: ms(o.admittedAtMs) };
|
|
312
|
+
}
|
|
313
|
+
export function parseServiceRequestIntentIndexRecord(raw) {
|
|
314
|
+
const o = closed(raw, ["v", "kind", "owner", "endpoint", "instanceId", "intents"]);
|
|
315
|
+
if (o.v !== 1 || o.kind !== "service-request-intent-index")
|
|
316
|
+
bad("unknown version or kind");
|
|
317
|
+
if (!Array.isArray(o.intents) || o.intents.length === 0 || o.intents.length > S3_MAX_INTENTS_PER_INSTANCE)
|
|
318
|
+
bad("invalid intent list");
|
|
319
|
+
const intents = o.intents.map((x) => { const e = closed(x, ["operationId", "operation"]); return { operationId: hexN(e.operationId, 32), operation: pick(e.operation, SERVE_OPERATIONS) }; });
|
|
320
|
+
if (intents.some((x, i) => i > 0 && intents[i - 1].operationId >= x.operationId))
|
|
321
|
+
bad("intents must be sorted and unique");
|
|
322
|
+
return { v: 1, kind: "service-request-intent-index", owner: owner(o.owner), endpoint: endpointName(o.endpoint), instanceId: token(o.instanceId, "instanceId"), intents };
|
|
323
|
+
}
|
|
324
|
+
export function parseServiceAdmissionRecord(raw) {
|
|
325
|
+
const o = closed(raw, ["v", "kind", "state", "epoch", "restoreId"]);
|
|
326
|
+
if (o.v !== 1 || o.kind !== "service-host-admission")
|
|
327
|
+
bad("unknown version or kind");
|
|
328
|
+
const epoch = ms(o.epoch);
|
|
329
|
+
if (epoch < 1)
|
|
330
|
+
bad("admission epochs start at 1");
|
|
331
|
+
return { v: 1, kind: "service-host-admission", state: pick(o.state, ["open", "quarantined"]), epoch, restoreId: o.restoreId === null ? null : hexN(o.restoreId, 32) };
|
|
332
|
+
}
|
|
333
|
+
/** Leader-read one S2-A key. A deletion marker or spent tombstone holds under a key-specific
|
|
334
|
+
* reason and is never recreated; anything its closed parser refuses holds as malformed. */
|
|
335
|
+
async function read(ctx, key, what, parse) {
|
|
336
|
+
const cur = await leader(ctx, "auth", key);
|
|
337
|
+
if (cur.state === "absent")
|
|
338
|
+
return cur;
|
|
339
|
+
if (cur.state === "marker")
|
|
340
|
+
return held(`${what}-tombstone`, ctx.step, `${key} carries a ${cur.op} marker; a spent key is never recreated`);
|
|
341
|
+
if (cur.state === "malformed")
|
|
342
|
+
return held(`${what}-malformed`, ctx.step, `${key} is not JSON`);
|
|
343
|
+
try {
|
|
344
|
+
return { state: "value", row: parse(cur.value), bytes: cur.bytes, revision: cur.revision };
|
|
345
|
+
}
|
|
346
|
+
catch (e) {
|
|
347
|
+
return held(`${what}-malformed`, ctx.step, message(e));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const readOp = (ctx, id) => read(ctx, opKey(id), "operation", parseServiceHostOperationRecord);
|
|
351
|
+
const readProposal = (ctx, id) => read(ctx, proposalKey(id), "proposal", parseServiceProposalRecord);
|
|
352
|
+
const readCapacity = (ctx) => read(ctx, CAPACITY_KEY, "capacity", parseServiceCapacityRecord);
|
|
353
|
+
const readAssignment = (ctx, endpointName, instanceId) => read(ctx, assignmentKey(endpointName, instanceId), "assignment", parseServiceAssignmentRecord);
|
|
354
|
+
const readAdmission = (ctx) => read(ctx, ADMISSION_KEY, "admission", parseServiceAdmissionRecord);
|
|
355
|
+
const readIntent = (ctx, id) => read(ctx, intentKey(id), "intent", parseServiceRequestIntentRecord);
|
|
356
|
+
const readIntentIndex = (ctx, endpointName, instanceId) => read(ctx, intentIndexKey(endpointName, instanceId), "intent-index", parseServiceRequestIntentIndexRecord);
|
|
357
|
+
/** One zero-history create (no prior) or exact-revision CAS of a closed record. */
|
|
358
|
+
function put(ctx, key, row, parse, prior) {
|
|
359
|
+
return write(ctx, "auth", key, encodeRoundTrip(row, parse), prior ? { revision: prior.revision, preimage: prior.bytes } : { create: true });
|
|
360
|
+
}
|
|
361
|
+
const byProposal = (a, b) => (a.proposalId < b.proposalId ? -1 : a.proposalId > b.proposalId ? 1 : 0);
|
|
362
|
+
const putCapacity = (ctx, cap, pending) => put(ctx, CAPACITY_KEY, { v: 1, kind: "service-proposal-capacity", pending: [...pending].sort(byProposal) }, parseServiceCapacityRecord, cap.state === "value" ? cap : undefined);
|
|
363
|
+
const isEntry = (proposalId, registerRequestId) => (e) => e.proposalId === proposalId && e.registerRequestId === registerRequestId;
|
|
364
|
+
/** Canonical expiry only: an entry past `expiresAtMs` is ineligible. No grace is temporal proof. */
|
|
365
|
+
const eligible = (cap, now) => (cap.state === "value" ? cap.row.pending.filter((e) => e.expiresAtMs > now) : []);
|
|
366
|
+
async function nameOf(ctx, endpointName) {
|
|
367
|
+
const r = await bounded(ctx, () => readNativeServiceName(ctx.h.registry, { accountPublicKey: ctx.h.accountPublicKey, space: ctx.h.space }, endpointName));
|
|
368
|
+
if (!r.ok)
|
|
369
|
+
return held("unavailable", ctx.step, "timedOut" in r ? "the service name read timed out" : message(r.error));
|
|
370
|
+
return r.value;
|
|
371
|
+
}
|
|
372
|
+
/** Remove one proposal's capacity entry. True only when a read or this CAS observed it gone; an
|
|
373
|
+
* unobserved release is debt, never success. */
|
|
374
|
+
async function release(ctx, proposalId, registerRequestId) {
|
|
375
|
+
try {
|
|
376
|
+
for (let i = 0; i < S2A_MAX_REDECIDE; i++) {
|
|
377
|
+
const cap = await readCapacity(ctx);
|
|
378
|
+
if (cap.state === "absent" || !cap.row.pending.some(isEntry(proposalId, registerRequestId)))
|
|
379
|
+
return true;
|
|
380
|
+
if ((await putCapacity(ctx, cap, cap.row.pending.filter((e) => !isEntry(proposalId, registerRequestId)(e)))) !== "moved")
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
catch (e) {
|
|
386
|
+
if (e instanceof Held && e.reason !== "readiness" && e.reason !== "post-write-readiness")
|
|
387
|
+
return false;
|
|
388
|
+
throw e;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const isFinal = (p) => p.state === "complete" || p.state === "refused" || p.state === "superseded";
|
|
392
|
+
const sameDebt = (a, b) => canonicalJson(a) === canonicalJson(b);
|
|
393
|
+
/** Re-read and CAS the live operation until `next` is durable or reports nothing to change. A
|
|
394
|
+
* terminal record is returned as stored. */
|
|
395
|
+
async function updateOp(ctx, operationId, next) {
|
|
396
|
+
for (let i = 0; i < S2A_MAX_REDECIDE; i++) {
|
|
397
|
+
const cur = await readOp(ctx, operationId);
|
|
398
|
+
if (cur.state === "absent")
|
|
399
|
+
return held("reference-unavailable", ctx.step, "the operation binding is not readable");
|
|
400
|
+
if (cur.row.kind !== "service-host-operation")
|
|
401
|
+
return { op: cur.row, wrote: false };
|
|
402
|
+
const n = next(cur.row);
|
|
403
|
+
if (n === undefined)
|
|
404
|
+
return { op: cur.row, wrote: false };
|
|
405
|
+
if ((await put(ctx, opKey(operationId), n, parseServiceHostOperationRecord, cur)) !== "moved")
|
|
406
|
+
return { op: n, wrote: true };
|
|
407
|
+
}
|
|
408
|
+
return held("contention", ctx.step);
|
|
409
|
+
}
|
|
410
|
+
/** Record an outcome (a stored one wins), keep any debt, then compact once no debt remains.
|
|
411
|
+
* `wrote` is whether this invocation recorded the outcome. */
|
|
412
|
+
async function settle(ctx, operationId, outcome, debts) {
|
|
413
|
+
const step = await updateOp(ctx, operationId, (cur) => {
|
|
414
|
+
const progress = isFinal(cur.progress) ? cur.progress : outcome;
|
|
415
|
+
if (progress === undefined || (progress === cur.progress && sameDebt(cur.debt, debts)))
|
|
416
|
+
return undefined;
|
|
417
|
+
return { ...cur, progress, debt: debts };
|
|
418
|
+
});
|
|
419
|
+
const wrote = step.wrote && step.op.kind === "service-host-operation" && outcome !== undefined && step.op.progress === outcome;
|
|
420
|
+
if (step.op.kind !== "service-host-operation" || !isFinal(step.op.progress) || step.op.debt.length)
|
|
421
|
+
return { op: step.op, wrote };
|
|
422
|
+
for (let i = 0; i < S2A_MAX_REDECIDE; i++) {
|
|
423
|
+
const cur = await readOp(ctx, operationId);
|
|
424
|
+
if (cur.state === "absent")
|
|
425
|
+
return held("reference-unavailable", ctx.step, "the operation binding is not readable");
|
|
426
|
+
if (cur.row.kind !== "service-host-operation")
|
|
427
|
+
return { op: cur.row, wrote };
|
|
428
|
+
const live = cur.row;
|
|
429
|
+
if (!isFinal(live.progress) || live.debt.length)
|
|
430
|
+
return { op: live, wrote };
|
|
431
|
+
const terminal = { v: 1, kind: "service-host-operation-terminal", operationId: live.operationId, requestId: live.requestId,
|
|
432
|
+
requestDigest: live.requestDigest, operation: live.operation, account: live.account, granter: live.granter, admittedAtMs: live.admittedAtMs,
|
|
433
|
+
projection: live.projection, outcome: live.progress };
|
|
434
|
+
if ((await put(ctx, opKey(operationId), terminal, parseServiceHostOperationRecord, cur)) !== "moved")
|
|
435
|
+
return { op: terminal, wrote };
|
|
436
|
+
}
|
|
437
|
+
return held("contention", ctx.step);
|
|
438
|
+
}
|
|
439
|
+
const outcomeOf = (op) => (op.kind === "service-host-operation-terminal" ? op.outcome : isFinal(op.progress) ? op.progress : undefined);
|
|
440
|
+
const debtOf = (op) => (op.kind === "service-host-operation" ? op.debt : []);
|
|
441
|
+
/** Retry recorded capacity debt; returns the debt that remains. */
|
|
442
|
+
async function retryDebt(ctx, op, registerRequestIdOf) {
|
|
443
|
+
const left = [];
|
|
444
|
+
for (const d of debtOf(op)) {
|
|
445
|
+
const reg = await registerRequestIdOf(d.proposalId);
|
|
446
|
+
if (d.kind !== "capacity-release" || reg === undefined || !(await release(ctx, d.proposalId, reg)))
|
|
447
|
+
left.push(d);
|
|
448
|
+
}
|
|
449
|
+
return left;
|
|
450
|
+
}
|
|
451
|
+
/** The one key source for an invocation's writes: its own S2-A records, plus native custody's keys
|
|
452
|
+
* for the located reservation. A decide with a reservation can bootstrap it; a revoke only cancels. */
|
|
453
|
+
export function serviceHostScopeKeys(account, t) {
|
|
454
|
+
const auth = [], records = [], authFamilies = [];
|
|
455
|
+
const opKeyOf = () => opKey(hexN(t.operationId, 32));
|
|
456
|
+
const custody = (run) => {
|
|
457
|
+
if (t.owner === null || t.endpoint === null || t.instanceId === null || t.lifecycleUid === null)
|
|
458
|
+
return;
|
|
459
|
+
const actor = serviceEndpointActor({ ...account, owner: t.owner, endpoint: t.endpoint, instanceId: t.instanceId });
|
|
460
|
+
const k = serviceCustodyKeys({ owner: t.owner, actor, endpoint: t.endpoint, instanceId: t.instanceId, lifecycleUid: t.lifecycleUid }, run);
|
|
461
|
+
records.push(...k.records);
|
|
462
|
+
auth.push(...k.auth);
|
|
463
|
+
authFamilies.push(...k.authFamilies);
|
|
464
|
+
};
|
|
465
|
+
switch (t.operation) {
|
|
466
|
+
case "startup":
|
|
467
|
+
auth.push(ADMISSION_KEY);
|
|
468
|
+
break;
|
|
469
|
+
case "register":
|
|
470
|
+
auth.push(opKeyOf(), proposalKey(hexN(t.proposalId, 64)), CAPACITY_KEY);
|
|
471
|
+
break;
|
|
472
|
+
case "decide":
|
|
473
|
+
auth.push(opKeyOf(), proposalKey(hexN(t.proposalId, 64)), CAPACITY_KEY);
|
|
474
|
+
if (t.lifecycleUid !== null && t.endpoint !== null && t.instanceId !== null) {
|
|
475
|
+
auth.push(assignmentKey(t.endpoint, t.instanceId));
|
|
476
|
+
custody("bootstrap");
|
|
477
|
+
}
|
|
478
|
+
break;
|
|
479
|
+
case "revoke":
|
|
480
|
+
if (t.endpoint === null || t.instanceId === null)
|
|
481
|
+
bad("a revoke scope names its selector");
|
|
482
|
+
auth.push(opKeyOf(), assignmentKey(t.endpoint, t.instanceId), CAPACITY_KEY, ...(t.proposalId === null ? [] : [proposalKey(hexN(t.proposalId, 64))]));
|
|
483
|
+
custody("cancel");
|
|
484
|
+
break;
|
|
485
|
+
case "name-bind":
|
|
486
|
+
if (t.endpoint === null)
|
|
487
|
+
bad("a name binding scope names its endpoint");
|
|
488
|
+
auth.push(serviceNameKey(t.endpoint));
|
|
489
|
+
break;
|
|
490
|
+
case "serve": {
|
|
491
|
+
// A serving request writes its intent and its instance's index, then only its own effect's keys.
|
|
492
|
+
if (t.endpoint === null || t.instanceId === null || t.serve === undefined)
|
|
493
|
+
bad("a serving scope names its selector and operation");
|
|
494
|
+
auth.push(intentKey(hexN(t.operationId, 32)), intentIndexKey(t.endpoint, t.instanceId));
|
|
495
|
+
if (t.serve === "register" || t.serve === "activate")
|
|
496
|
+
auth.push(epgateKey(t.endpoint, t.instanceId));
|
|
497
|
+
records.push(...serveRecordKeys(t).writes);
|
|
498
|
+
break;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
const uniq = (a) => Object.freeze([...new Set(a)]);
|
|
502
|
+
return Object.freeze({ records: uniq(records), auth: uniq(auth), authFamilies: uniq(authFamilies) });
|
|
503
|
+
}
|
|
504
|
+
/** A native registration writes and reads the spec and the endpoint's governance head; readiness
|
|
505
|
+
* writes the status and reads it with the spec. */
|
|
506
|
+
function serveRecordKeys(t) {
|
|
507
|
+
if (t.operation !== "serve" || t.endpoint === null || t.instanceId === null)
|
|
508
|
+
return { writes: [], reads: [] };
|
|
509
|
+
const sel = [t.endpoint, assertLifecycleToken(t.instanceId, "instanceId")];
|
|
510
|
+
const spec = recordSpecKey(RECORD_KINDS.svc, sel), status = recordStatusKey(RECORD_KINDS.svc, sel), govern = recordAtomicKey(GOVERN_HEAD, [t.endpoint]);
|
|
511
|
+
if (t.serve === "register")
|
|
512
|
+
return { writes: [spec, govern], reads: [spec, govern] };
|
|
513
|
+
if (t.serve === "ready")
|
|
514
|
+
return { writes: [status], reads: [spec, status] };
|
|
515
|
+
return { writes: [], reads: [] };
|
|
516
|
+
}
|
|
517
|
+
/** What one invocation's effect credential may read by Direct Get and publish to the contract store. */
|
|
518
|
+
export function serviceHostScopeReads(t) {
|
|
519
|
+
const contract = t.operation === "serve" && t.serve === "register" ? [...(t.contract ?? [])] : [];
|
|
520
|
+
return Object.freeze({ records: Object.freeze([...new Set(serveRecordKeys(t).reads)]), contract: Object.freeze([...new Set(contract)]) });
|
|
521
|
+
}
|
|
522
|
+
const NO_WRITES = Object.freeze({ records: Object.freeze([]), auth: Object.freeze([]), authFamilies: Object.freeze([]) });
|
|
523
|
+
/** Open one invocation's effect connection: a fresh identity whose credential publishes exactly
|
|
524
|
+
* `scope.keys`, never reconnects or renews, and expires at `scope.deadlineMs` rounded up to the
|
|
525
|
+
* whole second. The registry over it is the effect registry. */
|
|
526
|
+
export async function openServiceHostEffect(opts, scope) {
|
|
527
|
+
const client = await openAuthorityClient({
|
|
528
|
+
server: opts.server, space: opts.space, dataAccount: opts.dataAccount, log: opts.log, label: serviceHostEffectLabel(opts.space),
|
|
529
|
+
grants: (connId) => serviceHostEffectGrants(opts.space, connId, scope.keys, scope.reads), noReconnect: true, serviceHostInvocation: { deadlineMs: scope.deadlineMs },
|
|
530
|
+
...(opts.onConnection ? { onConnection: opts.onConnection } : {}),
|
|
531
|
+
});
|
|
532
|
+
try {
|
|
533
|
+
return { registry: await openLifecycleRegistry(client.nc, opts.space), gone: client.gone, close: () => client.close(),
|
|
534
|
+
contract: await contractStoreContext(client.nc, opts.space) };
|
|
535
|
+
}
|
|
536
|
+
catch (e) {
|
|
537
|
+
await client.close().catch(() => { });
|
|
538
|
+
throw e;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const denied = (reason, boundOperationId) => ({ state: "denied", reason, ...(boundOperationId ? { boundOperationId } : {}) });
|
|
542
|
+
const refusedArm = (operationId, reason, debts, boundOperationId) => ({ state: "refused", operationId, reason, ...(boundOperationId ? { boundOperationId } : {}), ...(debts.length ? { debt: debts } : {}) });
|
|
543
|
+
const finalArm = (operationId, f, debts) => f.state === "refused" ? refusedArm(operationId, f.reason, debts) : refusedArm(operationId, "superseded", debts, f.boundOperationId);
|
|
544
|
+
function badRequest(detail) { throw new EpEnvelopeError("bad-request", `service host request: ${detail}`); }
|
|
545
|
+
/** The host register request a public register's proposal phase admits. Serving recomputes it, so
|
|
546
|
+
* one public request key binds one body across both phases. */
|
|
547
|
+
export function serviceRegisterHostRequest(r) {
|
|
548
|
+
return { v: 1, kind: "service-endpoint-host", requestId: r.engineRequestId, operation: "register", granter: { kind: "owner", owner: r.owner },
|
|
549
|
+
input: { owner: r.owner, endpoint: r.endpoint, instanceId: r.instanceId, clusterDigests: r.clusterDigests, commands: r.commands,
|
|
550
|
+
publicRequest: { requestId: r.requestId, requestDigest: r.requestDigest } } };
|
|
551
|
+
}
|
|
552
|
+
/** Structural admission of the internal register request. */
|
|
553
|
+
export function parseServiceRegisterRequest(raw) {
|
|
554
|
+
let o, input, g;
|
|
555
|
+
try {
|
|
556
|
+
o = closed(raw, ["v", "kind", "requestId", "operation", "input", "granter"]);
|
|
557
|
+
if (o.v !== 1 || o.kind !== "service-endpoint-host" || o.operation !== "register")
|
|
558
|
+
bad("unknown version, kind or operation");
|
|
559
|
+
hexN(o.requestId, 32);
|
|
560
|
+
g = closed(o.granter, ["kind", "owner"]);
|
|
561
|
+
pick(g.kind, ["owner"]);
|
|
562
|
+
owner(g.owner);
|
|
563
|
+
input = closed(o.input, ["owner", "endpoint", "instanceId", "clusterDigests", "commands"], ["publicRequest"]);
|
|
564
|
+
owner(input.owner);
|
|
565
|
+
endpointName(input.endpoint);
|
|
566
|
+
token(input.instanceId, "instanceId");
|
|
567
|
+
if (Object.hasOwn(input, "publicRequest")) {
|
|
568
|
+
const pr = closed(input.publicRequest, ["requestId", "requestDigest"]);
|
|
569
|
+
hexN(pr.requestId, 32);
|
|
570
|
+
digest(pr.requestDigest);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
catch (e) {
|
|
574
|
+
return badRequest(message(e));
|
|
575
|
+
}
|
|
576
|
+
const clusterDigests = parseServiceProposalClusters(input.clusterDigests), commands = parseServiceProposalCommands(input.commands);
|
|
577
|
+
if (commands.some((c) => !clusterDigests.includes(c.clusterDigest)))
|
|
578
|
+
badRequest("every command must come from a submitted cluster");
|
|
579
|
+
return structuredClone(o);
|
|
580
|
+
}
|
|
581
|
+
export async function openServiceHostOperations(opts) {
|
|
582
|
+
if (opts.startup === undefined)
|
|
583
|
+
throw new EpEnvelopeError("failed-precondition", "service host operations require an explicit startup kind");
|
|
584
|
+
if (opts.startup.kind !== "normal")
|
|
585
|
+
throw new EpEnvelopeError("unimplemented", "restore startup is not supported; a restored store stays closed");
|
|
586
|
+
// Discovery only reads: its host writes nothing at all.
|
|
587
|
+
const discovery = openServiceCustodyHost({ registry: opts.discovery, hold: opts.hold, accountPublicKey: opts.accountPublicKey, writes: NO_WRITES });
|
|
588
|
+
const space = discovery.host.space, accountPublicKey = opts.accountPublicKey;
|
|
589
|
+
const live = new Set();
|
|
590
|
+
let tripped, closed = false;
|
|
591
|
+
/** Every register and handle call, from entry until it returns. */
|
|
592
|
+
const entered = new Set();
|
|
593
|
+
const tracked = (p) => {
|
|
594
|
+
entered.add(p);
|
|
595
|
+
p.then(() => entered.delete(p), () => entered.delete(p));
|
|
596
|
+
return p;
|
|
597
|
+
};
|
|
598
|
+
const discoveryCtx = (step) => { const c = context(HOSTS.get(discovery.host), Date.now() + S2A_INVOCATION_MS); c.step = step; return c; };
|
|
599
|
+
/** One invocation: an effect connection for the discovered scope and its original deadline, one
|
|
600
|
+
* custody host over it sharing the process hold and writing only the scope's keys, and the
|
|
601
|
+
* engine latch. The connection's loss trips the host. Every awaited call returns before close. */
|
|
602
|
+
async function invoke(target, run) {
|
|
603
|
+
if (tripped !== undefined)
|
|
604
|
+
throw new Held("readiness", "open", `the service host is fenced: ${tripped}`);
|
|
605
|
+
const keys = serviceHostScopeKeys({ accountPublicKey, space }, target), reads = serviceHostScopeReads(target);
|
|
606
|
+
const deadlineMs = Date.now() + S2A_INVOCATION_MS;
|
|
607
|
+
const scope = Object.freeze({ ...target, keys, reads, deadlineMs });
|
|
608
|
+
let effect;
|
|
609
|
+
try {
|
|
610
|
+
effect = await opts.openEffect(scope);
|
|
611
|
+
}
|
|
612
|
+
catch (e) {
|
|
613
|
+
throw new Held("unavailable", "effect-open", `the invocation's effect connection did not open: ${message(e)}`);
|
|
614
|
+
}
|
|
615
|
+
try {
|
|
616
|
+
const opened = openServiceCustodyHost({ registry: effect.registry, hold: opts.hold, accountPublicKey, writes: keys }), host = opened.host;
|
|
617
|
+
if (host.space !== space)
|
|
618
|
+
throw new EpEnvelopeError("permission-denied", "the effect registry belongs to another space");
|
|
619
|
+
// A native registration and a ready status write through their own client, not the custody
|
|
620
|
+
// writer: a trip also closes their connection, so none of their requests is issued after the
|
|
621
|
+
// fence. A request already issued stays unknown.
|
|
622
|
+
const native = target.operation === "serve" && (target.serve === "register" || target.serve === "ready");
|
|
623
|
+
const trip = (reason) => { opened.trip(reason); if (native)
|
|
624
|
+
void effect.close().catch(() => { }); };
|
|
625
|
+
live.add(trip);
|
|
626
|
+
if (tripped !== undefined)
|
|
627
|
+
trip(tripped);
|
|
628
|
+
// Attached before the first request: a write issued after the loss holds, one in flight across it reports held.
|
|
629
|
+
void effect.gone.then(() => trip("the invocation's effect connection is gone"));
|
|
630
|
+
const h = HOSTS.get(host);
|
|
631
|
+
try {
|
|
632
|
+
return await run({ host, h, writes: h.writes, endMs: deadlineMs, operationId: target.operationId ?? "", bound: true, ...(effect.contract ? { contract: effect.contract } : {}) });
|
|
633
|
+
}
|
|
634
|
+
finally {
|
|
635
|
+
live.delete(trip);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
finally {
|
|
639
|
+
await effect.close();
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
const readCtx = (inv, step) => { const c = context(inv.h, inv.endMs); c.step = step; return c; };
|
|
643
|
+
/** Hold before a native custody run whose keys this invocation's scope does not cover. A replay
|
|
644
|
+
* of the same request discovers the stored reservation and scopes it. */
|
|
645
|
+
const coverCustody = (inv, ctx, reservation, run) => {
|
|
646
|
+
const need = serviceReservationKeys(inv.host, reservation, run);
|
|
647
|
+
const gap = [...need.records.filter((k) => !scopeCovers(inv.writes, "records", k)), ...need.auth.filter((k) => !scopeCovers(inv.writes, "auth", k)),
|
|
648
|
+
...need.authFamilies.filter((f) => !inv.writes.authFamilies.includes(f))];
|
|
649
|
+
if (gap.length)
|
|
650
|
+
held("scope-mismatch", ctx.step, `native custody needs ${gap.join(", ")} outside this invocation's write scope`);
|
|
651
|
+
};
|
|
652
|
+
/** Normal advancement only, and only before the original deadline. */
|
|
653
|
+
const authorityCtx = (inv, notAfterMs, step) => { const c = context(inv.h, Math.min(inv.endMs, notAfterMs)); c.step = step; return c; };
|
|
654
|
+
/** A held step names its operation whenever the request may be bound: until this invocation's own
|
|
655
|
+
* binding read finds the key absent, and again once its create may be issued. Only a step after
|
|
656
|
+
* that absence is a zero-write denial, and the absence is point-in-time: another invocation of
|
|
657
|
+
* the same request already in flight can still bind it later. */
|
|
658
|
+
const heldArm = (inv, e) => {
|
|
659
|
+
const reason = e.reason === "deadline" && Date.now() >= inv.endMs ? "reconcile-pending" : e.reason;
|
|
660
|
+
return inv.bound ? { state: "held", operationId: inv.operationId, reason, phase: e.step, ...(inv.assignmentDigest ? { assignmentDigest: inv.assignmentDigest } : {}) } : denied("unavailable");
|
|
661
|
+
};
|
|
662
|
+
/** A discovery read failed. A request that is or may be bound is never a zero-write denial; one
|
|
663
|
+
* bound to another body is a conflict, as its admission would answer. */
|
|
664
|
+
const discoveryHeld = (operationId, requestDigest, found, e, assignmentDigest) => {
|
|
665
|
+
if (found?.state === "absent")
|
|
666
|
+
return denied("unavailable");
|
|
667
|
+
if (found?.state === "value" && found.row.requestDigest !== requestDigest)
|
|
668
|
+
return denied("conflict");
|
|
669
|
+
return { state: "held", operationId, reason: e.reason, phase: e.step, ...(assignmentDigest ? { assignmentDigest } : {}) };
|
|
670
|
+
};
|
|
671
|
+
/** The held arm of a request this invocation read nothing about: its deterministic operation
|
|
672
|
+
* coordinate, the same whether or not the request is bound or its granter authorized. */
|
|
673
|
+
const unread = (requestId, reason, phase, assignmentDigest) => ({ state: "held", operationId: serviceHostOperationId(space, requestId), reason, phase, ...(assignmentDigest ? { assignmentDigest } : {}) });
|
|
674
|
+
const interactive = (o) => {
|
|
675
|
+
try {
|
|
676
|
+
return (findInteractiveActor(opts.ledgerDir, o, "cli") ?? findInteractiveActor(opts.ledgerDir, o, "web")) !== undefined;
|
|
677
|
+
}
|
|
678
|
+
catch {
|
|
679
|
+
return false;
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
// Normal-startup admission: absent creates `open` epoch 1; `open` is used; `quarantined` stays.
|
|
683
|
+
// An unknown or unavailable create fails construction closed.
|
|
684
|
+
await invoke({ operation: "startup", operationId: null, proposalId: null, owner: null, endpoint: null, instanceId: null, lifecycleUid: null }, async (inv) => {
|
|
685
|
+
const ctx = readCtx(inv, "admission");
|
|
686
|
+
for (let i = 0; i < S2A_MAX_REDECIDE; i++) {
|
|
687
|
+
const a = await readAdmission(ctx);
|
|
688
|
+
if (a.state === "value")
|
|
689
|
+
return;
|
|
690
|
+
await put(ctx, ADMISSION_KEY, { v: 1, kind: "service-host-admission", state: "open", epoch: 1, restoreId: null }, parseServiceAdmissionRecord);
|
|
691
|
+
}
|
|
692
|
+
held("contention", ctx.step);
|
|
693
|
+
});
|
|
694
|
+
// ---- register (R1-R7) ---------------------------------------------------------------------------
|
|
695
|
+
const proposalBodyDigest = (sel, clusterDigests, commands) => contractDigest({ v: 1, kind: "service-proposal-body", accountPublicKey, space, ...sel, clusterDigests, commands });
|
|
696
|
+
/** R3: the initial refusal reasons, from one read of each authority. */
|
|
697
|
+
async function registerRefusal(ctx, sel, cap, now, epoch) {
|
|
698
|
+
const adm = await readAdmission(ctx);
|
|
699
|
+
if (adm.state === "absent" || adm.row.state !== "open" || (epoch !== undefined && adm.row.epoch !== epoch))
|
|
700
|
+
return { reason: "admission-closed", epoch: adm.state === "value" ? adm.row.epoch : 0 };
|
|
701
|
+
const live = eligible(cap, now);
|
|
702
|
+
if (live.some((e) => e.endpoint === sel.endpoint && e.instanceId === sel.instanceId))
|
|
703
|
+
return { reason: "selector-pending", epoch: adm.row.epoch };
|
|
704
|
+
if (live.length >= S2A_MAX_PENDING || live.filter((e) => e.owner === sel.owner).length >= S2A_MAX_PENDING_PER_OWNER)
|
|
705
|
+
return { reason: "resource-exhausted", epoch: adm.row.epoch };
|
|
706
|
+
if ((await readAssignment(ctx, sel.endpoint, sel.instanceId)).state === "value")
|
|
707
|
+
return { reason: "selector-bound", epoch: adm.row.epoch };
|
|
708
|
+
const name = await nameOf(ctx, sel.endpoint);
|
|
709
|
+
if (name !== undefined && name.owner !== sel.owner)
|
|
710
|
+
return { reason: "name-owned", epoch: adm.row.epoch };
|
|
711
|
+
return { epoch: adm.row.epoch };
|
|
712
|
+
}
|
|
713
|
+
async function register(raw) {
|
|
714
|
+
const req = parseServiceRegisterRequest(raw);
|
|
715
|
+
const env = { v: 1, kind: "service-endpoint-host", operation: "register", requestId: req.requestId, space, accountPublicKey };
|
|
716
|
+
const out = (a) => ({ ...env, ...a });
|
|
717
|
+
// The latch comes before the ledger and every broker read; a tripped engine cannot tell a bound request.
|
|
718
|
+
if (tripped !== undefined || closed)
|
|
719
|
+
return out(unread(req.requestId, "readiness", "open"));
|
|
720
|
+
const { owner: o, endpoint: e, instanceId: i, clusterDigests, commands } = req.input;
|
|
721
|
+
if (req.granter.owner !== o)
|
|
722
|
+
return out(denied("permission-denied")); // never binds
|
|
723
|
+
// Authorization precedes every request-key read, and its refusal is one answer whether or not the request is bound.
|
|
724
|
+
if (!interactive(o))
|
|
725
|
+
return out(unread(req.requestId, "permission-denied", "authorize"));
|
|
726
|
+
// A native control endpoint is never a service name: refused before any effect, like a name binding.
|
|
727
|
+
try {
|
|
728
|
+
serviceNameKey(e);
|
|
729
|
+
}
|
|
730
|
+
catch (err) {
|
|
731
|
+
if (err instanceof EpEnvelopeError && err.code === "permission-denied")
|
|
732
|
+
return out(denied("permission-denied"));
|
|
733
|
+
throw err;
|
|
734
|
+
} // never binds
|
|
735
|
+
const operationId = serviceHostOperationId(space, req.requestId), requestDigest = requestDigestOf(req);
|
|
736
|
+
const proposalDigest = proposalBodyDigest({ owner: o, endpoint: e, instanceId: i }, clusterDigests, commands);
|
|
737
|
+
const proposalId = proposalIdOf(proposalDigest, req.requestId);
|
|
738
|
+
const proposalRow = (p) => ({ v: 1, kind: "service-proposal", proposalId, proposalDigest, account: { accountPublicKey, space },
|
|
739
|
+
owner: o, endpoint: e, instanceId: i, register: { requestId: req.requestId, requestDigest, operationId }, createdAtMs: p.createdAtMs, expiresAtMs: p.expiresAtMs,
|
|
740
|
+
clusterDigests, commands, state: { state: "proposed" } });
|
|
741
|
+
if (enc.encode(JSON.stringify(proposalRow({ kind: "register", proposalId, proposalDigest, createdAtMs: 0, expiresAtMs: 0 }))).length > S2A_MAX_PROPOSAL_BYTES)
|
|
742
|
+
return out(denied("bad-request"));
|
|
743
|
+
const sel = { owner: o, endpoint: e, instanceId: i };
|
|
744
|
+
try {
|
|
745
|
+
return out(await invoke({ operation: "register", operationId, proposalId, owner: o, endpoint: e, instanceId: i, lifecycleUid: null }, async (inv) => {
|
|
746
|
+
try {
|
|
747
|
+
const ctx = readCtx(inv, "R2-binding");
|
|
748
|
+
for (let n = 0;; n++) {
|
|
749
|
+
if (n >= S2A_MAX_REDECIDE)
|
|
750
|
+
held("contention", ctx.step);
|
|
751
|
+
const op = await readOp(ctx, operationId);
|
|
752
|
+
if (op.state === "value") {
|
|
753
|
+
if (op.row.requestDigest !== requestDigest)
|
|
754
|
+
return denied("conflict");
|
|
755
|
+
return await advanceRegister(inv, op.row);
|
|
756
|
+
}
|
|
757
|
+
inv.bound = false;
|
|
758
|
+
ctx.step = "R3-decide";
|
|
759
|
+
const now = Date.now(), cap = await readCapacity(ctx);
|
|
760
|
+
const decision = await registerRefusal(ctx, sel, cap, now);
|
|
761
|
+
if (decision.reason !== undefined)
|
|
762
|
+
return denied(decision.reason);
|
|
763
|
+
ctx.step = "R4-binding";
|
|
764
|
+
const binding = { v: 1, kind: "service-host-operation", operationId, requestId: req.requestId, requestDigest, operation: "register",
|
|
765
|
+
account: { accountPublicKey, space }, granter: req.granter, admittedAtMs: now, notAfterMs: now + S2A_OPERATION_TTL_MS, admissionEpoch: decision.epoch,
|
|
766
|
+
projection: { subjectOwner: o, endpoint: e, instanceId: i, proposalId },
|
|
767
|
+
plan: { kind: "register", proposalId, proposalDigest, createdAtMs: now, expiresAtMs: now + S2A_PROPOSAL_LIFETIME_MS }, progress: { state: "admitted" }, debt: [] };
|
|
768
|
+
// The create may be issued from here, so a held step names the operation.
|
|
769
|
+
inv.bound = true;
|
|
770
|
+
if ((await put(ctx, opKey(operationId), binding, parseServiceHostOperationRecord)) !== "moved")
|
|
771
|
+
return await advanceRegister(inv, binding);
|
|
772
|
+
// A lost create is a concurrent first use, so the request stays possibly bound: re-read, then conflict or replay.
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
catch (err) {
|
|
776
|
+
if (err instanceof Held)
|
|
777
|
+
return heldArm(inv, err);
|
|
778
|
+
throw err;
|
|
779
|
+
}
|
|
780
|
+
}));
|
|
781
|
+
}
|
|
782
|
+
catch (err) {
|
|
783
|
+
if (err instanceof Held)
|
|
784
|
+
return out(unread(req.requestId, err.reason, err.step));
|
|
785
|
+
throw err;
|
|
786
|
+
}
|
|
787
|
+
async function advanceRegister(inv, first) {
|
|
788
|
+
const ctx = readCtx(inv, "R-replay");
|
|
789
|
+
// The digest is the binding's own: a matching request digest fixes the same proposal body.
|
|
790
|
+
const registered = (r, outcome, debts = []) => ({ state: "registered", outcome, operationId, proposalDigest, ...r, ...(debts.length ? { debt: debts } : {}) });
|
|
791
|
+
const recorded = async (op) => {
|
|
792
|
+
const f = outcomeOf(op), left = await retryDebt(ctx, op, async () => req.requestId);
|
|
793
|
+
const s = await settle(ctx, operationId, undefined, left);
|
|
794
|
+
return f.state === "complete" ? registered(f.result, "recorded", debtOf(s.op)) : finalArm(operationId, f, debtOf(s.op));
|
|
795
|
+
};
|
|
796
|
+
if (outcomeOf(first) !== undefined)
|
|
797
|
+
return recorded(first);
|
|
798
|
+
const op = first, p = op.plan;
|
|
799
|
+
/** Close the op: release my entry first, then record the outcome with any debt. */
|
|
800
|
+
const close = async (f) => {
|
|
801
|
+
const debts = (await release(ctx, proposalId, req.requestId)) ? [] : [{ kind: "capacity-release", proposalId }];
|
|
802
|
+
const s = await settle(ctx, operationId, f, debts), stored = outcomeOf(s.op);
|
|
803
|
+
if (stored.state === "complete")
|
|
804
|
+
return registered(stored.result, s.wrote ? "applied" : "recorded", debtOf(s.op));
|
|
805
|
+
return finalArm(operationId, stored, debtOf(s.op));
|
|
806
|
+
};
|
|
807
|
+
/** R6's one rule for a present proposal. */
|
|
808
|
+
const fromProposal = async (prop) => {
|
|
809
|
+
const r = prop.row;
|
|
810
|
+
if (r.proposalDigest !== proposalDigest || r.register.operationId !== operationId || r.register.requestId !== req.requestId || r.register.requestDigest !== requestDigest)
|
|
811
|
+
return close({ state: "refused", reason: "conflict" });
|
|
812
|
+
if (r.kind === "service-proposal-terminal" && r.state === "expired")
|
|
813
|
+
return close({ state: "refused", reason: "expired" });
|
|
814
|
+
const done = { state: "complete", result: { proposalId, revision: prop.revision, expiresAtMs: p.expiresAtMs } };
|
|
815
|
+
if (r.kind === "service-proposal") {
|
|
816
|
+
const s = await settle(ctx, operationId, done, []), stored = outcomeOf(s.op);
|
|
817
|
+
return stored.state === "complete" ? registered(stored.result, s.wrote ? "applied" : "recorded") : finalArm(operationId, stored, debtOf(s.op));
|
|
818
|
+
}
|
|
819
|
+
return close(done);
|
|
820
|
+
};
|
|
821
|
+
/** Past the register bound with the proposal absent: same-key fence, release, refuse. */
|
|
822
|
+
const bound = async () => {
|
|
823
|
+
ctx.step = "R-bound";
|
|
824
|
+
const fence = { v: 1, kind: "service-proposal-terminal", proposalId, proposalDigest, owner: o, endpoint: e, instanceId: i,
|
|
825
|
+
register: { requestId: req.requestId, requestDigest, operationId }, state: "expired", operationId: null, approvalDigest: null, reason: "expired", atMs: Date.now() };
|
|
826
|
+
await put(ctx, proposalKey(proposalId), fence, parseServiceProposalRecord);
|
|
827
|
+
const prop = await readProposal(ctx, proposalId);
|
|
828
|
+
if (prop.state === "absent")
|
|
829
|
+
return held("proposal-unreadable", ctx.step, "the fenced proposal key is not readable");
|
|
830
|
+
return fromProposal(prop);
|
|
831
|
+
};
|
|
832
|
+
ctx.step = "R6-proposal";
|
|
833
|
+
let prop = await readProposal(ctx, proposalId);
|
|
834
|
+
if (prop.state === "value")
|
|
835
|
+
return fromProposal(prop);
|
|
836
|
+
if (Date.now() >= op.notAfterMs)
|
|
837
|
+
return bound();
|
|
838
|
+
for (let n = 0;; n++) {
|
|
839
|
+
if (n >= S2A_MAX_REDECIDE)
|
|
840
|
+
held("contention", "R5-capacity");
|
|
841
|
+
ctx.step = "R5-capacity";
|
|
842
|
+
const now = Date.now(), cap = await readCapacity(ctx);
|
|
843
|
+
if (cap.state === "value" && cap.row.pending.some(isEntry(proposalId, req.requestId)))
|
|
844
|
+
break;
|
|
845
|
+
const decision = await registerRefusal(ctx, sel, cap, now, op.admissionEpoch);
|
|
846
|
+
if (decision.reason !== undefined)
|
|
847
|
+
return close({ state: "refused", reason: decision.reason });
|
|
848
|
+
if (Date.now() >= op.notAfterMs)
|
|
849
|
+
return bound();
|
|
850
|
+
const entry = { proposalId, owner: o, endpoint: e, instanceId: i, registerRequestId: req.requestId, expiresAtMs: p.expiresAtMs };
|
|
851
|
+
if ((await putCapacity(authorityCtx(inv, op.notAfterMs, "R5-capacity"), cap, [...eligible(cap, now), entry])) !== "moved")
|
|
852
|
+
break;
|
|
853
|
+
}
|
|
854
|
+
if (Date.now() >= op.notAfterMs)
|
|
855
|
+
return bound();
|
|
856
|
+
await put(authorityCtx(inv, op.notAfterMs, "R6-proposal"), proposalKey(proposalId), proposalRow(p), parseServiceProposalRecord);
|
|
857
|
+
ctx.step = "R6-proposal";
|
|
858
|
+
prop = await readProposal(ctx, proposalId);
|
|
859
|
+
if (prop.state === "absent")
|
|
860
|
+
return held("proposal-unreadable", ctx.step, "the created proposal is not readable");
|
|
861
|
+
return fromProposal(prop);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
const tombstone = (p, state, operationId, approvalDigest, reason) => ({
|
|
865
|
+
v: 1, kind: "service-proposal-terminal", proposalId: p.proposalId, proposalDigest: p.proposalDigest, owner: p.owner, endpoint: p.endpoint, instanceId: p.instanceId,
|
|
866
|
+
register: p.register, state, operationId, approvalDigest, reason, atMs: Date.now()
|
|
867
|
+
});
|
|
868
|
+
const causeReason = (c) => (c?.cause === "approval-expired" ? "expired" : "revoked");
|
|
869
|
+
const toRefusal = (t) => t.state === "approved" ? { reason: "superseded", bound: t.operationId ?? undefined } : { reason: t.reason ?? "expired" };
|
|
870
|
+
/** Close a live proposal with a terminal CAS when `matches` still holds; re-read on a loss.
|
|
871
|
+
* Returns the proposal as finally read. Proposal-first: callers release only after this. */
|
|
872
|
+
async function closeProposal(ctx, proposalId, matches) {
|
|
873
|
+
for (let i = 0; i < S2A_MAX_REDECIDE; i++) {
|
|
874
|
+
const prop = await readProposal(ctx, proposalId);
|
|
875
|
+
if (prop.state === "absent")
|
|
876
|
+
return held("proposal-unreadable", ctx.step, "the proposal record is not readable");
|
|
877
|
+
if (prop.row.kind !== "service-proposal")
|
|
878
|
+
return prop;
|
|
879
|
+
const next = matches(prop.row, prop.revision);
|
|
880
|
+
if (next === undefined)
|
|
881
|
+
return prop;
|
|
882
|
+
await put(ctx, proposalKey(proposalId), next, parseServiceProposalRecord, prop);
|
|
883
|
+
}
|
|
884
|
+
return held("contention", ctx.step);
|
|
885
|
+
}
|
|
886
|
+
/** Release a terminal proposal's entry; the debt it leaves when unobserved. */
|
|
887
|
+
const releaseFor = async (ctx, p) => (await release(ctx, p.proposalId, p.register.requestId)) ? [] : [{ kind: "capacity-release", proposalId: p.proposalId }];
|
|
888
|
+
/** V4: once deny-new has landed (or the assignment is fenced), close an `approving` proposal of
|
|
889
|
+
* this assignment truthfully from the gate, then release its capacity. */
|
|
890
|
+
async function v4(ctx, asg, gate) {
|
|
891
|
+
ctx.step = "V4-close";
|
|
892
|
+
const landed = gate && (gate.kind === "approved" || gate.kind === "revoked") ? gate.row.serviceAuthority.approvalDigest : null;
|
|
893
|
+
const prop = await closeProposal(ctx, asg.proposalId, (p) => p.state.state === "approving" && p.state.assignmentDigest === asg.assignmentDigest
|
|
894
|
+
? (landed ? tombstone(p, "approved", p.state.operationId, landed, null) : tombstone(p, "refused", p.state.operationId, null, causeReason(asg.cancel)))
|
|
895
|
+
: undefined);
|
|
896
|
+
return prop.row.kind === "service-proposal-terminal" ? releaseFor(ctx, prop.row) : [];
|
|
897
|
+
}
|
|
898
|
+
/** Deny-new from one read of this assignment's endpoint gate, on every revoke path that reads it. Another
|
|
899
|
+
* assignment's row at the key is no evidence about this one's deny-new: `unknown`, as a failed read. */
|
|
900
|
+
const denyNewOf = (gate) => (gate.kind === "revoked" ? "landed" : gate.kind === "foreign" ? "unknown" : "not-landed");
|
|
901
|
+
/** V3: native cancellation of a bound assignment with the material recorded in `svcasg`, then
|
|
902
|
+
* the endpoint gate read that decides whether deny-new landed. S1 is awaited, never raced. */
|
|
903
|
+
async function nativeCancel(inv, ctx, asg, cancel) {
|
|
904
|
+
coverCustody(inv, ctx, asg.reservation, "cancel");
|
|
905
|
+
let s1;
|
|
906
|
+
try {
|
|
907
|
+
const intent = sealServiceCancelIntent(inv.host, { reservation: asg.reservation, opId: cancel.operationId, requestDigest: cancel.requestDigest });
|
|
908
|
+
s1 = await cancelServiceCustody(intent, { deadlineMs: ctx.deadlineMs });
|
|
909
|
+
}
|
|
910
|
+
catch (e) {
|
|
911
|
+
if (!(e instanceof EpEnvelopeError))
|
|
912
|
+
throw e;
|
|
913
|
+
s1 = { kind: "seal-refused", detail: e.message };
|
|
914
|
+
}
|
|
915
|
+
ctx.step = "V3-gate";
|
|
916
|
+
try {
|
|
917
|
+
const gate = await endpoint(ctx, serviceAssignmentGate(inv.host, asg.reservation));
|
|
918
|
+
return { s1, gate, denyNew: denyNewOf(gate) };
|
|
919
|
+
}
|
|
920
|
+
catch (e) {
|
|
921
|
+
if (e instanceof Held || e instanceof EpEnvelopeError)
|
|
922
|
+
return { s1, denyNew: "unknown" };
|
|
923
|
+
throw e;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
const view = (t) => ({ proposalId: t.proposalId, proposalDigest: t.proposalDigest, owner: t.owner, endpoint: t.endpoint,
|
|
927
|
+
instanceId: t.instanceId, register: t.register, state: t.state, operationId: t.operationId, approvalDigest: t.approvalDigest, reason: t.reason, at: seconds(t.atMs) });
|
|
928
|
+
async function decide(req, authorized) {
|
|
929
|
+
// Authorization precedes every request-key read, and its refusal is one answer whether or not the request is bound.
|
|
930
|
+
if (!authorized)
|
|
931
|
+
return unread(req.requestId, "permission-denied", "authorize");
|
|
932
|
+
const operationId = serviceHostOperationId(space, req.requestId), requestDigest = requestDigestOf(req), { proposalId } = req.input;
|
|
933
|
+
const approve = req.input.decision === "approve";
|
|
934
|
+
const out = (o) => ({ kind: "out", out: o });
|
|
935
|
+
const granterOwner = req.granter.kind === "owner" ? req.granter.owner : null;
|
|
936
|
+
const scope = { operation: "decide", operationId, proposalId, owner: null, endpoint: null, instanceId: null, lifecycleUid: null };
|
|
937
|
+
let found;
|
|
938
|
+
try {
|
|
939
|
+
const dctx = discoveryCtx("discover");
|
|
940
|
+
const op = found = await readOp(dctx, operationId), prop = await readProposal(dctx, proposalId);
|
|
941
|
+
// A stored approve scopes its own reservation; a first use scopes the one it is about to admit.
|
|
942
|
+
if (op.state === "value" && op.row.kind === "service-host-operation" && op.row.plan.kind === "approve") {
|
|
943
|
+
const a = op.row.plan.reservation.assignment;
|
|
944
|
+
Object.assign(scope, { owner: a.owner, endpoint: a.endpoint, instanceId: a.instanceId, lifecycleUid: op.row.plan.reservation.native.lifecycleUid });
|
|
945
|
+
}
|
|
946
|
+
else if (op.state === "absent" && approve) {
|
|
947
|
+
scope.lifecycleUid = mintLifecycleUid();
|
|
948
|
+
if (prop.state === "value")
|
|
949
|
+
Object.assign(scope, { owner: prop.row.owner, endpoint: prop.row.endpoint, instanceId: prop.row.instanceId });
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
catch (e) {
|
|
953
|
+
if (e instanceof Held)
|
|
954
|
+
return discoveryHeld(operationId, requestDigest, found, e);
|
|
955
|
+
throw e;
|
|
956
|
+
}
|
|
957
|
+
try {
|
|
958
|
+
return await invoke(scope, async (inv) => {
|
|
959
|
+
inv.operationId = operationId;
|
|
960
|
+
try {
|
|
961
|
+
const ctx = readCtx(inv, "admit");
|
|
962
|
+
for (let n = 0;; n++) {
|
|
963
|
+
if (n >= S2A_MAX_REDECIDE)
|
|
964
|
+
held("contention", ctx.step);
|
|
965
|
+
const op = await readOp(ctx, operationId);
|
|
966
|
+
if (op.state === "value" && op.row.requestDigest === requestDigest) {
|
|
967
|
+
if (req.granter.kind === "owner" && op.row.projection.subjectOwner !== req.granter.owner)
|
|
968
|
+
return { state: "held", operationId, reason: "permission-denied", phase: "authorize" };
|
|
969
|
+
return approve ? await advanceApprove(inv, op.row) : await advanceRefuse(inv, op.row);
|
|
970
|
+
}
|
|
971
|
+
if (op.state === "value")
|
|
972
|
+
return denied("conflict");
|
|
973
|
+
inv.bound = false;
|
|
974
|
+
const admitted = approve ? await admitApprove(inv, ctx, scope.lifecycleUid) : await admitRefuse(inv, ctx);
|
|
975
|
+
if (admitted.kind === "out")
|
|
976
|
+
return admitted.out;
|
|
977
|
+
inv.bound = true;
|
|
978
|
+
if ((await put(ctx, opKey(operationId), admitted.binding, parseServiceHostOperationRecord)) !== "moved")
|
|
979
|
+
return approve ? await advanceApprove(inv, admitted.binding) : await advanceRefuse(inv, admitted.binding);
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
catch (err) {
|
|
983
|
+
if (err instanceof Held)
|
|
984
|
+
return heldArm(inv, err);
|
|
985
|
+
throw err;
|
|
986
|
+
}
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
catch (err) {
|
|
990
|
+
if (err instanceof Held)
|
|
991
|
+
return discoveryHeld(operationId, requestDigest, found, err);
|
|
992
|
+
throw err;
|
|
993
|
+
}
|
|
994
|
+
async function admitApprove(inv, ctx, lifecycleUid) {
|
|
995
|
+
ctx.step = "A0-admit";
|
|
996
|
+
const adm = await readAdmission(ctx);
|
|
997
|
+
if (adm.state === "absent" || adm.row.state !== "open")
|
|
998
|
+
return out(denied("admission-closed"));
|
|
999
|
+
const prop = await readProposal(ctx, proposalId);
|
|
1000
|
+
if (prop.state === "absent" || prop.row.owner !== granterOwner)
|
|
1001
|
+
return out(denied("not-found"));
|
|
1002
|
+
if (prop.row.kind !== "service-proposal")
|
|
1003
|
+
return out(denied("conflict"));
|
|
1004
|
+
const p = prop.row;
|
|
1005
|
+
if (p.state.state === "approving")
|
|
1006
|
+
return out(denied("approval-in-progress", p.state.operationId));
|
|
1007
|
+
if (prop.revision !== req.input.expectedProposalRevision)
|
|
1008
|
+
return out(denied("conflict"));
|
|
1009
|
+
const now = Date.now();
|
|
1010
|
+
if (p.expiresAtMs <= now)
|
|
1011
|
+
return out(denied("expired"));
|
|
1012
|
+
if (!eligible(await readCapacity(ctx), now).some(isEntry(proposalId, p.register.requestId)))
|
|
1013
|
+
return out(denied("expired"));
|
|
1014
|
+
const name = await nameOf(ctx, p.endpoint);
|
|
1015
|
+
if (name === undefined || name.owner !== p.owner)
|
|
1016
|
+
return out(denied("name-claim-required"));
|
|
1017
|
+
if (name.revision !== req.input.expectedNameRevision)
|
|
1018
|
+
return out(denied("conflict"));
|
|
1019
|
+
const ceiling = parseEndpointCapabilities(req.input.callerCeiling);
|
|
1020
|
+
if (ceiling.some((c) => c.endpoint !== p.endpoint || !p.commands.some((k) => k.name === c.command) || (c.instanceId !== undefined && c.instanceId !== p.instanceId)))
|
|
1021
|
+
return out(denied("bad-request"));
|
|
1022
|
+
const reservation = { v: 1, kind: "native-service-uid", native: { accountPublicKey, space, lifecycleUid },
|
|
1023
|
+
assignment: { owner: p.owner, actor: serviceEndpointActor({ accountPublicKey, space, owner: p.owner, endpoint: p.endpoint, instanceId: p.instanceId }),
|
|
1024
|
+
endpoint: p.endpoint, instanceId: p.instanceId, duty: "serve" },
|
|
1025
|
+
creation: { operationId, requestDigest } };
|
|
1026
|
+
serviceAssignmentGate(inv.host, reservation);
|
|
1027
|
+
ctx.step = "A-binding";
|
|
1028
|
+
return { kind: "bind", binding: { v: 1, kind: "service-host-operation", operationId, requestId: req.requestId, requestDigest, operation: "decide",
|
|
1029
|
+
account: { accountPublicKey, space }, granter: req.granter, admittedAtMs: now, notAfterMs: Math.min(now + S2A_OPERATION_TTL_MS, p.expiresAtMs),
|
|
1030
|
+
admissionEpoch: adm.row.epoch, projection: { subjectOwner: p.owner, endpoint: p.endpoint, instanceId: p.instanceId, proposalId },
|
|
1031
|
+
plan: { kind: "approve", proposalId, proposalDigest: p.proposalDigest, proposalRevision: prop.revision, nameRevision: name.revision, reservation,
|
|
1032
|
+
callerCeiling: ceiling, approvalExpiresAt: null }, progress: { state: "admitted" }, debt: [] } };
|
|
1033
|
+
}
|
|
1034
|
+
async function advanceApprove(inv, first) {
|
|
1035
|
+
const ctx = readCtx(inv, "A-replay");
|
|
1036
|
+
const stored = outcomeOf(first);
|
|
1037
|
+
if (stored !== undefined && stored.state === "complete")
|
|
1038
|
+
return finishApproved(inv, ctx, stored.result, "recorded");
|
|
1039
|
+
if (stored !== undefined) {
|
|
1040
|
+
const left = await retryDebt(ctx, first, async (pid) => { const p = await readProposal(ctx, pid); return p.state === "value" ? p.row.register.requestId : undefined; });
|
|
1041
|
+
const s = await settle(ctx, operationId, undefined, left);
|
|
1042
|
+
return finalArm(operationId, stored, debtOf(s.op));
|
|
1043
|
+
}
|
|
1044
|
+
const op = first, plan = op.plan, res = plan.reservation;
|
|
1045
|
+
const { binding, epgate } = serviceAssignmentGate(inv.host, res);
|
|
1046
|
+
const assignmentDigest = binding.assignmentDigest;
|
|
1047
|
+
inv.assignmentDigest = assignmentDigest;
|
|
1048
|
+
const approvalDigest = approvalDigestOf({ proposalDigest: plan.proposalDigest, assignmentDigest, callerCeiling: plan.callerCeiling,
|
|
1049
|
+
approvalExpiresAt: plan.approvalExpiresAt, nameAuthorityRevision: plan.nameRevision, operationId });
|
|
1050
|
+
const mine = (g) => (g.kind === "approved" || g.kind === "revoked") && g.row.serviceAuthority.approvalDigest === approvalDigest;
|
|
1051
|
+
/** Row 3: my approval CAS landed, possibly before a later revoke. A historical fact only. */
|
|
1052
|
+
const landed = async (g, approvedGateRevision) => {
|
|
1053
|
+
if (g.kind !== "approved" && g.kind !== "revoked")
|
|
1054
|
+
return held("approval-unreadable", ctx.step);
|
|
1055
|
+
const sa = g.row.serviceAuthority;
|
|
1056
|
+
const result = { proposalId, proposalDigest: plan.proposalDigest, operationId,
|
|
1057
|
+
assignment: { endpoint: res.assignment.endpoint, instanceId: res.assignment.instanceId, assignmentDigest, lifecycleUid: res.native.lifecycleUid },
|
|
1058
|
+
approvalDigest, approvalExpiresAt: plan.approvalExpiresAt, callerCeiling: plan.callerCeiling, nameAuthorityRevision: plan.nameRevision,
|
|
1059
|
+
rootCredentialId: sa.rootCredentialId, rootExpiresAt: sa.rootExpiresAt, approvedGateRevision };
|
|
1060
|
+
ctx.step = "A5a-result";
|
|
1061
|
+
const a5a = await updateOp(ctx, operationId, (cur) => (isFinal(cur.progress) ? undefined : { ...cur, progress: { state: "complete", result } }));
|
|
1062
|
+
const kept = outcomeOf(a5a.op);
|
|
1063
|
+
if (kept?.state !== "complete")
|
|
1064
|
+
return finalArm(operationId, kept, debtOf(a5a.op));
|
|
1065
|
+
return finishApproved(inv, ctx, kept.result, a5a.wrote ? "applied" : "recorded");
|
|
1066
|
+
};
|
|
1067
|
+
/** Proposal-first refusal: close my approving proposal, release, then record. R-bound also
|
|
1068
|
+
* closes a still-proposed target, by a CAS pinned to the revision this approval admitted. */
|
|
1069
|
+
const refuseWith = async (close, reason, target = false) => {
|
|
1070
|
+
ctx.step = `R-${reason}`;
|
|
1071
|
+
const prop = await closeProposal(ctx, proposalId, (p, revision) => (p.state.state === "approving" && p.state.operationId === operationId)
|
|
1072
|
+
|| (target && p.state.state === "proposed" && revision === plan.proposalRevision) ? tombstone(p, "refused", operationId, null, close) : undefined);
|
|
1073
|
+
const debts = prop.row.kind === "service-proposal-terminal" ? await releaseFor(ctx, prop.row) : [];
|
|
1074
|
+
const s = await settle(ctx, operationId, { state: "refused", reason }, debts);
|
|
1075
|
+
return finalArm(operationId, outcomeOf(s.op), debtOf(s.op));
|
|
1076
|
+
};
|
|
1077
|
+
for (let n = 0;; n++) {
|
|
1078
|
+
if (n >= 2 * S2A_MAX_REDECIDE)
|
|
1079
|
+
held("contention", ctx.step);
|
|
1080
|
+
ctx.step = "A-table";
|
|
1081
|
+
const gate = await endpoint(ctx, { epgate });
|
|
1082
|
+
if (mine(gate))
|
|
1083
|
+
return landed(gate, null); // row 3
|
|
1084
|
+
const prop = await readProposal(ctx, proposalId);
|
|
1085
|
+
if (prop.state === "absent")
|
|
1086
|
+
return held("proposal-unreadable", ctx.step, "the approved proposal record is not readable");
|
|
1087
|
+
if (prop.row.kind === "service-proposal-terminal") {
|
|
1088
|
+
if (prop.row.state === "approved" && prop.row.operationId === operationId)
|
|
1089
|
+
return held("approval-unreadable", ctx.step, "the proposal records my approval but the gate does not"); // row 4
|
|
1090
|
+
const debts = await releaseFor(ctx, prop.row), why = toRefusal(prop.row); // row 5
|
|
1091
|
+
const s = await settle(ctx, operationId, why.reason === "superseded" ? { state: "superseded", boundOperationId: why.bound } : { state: "refused", reason: why.reason }, debts);
|
|
1092
|
+
return finalArm(operationId, outcomeOf(s.op), debtOf(s.op));
|
|
1093
|
+
}
|
|
1094
|
+
const p = prop.row;
|
|
1095
|
+
if (p.state.state === "approving" && p.state.operationId !== operationId) { // row 9
|
|
1096
|
+
const s = await settle(ctx, operationId, { state: "superseded", boundOperationId: p.state.operationId }, []);
|
|
1097
|
+
return finalArm(operationId, outcomeOf(s.op), debtOf(s.op));
|
|
1098
|
+
}
|
|
1099
|
+
const asg = await readAssignment(ctx, res.assignment.endpoint, res.assignment.instanceId);
|
|
1100
|
+
if (asg.state === "value" && asg.row.assignmentDigest !== assignmentDigest)
|
|
1101
|
+
return refuseWith("selector-bound", "conflict", true); // R-bound
|
|
1102
|
+
if (asg.state === "value" && asg.row.state === "fenced")
|
|
1103
|
+
return refuseWith(causeReason(asg.row.cancel), causeReason(asg.row.cancel)); // row 11, R-fence
|
|
1104
|
+
const bound = asg.state === "value" ? asg.row : undefined;
|
|
1105
|
+
if (bound && gate.kind === "revoked")
|
|
1106
|
+
return refuseWith(causeReason(bound.cancel), causeReason(bound.cancel)); // R-revoked: deny-new landed without my digest
|
|
1107
|
+
if (Date.now() >= op.notAfterMs)
|
|
1108
|
+
return expirySettle(p, bound, gate); // row 6
|
|
1109
|
+
// Normal advancement (rows 7, 8, 10) only inside the original authority.
|
|
1110
|
+
const adm = await readAdmission(ctx);
|
|
1111
|
+
if (adm.state === "absent" || adm.row.state !== "open" || adm.row.epoch !== op.admissionEpoch)
|
|
1112
|
+
return { state: "held", operationId, reason: "admission-held", phase: "admission", assignmentDigest };
|
|
1113
|
+
if (p.state.state === "proposed") {
|
|
1114
|
+
if (prop.revision !== plan.proposalRevision)
|
|
1115
|
+
return held("proposal-moved", ctx.step, "the proposal moved from the revision this approval admitted");
|
|
1116
|
+
await put(authorityCtx(inv, op.notAfterMs, "A1-approving"), proposalKey(proposalId), { ...p, state: { state: "approving", operationId, assignmentDigest } }, parseServiceProposalRecord, prop);
|
|
1117
|
+
continue;
|
|
1118
|
+
}
|
|
1119
|
+
if (!bound) {
|
|
1120
|
+
if (op.progress.state !== "admitted")
|
|
1121
|
+
return held("reference-unavailable", ctx.step, "the assignment row is missing after custody began"); // row 12
|
|
1122
|
+
const row = { v: 1, kind: "service-assignment", state: "bound", endpoint: p.endpoint, instanceId: p.instanceId, owner: p.owner, proposalId,
|
|
1123
|
+
assignmentDigest, reservation: res, creation: { operationId }, cancel: null };
|
|
1124
|
+
await put(authorityCtx(inv, op.notAfterMs, "A2-assignment"), assignmentKey(p.endpoint, p.instanceId), row, parseServiceAssignmentRecord);
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
1127
|
+
if (bound.cancel !== null)
|
|
1128
|
+
return { state: "held", operationId, reason: "revoke-in-progress", phase: "A3-bootstrap", assignmentDigest };
|
|
1129
|
+
ctx.step = "A3-bootstrap";
|
|
1130
|
+
coverCustody(inv, ctx, res, "bootstrap");
|
|
1131
|
+
if (op.progress.state === "admitted")
|
|
1132
|
+
await updateOp(ctx, operationId, (cur) => (cur.progress.state === "admitted" ? { ...cur, progress: { state: "custody", step: "A3-bootstrap" } } : undefined));
|
|
1133
|
+
ctx.step = "A3-bootstrap";
|
|
1134
|
+
const boot = await runServiceBootstrap(sealServiceBootstrapIntent(inv.host, { reservation: res, notAfterMs: op.notAfterMs }));
|
|
1135
|
+
if (boot.kind === "held") {
|
|
1136
|
+
await updateOp(ctx, operationId, (cur) => (isFinal(cur.progress) ? undefined : { ...cur, progress: { state: "held", reason: boot.reason, phase: boot.step } })).catch((e) => { if (!(e instanceof Held))
|
|
1137
|
+
throw e; });
|
|
1138
|
+
return { state: "held", operationId, reason: boot.reason, phase: boot.step, assignmentDigest };
|
|
1139
|
+
}
|
|
1140
|
+
if (boot.kind === "cancelled" || boot.kind === "endpoint-revoked")
|
|
1141
|
+
continue; // the gate read decides R-revoked
|
|
1142
|
+
if (boot.kind !== "active")
|
|
1143
|
+
return refuseWith("custody-refused", "custody-refused");
|
|
1144
|
+
// A4: recheck by read, then the one approval-row builder at the gate revision read now.
|
|
1145
|
+
const actx = authorityCtx(inv, op.notAfterMs, "A4-approve");
|
|
1146
|
+
const name = await nameOf(actx, p.endpoint);
|
|
1147
|
+
if (name === undefined || name.owner !== p.owner || name.revision !== plan.nameRevision)
|
|
1148
|
+
return { state: "held", operationId, reason: "name-changed", phase: actx.step, assignmentDigest };
|
|
1149
|
+
const p4 = await readProposal(actx, proposalId), a4 = await readAssignment(actx, p.endpoint, p.instanceId), adm4 = await readAdmission(actx);
|
|
1150
|
+
if (p4.state !== "value" || p4.row.kind !== "service-proposal" || p4.row.state.state !== "approving" || p4.row.state.operationId !== operationId)
|
|
1151
|
+
continue;
|
|
1152
|
+
if (a4.state !== "value" || a4.row.state !== "bound" || a4.row.cancel !== null)
|
|
1153
|
+
continue;
|
|
1154
|
+
if (adm4.state === "absent" || adm4.row.state !== "open" || adm4.row.epoch !== op.admissionEpoch)
|
|
1155
|
+
return { state: "held", operationId, reason: "admission-held", phase: actx.step, assignmentDigest };
|
|
1156
|
+
const g4 = await endpoint(actx, { epgate });
|
|
1157
|
+
if (g4.kind !== "pending")
|
|
1158
|
+
continue;
|
|
1159
|
+
const args = { rootCredentialId: boot.root.credentialId, approvalDigest, nameAuthorityRevision: plan.nameRevision, approvalExpiresAt: plan.approvalExpiresAt, callerCeiling: plan.callerCeiling };
|
|
1160
|
+
let next;
|
|
1161
|
+
try {
|
|
1162
|
+
assertApprovableServiceGate(g4.row, args);
|
|
1163
|
+
const proof = await bounded(actx, () => readRegistryServiceRoot(inv.h.registry, binding, boot.root.credentialId, Date.now()));
|
|
1164
|
+
if (!proof.ok)
|
|
1165
|
+
return held("unavailable", actx.step, "timedOut" in proof ? "the root proof timed out" : message(proof.error));
|
|
1166
|
+
next = approvedServiceGateRow(g4.row, args, proof.value.expiresAt, Date.now());
|
|
1167
|
+
}
|
|
1168
|
+
catch (e) {
|
|
1169
|
+
if (e instanceof EpEnvelopeError)
|
|
1170
|
+
return { state: "held", operationId, reason: e.code, phase: actx.step, assignmentDigest };
|
|
1171
|
+
throw e;
|
|
1172
|
+
}
|
|
1173
|
+
const w = await write(actx, "auth", epgate.key, enc.encode(JSON.stringify(next)), { revision: g4.revision, preimage: g4.bytes });
|
|
1174
|
+
if (w !== "moved")
|
|
1175
|
+
return landed(await endpoint(ctx, { epgate }), w);
|
|
1176
|
+
}
|
|
1177
|
+
/** Row 6: after the original deadline only bookkeeping and authority-reducing steps run. */
|
|
1178
|
+
async function expirySettle(p, bound, gate) {
|
|
1179
|
+
ctx.step = "settle-expired";
|
|
1180
|
+
if (p.state.state === "proposed") {
|
|
1181
|
+
const s = await settle(ctx, operationId, { state: "refused", reason: "expired" }, []);
|
|
1182
|
+
return finalArm(operationId, outcomeOf(s.op), debtOf(s.op));
|
|
1183
|
+
}
|
|
1184
|
+
if (bound === undefined)
|
|
1185
|
+
return refuseWith("expired", "expired");
|
|
1186
|
+
let asg = bound;
|
|
1187
|
+
for (let i = 0; asg.cancel === null; i++) {
|
|
1188
|
+
if (i >= S2A_MAX_REDECIDE)
|
|
1189
|
+
held("contention", ctx.step);
|
|
1190
|
+
const cancel = { operationId: expiryOperationId(space, operationId), requestDigest: expiryRequestDigest(operationId, assignmentDigest), cause: "approval-expired" };
|
|
1191
|
+
const cur = await readAssignment(ctx, asg.endpoint, asg.instanceId);
|
|
1192
|
+
if (cur.state !== "value" || cur.row.state !== "bound" || cur.row.assignmentDigest !== assignmentDigest)
|
|
1193
|
+
return held("assignment-moved", ctx.step);
|
|
1194
|
+
if (cur.row.cancel !== null) {
|
|
1195
|
+
asg = cur.row;
|
|
1196
|
+
break;
|
|
1197
|
+
}
|
|
1198
|
+
await put(ctx, assignmentKey(asg.endpoint, asg.instanceId), { ...cur.row, cancel }, parseServiceAssignmentRecord, cur);
|
|
1199
|
+
const again = await readAssignment(ctx, asg.endpoint, asg.instanceId);
|
|
1200
|
+
if (again.state === "value" && again.row.state === "bound")
|
|
1201
|
+
asg = again.row;
|
|
1202
|
+
}
|
|
1203
|
+
const run = gate.kind === "revoked" ? { s1: undefined, denyNew: "landed", gate } : await nativeCancel(inv, ctx, asg, asg.cancel);
|
|
1204
|
+
if (run.denyNew !== "landed") {
|
|
1205
|
+
const reason = run.s1 === undefined ? "cancel-pending" : run.s1.kind === "held" ? run.s1.reason : run.s1.kind === "seal-refused" ? "cancel-intent-refused" : run.s1.kind;
|
|
1206
|
+
return { state: "held", operationId, reason, phase: "settle-expired", assignmentDigest, denyNew: run.denyNew };
|
|
1207
|
+
}
|
|
1208
|
+
const debts = await v4(ctx, asg, run.gate);
|
|
1209
|
+
if (run.gate && mine(run.gate))
|
|
1210
|
+
return landed(run.gate, null);
|
|
1211
|
+
const s = await settle(ctx, operationId, { state: "refused", reason: causeReason(asg.cancel) }, debts);
|
|
1212
|
+
return finalArm(operationId, outcomeOf(s.op), debtOf(s.op));
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
/** A5b-A5d after A5a: proposal terminal, capacity release, then compaction once no debt remains. */
|
|
1216
|
+
async function finishApproved(inv, ctx, result, outcome) {
|
|
1217
|
+
ctx.step = "A5b-proposal";
|
|
1218
|
+
const prop = await closeProposal(ctx, result.proposalId, (p) => p.state.state === "approving" && p.state.operationId === result.operationId
|
|
1219
|
+
? tombstone(p, "approved", result.operationId, result.approvalDigest, null) : undefined);
|
|
1220
|
+
ctx.step = "A5c-release";
|
|
1221
|
+
const debts = prop.row.kind === "service-proposal-terminal" ? await releaseFor(ctx, prop.row) : [];
|
|
1222
|
+
ctx.step = "A5d-compact";
|
|
1223
|
+
const s = await settle(ctx, operationId, undefined, debts), left = debtOf(s.op);
|
|
1224
|
+
void inv;
|
|
1225
|
+
return { state: "approved", outcome, approval: result, ...(left.length ? { debt: left } : {}) };
|
|
1226
|
+
}
|
|
1227
|
+
async function admitRefuse(inv, ctx) {
|
|
1228
|
+
ctx.step = "F0-admit";
|
|
1229
|
+
const prop = await readProposal(ctx, proposalId);
|
|
1230
|
+
if (prop.state === "absent" || (granterOwner !== null && prop.row.owner !== granterOwner))
|
|
1231
|
+
return out(denied("not-found"));
|
|
1232
|
+
if (prop.row.kind !== "service-proposal")
|
|
1233
|
+
return out(denied("conflict"));
|
|
1234
|
+
const p = prop.row;
|
|
1235
|
+
if (p.state.state === "approving") {
|
|
1236
|
+
// A stale approving{X}: X is terminal and did not complete, so it holds no native authority.
|
|
1237
|
+
const x = await readOp(ctx, p.state.operationId);
|
|
1238
|
+
if (x.state !== "value" || x.row.kind !== "service-host-operation-terminal" || x.row.outcome.state === "complete")
|
|
1239
|
+
return out(denied("approval-in-progress", p.state.operationId));
|
|
1240
|
+
}
|
|
1241
|
+
if (prop.revision !== req.input.expectedProposalRevision)
|
|
1242
|
+
return out(denied("conflict"));
|
|
1243
|
+
const adm = await readAdmission(ctx), now = Date.now();
|
|
1244
|
+
void inv;
|
|
1245
|
+
ctx.step = "F-binding";
|
|
1246
|
+
return { kind: "bind", binding: { v: 1, kind: "service-host-operation", operationId, requestId: req.requestId, requestDigest, operation: "decide",
|
|
1247
|
+
account: { accountPublicKey, space }, granter: req.granter, admittedAtMs: now, notAfterMs: now + S2A_OPERATION_TTL_MS,
|
|
1248
|
+
admissionEpoch: adm.state === "value" ? adm.row.epoch : 0, projection: { subjectOwner: p.owner, endpoint: p.endpoint, instanceId: p.instanceId, proposalId },
|
|
1249
|
+
plan: { kind: "refuse", proposalId, proposalRevision: prop.revision }, progress: { state: "admitted" }, debt: [] } };
|
|
1250
|
+
}
|
|
1251
|
+
async function advanceRefuse(inv, first) {
|
|
1252
|
+
const ctx = readCtx(inv, "F-replay");
|
|
1253
|
+
const refused = (r, outcome, debts = []) => ({ state: "refused", outcome, proposal: r.proposal, revision: r.revision, ...(debts.length ? { debt: debts } : {}) });
|
|
1254
|
+
const stored = outcomeOf(first);
|
|
1255
|
+
if (stored !== undefined) {
|
|
1256
|
+
const left = await retryDebt(ctx, first, async (pid) => { const p = await readProposal(ctx, pid); return p.state === "value" ? p.row.register.requestId : undefined; });
|
|
1257
|
+
const s = await settle(ctx, operationId, undefined, left);
|
|
1258
|
+
return stored.state === "complete" ? refused(stored.result, "recorded", debtOf(s.op)) : finalArm(operationId, stored, debtOf(s.op));
|
|
1259
|
+
}
|
|
1260
|
+
const op = first, plan = op.plan;
|
|
1261
|
+
const closeReason = req.granter.kind === "owner" ? "owner" : "operator";
|
|
1262
|
+
for (let n = 0;; n++) {
|
|
1263
|
+
if (n >= S2A_MAX_REDECIDE)
|
|
1264
|
+
held("contention", ctx.step);
|
|
1265
|
+
ctx.step = "F-table";
|
|
1266
|
+
const prop = await readProposal(ctx, proposalId);
|
|
1267
|
+
if (prop.state === "absent")
|
|
1268
|
+
return held("proposal-unreadable", ctx.step);
|
|
1269
|
+
if (prop.row.kind === "service-proposal-terminal") {
|
|
1270
|
+
const t = prop.row, debts = await releaseFor(ctx, t);
|
|
1271
|
+
if (t.state === "refused" && t.operationId === operationId) {
|
|
1272
|
+
const s = await settle(ctx, operationId, { state: "complete", result: { proposal: view(t), revision: prop.revision } }, debts);
|
|
1273
|
+
const kept = outcomeOf(s.op);
|
|
1274
|
+
return kept.state === "complete" ? refused(kept.result, s.wrote ? "applied" : "recorded", debtOf(s.op)) : finalArm(operationId, kept, debtOf(s.op));
|
|
1275
|
+
}
|
|
1276
|
+
const why = toRefusal(t);
|
|
1277
|
+
const s = await settle(ctx, operationId, why.reason === "superseded" ? { state: "superseded", boundOperationId: why.bound } : { state: "refused", reason: why.reason }, debts);
|
|
1278
|
+
return finalArm(operationId, outcomeOf(s.op), debtOf(s.op));
|
|
1279
|
+
}
|
|
1280
|
+
if (prop.revision !== plan.proposalRevision) {
|
|
1281
|
+
const s = await settle(ctx, operationId, { state: "refused", reason: "conflict" }, []);
|
|
1282
|
+
return finalArm(operationId, outcomeOf(s.op), debtOf(s.op));
|
|
1283
|
+
}
|
|
1284
|
+
if (Date.now() >= op.notAfterMs) {
|
|
1285
|
+
const s = await settle(ctx, operationId, { state: "refused", reason: "expired" }, []);
|
|
1286
|
+
return finalArm(operationId, outcomeOf(s.op), debtOf(s.op));
|
|
1287
|
+
}
|
|
1288
|
+
await put(authorityCtx(inv, op.notAfterMs, "F-refuse"), proposalKey(proposalId), tombstone(prop.row, "refused", operationId, null, closeReason), parseServiceProposalRecord, prop);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
// ---- revoke (V0-V5) -----------------------------------------------------------------------------
|
|
1293
|
+
async function revoke(req, authorized) {
|
|
1294
|
+
const operationId = serviceHostOperationId(space, req.requestId), requestDigest = requestDigestOf(req);
|
|
1295
|
+
const { endpoint: e, instanceId: i, expectedAssignmentDigest, cause } = req.input;
|
|
1296
|
+
const ownerCause = cause === "owner-request";
|
|
1297
|
+
if ((req.granter.kind === "owner") !== ownerCause)
|
|
1298
|
+
return denied("permission-denied"); // never binds
|
|
1299
|
+
// Authorization precedes every request-key read, and its refusal is one answer whether or not the request is bound.
|
|
1300
|
+
if (!authorized)
|
|
1301
|
+
return unread(req.requestId, "permission-denied", "authorize", expectedAssignmentDigest);
|
|
1302
|
+
const scope = { operation: "revoke", operationId, proposalId: null, owner: null, endpoint: e, instanceId: i, lifecycleUid: null };
|
|
1303
|
+
let found;
|
|
1304
|
+
try {
|
|
1305
|
+
const dctx = discoveryCtx("discover");
|
|
1306
|
+
found = await readOp(dctx, operationId);
|
|
1307
|
+
const asg = await readAssignment(dctx, e, i);
|
|
1308
|
+
if (asg.state === "value") {
|
|
1309
|
+
Object.assign(scope, { proposalId: asg.row.proposalId, owner: asg.row.owner });
|
|
1310
|
+
if (asg.row.state === "bound")
|
|
1311
|
+
scope.lifecycleUid = asg.row.reservation.native.lifecycleUid;
|
|
1312
|
+
}
|
|
1313
|
+
else {
|
|
1314
|
+
const entry = eligible(await readCapacity(dctx), Date.now()).find((x) => x.endpoint === e && x.instanceId === i);
|
|
1315
|
+
if (entry !== undefined)
|
|
1316
|
+
Object.assign(scope, { proposalId: entry.proposalId, owner: entry.owner });
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
catch (err) {
|
|
1320
|
+
if (err instanceof Held)
|
|
1321
|
+
return discoveryHeld(operationId, requestDigest, found, err, expectedAssignmentDigest);
|
|
1322
|
+
throw err;
|
|
1323
|
+
}
|
|
1324
|
+
try {
|
|
1325
|
+
return await invoke(scope, async (inv) => {
|
|
1326
|
+
inv.operationId = operationId;
|
|
1327
|
+
inv.assignmentDigest = expectedAssignmentDigest;
|
|
1328
|
+
try {
|
|
1329
|
+
const ctx = readCtx(inv, "V0-read");
|
|
1330
|
+
for (let n = 0;; n++) {
|
|
1331
|
+
if (n >= S2A_MAX_REDECIDE)
|
|
1332
|
+
held("contention", ctx.step);
|
|
1333
|
+
const op = await readOp(ctx, operationId);
|
|
1334
|
+
if (op.state === "value" && op.row.requestDigest === requestDigest) {
|
|
1335
|
+
if (req.granter.kind === "owner" && op.row.projection.subjectOwner !== req.granter.owner)
|
|
1336
|
+
return { state: "held", operationId, reason: "permission-denied", phase: "authorize", assignmentDigest: expectedAssignmentDigest };
|
|
1337
|
+
return await advanceRevoke(inv, op.row);
|
|
1338
|
+
}
|
|
1339
|
+
if (op.state === "value")
|
|
1340
|
+
return denied("conflict");
|
|
1341
|
+
inv.bound = false;
|
|
1342
|
+
// V0: the assignment, else an approving proposal at the selector, then the assignment again.
|
|
1343
|
+
let asg = await readAssignment(ctx, e, i);
|
|
1344
|
+
let prop = { state: "absent" };
|
|
1345
|
+
if (asg.state === "absent") {
|
|
1346
|
+
const entry = eligible(await readCapacity(ctx), Date.now()).find((x) => x.endpoint === e && x.instanceId === i);
|
|
1347
|
+
if (entry !== undefined)
|
|
1348
|
+
prop = await readProposal(ctx, entry.proposalId);
|
|
1349
|
+
asg = await readAssignment(ctx, e, i);
|
|
1350
|
+
}
|
|
1351
|
+
const subjectOwner = asg.state === "value" ? asg.row.owner : prop.state === "value" ? prop.row.owner : undefined;
|
|
1352
|
+
if (subjectOwner === undefined || (req.granter.kind === "owner" && req.granter.owner !== subjectOwner))
|
|
1353
|
+
return denied("not-found");
|
|
1354
|
+
if (asg.state === "value" && asg.row.assignmentDigest !== expectedAssignmentDigest)
|
|
1355
|
+
return denied("conflict");
|
|
1356
|
+
if (asg.state === "absent" && !(prop.state === "value" && prop.row.kind === "service-proposal" && prop.row.state.state === "approving"
|
|
1357
|
+
&& prop.row.state.assignmentDigest === expectedAssignmentDigest))
|
|
1358
|
+
return denied("not-found");
|
|
1359
|
+
const proposalId = asg.state === "value" ? asg.row.proposalId : prop.row.proposalId;
|
|
1360
|
+
const adm = await readAdmission(ctx), now = Date.now();
|
|
1361
|
+
ctx.step = "V1-binding";
|
|
1362
|
+
const binding = { v: 1, kind: "service-host-operation", operationId, requestId: req.requestId, requestDigest, operation: "revoke",
|
|
1363
|
+
account: { accountPublicKey, space }, granter: req.granter, admittedAtMs: now, notAfterMs: now + S2A_OPERATION_TTL_MS,
|
|
1364
|
+
admissionEpoch: adm.state === "value" ? adm.row.epoch : 0, projection: { subjectOwner, endpoint: e, instanceId: i, proposalId },
|
|
1365
|
+
plan: { kind: "revoke", endpoint: e, instanceId: i, expectedAssignmentDigest, cause }, progress: { state: "admitted" }, debt: [] };
|
|
1366
|
+
inv.bound = true;
|
|
1367
|
+
if ((await put(ctx, opKey(operationId), binding, parseServiceHostOperationRecord)) !== "moved")
|
|
1368
|
+
return await advanceRevoke(inv, binding);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
catch (err) {
|
|
1372
|
+
if (err instanceof Held)
|
|
1373
|
+
return heldArm(inv, err);
|
|
1374
|
+
throw err;
|
|
1375
|
+
}
|
|
1376
|
+
});
|
|
1377
|
+
}
|
|
1378
|
+
catch (err) {
|
|
1379
|
+
if (err instanceof Held)
|
|
1380
|
+
return discoveryHeld(operationId, requestDigest, found, err, expectedAssignmentDigest);
|
|
1381
|
+
throw err;
|
|
1382
|
+
}
|
|
1383
|
+
async function advanceRevoke(inv, first) {
|
|
1384
|
+
const ctx = readCtx(inv, "V-replay");
|
|
1385
|
+
const stored = outcomeOf(first);
|
|
1386
|
+
if (stored !== undefined)
|
|
1387
|
+
return stored.state === "complete" ? held("operation-malformed", ctx.step, "a revoke never completes") : finalArm(operationId, stored, debtOf(first));
|
|
1388
|
+
const op = first;
|
|
1389
|
+
const a = expectedAssignmentDigest;
|
|
1390
|
+
const progress = async (phase) => {
|
|
1391
|
+
await updateOp(ctx, operationId, (cur) => (cur.progress.state === "custody" && cur.progress.step === phase ? undefined : { ...cur, progress: { state: "custody", step: phase } }))
|
|
1392
|
+
.catch((err) => { if (!(err instanceof Held))
|
|
1393
|
+
throw err; });
|
|
1394
|
+
};
|
|
1395
|
+
/** The release debt this revoke owes: as stored until V4 observes again, then kept by a guarded
|
|
1396
|
+
* CAS whether it grows or clears. Every result reports it. */
|
|
1397
|
+
let owed = op.debt;
|
|
1398
|
+
const keepDebt = async (debts) => {
|
|
1399
|
+
owed = debts;
|
|
1400
|
+
await updateOp(ctx, operationId, (cur) => (sameDebt(cur.debt, debts) ? undefined : { ...cur, debt: debts }));
|
|
1401
|
+
};
|
|
1402
|
+
const owing = () => (owed.length ? { debt: owed } : {});
|
|
1403
|
+
for (let n = 0;; n++) {
|
|
1404
|
+
if (n >= S2A_MAX_REDECIDE)
|
|
1405
|
+
held("contention", ctx.step);
|
|
1406
|
+
ctx.step = "V0-read";
|
|
1407
|
+
const asg = await readAssignment(ctx, e, i);
|
|
1408
|
+
if (asg.state === "absent") {
|
|
1409
|
+
const prop = op.projection.proposalId === null ? undefined : await readProposal(ctx, op.projection.proposalId);
|
|
1410
|
+
if (prop?.state !== "value" || prop.row.kind !== "service-proposal" || prop.row.state.state !== "approving" || prop.row.state.assignmentDigest !== a) {
|
|
1411
|
+
const s = await settle(ctx, operationId, { state: "refused", reason: "conflict" }, []);
|
|
1412
|
+
return finalArm(operationId, outcomeOf(s.op), debtOf(s.op));
|
|
1413
|
+
}
|
|
1414
|
+
if (Date.now() >= op.notAfterMs)
|
|
1415
|
+
return { state: "held", operationId, reason: "expired", phase: "V2-fence", assignmentDigest: a, ...owing() };
|
|
1416
|
+
ctx.step = "V2-fence";
|
|
1417
|
+
const fence = { v: 1, kind: "service-assignment", state: "fenced", endpoint: e, instanceId: i, owner: prop.row.owner, proposalId: prop.row.proposalId,
|
|
1418
|
+
assignmentDigest: a, cancel: { operationId, requestDigest, cause } };
|
|
1419
|
+
await put(ctx, assignmentKey(e, i), fence, parseServiceAssignmentRecord);
|
|
1420
|
+
continue;
|
|
1421
|
+
}
|
|
1422
|
+
const row = asg.row;
|
|
1423
|
+
if (row.assignmentDigest !== a) {
|
|
1424
|
+
const s = await settle(ctx, operationId, { state: "refused", reason: "conflict" }, []);
|
|
1425
|
+
return finalArm(operationId, outcomeOf(s.op), debtOf(s.op));
|
|
1426
|
+
}
|
|
1427
|
+
if (row.state === "fenced") {
|
|
1428
|
+
await keepDebt(await v4(ctx, row, undefined));
|
|
1429
|
+
await progress("assignment-fenced");
|
|
1430
|
+
return { state: "pending", operationId, phase: "assignment-fenced", assignmentDigest: a, denyNew: "landed", ...owing() };
|
|
1431
|
+
}
|
|
1432
|
+
if (row.cancel === null) {
|
|
1433
|
+
if (Date.now() >= op.notAfterMs)
|
|
1434
|
+
return { state: "held", operationId, reason: "expired", phase: "V2-cancel", assignmentDigest: a, ...owing() };
|
|
1435
|
+
ctx.step = "V2-cancel";
|
|
1436
|
+
await put(ctx, assignmentKey(e, i), { ...row, cancel: { operationId, requestDigest, cause } }, parseServiceAssignmentRecord, asg);
|
|
1437
|
+
continue;
|
|
1438
|
+
}
|
|
1439
|
+
if (Date.now() >= op.notAfterMs) {
|
|
1440
|
+
// Spent: no further native step under this request; a new revoke adopts the binding.
|
|
1441
|
+
let denyNew = "unknown";
|
|
1442
|
+
try {
|
|
1443
|
+
denyNew = denyNewOf(await endpoint(ctx, serviceAssignmentGate(inv.host, row.reservation)));
|
|
1444
|
+
}
|
|
1445
|
+
catch (err) {
|
|
1446
|
+
if (!(err instanceof Held) && !(err instanceof EpEnvelopeError))
|
|
1447
|
+
throw err;
|
|
1448
|
+
}
|
|
1449
|
+
return { state: "held", operationId, reason: "expired", phase: "spent", assignmentDigest: a, denyNew, ...owing() };
|
|
1450
|
+
}
|
|
1451
|
+
ctx.step = "V3-cancel";
|
|
1452
|
+
const run = await nativeCancel(inv, ctx, row, row.cancel);
|
|
1453
|
+
if (run.denyNew === "landed")
|
|
1454
|
+
await keepDebt(await v4(ctx, row, run.gate));
|
|
1455
|
+
const s1 = run.s1;
|
|
1456
|
+
if (s1.kind === "pending" && run.denyNew === "landed") {
|
|
1457
|
+
await progress("cancelled-nonterminal");
|
|
1458
|
+
return { state: "pending", operationId, phase: "cancelled-nonterminal", assignmentDigest: a, denyNew: "landed", root: s1.root, ...owing() };
|
|
1459
|
+
}
|
|
1460
|
+
if (s1.kind === "uid-unconfirmed" && run.denyNew === "landed") {
|
|
1461
|
+
await progress("uid-unconfirmed");
|
|
1462
|
+
return { state: "pending", operationId, phase: "uid-unconfirmed", assignmentDigest: a, denyNew: "landed", ...owing() };
|
|
1463
|
+
}
|
|
1464
|
+
const reason = s1.kind === "held" ? s1.reason : s1.kind === "seal-refused" ? "cancel-intent-refused" : s1.kind === "pending" || s1.kind === "uid-unconfirmed" ? "deny-new-unconfirmed" : s1.kind;
|
|
1465
|
+
const phase = s1.kind === "held" ? s1.step : s1.kind === "uid-foreign" ? "U-uid" : "V3-cancel";
|
|
1466
|
+
return { state: "held", operationId, reason, phase, assignmentDigest: a, denyNew: run.denyNew, ...owing() };
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
async function operationRead(req) {
|
|
1471
|
+
try {
|
|
1472
|
+
const ctx = discoveryCtx("operation-read");
|
|
1473
|
+
const op = await readOp(ctx, req.input.operationId);
|
|
1474
|
+
if (op.state === "absent")
|
|
1475
|
+
return { state: "absent" };
|
|
1476
|
+
const g = req.granter, o = op.row;
|
|
1477
|
+
if (g.kind === "owner" && !((o.granter.kind === "owner" && o.granter.owner === g.owner) || o.projection.subjectOwner === g.owner))
|
|
1478
|
+
return { state: "absent" };
|
|
1479
|
+
const projection = { ...o.projection };
|
|
1480
|
+
if (o.kind === "service-host-operation-terminal")
|
|
1481
|
+
return { state: "present", record: { value: { operationId: o.operationId, requestDigest: o.requestDigest, operation: o.operation, projection, outcome: finalView(o.outcome) }, revision: op.revision } };
|
|
1482
|
+
const p = o.plan, assignmentDigest = p.kind === "approve" ? serviceAssignmentGate(discovery.host, p.reservation).binding.assignmentDigest : p.kind === "revoke" ? p.expectedAssignmentDigest : null;
|
|
1483
|
+
const g2 = o.progress;
|
|
1484
|
+
const progress = g2.state === "admitted" ? { state: "pending", phase: "admitted" } : g2.state === "custody" ? { state: "pending", phase: g2.step }
|
|
1485
|
+
: g2.state === "held" ? { state: "held", reason: g2.reason, phase: g2.phase } : finalView(g2);
|
|
1486
|
+
return { state: "present", record: { value: { operationId: o.operationId, requestDigest: o.requestDigest, operation: o.operation,
|
|
1487
|
+
target: { kind: "service", endpoint: o.projection.endpoint, instanceId: o.projection.instanceId, assignmentDigest }, projection, progress, debt: o.debt.map((d) => ({ ...d })) }, revision: op.revision } };
|
|
1488
|
+
}
|
|
1489
|
+
catch (err) {
|
|
1490
|
+
if (err instanceof Held)
|
|
1491
|
+
return denied("unavailable");
|
|
1492
|
+
throw err;
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
const finalView = (f) => f.state === "complete" ? { state: "complete", resultDigest: contractDigest(f.result) } : f.state === "refused" ? { state: "refused", reason: f.reason } : { state: "superseded", boundOperationId: f.boundOperationId };
|
|
1496
|
+
async function proposalRead(req) {
|
|
1497
|
+
try {
|
|
1498
|
+
const ctx = discoveryCtx("proposal-read");
|
|
1499
|
+
const prop = await readProposal(ctx, req.input.proposalId);
|
|
1500
|
+
if (prop.state === "absent" || (req.granter.kind === "owner" && prop.row.owner !== req.granter.owner))
|
|
1501
|
+
return { state: "absent" };
|
|
1502
|
+
const p = prop.row;
|
|
1503
|
+
if (p.kind === "service-proposal-terminal")
|
|
1504
|
+
return { state: "present", record: { value: view(p), revision: prop.revision } };
|
|
1505
|
+
const name = await nameOf(ctx, p.endpoint);
|
|
1506
|
+
return { state: "present", record: { value: { proposalId: p.proposalId, proposalDigest: p.proposalDigest, owner: p.owner, endpoint: p.endpoint, instanceId: p.instanceId,
|
|
1507
|
+
state: p.state.state, createdAt: seconds(p.createdAtMs), expiresAt: seconds(p.expiresAtMs), clusterDigests: [...p.clusterDigests], commands: structuredClone(p.commands),
|
|
1508
|
+
nameBinding: name ?? null, decision: p.state.state === "approving" ? { operationId: p.state.operationId, assignmentDigest: p.state.assignmentDigest } : null }, revision: prop.revision } };
|
|
1509
|
+
}
|
|
1510
|
+
catch (err) {
|
|
1511
|
+
if (err instanceof Held)
|
|
1512
|
+
return denied("unavailable");
|
|
1513
|
+
throw err;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
/** One zero-history create of the endpoint's name row through this invocation's custody host. Only
|
|
1517
|
+
* the identical request recovers an existing row; transfer, release and update do not exist. */
|
|
1518
|
+
async function nameBind(req) {
|
|
1519
|
+
const { endpoint: e, owner: o, expectedRevision, claimReference } = req.input;
|
|
1520
|
+
if (expectedRevision !== 0)
|
|
1521
|
+
return denied("unsupported"); // never binds
|
|
1522
|
+
let key;
|
|
1523
|
+
try {
|
|
1524
|
+
key = serviceNameKey(e);
|
|
1525
|
+
}
|
|
1526
|
+
catch (err) {
|
|
1527
|
+
if (err instanceof EpEnvelopeError && err.code === "permission-denied")
|
|
1528
|
+
return denied("permission-denied");
|
|
1529
|
+
throw err;
|
|
1530
|
+
}
|
|
1531
|
+
const bytes = serviceNameRowBytes({ accountPublicKey, space }, req), requestDigest = contractDigest(req);
|
|
1532
|
+
const operationId = serviceHostOperationId(space, req.requestId);
|
|
1533
|
+
try {
|
|
1534
|
+
return await invoke({ operation: "name-bind", operationId: null, proposalId: null, owner: null, endpoint: e, instanceId: null, lifecycleUid: null }, async (inv) => {
|
|
1535
|
+
inv.operationId = operationId;
|
|
1536
|
+
try {
|
|
1537
|
+
const ctx = readCtx(inv, "N-read");
|
|
1538
|
+
for (let n = 0;; n++) {
|
|
1539
|
+
if (n >= S2A_MAX_REDECIDE)
|
|
1540
|
+
held("contention", ctx.step);
|
|
1541
|
+
const found = await nameOf(ctx, e);
|
|
1542
|
+
if (found !== undefined) {
|
|
1543
|
+
if (found.requestId === req.requestId && found.requestDigest === requestDigest && found.owner === o && found.claimReference === claimReference)
|
|
1544
|
+
return { state: "bound", binding: found };
|
|
1545
|
+
return denied(found.requestId === req.requestId ? "conflict" : "name-bound");
|
|
1546
|
+
}
|
|
1547
|
+
ctx.step = "N-create";
|
|
1548
|
+
await write(ctx, "auth", key, bytes, { create: true });
|
|
1549
|
+
ctx.step = "N-read";
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
catch (err) {
|
|
1553
|
+
if (err instanceof Held)
|
|
1554
|
+
return heldArm(inv, err);
|
|
1555
|
+
throw err;
|
|
1556
|
+
}
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
catch (err) {
|
|
1560
|
+
if (err instanceof Held)
|
|
1561
|
+
return unread(req.requestId, err.reason, err.step);
|
|
1562
|
+
throw err;
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
const deniedServe = (reason) => ({ state: "denied", reason });
|
|
1566
|
+
/** The owner's own assignment at a selector; another owner's reads absent. */
|
|
1567
|
+
async function bindingOf(ctx, o, e, i) {
|
|
1568
|
+
const asg = await readAssignment(ctx, e, i);
|
|
1569
|
+
if (asg.state === "absent" || asg.row.owner !== o)
|
|
1570
|
+
return { state: "absent" };
|
|
1571
|
+
if (asg.row.state === "fenced")
|
|
1572
|
+
return { state: "fenced", row: asg.row };
|
|
1573
|
+
return { state: "bound", row: asg.row, ...serviceAssignmentGate(discovery.host, asg.row.reservation) };
|
|
1574
|
+
}
|
|
1575
|
+
/** A witness is a public coordinate: it must name this selector, owner, actor and lifecycle. */
|
|
1576
|
+
const namesBinding = (w, b) => namesSelector(w, b) && w.principal.actor === serviceEndpointActor(b) && w.principal.lifecycleUid === b.lifecycleUid;
|
|
1577
|
+
/** The part of {@link namesBinding} a selector alone decides, before anything is read. */
|
|
1578
|
+
const namesSelector = (w, s) => w.accountPublicKey === accountPublicKey && w.space === space && w.endpoint === s.endpoint && w.instanceId === s.instanceId && w.principal.owner === s.owner;
|
|
1579
|
+
/** The witness of a gate's current registration stamp, or undefined when the gate has none. A stamp
|
|
1580
|
+
* whose coordinates disagree with the gate's is inconsistent and holds. */
|
|
1581
|
+
function stampWitness(ctx, b, row) {
|
|
1582
|
+
const a = row.serviceAuthority, stamp = a.registration;
|
|
1583
|
+
if (stamp === undefined)
|
|
1584
|
+
return undefined;
|
|
1585
|
+
if (a.rootCredentialId === null || a.approvalDigest === null || stamp.processEpoch !== row.processEpoch
|
|
1586
|
+
|| stamp.registrationRevision !== row.registrationRevision || stamp.nameAuthorityRevision !== row.nameAuthorityRevision)
|
|
1587
|
+
return held("registration-inconsistent", ctx.step, "the registration stamp disagrees with its endpoint gate");
|
|
1588
|
+
return { accountPublicKey, space, endpoint: b.endpoint, instanceId: b.instanceId,
|
|
1589
|
+
principal: { owner: b.owner, actor: serviceEndpointActor(b), lifecycleUid: b.lifecycleUid },
|
|
1590
|
+
rootCredentialId: a.rootCredentialId, approvalDigest: a.approvalDigest, processEpoch: stamp.processEpoch,
|
|
1591
|
+
registrationRevision: stamp.registrationRevision, nameAuthorityRevision: stamp.nameAuthorityRevision,
|
|
1592
|
+
registrationRequestId: stamp.requestId, registrationProof: stamp.registrationProof };
|
|
1593
|
+
}
|
|
1594
|
+
/** The closures' manifest and root documents, each verified against its digest. */
|
|
1595
|
+
async function closureArtifacts(ctx, clusterDigests) {
|
|
1596
|
+
const reader = opts.serving?.readContractArtifact;
|
|
1597
|
+
if (reader === undefined)
|
|
1598
|
+
return held("unavailable", ctx.step, "this host has no contract store reader");
|
|
1599
|
+
const get = async (d) => {
|
|
1600
|
+
const r = await bounded(ctx, () => reader(d));
|
|
1601
|
+
if (!r.ok)
|
|
1602
|
+
return held("unavailable", ctx.step, "timedOut" in r ? `the contract artifact ${d} read timed out` : message(r.error));
|
|
1603
|
+
return r.value;
|
|
1604
|
+
};
|
|
1605
|
+
const out = [];
|
|
1606
|
+
for (const closure of clusterDigests) {
|
|
1607
|
+
const manifest = await get(closure);
|
|
1608
|
+
const document = await grammarHeld(ctx, async () => { const { root } = verifyClusterManifest(closure, manifest); const raw = await get(root); verifyClusterRoot(root, raw); return raw; });
|
|
1609
|
+
out.push(manifest, document);
|
|
1610
|
+
}
|
|
1611
|
+
return out;
|
|
1612
|
+
}
|
|
1613
|
+
/** A read that its own parser or verifier refuses holds as inconsistent, never as absence. */
|
|
1614
|
+
async function grammarHeld(ctx, f) {
|
|
1615
|
+
try {
|
|
1616
|
+
return await f();
|
|
1617
|
+
}
|
|
1618
|
+
catch (e) {
|
|
1619
|
+
if (e instanceof Held)
|
|
1620
|
+
throw e;
|
|
1621
|
+
return held("registration-inconsistent", ctx.step, message(e));
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
/** The registered spec at the witness revision, the name revision and the closures' artifacts. */
|
|
1625
|
+
async function snapshotOf(ctx, w, artifacts) {
|
|
1626
|
+
const cur = await leader(ctx, "records", recordSpecKey(RECORD_KINDS.svc, [w.endpoint, w.instanceId]));
|
|
1627
|
+
if (cur.state !== "value" || cur.revision !== w.registrationRevision)
|
|
1628
|
+
return held("registration-inconsistent", ctx.step, "the registered spec is not readable at the witness revision");
|
|
1629
|
+
const spec = await grammarHeld(ctx, () => parseServiceSpec(cur.value, { endpoint: w.endpoint }));
|
|
1630
|
+
if (spec.owner !== w.principal.owner)
|
|
1631
|
+
return held("registration-inconsistent", ctx.step, "the registered spec names another owner");
|
|
1632
|
+
return { spec: { value: spec, revision: cur.revision }, name: { authorized: true, revision: w.nameAuthorityRevision }, processEpoch: w.processEpoch,
|
|
1633
|
+
contractArtifacts: artifacts ?? await closureArtifacts(ctx, spec.clusterDigests) };
|
|
1634
|
+
}
|
|
1635
|
+
/** The service-reported ready status at the witness's epoch and spec revision, if recorded. */
|
|
1636
|
+
async function reportedAt(ctx, w) {
|
|
1637
|
+
const cur = await leader(ctx, "records", recordStatusKey(RECORD_KINDS.svc, [w.endpoint, w.instanceId]));
|
|
1638
|
+
if (cur.state === "absent" || cur.state === "marker")
|
|
1639
|
+
return undefined;
|
|
1640
|
+
if (cur.state === "malformed")
|
|
1641
|
+
return held("registration-inconsistent", ctx.step, "the service status is not JSON");
|
|
1642
|
+
const status = await grammarHeld(ctx, () => parseServiceStatus(cur.value));
|
|
1643
|
+
const at = status.reportedAt;
|
|
1644
|
+
return status.state === "ready" && status.epoch === w.processEpoch && status.observedSpecRevision === w.registrationRevision
|
|
1645
|
+
&& typeof at === "number" && Number.isSafeInteger(at) && at >= 0 ? at : undefined;
|
|
1646
|
+
}
|
|
1647
|
+
/** The live native root's expiry in Unix seconds; a refused proof is a definite refusal. */
|
|
1648
|
+
async function rootExpiresAt(ctx, inv, b, rootCredentialId) {
|
|
1649
|
+
const r = await bounded(ctx, () => readRegistryServiceRoot(inv.h.registry, b, rootCredentialId, Date.now()));
|
|
1650
|
+
if (r.ok)
|
|
1651
|
+
return r.value.expiresAt;
|
|
1652
|
+
if ("error" in r && r.error instanceof EpEnvelopeError && r.error.code === "permission-denied")
|
|
1653
|
+
return deniedServe("root-revoked");
|
|
1654
|
+
return held("unavailable", ctx.step, "timedOut" in r ? "the native root read timed out" : message(r.error));
|
|
1655
|
+
}
|
|
1656
|
+
/** Run one native multi-request step to completion or the invocation's original deadline, which
|
|
1657
|
+
* ends the step held; the step's own later requests then fail on the closed connection. */
|
|
1658
|
+
async function native(ctx, start) {
|
|
1659
|
+
const remaining = ctx.deadlineMs - Date.now();
|
|
1660
|
+
if (remaining <= 0)
|
|
1661
|
+
return held("deadline", ctx.step, "the original deadline has passed; no request starts");
|
|
1662
|
+
let timer;
|
|
1663
|
+
const timeout = new Promise((r) => { timer = setTimeout(() => r("timeout"), remaining); });
|
|
1664
|
+
const settled = start().then((value) => ({ value }), (error) => ({ error }));
|
|
1665
|
+
let r;
|
|
1666
|
+
try {
|
|
1667
|
+
r = await Promise.race([settled, timeout]);
|
|
1668
|
+
}
|
|
1669
|
+
finally {
|
|
1670
|
+
clearTimeout(timer);
|
|
1671
|
+
}
|
|
1672
|
+
if (r === "timeout")
|
|
1673
|
+
return held("deadline", ctx.step, "the original deadline was reached during a native step");
|
|
1674
|
+
if ("error" in r)
|
|
1675
|
+
return held("unknown", ctx.step, `the native step did not complete: ${message(r.error)}`);
|
|
1676
|
+
return r.value;
|
|
1677
|
+
}
|
|
1678
|
+
/** Classify the assignment's endpoint gate; anything but an approved or revoked service gate is
|
|
1679
|
+
* inconsistent for a bound selector past approval. */
|
|
1680
|
+
async function servedGate(ctx, b) {
|
|
1681
|
+
const g = await endpoint(ctx, b);
|
|
1682
|
+
if (g.kind !== "approved" && g.kind !== "revoked")
|
|
1683
|
+
return held("registration-inconsistent", ctx.step, `the endpoint gate is ${g.kind}`);
|
|
1684
|
+
return g;
|
|
1685
|
+
}
|
|
1686
|
+
const sameIntent = (a, b) => canonicalJson({ ...a, admittedAtMs: 0 }) === canonicalJson({ ...b, admittedAtMs: 0 });
|
|
1687
|
+
/** A register key the proposal phase already admitted stays bound to that admission's body and
|
|
1688
|
+
* proposal: a changed body is a conflict, and a key of another proposal answers that proposal's
|
|
1689
|
+
* own refusal, or a conflict. */
|
|
1690
|
+
async function keyHistory(ctx, req, b, id) {
|
|
1691
|
+
ctx.step = "S-key";
|
|
1692
|
+
const op = await readOp(ctx, id);
|
|
1693
|
+
if (op.state === "absent")
|
|
1694
|
+
return undefined;
|
|
1695
|
+
const r = req.register;
|
|
1696
|
+
const admitted = serviceRegisterHostRequest({ owner: req.owner, endpoint: req.endpoint, instanceId: req.instanceId, engineRequestId: req.engineRequestId,
|
|
1697
|
+
requestId: req.requestId, requestDigest: req.requestDigest, clusterDigests: r.clusterDigests, commands: r.commands });
|
|
1698
|
+
if (op.row.operation !== "register" || op.row.requestDigest !== requestDigestOf(admitted))
|
|
1699
|
+
return deniedServe("conflict");
|
|
1700
|
+
const proposalId = op.row.projection.proposalId;
|
|
1701
|
+
if (proposalId === b.row.proposalId)
|
|
1702
|
+
return undefined;
|
|
1703
|
+
const p = proposalId === null ? undefined : await readProposal(ctx, proposalId);
|
|
1704
|
+
if (p?.state === "value") {
|
|
1705
|
+
if (p.row.kind === "service-proposal-terminal" && (p.row.state === "refused" || p.row.state === "expired"))
|
|
1706
|
+
return deniedServe(p.row.state === "expired" ? "proposal-expired" : "proposal-refused");
|
|
1707
|
+
if (p.row.kind === "service-proposal" && p.row.state.state === "proposed" && Date.now() >= p.row.expiresAtMs)
|
|
1708
|
+
return deniedServe("proposal-expired");
|
|
1709
|
+
}
|
|
1710
|
+
return deniedServe("conflict");
|
|
1711
|
+
}
|
|
1712
|
+
/** R1 §A: a new admission's key history and state check, read before anything is written. */
|
|
1713
|
+
async function admissible(ctx, req, b, id) {
|
|
1714
|
+
if (req.operation === "register") {
|
|
1715
|
+
const k = await keyHistory(ctx, req, b, id);
|
|
1716
|
+
if (k !== undefined)
|
|
1717
|
+
return k;
|
|
1718
|
+
}
|
|
1719
|
+
ctx.step = "S-state";
|
|
1720
|
+
if (b.row.cancel !== null)
|
|
1721
|
+
return deniedServe("approval-revoked");
|
|
1722
|
+
const g = await servedGate(ctx, b);
|
|
1723
|
+
if (g.kind === "revoked")
|
|
1724
|
+
return deniedServe("approval-revoked");
|
|
1725
|
+
const a = g.row.serviceAuthority, current = stampWitness(ctx, b.binding, g.row);
|
|
1726
|
+
if (req.operation === "register") {
|
|
1727
|
+
if (a.activationIssued)
|
|
1728
|
+
return deniedServe("registration-active");
|
|
1729
|
+
const p = await readProposal(ctx, b.row.proposalId);
|
|
1730
|
+
if (p.state !== "value" || p.row.kind !== "service-proposal-terminal" || p.row.state !== "approved" || p.row.approvalDigest !== a.approvalDigest)
|
|
1731
|
+
return held("registration-inconsistent", ctx.step, "the assignment's approved proposal is not readable");
|
|
1732
|
+
const r = req.register;
|
|
1733
|
+
if (proposalBodyDigest({ owner: b.binding.owner, endpoint: b.binding.endpoint, instanceId: b.binding.instanceId }, r.clusterDigests, r.commands) !== p.row.proposalDigest)
|
|
1734
|
+
return deniedServe("approval-mismatch");
|
|
1735
|
+
const expected = req.witness === null ? undefined : serviceWitnessDigest(req.witness);
|
|
1736
|
+
if ((current === undefined) !== (expected === undefined) || (current !== undefined && serviceWitnessDigest(current) !== expected))
|
|
1737
|
+
return deniedServe("conflict");
|
|
1738
|
+
return undefined;
|
|
1739
|
+
}
|
|
1740
|
+
if (current === undefined || serviceWitnessDigest(current) !== serviceWitnessDigest(req.witness))
|
|
1741
|
+
return deniedServe("stale-witness");
|
|
1742
|
+
return undefined;
|
|
1743
|
+
}
|
|
1744
|
+
/** Append the request to its instance's index, or find it listed. */
|
|
1745
|
+
async function listIntent(ctx, b, id, operation) {
|
|
1746
|
+
ctx.step = "S-index";
|
|
1747
|
+
const { owner: o, endpoint: e, instanceId: i } = b.binding;
|
|
1748
|
+
for (let n = 0; n < S2A_MAX_REDECIDE; n++) {
|
|
1749
|
+
const cur = await readIntentIndex(ctx, e, i);
|
|
1750
|
+
if (cur.state === "value") {
|
|
1751
|
+
if (cur.row.owner !== o || cur.row.endpoint !== e || cur.row.instanceId !== i)
|
|
1752
|
+
return held("intent-index-inconsistent", ctx.step, "the intent index names another assignment");
|
|
1753
|
+
if (cur.row.intents.some((x) => x.operationId === id))
|
|
1754
|
+
return undefined;
|
|
1755
|
+
if (cur.row.intents.length >= S3_MAX_INTENTS_PER_INSTANCE)
|
|
1756
|
+
return deniedServe("resource-exhausted");
|
|
1757
|
+
}
|
|
1758
|
+
const intents = [...(cur.state === "value" ? cur.row.intents : []), { operationId: id, operation }]
|
|
1759
|
+
.sort((x, y) => (x.operationId < y.operationId ? -1 : 1));
|
|
1760
|
+
const row = { v: 1, kind: "service-request-intent-index", owner: o, endpoint: e, instanceId: i, intents };
|
|
1761
|
+
if ((await put(ctx, intentIndexKey(e, i), row, parseServiceRequestIntentIndexRecord, cur.state === "value" ? cur : undefined)) !== "moved")
|
|
1762
|
+
return undefined;
|
|
1763
|
+
}
|
|
1764
|
+
return held("contention", ctx.step);
|
|
1765
|
+
}
|
|
1766
|
+
async function serve(req) {
|
|
1767
|
+
if (tripped !== undefined || closed)
|
|
1768
|
+
return { state: "unavailable", detail: "the service host is fenced or closing" };
|
|
1769
|
+
const { owner: o, endpoint: e, instanceId: i } = req;
|
|
1770
|
+
if (req.caller === "owner" && !interactive(o))
|
|
1771
|
+
return deniedServe("permission-denied");
|
|
1772
|
+
const id = serviceHostOperationId(space, req.engineRequestId);
|
|
1773
|
+
let b;
|
|
1774
|
+
try {
|
|
1775
|
+
b = await bindingOf(discoveryCtx("S-binding"), o, e, i);
|
|
1776
|
+
}
|
|
1777
|
+
catch (err) {
|
|
1778
|
+
if (err instanceof Held)
|
|
1779
|
+
return { state: "unavailable", detail: `the assignment is not readable (${err.reason})` };
|
|
1780
|
+
throw err;
|
|
1781
|
+
}
|
|
1782
|
+
if (b.state === "absent")
|
|
1783
|
+
return deniedServe("permission-denied");
|
|
1784
|
+
if (b.state === "fenced")
|
|
1785
|
+
return deniedServe("approval-revoked");
|
|
1786
|
+
if (req.witness !== null && !namesBinding(req.witness, b.binding))
|
|
1787
|
+
return deniedServe("permission-denied");
|
|
1788
|
+
const intent = { v: 1, kind: "service-request-intent", operationId: id, operation: req.operation, account: { accountPublicKey, space },
|
|
1789
|
+
owner: o, endpoint: e, instanceId: i, requestId: req.requestId, requestDigest: req.requestDigest,
|
|
1790
|
+
registrationRequestId: req.witness?.registrationRequestId ?? null, witnessDigest: req.witness === null ? null : serviceWitnessDigest(req.witness),
|
|
1791
|
+
admittedAtMs: Date.now() };
|
|
1792
|
+
const contract = req.register?.artifacts.map((a) => contractDigest(a).slice("sha256:".length)) ?? [];
|
|
1793
|
+
const bound = b;
|
|
1794
|
+
try {
|
|
1795
|
+
return await invoke({ operation: "serve", serve: req.operation, operationId: id, proposalId: null, owner: o, endpoint: e, instanceId: i,
|
|
1796
|
+
lifecycleUid: b.binding.lifecycleUid, contract }, async (inv) => {
|
|
1797
|
+
const ctx = readCtx(inv, "S-intent");
|
|
1798
|
+
const found = await readIntent(ctx, id);
|
|
1799
|
+
if (found.state === "value") {
|
|
1800
|
+
if (!sameIntent(found.row, intent))
|
|
1801
|
+
return deniedServe("conflict");
|
|
1802
|
+
}
|
|
1803
|
+
else {
|
|
1804
|
+
const refusal = (await admissible(ctx, req, bound, id)) ?? (await listIntent(ctx, bound, id, req.operation));
|
|
1805
|
+
if (refusal !== undefined)
|
|
1806
|
+
return refusal;
|
|
1807
|
+
ctx.step = "S-intent-create";
|
|
1808
|
+
if ((await put(ctx, intentKey(id), intent, parseServiceRequestIntentRecord)) === "moved") {
|
|
1809
|
+
const again = await readIntent(ctx, id);
|
|
1810
|
+
if (again.state !== "value")
|
|
1811
|
+
return held("unknown", ctx.step, "the intent create lost but no intent is readable");
|
|
1812
|
+
if (!sameIntent(again.row, intent))
|
|
1813
|
+
return deniedServe("conflict");
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
switch (req.operation) {
|
|
1817
|
+
case "register": return registerEffect(ctx, inv, req, bound, id);
|
|
1818
|
+
case "activate":
|
|
1819
|
+
case "renew": return credentialEffect(ctx, inv, req, bound);
|
|
1820
|
+
case "ready": return readyEffect(ctx, inv, req, bound);
|
|
1821
|
+
}
|
|
1822
|
+
});
|
|
1823
|
+
}
|
|
1824
|
+
catch (err) {
|
|
1825
|
+
if (err instanceof Held)
|
|
1826
|
+
return { state: "unavailable", detail: `${req.operation} held (${err.reason} at ${err.step})` };
|
|
1827
|
+
throw err;
|
|
1828
|
+
}
|
|
1829
|
+
}
|
|
1830
|
+
/** Same-request recovery first, from the stamp alone; otherwise publish, barrier, spec and stamp. */
|
|
1831
|
+
async function registerEffect(ctx, inv, req, b, id) {
|
|
1832
|
+
ctx.step = "R-gate";
|
|
1833
|
+
const r = req.register;
|
|
1834
|
+
const stampedHere = (row) => {
|
|
1835
|
+
const stamp = row.serviceAuthority.registration;
|
|
1836
|
+
return stamp?.requestId === req.requestId && stamp.requestDigest === req.requestDigest;
|
|
1837
|
+
};
|
|
1838
|
+
const frozenHere = (row) => {
|
|
1839
|
+
const pending = row.serviceAuthority.pendingRegistration;
|
|
1840
|
+
return row.state === "frozen" && pending?.operationId === id && pending.requestId === req.requestId && pending.requestDigest === req.requestDigest;
|
|
1841
|
+
};
|
|
1842
|
+
const recovered = async (g) => {
|
|
1843
|
+
if (!stampedHere(g.row))
|
|
1844
|
+
return undefined;
|
|
1845
|
+
const witness = stampWitness(ctx, b.binding, g.row);
|
|
1846
|
+
return { state: "registered", witness, snapshot: await snapshotOf(ctx, witness, r.artifacts) };
|
|
1847
|
+
};
|
|
1848
|
+
// Revocation answers first, as status does; then a completed same request recovers from its
|
|
1849
|
+
// stamp before any expected or activation check, with no barrier and no epoch advance.
|
|
1850
|
+
const g = await servedGate(ctx, b);
|
|
1851
|
+
if (g.kind === "revoked" || b.row.cancel !== null)
|
|
1852
|
+
return deniedServe("approval-revoked");
|
|
1853
|
+
const done = await recovered(g);
|
|
1854
|
+
if (done !== undefined)
|
|
1855
|
+
return done;
|
|
1856
|
+
const a = g.row.serviceAuthority;
|
|
1857
|
+
if (a.activationIssued)
|
|
1858
|
+
return deniedServe("registration-active");
|
|
1859
|
+
const resuming = frozenHere(g.row);
|
|
1860
|
+
const expected = req.witness === null ? undefined : serviceWitnessDigest(req.witness);
|
|
1861
|
+
const expectationHolds = (row) => {
|
|
1862
|
+
const current = stampWitness(ctx, b.binding, row);
|
|
1863
|
+
return current === undefined ? expected === undefined : serviceWitnessDigest(current) === expected;
|
|
1864
|
+
};
|
|
1865
|
+
if (!resuming) {
|
|
1866
|
+
if (g.row.state !== "open")
|
|
1867
|
+
return deniedServe("registration-pending");
|
|
1868
|
+
if (!expectationHolds(g.row))
|
|
1869
|
+
return deniedServe("conflict");
|
|
1870
|
+
if (!serviceGateApprovalLive(a, seconds(Date.now())))
|
|
1871
|
+
return deniedServe("expired");
|
|
1872
|
+
}
|
|
1873
|
+
if (inv.contract === undefined)
|
|
1874
|
+
return held("unavailable", ctx.step, "the effect connection has no contract store");
|
|
1875
|
+
ctx.step = "R-publish";
|
|
1876
|
+
for (const artifact of r.artifacts) {
|
|
1877
|
+
await readyBefore(ctx);
|
|
1878
|
+
const p = await bounded(ctx, () => publishContractArtifact(inv.contract, contractArtifactCanonicalBytes(artifact)));
|
|
1879
|
+
if (!p.ok)
|
|
1880
|
+
return held("unknown", ctx.step, "timedOut" in p ? "a contract artifact publish timed out" : message(p.error));
|
|
1881
|
+
}
|
|
1882
|
+
ctx.step = "R-register";
|
|
1883
|
+
const stores = registryStores(inv.h.registry), artifacts = new Map(r.artifacts.map((v) => [contractDigest(v), v]));
|
|
1884
|
+
// A service family is empty before activation, so the barrier keeps no repair cursor.
|
|
1885
|
+
const { progress: _progress, ...observed } = endpointRegistrationBarrier(stores.authKv, space, { endpoint: b.binding.endpoint, instanceId: b.binding.instanceId, opId: id, serviceRegistration: { requestId: req.requestId, requestDigest: req.requestDigest } });
|
|
1886
|
+
void _progress;
|
|
1887
|
+
// The barrier freezes the gate revision it observes after the publishes above, whenever it does
|
|
1888
|
+
// not find the gate frozen by this operation: also for a request that read it frozen and finds
|
|
1889
|
+
// it reopened. The checks above ran on an earlier read, so they are made again on exactly the
|
|
1890
|
+
// revision being frozen, and nothing is frozen or written unless they hold. A stamp that already
|
|
1891
|
+
// names this request was completed by another invocation of it and answers as a replay does; a
|
|
1892
|
+
// freeze of this operation with no stamp yet is that invocation still running, so this one holds.
|
|
1893
|
+
let decided;
|
|
1894
|
+
const barrier = { ...observed, freeze: async (revision) => {
|
|
1895
|
+
const now = await servedGate(ctx, b);
|
|
1896
|
+
if (now.kind === "revoked")
|
|
1897
|
+
decided = "approval-revoked";
|
|
1898
|
+
else if (stampedHere(now.row))
|
|
1899
|
+
decided = "stamped";
|
|
1900
|
+
else if (frozenHere(now.row))
|
|
1901
|
+
decided = "running";
|
|
1902
|
+
else if (now.revision !== revision || now.row.state !== "open" || !expectationHolds(now.row))
|
|
1903
|
+
decided = "conflict";
|
|
1904
|
+
return decided === undefined ? await observed.freeze(revision) : null;
|
|
1905
|
+
} };
|
|
1906
|
+
await readyBefore(ctx);
|
|
1907
|
+
try {
|
|
1908
|
+
await native(ctx, () => registerServiceInstance(stores.recordsKv, { space, instanceId: b.binding.instanceId, registrant: { owner: b.binding.owner },
|
|
1909
|
+
spec: { endpoint: b.binding.endpoint, owner: b.binding.owner, clusterDigests: [...r.clusterDigests], protocol: { v: 1 } },
|
|
1910
|
+
authority: nativeServiceNameAuthority(inv.h.registry, { space, accountPublicKey }), barrier, readClusterArtifact: (d) => artifacts.get(d) }));
|
|
1911
|
+
}
|
|
1912
|
+
catch (err) {
|
|
1913
|
+
if (decided === undefined || !(err instanceof Held))
|
|
1914
|
+
throw err;
|
|
1915
|
+
if (decided === "running")
|
|
1916
|
+
return held("unknown", ctx.step, "another invocation of this request froze the gate and has not completed");
|
|
1917
|
+
if (decided !== "stamped")
|
|
1918
|
+
return deniedServe(decided);
|
|
1919
|
+
ctx.step = "R-confirm";
|
|
1920
|
+
const now = await servedGate(ctx, b);
|
|
1921
|
+
if (now.kind === "revoked")
|
|
1922
|
+
return deniedServe("approval-revoked");
|
|
1923
|
+
return (await recovered(now)) ?? held("unknown", ctx.step, "this request's registration was replaced while it was confirmed");
|
|
1924
|
+
}
|
|
1925
|
+
await readyAfter(ctx);
|
|
1926
|
+
ctx.step = "R-confirm";
|
|
1927
|
+
const after = await recovered(await servedGate(ctx, b));
|
|
1928
|
+
return after ?? held("unknown", ctx.step, "the registration completed but its stamp does not name this request");
|
|
1929
|
+
}
|
|
1930
|
+
/** activate: the monotonic activation CAS at the current witness; renew: the same checks with no
|
|
1931
|
+
* write. Either returns the bounds fresh material must keep; no material is minted here. */
|
|
1932
|
+
async function credentialEffect(ctx, inv, req, b) {
|
|
1933
|
+
for (let n = 0; n < S2A_MAX_REDECIDE; n++) {
|
|
1934
|
+
ctx.step = req.operation === "activate" ? "A-gate" : "W-gate";
|
|
1935
|
+
const g = await servedGate(ctx, b);
|
|
1936
|
+
if (g.kind === "revoked" || b.row.cancel !== null)
|
|
1937
|
+
return deniedServe("approval-revoked");
|
|
1938
|
+
const a = g.row.serviceAuthority, current = stampWitness(ctx, b.binding, g.row);
|
|
1939
|
+
if (current === undefined || serviceWitnessDigest(current) !== serviceWitnessDigest(req.witness))
|
|
1940
|
+
return deniedServe("stale-witness");
|
|
1941
|
+
if (g.row.state !== "open")
|
|
1942
|
+
return deniedServe("registration-pending");
|
|
1943
|
+
if (!serviceGateApprovalLive(a, seconds(Date.now())))
|
|
1944
|
+
return deniedServe("expired");
|
|
1945
|
+
const root = await rootExpiresAt(ctx, inv, b.binding, current.rootCredentialId);
|
|
1946
|
+
if (typeof root !== "number")
|
|
1947
|
+
return root;
|
|
1948
|
+
if (!a.activationIssued) {
|
|
1949
|
+
if (req.operation === "renew")
|
|
1950
|
+
return deniedServe("stale-witness");
|
|
1951
|
+
ctx.step = "A-activate";
|
|
1952
|
+
const next = await grammarHeld(ctx, () => activatedServiceGateRow(g.row, b.binding, current, Date.now()));
|
|
1953
|
+
if (next !== undefined) {
|
|
1954
|
+
const bytes = enc.encode(JSON.stringify(next));
|
|
1955
|
+
await grammarHeld(ctx, () => b.epgate.parse(bytes));
|
|
1956
|
+
if ((await write(ctx, "auth", b.epgate.key, bytes, { revision: g.revision, preimage: g.bytes })) === "moved")
|
|
1957
|
+
continue;
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
return { state: "current", witness: current, snapshot: await snapshotOf(ctx, current), rootExpiresAt: root, approvalExpiresAt: a.approvalExpiresAt };
|
|
1961
|
+
}
|
|
1962
|
+
return held("contention", ctx.step);
|
|
1963
|
+
}
|
|
1964
|
+
/** ready: the service-reported status at the current activated witness, fenced on its epoch. */
|
|
1965
|
+
async function readyEffect(ctx, inv, req, b) {
|
|
1966
|
+
ctx.step = "Y-gate";
|
|
1967
|
+
const g = await servedGate(ctx, b);
|
|
1968
|
+
if (g.kind === "revoked" || b.row.cancel !== null)
|
|
1969
|
+
return deniedServe("approval-revoked");
|
|
1970
|
+
const a = g.row.serviceAuthority, current = stampWitness(ctx, b.binding, g.row);
|
|
1971
|
+
if (current === undefined || serviceWitnessDigest(current) !== serviceWitnessDigest(req.witness) || !a.activationIssued)
|
|
1972
|
+
return deniedServe("stale-witness");
|
|
1973
|
+
if (g.row.state !== "open")
|
|
1974
|
+
return deniedServe("registration-pending");
|
|
1975
|
+
if (!serviceGateApprovalLive(a, seconds(Date.now())))
|
|
1976
|
+
return deniedServe("expired");
|
|
1977
|
+
const root = await rootExpiresAt(ctx, inv, b.binding, current.rootCredentialId);
|
|
1978
|
+
if (typeof root !== "number")
|
|
1979
|
+
return root;
|
|
1980
|
+
const name = await nameOf(ctx, b.binding.endpoint);
|
|
1981
|
+
if (name === undefined || name.owner !== b.binding.owner || name.revision !== current.nameAuthorityRevision)
|
|
1982
|
+
return deniedServe("stale-witness");
|
|
1983
|
+
ctx.step = "Y-status";
|
|
1984
|
+
const at = seconds(Date.now()), stores = registryStores(inv.h.registry);
|
|
1985
|
+
await readyBefore(ctx);
|
|
1986
|
+
await native(ctx, () => writeServiceStatus(stores.recordsKv, { endpoint: b.binding.endpoint, instanceId: b.binding.instanceId, epoch: current.processEpoch,
|
|
1987
|
+
status: { epoch: current.processEpoch, state: "ready", observedSpecRevision: current.registrationRevision, reportedAt: at },
|
|
1988
|
+
readProcessEpoch: async () => {
|
|
1989
|
+
const now = await servedGate(ctx, b);
|
|
1990
|
+
if (now.row.state !== "open" || stampWitness(ctx, b.binding, now.row)?.registrationRequestId !== current.registrationRequestId)
|
|
1991
|
+
throw new EpEnvelopeError("conflict", "the registration changed before the ready status was written");
|
|
1992
|
+
return now.row.processEpoch;
|
|
1993
|
+
} }));
|
|
1994
|
+
await readyAfter(ctx);
|
|
1995
|
+
return { state: "ready", witness: current, reportedAt: at };
|
|
1996
|
+
}
|
|
1997
|
+
// ---- serving status (SPEC 13.1 precedence) ------------------------------------------------------
|
|
1998
|
+
async function serveStatus(q) {
|
|
1999
|
+
if (tripped !== undefined || closed)
|
|
2000
|
+
return { kind: "unavailable", detail: "the service host is fenced or closing" };
|
|
2001
|
+
// An owner reads only while it holds an interactive row, as for every owner request; a service
|
|
2002
|
+
// reads only its own selector. Both are refused before any read.
|
|
2003
|
+
if (q.reader.kind === "owner" && !interactive(q.owner))
|
|
2004
|
+
return { kind: "denied", reason: "permission-denied" };
|
|
2005
|
+
if (q.reader.kind === "service" && !namesSelector(q.reader.witness, q))
|
|
2006
|
+
return { kind: "not-found" };
|
|
2007
|
+
try {
|
|
2008
|
+
return await statusOf(discoveryCtx("T-read"), q);
|
|
2009
|
+
}
|
|
2010
|
+
catch (err) {
|
|
2011
|
+
if (err instanceof Held)
|
|
2012
|
+
return { kind: "unavailable", detail: `status held (${err.reason} at ${err.step})` };
|
|
2013
|
+
throw err;
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
2016
|
+
async function statusOf(ctx, q) {
|
|
2017
|
+
const status = (s) => ({ kind: "status", status: s });
|
|
2018
|
+
const unavailable = (detail) => ({ kind: "unavailable", detail });
|
|
2019
|
+
const readerDigest = q.reader.kind === "service" ? serviceWitnessDigest(q.reader.witness) : null;
|
|
2020
|
+
let intent;
|
|
2021
|
+
if (q.target.kind === "operation") {
|
|
2022
|
+
const id = serviceHostOperationId(space, q.target.engineRequestId);
|
|
2023
|
+
const found = await readIntent(ctx, id);
|
|
2024
|
+
if (found.state === "absent") {
|
|
2025
|
+
const idx = await readIntentIndex(ctx, q.endpoint, q.instanceId);
|
|
2026
|
+
if (idx.state === "value" && idx.row.owner === q.owner && idx.row.intents.some((x) => x.operationId === id))
|
|
2027
|
+
return unavailable("the request is listed without its intent; replay it");
|
|
2028
|
+
return q.target.operation === "register" && readerDigest === null ? { kind: "proposal-phase" } : { kind: "not-found" };
|
|
2029
|
+
}
|
|
2030
|
+
intent = found.row;
|
|
2031
|
+
if (intent.owner !== q.owner || (readerDigest !== null && intent.witnessDigest !== readerDigest))
|
|
2032
|
+
return { kind: "not-found" };
|
|
2033
|
+
}
|
|
2034
|
+
const b = await bindingOf(ctx, q.owner, q.endpoint, q.instanceId);
|
|
2035
|
+
if (b.state === "absent")
|
|
2036
|
+
return intent === undefined ? { kind: "not-found" } : unavailable("the intent's assignment is not readable");
|
|
2037
|
+
// A service reader's own incarnation before any precedence rule. A fenced assignment keeps no
|
|
2038
|
+
// lifecycle, so only the selector check above applies to it.
|
|
2039
|
+
if (b.state === "bound" && q.reader.kind === "service" && !namesBinding(q.reader.witness, b.binding))
|
|
2040
|
+
return { kind: "not-found" };
|
|
2041
|
+
// Rule 3: cancellation and revocation first.
|
|
2042
|
+
if (b.state === "fenced")
|
|
2043
|
+
return status({ state: "revoking", operationId: b.row.cancel.operationId, phase: "deny-new-landed" });
|
|
2044
|
+
const g = await endpoint(ctx, b);
|
|
2045
|
+
if (b.row.cancel !== null)
|
|
2046
|
+
return status({ state: "revoking", operationId: b.row.cancel.operationId, phase: g.kind === "revoked" ? "deny-new-landed" : "deny-new-pending" });
|
|
2047
|
+
if (g.kind === "revoked" || g.kind === "foreign")
|
|
2048
|
+
return unavailable(`the endpoint gate is ${g.kind} without a bound cancel`);
|
|
2049
|
+
if (g.kind === "absent" || g.kind === "pending") {
|
|
2050
|
+
if (intent !== undefined)
|
|
2051
|
+
return unavailable("the intent's approval is not readable");
|
|
2052
|
+
return status({ state: "registering", operationId: b.row.creation.operationId, phase: "approving" });
|
|
2053
|
+
}
|
|
2054
|
+
const a = g.row.serviceAuthority, stamp = a.registration, pending = a.pendingRegistration;
|
|
2055
|
+
const current = stampWitness(ctx, b.binding, g.row);
|
|
2056
|
+
const live = async (w) => {
|
|
2057
|
+
const snapshot = await snapshotOf(ctx, w), at = await reportedAt(ctx, w);
|
|
2058
|
+
return status(at === undefined ? { state: "registered", witness: w, snapshot } : { state: "ready", witness: w, snapshot, reportedAt: at });
|
|
2059
|
+
};
|
|
2060
|
+
if (intent === undefined) {
|
|
2061
|
+
// The instance: the reader's own incarnation, or the owner's current state.
|
|
2062
|
+
if (q.reader.kind === "service") {
|
|
2063
|
+
if (stamp === undefined)
|
|
2064
|
+
return unavailable("the reader's registration has no stamp");
|
|
2065
|
+
if (stamp.requestId !== q.reader.witness.registrationRequestId)
|
|
2066
|
+
return status({ state: "superseded", registrationRequestId: q.reader.witness.registrationRequestId, currentRegistrationRequestId: stamp.requestId });
|
|
2067
|
+
}
|
|
2068
|
+
if (g.row.state === "frozen")
|
|
2069
|
+
return pending ? status({ state: "registering", operationId: pending.operationId, phase: "barrier" }) : unavailable("the gate is frozen without a pending registration");
|
|
2070
|
+
if (current === undefined)
|
|
2071
|
+
return status({ state: "approved-unregistered", approvalDigest: a.approvalDigest });
|
|
2072
|
+
if (readerDigest !== null && serviceWitnessDigest(current) !== readerDigest)
|
|
2073
|
+
return unavailable("the reader's witness disagrees with the current stamp");
|
|
2074
|
+
return live(current);
|
|
2075
|
+
}
|
|
2076
|
+
if (intent.operation === "register") {
|
|
2077
|
+
// Rule 5: the permanent answers before the transient ones.
|
|
2078
|
+
if (stamp?.requestId === intent.requestId && stamp.requestDigest === intent.requestDigest)
|
|
2079
|
+
return live(current);
|
|
2080
|
+
if (stamp !== undefined && (stamp.requestId !== intent.registrationRequestId || a.activationIssued))
|
|
2081
|
+
return status({ state: "superseded", registrationRequestId: intent.requestId, currentRegistrationRequestId: stamp.requestId });
|
|
2082
|
+
if (pending?.requestId === intent.requestId && pending.requestDigest === intent.requestDigest)
|
|
2083
|
+
return status({ state: "registering", operationId: intent.operationId, phase: "barrier" });
|
|
2084
|
+
if ((stamp === undefined && intent.registrationRequestId === null) || (stamp?.requestId === intent.registrationRequestId && !a.activationIssued))
|
|
2085
|
+
return status({ state: "registering", operationId: intent.operationId, phase: "admitted" });
|
|
2086
|
+
return unavailable("the register intent matches no registration state");
|
|
2087
|
+
}
|
|
2088
|
+
// Rule 6: activate, renew and ready name a registration.
|
|
2089
|
+
if (stamp === undefined)
|
|
2090
|
+
return unavailable("the intent's registration has no stamp");
|
|
2091
|
+
if (stamp.requestId !== intent.registrationRequestId)
|
|
2092
|
+
return status({ state: "superseded", registrationRequestId: intent.registrationRequestId, currentRegistrationRequestId: stamp.requestId });
|
|
2093
|
+
if (g.row.state === "frozen")
|
|
2094
|
+
return pending ? status({ state: "registering", operationId: pending.operationId, phase: "barrier" }) : unavailable("the gate is frozen without a pending registration");
|
|
2095
|
+
if (current !== undefined && serviceWitnessDigest(current) === intent.witnessDigest)
|
|
2096
|
+
return live(current);
|
|
2097
|
+
return unavailable("the intent's witness disagrees with the current stamp");
|
|
2098
|
+
}
|
|
2099
|
+
/** The callout's assignment: the current bound assignment the witness names, never a guess. */
|
|
2100
|
+
async function serviceBinding(w) {
|
|
2101
|
+
if (tripped !== undefined || closed)
|
|
2102
|
+
throw new EpEnvelopeError("unavailable", "the service host is fenced or closing");
|
|
2103
|
+
const b = await bindingOf(discoveryCtx("C-binding"), w.principal.owner, w.endpoint, w.instanceId);
|
|
2104
|
+
if (b.state !== "bound" || b.row.cancel !== null || !namesBinding(w, b.binding))
|
|
2105
|
+
throw new EpEnvelopeError("permission-denied", "the service witness names no current bound assignment");
|
|
2106
|
+
return { ...b.binding };
|
|
2107
|
+
}
|
|
2108
|
+
// ---- one owner's assignment state (read only) ---------------------------------------------------
|
|
2109
|
+
async function serviceState(o, e, i) {
|
|
2110
|
+
if (tripped !== undefined || closed)
|
|
2111
|
+
return { state: "unavailable" };
|
|
2112
|
+
try {
|
|
2113
|
+
const ctx = discoveryCtx("state-read");
|
|
2114
|
+
const asg = await readAssignment(ctx, e, i);
|
|
2115
|
+
if (asg.state === "absent" || asg.row.owner !== o)
|
|
2116
|
+
return { state: "absent" };
|
|
2117
|
+
const a = asg.row, ids = { proposalId: a.proposalId, assignmentDigest: a.assignmentDigest };
|
|
2118
|
+
if (a.state === "fenced")
|
|
2119
|
+
return { state: "revoking", ...ids, operationId: a.cancel.operationId, denyNew: "landed" };
|
|
2120
|
+
const gate = await endpoint(ctx, serviceAssignmentGate(discovery.host, a.reservation));
|
|
2121
|
+
if (a.cancel !== null)
|
|
2122
|
+
return { state: "revoking", ...ids, operationId: a.cancel.operationId, denyNew: denyNewOf(gate) };
|
|
2123
|
+
const approvalDigest = gate.kind === "approved" ? gate.row.serviceAuthority.approvalDigest : null;
|
|
2124
|
+
if (approvalDigest !== null && gate.kind === "approved")
|
|
2125
|
+
return { state: "approved", ...ids, approvalDigest, registrationRequestId: gate.row.serviceAuthority.registration?.requestId ?? null };
|
|
2126
|
+
if (gate.kind === "absent" || gate.kind === "pending")
|
|
2127
|
+
return { state: "pending-root", ...ids };
|
|
2128
|
+
// A revoked gate without a bound cancel identity, an approved gate without its digest, or another assignment's row.
|
|
2129
|
+
return { state: "unavailable" };
|
|
2130
|
+
}
|
|
2131
|
+
catch (err) {
|
|
2132
|
+
if (err instanceof Held || err instanceof EpEnvelopeError)
|
|
2133
|
+
return { state: "unavailable" };
|
|
2134
|
+
throw err;
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
// ---- dispatch -----------------------------------------------------------------------------------
|
|
2138
|
+
async function handle(raw) {
|
|
2139
|
+
const req = parseServiceHostRequest(raw);
|
|
2140
|
+
const env = { v: 1, kind: "service-endpoint-host", operation: req.operation, requestId: req.requestId, space, accountPublicKey };
|
|
2141
|
+
const out = (r) => ({ ...env, ...r });
|
|
2142
|
+
// The latch comes before the ledger and every broker read. A request that could bind holds under its
|
|
2143
|
+
// operation coordinate, with no deny-new claim because no gate was read; a read never binds.
|
|
2144
|
+
if (tripped !== undefined || closed)
|
|
2145
|
+
return out(req.operation === "decide" || req.operation === "revoke" || req.operation === "name-bind"
|
|
2146
|
+
? unread(req.requestId, "readiness", "open", req.operation === "revoke" ? req.input.expectedAssignmentDigest : undefined) : denied("unavailable"));
|
|
2147
|
+
const authorized = req.granter.kind === "operator" || interactive(req.granter.owner);
|
|
2148
|
+
switch (req.operation) {
|
|
2149
|
+
case "decide": return out(await decide(req, authorized));
|
|
2150
|
+
case "revoke": return out(await revoke(req, authorized));
|
|
2151
|
+
case "name-bind": return out(await nameBind(req));
|
|
2152
|
+
case "operation-read": return out(authorized ? await operationRead(req) : denied("permission-denied"));
|
|
2153
|
+
case "proposal-read": return out(authorized ? await proposalRead(req) : denied("permission-denied"));
|
|
2154
|
+
default: return out(denied("unsupported"));
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
return Object.freeze({
|
|
2158
|
+
space, accountPublicKey,
|
|
2159
|
+
register: (raw) => tracked(register(raw)),
|
|
2160
|
+
handle: (raw) => tracked(handle(raw)),
|
|
2161
|
+
serviceState: (owner, endpoint, instanceId) => tracked(serviceState(owner, endpoint, instanceId)),
|
|
2162
|
+
serve: (request) => tracked(serve(request)),
|
|
2163
|
+
serveStatus: (query) => tracked(serveStatus(query)),
|
|
2164
|
+
serviceBinding: (witness) => tracked(serviceBinding(witness)),
|
|
2165
|
+
refuse() { closed = true; },
|
|
2166
|
+
async close() {
|
|
2167
|
+
closed = true;
|
|
2168
|
+
while (entered.size > 0)
|
|
2169
|
+
await Promise.allSettled([...entered]);
|
|
2170
|
+
},
|
|
2171
|
+
trip(reason) {
|
|
2172
|
+
if (tripped === undefined)
|
|
2173
|
+
tripped = reason;
|
|
2174
|
+
discovery.trip(tripped);
|
|
2175
|
+
for (const t of live)
|
|
2176
|
+
t(tripped);
|
|
2177
|
+
},
|
|
2178
|
+
});
|
|
2179
|
+
}
|
|
2180
|
+
//# sourceMappingURL=service-host-ops.js.map
|