@cotal-ai/core 0.12.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/acls.d.ts +31 -7
- package/dist/acls.d.ts.map +1 -1
- package/dist/acls.js +105 -17
- package/dist/acls.js.map +1 -1
- package/dist/agent-file.js +1 -1
- package/dist/agent-file.js.map +1 -1
- package/dist/auth-provider.d.ts +8 -0
- package/dist/auth-provider.d.ts.map +1 -1
- package/dist/auth-provider.js.map +1 -1
- package/dist/backup.d.ts.map +1 -1
- package/dist/backup.js +6 -4
- package/dist/backup.js.map +1 -1
- package/dist/broker-floor.d.ts +34 -0
- package/dist/broker-floor.d.ts.map +1 -0
- package/dist/broker-floor.js +39 -0
- package/dist/broker-floor.js.map +1 -0
- package/dist/canonical.d.ts +24 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +159 -0
- package/dist/canonical.js.map +1 -0
- package/dist/connector.d.ts +4 -0
- package/dist/connector.d.ts.map +1 -1
- package/dist/endpoint-action.d.ts +424 -0
- package/dist/endpoint-action.d.ts.map +1 -0
- package/dist/endpoint-action.js +910 -0
- package/dist/endpoint-action.js.map +1 -0
- package/dist/endpoint-binding.d.ts +344 -0
- package/dist/endpoint-binding.d.ts.map +1 -0
- package/dist/endpoint-binding.js +753 -0
- package/dist/endpoint-binding.js.map +1 -0
- package/dist/endpoint-checkpoint.d.ts +292 -0
- package/dist/endpoint-checkpoint.d.ts.map +1 -0
- package/dist/endpoint-checkpoint.js +818 -0
- package/dist/endpoint-checkpoint.js.map +1 -0
- package/dist/endpoint-cluster.d.ts +95 -0
- package/dist/endpoint-cluster.d.ts.map +1 -0
- package/dist/endpoint-cluster.js +194 -0
- package/dist/endpoint-cluster.js.map +1 -0
- package/dist/endpoint-contract-store.d.ts +90 -0
- package/dist/endpoint-contract-store.d.ts.map +1 -0
- package/dist/endpoint-contract-store.js +348 -0
- package/dist/endpoint-contract-store.js.map +1 -0
- package/dist/endpoint-envelope.d.ts +165 -0
- package/dist/endpoint-envelope.d.ts.map +1 -0
- package/dist/endpoint-envelope.js +346 -0
- package/dist/endpoint-envelope.js.map +1 -0
- package/dist/endpoint-grants.d.ts +123 -0
- package/dist/endpoint-grants.d.ts.map +1 -0
- package/dist/endpoint-grants.js +215 -0
- package/dist/endpoint-grants.js.map +1 -0
- package/dist/endpoint-guard.d.ts +222 -0
- package/dist/endpoint-guard.d.ts.map +1 -0
- package/dist/endpoint-guard.js +468 -0
- package/dist/endpoint-guard.js.map +1 -0
- package/dist/endpoint-handle.d.ts +176 -0
- package/dist/endpoint-handle.d.ts.map +1 -0
- package/dist/endpoint-handle.js +608 -0
- package/dist/endpoint-handle.js.map +1 -0
- package/dist/endpoint-journal.d.ts +225 -0
- package/dist/endpoint-journal.d.ts.map +1 -0
- package/dist/endpoint-journal.js +431 -0
- package/dist/endpoint-journal.js.map +1 -0
- package/dist/endpoint-receipt.d.ts +225 -0
- package/dist/endpoint-receipt.d.ts.map +1 -0
- package/dist/endpoint-receipt.js +417 -0
- package/dist/endpoint-receipt.js.map +1 -0
- package/dist/endpoint-records.d.ts +243 -0
- package/dist/endpoint-records.d.ts.map +1 -0
- package/dist/endpoint-records.js +591 -0
- package/dist/endpoint-records.js.map +1 -0
- package/dist/endpoint-serve.d.ts +176 -0
- package/dist/endpoint-serve.d.ts.map +1 -0
- package/dist/endpoint-serve.js +446 -0
- package/dist/endpoint-serve.js.map +1 -0
- package/dist/endpoint-service.d.ts +518 -0
- package/dist/endpoint-service.d.ts.map +1 -0
- package/dist/endpoint-service.js +973 -0
- package/dist/endpoint-service.js.map +1 -0
- package/dist/endpoint-session.d.ts +398 -0
- package/dist/endpoint-session.d.ts.map +1 -0
- package/dist/endpoint-session.js +834 -0
- package/dist/endpoint-session.js.map +1 -0
- package/dist/endpoint-signing.d.ts +105 -0
- package/dist/endpoint-signing.d.ts.map +1 -0
- package/dist/endpoint-signing.js +190 -0
- package/dist/endpoint-signing.js.map +1 -0
- package/dist/endpoint-subjects.d.ts +222 -0
- package/dist/endpoint-subjects.d.ts.map +1 -0
- package/dist/endpoint-subjects.js +381 -0
- package/dist/endpoint-subjects.js.map +1 -0
- package/dist/endpoint-supervisor.d.ts +19 -0
- package/dist/endpoint-supervisor.d.ts.map +1 -0
- package/dist/endpoint-supervisor.js +12 -0
- package/dist/endpoint-supervisor.js.map +1 -0
- package/dist/endpoint-traits.d.ts +225 -0
- package/dist/endpoint-traits.d.ts.map +1 -0
- package/dist/endpoint-traits.js +469 -0
- package/dist/endpoint-traits.js.map +1 -0
- package/dist/endpoint-verbs.d.ts +277 -0
- package/dist/endpoint-verbs.d.ts.map +1 -0
- package/dist/endpoint-verbs.js +576 -0
- package/dist/endpoint-verbs.js.map +1 -0
- package/dist/endpoint-virtual.d.ts +308 -0
- package/dist/endpoint-virtual.d.ts.map +1 -0
- package/dist/endpoint-virtual.js +440 -0
- package/dist/endpoint-virtual.js.map +1 -0
- package/dist/endpoint-work.d.ts +304 -0
- package/dist/endpoint-work.d.ts.map +1 -0
- package/dist/endpoint-work.js +728 -0
- package/dist/endpoint-work.js.map +1 -0
- package/dist/endpoint.d.ts +66 -22
- package/dist/endpoint.d.ts.map +1 -1
- package/dist/endpoint.js +289 -89
- package/dist/endpoint.js.map +1 -1
- package/dist/evict.d.ts +95 -32
- package/dist/evict.d.ts.map +1 -1
- package/dist/evict.js +193 -3
- package/dist/evict.js.map +1 -1
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -1
- package/dist/lease.d.ts +4 -0
- package/dist/lease.d.ts.map +1 -1
- package/dist/lease.js.map +1 -1
- package/dist/members.d.ts +4 -4
- package/dist/members.d.ts.map +1 -1
- package/dist/members.js +12 -12
- package/dist/members.js.map +1 -1
- package/dist/provision.d.ts +70 -18
- package/dist/provision.d.ts.map +1 -1
- package/dist/provision.js +194 -33
- package/dist/provision.js.map +1 -1
- package/dist/safe-pattern.d.ts +33 -0
- package/dist/safe-pattern.d.ts.map +1 -0
- package/dist/safe-pattern.js +486 -0
- package/dist/safe-pattern.js.map +1 -0
- package/dist/schema-profile.d.ts +93 -0
- package/dist/schema-profile.d.ts.map +1 -0
- package/dist/schema-profile.js +284 -0
- package/dist/schema-profile.js.map +1 -0
- package/dist/streams.d.ts +13 -8
- package/dist/streams.d.ts.map +1 -1
- package/dist/streams.js +35 -18
- package/dist/streams.js.map +1 -1
- package/dist/subjects.d.ts +100 -40
- package/dist/subjects.d.ts.map +1 -1
- package/dist/subjects.js +136 -60
- package/dist/subjects.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +3 -1
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
import { jetstream, jetstreamManager } from "@nats-io/jetstream";
|
|
2
|
+
import { headers as natsHeaders } from "@nats-io/transport-node";
|
|
3
|
+
import { canonicalJson, contractDigest } from "./canonical.js";
|
|
4
|
+
import { EpEnvelopeError } from "./endpoint-envelope.js";
|
|
5
|
+
import { epfSubject, assertIdToken } from "./endpoint-subjects.js";
|
|
6
|
+
import { RECORD_KINDS, recordSpecKey, recordStatusKey, createRecordEntry, updateRecordEntry, assertStatusValue, openRecordsBucket, readRecordLeader } from "./endpoint-records.js";
|
|
7
|
+
import { epfStreamName, epfGoalBindSubject, readLastFact, parseDecisionFact } from "./endpoint-journal.js";
|
|
8
|
+
import { readCheckpointSpec, readCheckpointSettle } from "./endpoint-checkpoint.js";
|
|
9
|
+
import { mintReceiptFromFacts, receiptOutcomeOfGoal, publishReceipt, readReceipt, assertReceiptAttestsSameFacts, assertReceiptStoreContext, assertReceiptStoreConnection, } from "./endpoint-receipt.js";
|
|
10
|
+
const BRANDED_CONTEXTS = new WeakSet();
|
|
11
|
+
/** The context's source connection, held privately for the §13.4 one-connection bond: emission
|
|
12
|
+
* wiring proves its receipt store derives from EXACTLY this connection, so a same-space store
|
|
13
|
+
* on a different broker (which passes any string-space compare) can never splice receipts
|
|
14
|
+
* across brokers. */
|
|
15
|
+
const ACTION_CONNECTIONS = new WeakMap();
|
|
16
|
+
export async function actionContext(nc, space) {
|
|
17
|
+
if (nc === null || typeof nc !== "object" || typeof nc.close !== "function")
|
|
18
|
+
throw new EpEnvelopeError("failed-precondition", "an action context is constructed from ONE binding-layer connection; separate resources are never accepted (SPEC 13.4)");
|
|
19
|
+
if (typeof space !== "string" || space.length === 0)
|
|
20
|
+
throw new EpEnvelopeError("failed-precondition", "an action context needs a space");
|
|
21
|
+
const js = jetstream(nc);
|
|
22
|
+
const jsm = await jetstreamManager(nc);
|
|
23
|
+
const kv = await openRecordsBucket(nc, space);
|
|
24
|
+
const ctx = Object.freeze({ kv, js, jsm, space });
|
|
25
|
+
BRANDED_CONTEXTS.add(ctx);
|
|
26
|
+
ACTION_CONNECTIONS.set(ctx, nc);
|
|
27
|
+
return ctx;
|
|
28
|
+
}
|
|
29
|
+
function assertCtx(ctx) {
|
|
30
|
+
if (!BRANDED_CONTEXTS.has(ctx))
|
|
31
|
+
throw new EpEnvelopeError("failed-precondition", `the action context was not constructed by actionContext(); a hand-assembled resource bundle never authorizes - the space bond is constructed, not asserted (SPEC 13.4)`);
|
|
32
|
+
}
|
|
33
|
+
const OWNER_PROOFS = new WeakMap();
|
|
34
|
+
export function ownerCommitProof(ctx) {
|
|
35
|
+
assertCtx(ctx);
|
|
36
|
+
const proof = Object.freeze({ space: ctx.space });
|
|
37
|
+
OWNER_PROOFS.set(proof, ctx);
|
|
38
|
+
return proof;
|
|
39
|
+
}
|
|
40
|
+
/** `false` = no proof presented; `true` = a genuine proof minted from THIS context. A proof
|
|
41
|
+
* from any other (or hand-assembled) source is a loud refusal, never a silent downgrade. */
|
|
42
|
+
function assertOwnerProof(proof, ctx, what) {
|
|
43
|
+
if (proof === undefined)
|
|
44
|
+
return false;
|
|
45
|
+
if (OWNER_PROOFS.get(proof) !== ctx)
|
|
46
|
+
throw new EpEnvelopeError("permission-denied", `${what} presents an owner proof that was not minted from THIS action context (ownerCommitProof); owner authority is construction-bound, never a raw flag (SPEC 13.6)`);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
const GUARD_CLEARANCES = new WeakMap();
|
|
50
|
+
let clearanceMintClaimed = false;
|
|
51
|
+
/** ONE-SHOT handoff of the clearance mint to THE gate (endpoint-guard claims it at module
|
|
52
|
+
* load; the package always loads it). Every later call is a loud refusal: there is exactly
|
|
53
|
+
* one gate, so a second claimant is by definition not it (SPEC 13.6). */
|
|
54
|
+
export function claimGuardClearanceMint() {
|
|
55
|
+
if (clearanceMintClaimed)
|
|
56
|
+
throw new EpEnvelopeError("permission-denied", "the guard-clearance mint is already claimed by THE gate; a guarded goal's edge into running opens only through it (SPEC 13.6)");
|
|
57
|
+
clearanceMintClaimed = true;
|
|
58
|
+
return (ctx, goalId) => {
|
|
59
|
+
assertCtx(ctx);
|
|
60
|
+
const clearance = Object.freeze({ goalId: assertIdToken(goalId, "goalId") });
|
|
61
|
+
GUARD_CLEARANCES.set(clearance, ctx);
|
|
62
|
+
return clearance;
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
/** Verify a presented clearance against THIS context and THIS goal. A clearance is never
|
|
66
|
+
* ignored: presenting an invalid one is a loud refusal even where none was required. */
|
|
67
|
+
function assertGuardClearance(clearance, ctx, goalId, what) {
|
|
68
|
+
if (GUARD_CLEARANCES.get(clearance) !== ctx || clearance.goalId !== goalId)
|
|
69
|
+
throw new EpEnvelopeError("permission-denied", `${what} presents a guard clearance that was not minted by THE gate from THIS context for THIS goal; gate passage is construction-bound, never asserted (SPEC 13.6)`);
|
|
70
|
+
}
|
|
71
|
+
/** Snapshot a caller-supplied ref to a validated DETACHED copy at seam entry, BEFORE the first
|
|
72
|
+
* await: a shared mutable ref can otherwise split one operation's identity across its reads,
|
|
73
|
+
* its CAS, and its terminal publish. Exported for the guard seams, which detach the goal they
|
|
74
|
+
* bind, resume, and project against the same discipline. */
|
|
75
|
+
export function snapshotRef(ref) {
|
|
76
|
+
const c = ref?.caller;
|
|
77
|
+
if (ref === null || typeof ref !== "object" || typeof ref.endpoint !== "string"
|
|
78
|
+
|| c === null || typeof c !== "object" || typeof c.owner !== "string" || typeof c.actor !== "string" || typeof c.uid !== "string")
|
|
79
|
+
throw new EpEnvelopeError("failed-precondition", "a goal ref must carry a string endpoint and a full caller triple (SPEC 13.2)");
|
|
80
|
+
return { endpoint: ref.endpoint, caller: { owner: c.owner, actor: c.actor, uid: c.uid }, goalId: assertIdToken(ref.goalId, "goalId") };
|
|
81
|
+
}
|
|
82
|
+
/** Derive a goal ref STRUCTURALLY from the broker-authenticated request (§13.6): the caller
|
|
83
|
+
* triple comes from the subject the broker admitted, DETACHED so a later mutation of the parsed
|
|
84
|
+
* request cannot retarget it, never from body fields — a seam taking this ref addresses only
|
|
85
|
+
* the authenticated caller's own goals. */
|
|
86
|
+
export function goalRefOf(request, goalId) {
|
|
87
|
+
const c = request.caller;
|
|
88
|
+
return { endpoint: request.endpoint, caller: { owner: c.owner, actor: c.actor, uid: c.uid }, goalId: assertIdToken(goalId, "goalId") };
|
|
89
|
+
}
|
|
90
|
+
/** The goal's terminal-result fact subject (`epf.<e>.goal.<triple>.<goalId>.result`, §13.2). */
|
|
91
|
+
export function goalResultSubject(space, ref) {
|
|
92
|
+
return epfSubject(space, ref.endpoint, ["goal", ref.caller.owner, ref.caller.actor, ref.caller.uid, ref.goalId, "result"]);
|
|
93
|
+
}
|
|
94
|
+
/** The per-goal progress EVENT topic tail (§13.2 reserved topics). */
|
|
95
|
+
export function goalProgressTopic(ref) {
|
|
96
|
+
return ["goal", ref.caller.owner, ref.caller.actor, ref.caller.uid, assertIdToken(ref.goalId, "goalId"), "progress"];
|
|
97
|
+
}
|
|
98
|
+
function goalQualifiers(ref) {
|
|
99
|
+
return [ref.endpoint, ref.caller.owner, ref.caller.actor, ref.caller.uid, ref.goalId];
|
|
100
|
+
}
|
|
101
|
+
function assertSafeInt(v, what) {
|
|
102
|
+
if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 0)
|
|
103
|
+
throw new EpEnvelopeError("failed-precondition", `${what} must be a non-negative safe integer; got ${JSON.stringify(v)}`);
|
|
104
|
+
return v;
|
|
105
|
+
}
|
|
106
|
+
function assertClosedKeys(o, allowed, what) {
|
|
107
|
+
for (const k of Object.keys(o))
|
|
108
|
+
if (!allowed.includes(k))
|
|
109
|
+
throw new EpEnvelopeError("internal", `${what} carries unknown field ${JSON.stringify(k)}; a closed schema admits no extras - garbled state never authorizes (SPEC 13.4)`);
|
|
110
|
+
}
|
|
111
|
+
/** Bound an executor-epoch resolver await: past the budget the caller REFUSES `unavailable`
|
|
112
|
+
* (fail-closed, retryable) instead of hanging. Races `Promise.resolve` unconditionally so a
|
|
113
|
+
* non-native thenable cannot bypass the deadline. */
|
|
114
|
+
async function resolveWithBudget(p, budgetMs) {
|
|
115
|
+
let timer;
|
|
116
|
+
const deadline = new Promise((_, reject) => {
|
|
117
|
+
timer = setTimeout(() => reject(new EpEnvelopeError("unavailable", `the executor-epoch resolver did not answer within ${budgetMs}ms; a stuck lifecycle authority is a bounded refusal, never a hung transition (SPEC 13.6)`)), budgetMs);
|
|
118
|
+
});
|
|
119
|
+
try {
|
|
120
|
+
return await Promise.race([Promise.resolve(p), deadline]);
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
clearTimeout(timer);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function parseBind(raw, subject, goalId) {
|
|
127
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
128
|
+
throw new EpEnvelopeError("internal", `goal-bind fact on ${subject} is not an object; garbled mediated fact state never authorizes (SPEC 13.4)`);
|
|
129
|
+
const o = raw;
|
|
130
|
+
assertClosedKeys(o, ["v", "goalId", "fingerprint"], `goal-bind fact on ${subject}`);
|
|
131
|
+
if (o.v !== 1 || typeof o.goalId !== "string" || typeof o.fingerprint !== "string" || o.fingerprint.length === 0)
|
|
132
|
+
throw new EpEnvelopeError("internal", `goal-bind fact on ${subject} is malformed; garbled state never authorizes (SPEC 13.4)`);
|
|
133
|
+
if (o.goalId !== goalId)
|
|
134
|
+
throw new EpEnvelopeError("internal", `goal-bind fact on ${subject} names goalId ${JSON.stringify(o.goalId)}, not its subject's ${goalId}; a mis-subjected fact never authorizes (SPEC 13.4)`);
|
|
135
|
+
return { v: 1, goalId: o.goalId, fingerprint: o.fingerprint };
|
|
136
|
+
}
|
|
137
|
+
/** Bind a goal to its accepted fingerprint BEFORE acceptance (the canonicalizer's seam): a
|
|
138
|
+
* create-only CAS per goalId. The winner proceeds; a loser reads the recorded bind and decides
|
|
139
|
+
* (same fingerprint = retry, different = `conflict` before acceptance and effect). The subject
|
|
140
|
+
* derives from the goal ref — ONE entry-derived identity (a caller derives it from the
|
|
141
|
+
* broker-authenticated request via {@link goalRefOf} exactly once), never body fields. */
|
|
142
|
+
export async function bindGoal(ctx, ref, fingerprint) {
|
|
143
|
+
assertCtx(ctx);
|
|
144
|
+
const snap = snapshotRef(ref);
|
|
145
|
+
const goalId = snap.goalId;
|
|
146
|
+
if (typeof fingerprint !== "string" || fingerprint.length === 0)
|
|
147
|
+
throw new EpEnvelopeError("failed-precondition", "a goal bind needs a non-empty fingerprint (SPEC 13.4)");
|
|
148
|
+
const subject = epfGoalBindSubject(ctx.space, snap, goalId);
|
|
149
|
+
const fact = { v: 1, goalId: assertIdToken(goalId, "goalId"), fingerprint };
|
|
150
|
+
const res = await publishCreateOnly(ctx.js, subject, new TextEncoder().encode(JSON.stringify(fact)));
|
|
151
|
+
if (res.won)
|
|
152
|
+
return { bound: true };
|
|
153
|
+
const raw = await readLastFact(ctx.jsm, epfStreamName(ctx.space), subject);
|
|
154
|
+
if (raw === undefined)
|
|
155
|
+
throw new EpEnvelopeError("internal", `the goal-bind CAS for ${subject} was lost but no winning fact is readable (SPEC 13.4)`);
|
|
156
|
+
return { bound: false, existing: parseBind(raw, subject, goalId) };
|
|
157
|
+
}
|
|
158
|
+
async function publishCreateOnly(js, subject, bytes) {
|
|
159
|
+
const h = natsHeaders();
|
|
160
|
+
h.set("Nats-Expected-Last-Subject-Sequence", "0");
|
|
161
|
+
try {
|
|
162
|
+
await js.publish(subject, bytes, { headers: h });
|
|
163
|
+
return { won: true };
|
|
164
|
+
}
|
|
165
|
+
catch (e) {
|
|
166
|
+
const code = e?.code;
|
|
167
|
+
if (code === 10071 || code === 10164)
|
|
168
|
+
return { won: false };
|
|
169
|
+
throw e;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** Resolve a goal submission against the bind rail (the canonicalizer's composed seam): NEW
|
|
173
|
+
* work (bind won); the caller's retry serving the CACHED decision/outcome (same fingerprint +
|
|
174
|
+
* a persisted goal whose spec AGREES with the bind); orphan ADOPTION (same fingerprint, no goal
|
|
175
|
+
* record — the bind winner crashed pre-acceptance); or `conflict` (different fingerprint). A
|
|
176
|
+
* persisted spec whose fingerprint disagrees with the bind is a loud `internal` (garbled
|
|
177
|
+
* authority chain), never a silent cached serve. */
|
|
178
|
+
export async function resolveGoalSubmission(ctx, request, goalId, fingerprint) {
|
|
179
|
+
// ONE entry-derived identity: the ref derives from the broker-authenticated request EXACTLY
|
|
180
|
+
// ONCE, BEFORE any await, and the bind, the spec read, and the result read all use this
|
|
181
|
+
// detached snapshot — a mutable request can never bind caller A and then serve caller B's
|
|
182
|
+
// cached outcome from the post-await reads.
|
|
183
|
+
const ref = snapshotRef(goalRefOf(request, goalId));
|
|
184
|
+
const bound = await bindGoal(ctx, ref, fingerprint);
|
|
185
|
+
if (bound.bound)
|
|
186
|
+
return { kind: "new" };
|
|
187
|
+
if (bound.existing.fingerprint !== fingerprint)
|
|
188
|
+
return { kind: "conflict", bind: bound.existing };
|
|
189
|
+
const spec = await readGoalSpec(ctx, ref);
|
|
190
|
+
if (spec === undefined)
|
|
191
|
+
return { kind: "adopted", bind: bound.existing };
|
|
192
|
+
if (spec.value.fingerprint !== bound.existing.fingerprint)
|
|
193
|
+
throw new EpEnvelopeError("internal", `goal "${ref.goalId}" has a persisted spec fingerprint that disagrees with its bind fact; the authority chain (bind = spec) is broken - never serve a cached outcome over it (SPEC 13.4/13.6)`);
|
|
194
|
+
const result = await readGoalResult(ctx, ref);
|
|
195
|
+
// The cached outcome must complete the SAME authority chain: bind = spec = result. A result
|
|
196
|
+
// fact whose fingerprint disagrees with the accepted spec (digest-consistent or not) is a
|
|
197
|
+
// foreign outcome and is never served as this goal's cached decision.
|
|
198
|
+
if (result !== undefined && result.fingerprint !== spec.value.fingerprint)
|
|
199
|
+
throw new EpEnvelopeError("internal", `goal "${ref.goalId}" has a recorded result whose fingerprint disagrees with its accepted spec; a foreign-fingerprint fact is never served as the cached outcome (SPEC 13.4/13.6)`);
|
|
200
|
+
return { kind: "cached", bind: bound.existing, ...(result !== undefined ? { result } : {}) };
|
|
201
|
+
}
|
|
202
|
+
// ---- the goal record: spec + status projection (§13.6 item 2/3, commit-path-owned) -----------
|
|
203
|
+
/** The §13.6 single status vocabulary for every long-running surface. */
|
|
204
|
+
export const GOAL_STATES = Object.freeze(["accepted", "running", "waiting", "cancelling", "succeeded", "failed", "cancelled", "expired", "uncertain"]);
|
|
205
|
+
/** All five are TERMINAL and immutable; first-terminal-fact-wins applies uniformly. */
|
|
206
|
+
export const GOAL_TERMINAL_STATES = Object.freeze(["succeeded", "failed", "cancelled", "expired", "uncertain"]);
|
|
207
|
+
/** The legal §13.6 transitions: `accepted → running ⇄ waiting`, `cancelling` between a cancel
|
|
208
|
+
* and its terminal, every non-terminal may project a terminal, a terminal absorbs. */
|
|
209
|
+
export function isLegalGoalTransition(from, to) {
|
|
210
|
+
if (GOAL_TERMINAL_STATES.includes(from))
|
|
211
|
+
return false;
|
|
212
|
+
if (GOAL_TERMINAL_STATES.includes(to))
|
|
213
|
+
return true;
|
|
214
|
+
switch (from) {
|
|
215
|
+
case "accepted": return to === "running" || to === "waiting" || to === "cancelling";
|
|
216
|
+
case "running": return to === "waiting" || to === "cancelling";
|
|
217
|
+
case "waiting": return to === "running" || to === "cancelling";
|
|
218
|
+
case "cancelling": return false;
|
|
219
|
+
default: return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
/** Closed spec validation, identity-bound to the ref whose key it was read from (§13.4): the
|
|
223
|
+
* caller's lifecycle evidence MUST equal the subject's uid (a mis-attributed spec never
|
|
224
|
+
* authorizes). */
|
|
225
|
+
function parseSpec(raw, key, ref) {
|
|
226
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
227
|
+
throw new EpEnvelopeError("internal", `goal spec ${key} is not an object; garbled mediated record state never authorizes (SPEC 13.4)`);
|
|
228
|
+
const o = raw;
|
|
229
|
+
assertClosedKeys(o, ["v", "goalId", "fingerprint", "command", "caller", "target", "requestId", "guard", "sourceSeq", "acceptedAt", "readinessDeadlineMs"], `goal spec ${key}`);
|
|
230
|
+
if (o.guard !== undefined && (typeof o.guard !== "string" || o.guard.length === 0))
|
|
231
|
+
throw new EpEnvelopeError("internal", `goal spec ${key} carries a malformed guard binding; garbled state never authorizes (SPEC 13.4/13.6)`);
|
|
232
|
+
if (o.v !== 1 || typeof o.goalId !== "string" || typeof o.fingerprint !== "string" || o.fingerprint.length === 0 || typeof o.command !== "string")
|
|
233
|
+
throw new EpEnvelopeError("internal", `goal spec ${key} is malformed; garbled state never authorizes (SPEC 13.4)`);
|
|
234
|
+
try {
|
|
235
|
+
assertIdToken(o.requestId, "requestId");
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
throw new EpEnvelopeError("internal", `goal spec ${key} carries no valid requestId (the goal's address for its durable acceptance fact); garbled state never authorizes (SPEC 13.4/13.10)`);
|
|
239
|
+
}
|
|
240
|
+
if (o.goalId !== ref.goalId)
|
|
241
|
+
throw new EpEnvelopeError("internal", `goal spec ${key} names goalId ${JSON.stringify(o.goalId)}, not its key's ${ref.goalId}; a mis-keyed record never authorizes (SPEC 13.4)`);
|
|
242
|
+
const c = o.caller;
|
|
243
|
+
if (!c || typeof c !== "object" || typeof c.id !== "string" || typeof c.lifecycleUid !== "string")
|
|
244
|
+
throw new EpEnvelopeError("internal", `goal spec ${key} carries no valid caller identity; garbled state never authorizes (SPEC 13.4)`);
|
|
245
|
+
assertClosedKeys(c, ["id", "lifecycleUid"], `goal spec ${key} caller`);
|
|
246
|
+
if (c.lifecycleUid !== ref.caller.uid)
|
|
247
|
+
throw new EpEnvelopeError("internal", `goal spec ${key} caller lifecycle ${JSON.stringify(c.lifecycleUid)} is not its subject's uid ${ref.caller.uid}; a mis-attributed spec never authorizes (SPEC 13.4)`);
|
|
248
|
+
if (c.id !== `${ref.caller.owner}.${ref.caller.actor}`)
|
|
249
|
+
throw new EpEnvelopeError("internal", `goal spec ${key} caller id ${JSON.stringify(c.id)} does not name its subject's principal ${ref.caller.owner}.${ref.caller.actor}; a mis-attributed spec never authorizes (SPEC 13.4)`);
|
|
250
|
+
for (const [n, v] of [["sourceSeq", o.sourceSeq], ["acceptedAt", o.acceptedAt]])
|
|
251
|
+
if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 0)
|
|
252
|
+
throw new EpEnvelopeError("internal", `goal spec ${key} field ${n} is not a safe integer; garbled state never authorizes (SPEC 13.4)`);
|
|
253
|
+
if (o.readinessDeadlineMs !== undefined && (typeof o.readinessDeadlineMs !== "number" || !Number.isSafeInteger(o.readinessDeadlineMs) || o.readinessDeadlineMs <= 0))
|
|
254
|
+
throw new EpEnvelopeError("internal", `goal spec ${key} readinessDeadlineMs is not a positive integer; garbled state never authorizes (SPEC 13.6)`);
|
|
255
|
+
if (o.target !== undefined) {
|
|
256
|
+
const t = o.target;
|
|
257
|
+
if (t === null || typeof t !== "object")
|
|
258
|
+
throw new EpEnvelopeError("internal", `goal spec ${key} target is not an object; garbled state never authorizes (SPEC 13.4)`);
|
|
259
|
+
assertClosedKeys(t, ["owner", "actor", "lifecycleUid", "mappingRevision"], `goal spec ${key} target`);
|
|
260
|
+
if (typeof t.owner !== "string" || typeof t.actor !== "string" || typeof t.lifecycleUid !== "string"
|
|
261
|
+
|| typeof t.mappingRevision !== "number" || !Number.isSafeInteger(t.mappingRevision) || t.mappingRevision < 0)
|
|
262
|
+
throw new EpEnvelopeError("internal", `goal spec ${key} carries a malformed target tuple; garbled state never authorizes (SPEC 13.4)`);
|
|
263
|
+
}
|
|
264
|
+
return o;
|
|
265
|
+
}
|
|
266
|
+
/** Read the persisted accepted goal (`undefined` = never accepted). A DEL marker refuses. */
|
|
267
|
+
export async function readGoalSpec(ctx, ref) {
|
|
268
|
+
assertCtx(ctx);
|
|
269
|
+
const snap = snapshotRef(ref);
|
|
270
|
+
const key = recordSpecKey(RECORD_KINDS.goal, goalQualifiers(snap));
|
|
271
|
+
const entry = await ctx.kv.get(key);
|
|
272
|
+
if (!entry)
|
|
273
|
+
return undefined;
|
|
274
|
+
if (entry.operation !== "PUT")
|
|
275
|
+
throw new EpEnvelopeError("failed-precondition", `the goal spec ${key} carries a ${entry.operation} marker; a deletion never erases an accepted goal - reconcile the store (SPEC 13.4)`);
|
|
276
|
+
return { value: parseSpec(JSON.parse(new TextDecoder().decode(entry.value)), key, snap), revision: entry.revision };
|
|
277
|
+
}
|
|
278
|
+
/** Create the goal record at acceptance, IDEMPOTENTLY (spec create-only, then the `accepted`
|
|
279
|
+
* status): a crash between the two writes (or an adopted-retry replay) re-reads the spec,
|
|
280
|
+
* requires it CONTENT-IDENTICAL, and ensures the status — no stranded spec-only goal. */
|
|
281
|
+
export async function createGoal(ctx, ref, spec) {
|
|
282
|
+
assertCtx(ctx);
|
|
283
|
+
const snap = snapshotRef(ref);
|
|
284
|
+
const value = { v: 1, goalId: snap.goalId, ...spec };
|
|
285
|
+
const specKey = recordSpecKey(RECORD_KINDS.goal, goalQualifiers(snap));
|
|
286
|
+
let specRevision;
|
|
287
|
+
try {
|
|
288
|
+
specRevision = await createRecordEntry(ctx.kv, specKey, value);
|
|
289
|
+
}
|
|
290
|
+
catch (e) {
|
|
291
|
+
if (!(e instanceof EpEnvelopeError && e.code === "conflict"))
|
|
292
|
+
throw e;
|
|
293
|
+
const existing = await readGoalSpec(ctx, snap);
|
|
294
|
+
if (existing === undefined)
|
|
295
|
+
throw new EpEnvelopeError("conflict", `the goal spec CAS for ${specKey} was lost but no record is readable; re-read and re-decide (SPEC 13.4)`);
|
|
296
|
+
if (canonicalJson(existing.value) !== canonicalJson(value))
|
|
297
|
+
throw new EpEnvelopeError("conflict", `goal "${snap.goalId}" already has a DIFFERENT accepted definition; one goalId never carries two specs (SPEC 13.6)`);
|
|
298
|
+
specRevision = existing.revision;
|
|
299
|
+
}
|
|
300
|
+
const statusKey = recordStatusKey(RECORD_KINDS.goal, goalQualifiers(snap));
|
|
301
|
+
const statusEntry = await ctx.kv.get(statusKey);
|
|
302
|
+
if (!statusEntry || statusEntry.operation !== "PUT") {
|
|
303
|
+
if (statusEntry && statusEntry.operation !== "PUT")
|
|
304
|
+
throw new EpEnvelopeError("failed-precondition", `the goal status ${statusKey} carries a ${statusEntry.operation} marker; a deletion never erases a goal's projection (SPEC 13.4)`);
|
|
305
|
+
try {
|
|
306
|
+
await createRecordEntry(ctx.kv, statusKey, assertStatusValue({ state: "accepted", observedSpecRevision: specRevision }));
|
|
307
|
+
}
|
|
308
|
+
catch (e) {
|
|
309
|
+
if (!(e instanceof EpEnvelopeError && e.code === "conflict"))
|
|
310
|
+
throw e;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return { specRevision };
|
|
314
|
+
}
|
|
315
|
+
/** Closed, STATE-DEPENDENT status validation (§13.4/§13.6). */
|
|
316
|
+
function parseStatus(raw, key) {
|
|
317
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
318
|
+
throw new EpEnvelopeError("internal", `goal status ${key} is not an object; garbled mediated record state never authorizes (SPEC 13.4)`);
|
|
319
|
+
const o = raw;
|
|
320
|
+
assertClosedKeys(o, ["state", "checkpoint", "cancelMode", "observedSpecRevision"], `goal status ${key}`);
|
|
321
|
+
if (typeof o.state !== "string" || !GOAL_STATES.includes(o.state))
|
|
322
|
+
throw new EpEnvelopeError("internal", `goal status ${key} carries unknown state ${JSON.stringify(o.state)}; garbled state never authorizes (SPEC 13.6)`);
|
|
323
|
+
if (typeof o.observedSpecRevision !== "number" || !Number.isSafeInteger(o.observedSpecRevision) || o.observedSpecRevision < 0)
|
|
324
|
+
throw new EpEnvelopeError("internal", `goal status ${key} has no valid observedSpecRevision (SPEC 13.4)`);
|
|
325
|
+
if (o.checkpoint !== undefined) {
|
|
326
|
+
if (o.state !== "waiting")
|
|
327
|
+
throw new EpEnvelopeError("internal", `goal status ${key} carries a checkpoint outside \`waiting\`; garbled cross-variant state never authorizes (SPEC 13.6)`);
|
|
328
|
+
const cp = o.checkpoint;
|
|
329
|
+
if (cp === null || typeof cp !== "object")
|
|
330
|
+
throw new EpEnvelopeError("internal", `goal status ${key} checkpoint is not an object (SPEC 13.6)`);
|
|
331
|
+
assertClosedKeys(cp, ["token", "deadlineGeneration"], `goal status ${key} checkpoint`);
|
|
332
|
+
if (typeof cp.token !== "string" || cp.token.length === 0 || typeof cp.deadlineGeneration !== "number" || !Number.isSafeInteger(cp.deadlineGeneration) || cp.deadlineGeneration < 0)
|
|
333
|
+
throw new EpEnvelopeError("internal", `goal status ${key} carries a malformed checkpoint coordinate; garbled state never authorizes (SPEC 13.6)`);
|
|
334
|
+
}
|
|
335
|
+
if (o.cancelMode !== undefined) {
|
|
336
|
+
if (o.state !== "cancelling")
|
|
337
|
+
throw new EpEnvelopeError("internal", `goal status ${key} carries a cancelMode outside \`cancelling\`; garbled cross-variant state never authorizes (SPEC 13.6)`);
|
|
338
|
+
if (o.cancelMode !== "graceful" && o.cancelMode !== "terminate")
|
|
339
|
+
throw new EpEnvelopeError("internal", `goal status ${key} carries unknown cancelMode ${JSON.stringify(o.cancelMode)}; garbled state never authorizes (SPEC 13.6)`);
|
|
340
|
+
}
|
|
341
|
+
return o;
|
|
342
|
+
}
|
|
343
|
+
/** Read the goal's current status projection (`undefined` = unknown goal). */
|
|
344
|
+
export async function readGoalStatus(ctx, ref) {
|
|
345
|
+
assertCtx(ctx);
|
|
346
|
+
const key = recordStatusKey(RECORD_KINDS.goal, goalQualifiers(snapshotRef(ref)));
|
|
347
|
+
const entry = await ctx.kv.get(key);
|
|
348
|
+
if (!entry)
|
|
349
|
+
return undefined;
|
|
350
|
+
if (entry.operation !== "PUT")
|
|
351
|
+
throw new EpEnvelopeError("failed-precondition", `the goal status ${key} carries a ${entry.operation} marker; a deletion never erases a goal's projection - reconcile the store (SPEC 13.4)`);
|
|
352
|
+
return { value: parseStatus(JSON.parse(new TextDecoder().decode(entry.value)), key), revision: entry.revision };
|
|
353
|
+
}
|
|
354
|
+
/** LEADER-SERVED goal reads for the FENCING paths (the H2 epoch-CAS gap): the status read that
|
|
355
|
+
* supplies a transition's CAS revision, and the spec read that gates a terminal commit's epoch
|
|
356
|
+
* proof, must be read-your-writes against the leader — PINNED here, never inherited. The
|
|
357
|
+
* records bucket is `allow_direct`, which invites follower-served Direct Gets; whether
|
|
358
|
+
* `kv.get` actually issues one is a CLIENT-VERSION accident (@nats-io/kv 3.4.0's open path
|
|
359
|
+
* happens to leave `direct` off and rides STREAM.MSG.GET today), and a fence must not rest on
|
|
360
|
+
* an accident a client upgrade silently flips: a stale revision only loses the CAS later, but
|
|
361
|
+
* the epoch proof PAIRED with that read would have validated against a superseded projection —
|
|
362
|
+
* the staleness the checkpoint arm-fence closed. Non-fencing reads stay on `kv.get` (the
|
|
363
|
+
* same split as the checkpoint module). */
|
|
364
|
+
async function readGoalSpecLeader(ctx, snap) {
|
|
365
|
+
const key = recordSpecKey(RECORD_KINDS.goal, goalQualifiers(snap));
|
|
366
|
+
const entry = await readRecordLeader(ctx.jsm, ctx.space, key);
|
|
367
|
+
return entry === undefined ? undefined : { value: parseSpec(entry.value, key, snap), revision: entry.revision };
|
|
368
|
+
}
|
|
369
|
+
async function readGoalStatusLeader(ctx, snap) {
|
|
370
|
+
const key = recordStatusKey(RECORD_KINDS.goal, goalQualifiers(snap));
|
|
371
|
+
const entry = await readRecordLeader(ctx.jsm, ctx.space, key);
|
|
372
|
+
return entry === undefined ? undefined : { value: parseStatus(entry.value, key), revision: entry.revision };
|
|
373
|
+
}
|
|
374
|
+
/** Leader-read a goal's status projection by ref ALONE (no {@link ActionContext}) — a fencing read
|
|
375
|
+
* for a caller OUTSIDE the action module. The retirement drain uses it to decide whether an
|
|
376
|
+
* accepted ACTION goal is still `accepted` (never entered `running`, the guard/currency-fenced
|
|
377
|
+
* effecting edge, so provably never effected) before it may create-only cancel it: a `cancelled`
|
|
378
|
+
* terminal must mean the effect did NOT run (SPEC 13.6). `undefined` = no goal record at all
|
|
379
|
+
* (never created ⇒ never ran). Leader-served (read-your-writes), so a running executor's
|
|
380
|
+
* transition is never missed as a stale absence and a mis-read never authorizes a false cancel. */
|
|
381
|
+
export async function readGoalStatusByRefLeader(jsm, space, ref) {
|
|
382
|
+
const key = recordStatusKey(RECORD_KINDS.goal, goalQualifiers(snapshotRef(ref)));
|
|
383
|
+
const entry = await readRecordLeader(jsm, space, key);
|
|
384
|
+
return entry === undefined ? undefined : parseStatus(entry.value, key);
|
|
385
|
+
}
|
|
386
|
+
/** Assert an EXECUTOR's fresh lifecycle/epoch currency against a target-pinned spec (§13.6 item
|
|
387
|
+
* 7). A non-pinned goal accepts NO executor (a supplied one is a wiring confusion, refused). A
|
|
388
|
+
* pinned goal requires the executor's authenticated (lifecycleUid, epoch) plus a fresh-epoch
|
|
389
|
+
* resolver, and refuses a same-name successor (`expired`), a superseded/retired epoch
|
|
390
|
+
* (`expired`), a non-integer answer (`internal`), or a stuck resolver (`unavailable`). */
|
|
391
|
+
async function assertExecutorCurrency(spec, goalId, executor, resolveCurrentEpoch, budgetMs) {
|
|
392
|
+
if (spec.target === undefined) {
|
|
393
|
+
if (executor !== undefined || resolveCurrentEpoch !== undefined)
|
|
394
|
+
throw new EpEnvelopeError("failed-precondition", `goal "${goalId}" pins no target lifecycle; an executor/resolver here indicates a wiring confusion, refused (SPEC 13.6)`);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (executor === undefined || typeof executor.lifecycleUid !== "string" || !Number.isSafeInteger(executor.epoch) || executor.epoch < 0)
|
|
398
|
+
throw new EpEnvelopeError("failed-precondition", `goal "${goalId}" is pinned to a target lifecycle; this requires the executor's authenticated (lifecycleUid, epoch) (SPEC 13.6 item 7)`);
|
|
399
|
+
if (typeof resolveCurrentEpoch !== "function")
|
|
400
|
+
throw new EpEnvelopeError("failed-precondition", `goal "${goalId}" is pinned to a target lifecycle; this requires a fresh-epoch resolver (SPEC 13.6: a superseded epoch cannot commit transitions)`);
|
|
401
|
+
if (executor.lifecycleUid !== spec.target.lifecycleUid)
|
|
402
|
+
throw new EpEnvelopeError("expired", `goal "${goalId}" was accepted against lifecycle ${spec.target.lifecycleUid} but the executor is ${executor.lifecycleUid}; a goal is never effectful against a same-name successor (SPEC 13.6)`);
|
|
403
|
+
const current = await resolveWithBudget(resolveCurrentEpoch(spec.target), budgetMs);
|
|
404
|
+
if (current !== null && (typeof current !== "number" || !Number.isSafeInteger(current) || current < 0))
|
|
405
|
+
throw new EpEnvelopeError("internal", `the fresh-epoch resolver returned ${JSON.stringify(current)}; a non-integer epoch never authorizes (SPEC 13.6)`);
|
|
406
|
+
if (current === null)
|
|
407
|
+
throw new EpEnvelopeError("expired", `goal "${goalId}"'s target lifecycle is retired/unknown; a retired executor cannot commit transitions (SPEC 13.6)`);
|
|
408
|
+
if (current !== executor.epoch)
|
|
409
|
+
throw new EpEnvelopeError("expired", `goal "${goalId}"'s executor carries epoch ${executor.epoch} but the current process epoch is ${current}; a superseded epoch cannot commit transitions (SPEC 13.6)`);
|
|
410
|
+
}
|
|
411
|
+
/** CAS the goal's status through the NON-TERMINAL machine. A TERMINAL target is REFUSED (a
|
|
412
|
+
* terminal status exists only as {@link projectGoalTerminal}). An EXECUTOR-authored PROGRESS
|
|
413
|
+
* transition (`running`/`waiting`) on a TARGET-PINNED goal MUST prove the executor's fresh
|
|
414
|
+
* currency; a `cancelling` transition is owner/caller-authored (its authority is the cancel's
|
|
415
|
+
* broker-authenticated caller, {@link requestGoalCancel}) and takes no executor; an OWNER pause
|
|
416
|
+
* (guard hold → `waiting`) may declare `ownerAuthored`. A target-pinned progress transition
|
|
417
|
+
* with neither executor nor `ownerAuthored` is refused. */
|
|
418
|
+
export async function transitionGoal(ctx, ref, to, opts = {}) {
|
|
419
|
+
assertCtx(ctx);
|
|
420
|
+
// ENTRY SNAPSHOT (single-read, before the first await): the ref, the executor, the resolver
|
|
421
|
+
// reference, the owner proof, the gate clearance, and the projected fields all detach here —
|
|
422
|
+
// nothing below reads `opts` again, so a caller mutating it across an await cannot move an
|
|
423
|
+
// authority coordinate (an ownerAuthored false→true flip mid-read was exactly such a bypass).
|
|
424
|
+
const snap = snapshotRef(ref);
|
|
425
|
+
const executor = opts.executor !== undefined ? { lifecycleUid: String(opts.executor.lifecycleUid), epoch: opts.executor.epoch } : undefined;
|
|
426
|
+
const resolveCurrentEpoch = opts.resolveCurrentEpoch;
|
|
427
|
+
const ownerAuthored = assertOwnerProof(opts.owner, ctx, `the transition of goal "${snap.goalId}"`);
|
|
428
|
+
const clearance = opts.clearance;
|
|
429
|
+
// A presented clearance is NEVER ignored: verify it against THIS context and THIS goal up
|
|
430
|
+
// front (a hand-assembled or foreign proof is a loud refusal on every edge, not only the
|
|
431
|
+
// guarded one). Whether one is REQUIRED is decided below against the accepted spec.
|
|
432
|
+
if (clearance !== undefined)
|
|
433
|
+
assertGuardClearance(clearance, ctx, snap.goalId, `the transition of goal "${snap.goalId}"`);
|
|
434
|
+
const budget = opts.epochResolveBudgetMs ?? 5_000;
|
|
435
|
+
if (!Number.isSafeInteger(budget) || budget <= 0)
|
|
436
|
+
throw new EpEnvelopeError("failed-precondition", `epochResolveBudgetMs must be a positive integer; got ${JSON.stringify(opts.epochResolveBudgetMs)}`);
|
|
437
|
+
const fieldsIn = opts.fields;
|
|
438
|
+
const checkpointIn = fieldsIn?.checkpoint;
|
|
439
|
+
const fields = {
|
|
440
|
+
...(checkpointIn !== undefined ? { checkpoint: { token: String(checkpointIn.token), deadlineGeneration: checkpointIn.deadlineGeneration } } : {}),
|
|
441
|
+
...(fieldsIn?.cancelMode !== undefined ? { cancelMode: fieldsIn.cancelMode } : {}),
|
|
442
|
+
};
|
|
443
|
+
if (GOAL_TERMINAL_STATES.includes(to))
|
|
444
|
+
throw new EpEnvelopeError("failed-precondition", `a goal status never transitions to terminal "${to}" directly; commit the result fact and project it - the journal owns terminals (SPEC 13.6)`);
|
|
445
|
+
const spec = await readGoalSpec(ctx, snap);
|
|
446
|
+
if (spec === undefined)
|
|
447
|
+
throw new EpEnvelopeError("failed-precondition", `goal "${snap.goalId}" is unknown; a transition projects only an accepted goal (SPEC 13.6)`);
|
|
448
|
+
// THE structural guard fence (SPEC 13.6: a guarded command MUST NOT effect until the guard
|
|
449
|
+
// answered allow; `running` IS effecting): a goal whose ACCEPTED record binds a guard opens
|
|
450
|
+
// its edge into `running` only with THE gate's clearance. This closes BOTH public bypasses -
|
|
451
|
+
// the unpinned edge (no proof at all) and the pinned+owner edge (any context holder mints
|
|
452
|
+
// owner proofs; the gate's own allow/release/reconcile arms are the only clearance minters).
|
|
453
|
+
if (to === "running" && spec.value.guard !== undefined && clearance === undefined)
|
|
454
|
+
throw new EpEnvelopeError("permission-denied", `goal "${snap.goalId}" is guarded by "${spec.value.guard}" and MUST NOT effect until that guard answered allow; only THE gate's clearance opens its edge into running - an owner proof or executor currency alone never does (SPEC 13.6)`);
|
|
455
|
+
const isProgress = to === "running" || to === "waiting";
|
|
456
|
+
let needsCurrency = false;
|
|
457
|
+
if (isProgress && spec.value.target !== undefined) {
|
|
458
|
+
if (executor === undefined && !ownerAuthored)
|
|
459
|
+
throw new EpEnvelopeError("failed-precondition", `goal "${snap.goalId}" is target-pinned; an executor-authored progress transition to "${to}" must prove the executor's fresh currency (or present the owner's construction-bound proof) - a superseded epoch cannot commit transitions (SPEC 13.6 item 7)`);
|
|
460
|
+
needsCurrency = executor !== undefined;
|
|
461
|
+
}
|
|
462
|
+
else if (executor !== undefined || resolveCurrentEpoch !== undefined) {
|
|
463
|
+
throw new EpEnvelopeError("failed-precondition", `a transition to "${to}"${spec.value.target === undefined ? " on a non-target goal" : ""} takes no executor/resolver (SPEC 13.6)`);
|
|
464
|
+
}
|
|
465
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
466
|
+
// LEADER-SERVED (H2): this read supplies the CAS revision below AND pairs the currency
|
|
467
|
+
// proof; a follower-served revision would let the proof validate against a superseded
|
|
468
|
+
// projection and only find out at the CAS. The fence reads its coordinate from the leader.
|
|
469
|
+
const current = await readGoalStatusLeader(ctx, snap);
|
|
470
|
+
if (current === undefined)
|
|
471
|
+
throw new EpEnvelopeError("failed-precondition", `goal "${snap.goalId}" is unknown; a transition projects only an accepted goal (SPEC 13.6)`);
|
|
472
|
+
if (!isLegalGoalTransition(current.value.state, to))
|
|
473
|
+
throw new EpEnvelopeError("failed-precondition", `goal "${snap.goalId}" cannot transition ${current.value.state} -> ${to} (SPEC 13.6: accepted -> running <-> waiting -> terminal, cancelling between a cancel and its terminal; terminals are immutable)`);
|
|
474
|
+
// The currency check is PAIRED 1:1 with its CAS attempt, immediately before it: a lost CAS
|
|
475
|
+
// re-reads the status AND re-proves the executor's epoch, so a takeover landing between
|
|
476
|
+
// attempts refuses on the retry instead of committing on a stale first resolve (SPEC 13.6
|
|
477
|
+
// item 7: a superseded epoch cannot commit transitions).
|
|
478
|
+
if (needsCurrency)
|
|
479
|
+
await assertExecutorCurrency(spec.value, snap.goalId, executor, resolveCurrentEpoch, budget);
|
|
480
|
+
const next = assertStatusValue({
|
|
481
|
+
state: to,
|
|
482
|
+
...(to === "waiting" && fields.checkpoint !== undefined ? { checkpoint: fields.checkpoint } : {}),
|
|
483
|
+
...(to === "cancelling" && fields.cancelMode !== undefined ? { cancelMode: fields.cancelMode } : {}),
|
|
484
|
+
observedSpecRevision: current.value.observedSpecRevision,
|
|
485
|
+
});
|
|
486
|
+
try {
|
|
487
|
+
await updateRecordEntry(ctx.kv, recordStatusKey(RECORD_KINDS.goal, goalQualifiers(snap)), next, current.revision);
|
|
488
|
+
return next;
|
|
489
|
+
}
|
|
490
|
+
catch (e) {
|
|
491
|
+
if (e instanceof EpEnvelopeError && e.code === "conflict")
|
|
492
|
+
continue;
|
|
493
|
+
throw e;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
throw new EpEnvelopeError("conflict", `the goal status for "${snap.goalId}" moved twice during one transition; re-read and re-decide (SPEC 13.4)`);
|
|
497
|
+
}
|
|
498
|
+
/** Project the WINNING terminal fact onto the status — the ONLY path a status reaches a terminal
|
|
499
|
+
* state, and the crash reconciler for a commit that fenced the fact but died before projecting.
|
|
500
|
+
* Cross-checks the fact's fingerprint against the persisted spec (a terminal fact whose
|
|
501
|
+
* fingerprint disagrees with the accepted goal is a garbled authority chain). */
|
|
502
|
+
export async function projectGoalTerminal(ctx, ref) {
|
|
503
|
+
assertCtx(ctx);
|
|
504
|
+
const snap = snapshotRef(ref);
|
|
505
|
+
const fact = await readGoalResult(ctx, snap);
|
|
506
|
+
if (fact === undefined)
|
|
507
|
+
throw new EpEnvelopeError("failed-precondition", `goal "${snap.goalId}" has no terminal fact; only a committed result projects a terminal status (SPEC 13.6)`);
|
|
508
|
+
const spec = await readGoalSpec(ctx, snap);
|
|
509
|
+
if (spec === undefined)
|
|
510
|
+
throw new EpEnvelopeError("internal", `goal "${snap.goalId}" has a terminal fact but no accepted spec; garbled state never authorizes (SPEC 13.4)`);
|
|
511
|
+
if (fact.fingerprint !== spec.value.fingerprint)
|
|
512
|
+
throw new EpEnvelopeError("internal", `goal "${snap.goalId}"'s terminal fact fingerprint disagrees with its accepted spec; a projection never follows a garbled fact (SPEC 13.6)`);
|
|
513
|
+
const key = recordStatusKey(RECORD_KINDS.goal, goalQualifiers(snap));
|
|
514
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
515
|
+
const current = await readGoalStatus(ctx, snap);
|
|
516
|
+
if (current === undefined) {
|
|
517
|
+
const created = assertStatusValue({ state: fact.state, observedSpecRevision: spec.revision });
|
|
518
|
+
try {
|
|
519
|
+
await createRecordEntry(ctx.kv, key, created);
|
|
520
|
+
return created;
|
|
521
|
+
}
|
|
522
|
+
catch (e) {
|
|
523
|
+
if (e instanceof EpEnvelopeError && e.code === "conflict")
|
|
524
|
+
continue;
|
|
525
|
+
throw e;
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
if (current.value.state === fact.state)
|
|
529
|
+
return current.value;
|
|
530
|
+
if (GOAL_TERMINAL_STATES.includes(current.value.state))
|
|
531
|
+
throw new EpEnvelopeError("internal", `goal "${snap.goalId}" status is terminal ${current.value.state} but the winning fact is ${fact.state}; a projection never contradicts the journal (SPEC 13.6)`);
|
|
532
|
+
const next = assertStatusValue({ state: fact.state, observedSpecRevision: current.value.observedSpecRevision });
|
|
533
|
+
try {
|
|
534
|
+
await updateRecordEntry(ctx.kv, key, next, current.revision);
|
|
535
|
+
return next;
|
|
536
|
+
}
|
|
537
|
+
catch (e) {
|
|
538
|
+
if (e instanceof EpEnvelopeError && e.code === "conflict")
|
|
539
|
+
continue;
|
|
540
|
+
throw e;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
throw new EpEnvelopeError("conflict", `the goal status ${key} moved twice during one terminal projection; re-read and re-decide (SPEC 13.4)`);
|
|
544
|
+
}
|
|
545
|
+
/** Build the retirement-cancelled goal terminal (§13.8 option (i)): the FIRST-CLASS `cancelled`
|
|
546
|
+
* outcome state the goal union ALREADY carries — no new wire shape — bound to the acceptance's
|
|
547
|
+
* fingerprint, with the retirement attribution riding the digest-bound payload
|
|
548
|
+
* (`data.cancelledBy = { opId, target }`). Published create-only on the goal's result subject,
|
|
549
|
+
* so a racing real commit wins by landing first (first-terminal-wins, §13.8). A retirement
|
|
550
|
+
* cancels only ITS OWN target's accepted goals. */
|
|
551
|
+
export function goalCancelledResultOf(acceptance, cancelled, ts) {
|
|
552
|
+
const goalId = acceptance.request.goalId;
|
|
553
|
+
if (typeof goalId !== "string" || goalId.length === 0)
|
|
554
|
+
throw new EpEnvelopeError("failed-precondition", "a cancelled goal terminal requires the acceptance's goalId (SPEC 13.6)");
|
|
555
|
+
if (typeof cancelled.opId !== "string" || cancelled.opId.length === 0 || cancelled.opId.length > 64)
|
|
556
|
+
throw new EpEnvelopeError("failed-precondition", "a cancelled goal terminal requires the retirement opId (SPEC 13.8)");
|
|
557
|
+
if (acceptance.target === undefined || acceptance.target.lifecycleUid !== cancelled.target.lifecycleUid)
|
|
558
|
+
throw new EpEnvelopeError("failed-precondition", `a retirement cancels only ITS target's accepted goals: the acceptance targets ${acceptance.target?.lifecycleUid ?? "(none)"}, not ${cancelled.target.lifecycleUid} (SPEC 13.8)`);
|
|
559
|
+
const data = { cancelledBy: { opId: cancelled.opId, target: { ...cancelled.target } } };
|
|
560
|
+
return { v: 1, goalId, fingerprint: acceptance.fingerprint, state: "cancelled", outcomeDigest: contractDigest(data), data, ts };
|
|
561
|
+
}
|
|
562
|
+
/** The §13.6 item-5 tombstone serving form for a payload-evicted retry. */
|
|
563
|
+
export function goalTombstone(fact) {
|
|
564
|
+
return { v: 1, goalId: fact.goalId, fingerprint: fact.fingerprint, state: fact.state, outcomeDigest: fact.outcomeDigest, data: { evicted: true }, ts: fact.ts };
|
|
565
|
+
}
|
|
566
|
+
/** Closed validation, IDENTITY-BOUND to the ref, with the tombstone digest RE-VERIFIED. */
|
|
567
|
+
export function parseGoalResultFact(raw, subject, ref) {
|
|
568
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw))
|
|
569
|
+
throw new EpEnvelopeError("internal", `goal result fact on ${subject} is not an object; garbled state never authorizes (SPEC 13.6)`);
|
|
570
|
+
const o = raw;
|
|
571
|
+
assertClosedKeys(o, ["v", "goalId", "fingerprint", "state", "outcomeDigest", "data", "ts"], `goal result fact on ${subject}`);
|
|
572
|
+
if (o.v !== 1 || typeof o.goalId !== "string" || typeof o.fingerprint !== "string" || o.fingerprint.length === 0
|
|
573
|
+
|| typeof o.state !== "string" || !GOAL_TERMINAL_STATES.includes(o.state)
|
|
574
|
+
|| typeof o.outcomeDigest !== "string" || o.outcomeDigest.length === 0
|
|
575
|
+
|| typeof o.ts !== "number" || !Number.isSafeInteger(o.ts) || o.ts < 0)
|
|
576
|
+
throw new EpEnvelopeError("internal", `goal result fact on ${subject} is malformed; garbled state never authorizes (SPEC 13.6)`);
|
|
577
|
+
if (o.goalId !== ref.goalId)
|
|
578
|
+
throw new EpEnvelopeError("internal", `goal result fact on ${subject} names goalId ${JSON.stringify(o.goalId)}, not its subject's ${ref.goalId}; a mis-subjected fact never authorizes (SPEC 13.4)`);
|
|
579
|
+
if (contractDigest(o.data === undefined ? null : o.data) !== o.outcomeDigest)
|
|
580
|
+
throw new EpEnvelopeError("internal", `goal result fact on ${subject} carries an outcomeDigest that does not match its payload; a digest-inconsistent fact never authorizes (SPEC 13.6)`);
|
|
581
|
+
return o;
|
|
582
|
+
}
|
|
583
|
+
/** Read the goal's cached terminal outcome (`undefined` = not terminal yet). */
|
|
584
|
+
export async function readGoalResult(ctx, ref) {
|
|
585
|
+
assertCtx(ctx);
|
|
586
|
+
const snap = snapshotRef(ref);
|
|
587
|
+
const subject = goalResultSubject(ctx.space, snap);
|
|
588
|
+
const raw = await readLastFact(ctx.jsm, epfStreamName(ctx.space), subject);
|
|
589
|
+
return raw === undefined ? undefined : parseGoalResultFact(raw, subject, snap);
|
|
590
|
+
}
|
|
591
|
+
/** The raw create-only terminal CAS (PRIVATE): the state is already authorized by the cause,
|
|
592
|
+
* the spec already read. Snapshots the payload strict-canonical BEFORE the CAS, stamps the
|
|
593
|
+
* fingerprint FROM the spec, projects the winner, and proves a lost-CAS winner's fingerprint
|
|
594
|
+
* agrees with the spec. */
|
|
595
|
+
async function commitTerminalFact(ctx, snap, spec, state, data, now) {
|
|
596
|
+
const snapshotData = data === undefined ? undefined : JSON.parse(canonicalJson(data));
|
|
597
|
+
const fact = {
|
|
598
|
+
v: 1, goalId: snap.goalId, fingerprint: spec.fingerprint, state,
|
|
599
|
+
outcomeDigest: contractDigest(snapshotData === undefined ? null : snapshotData),
|
|
600
|
+
...(snapshotData !== undefined ? { data: snapshotData } : {}), ts: now,
|
|
601
|
+
};
|
|
602
|
+
const subject = goalResultSubject(ctx.space, snap);
|
|
603
|
+
const res = await publishCreateOnly(ctx.js, subject, new TextEncoder().encode(JSON.stringify(fact)));
|
|
604
|
+
const winner = res.won ? fact : await readGoalResult(ctx, snap);
|
|
605
|
+
if (winner === undefined)
|
|
606
|
+
throw new EpEnvelopeError("internal", `the goal terminal CAS for ${subject} was lost but no winning fact is readable (SPEC 13.4)`);
|
|
607
|
+
if (winner.fingerprint !== spec.fingerprint)
|
|
608
|
+
throw new EpEnvelopeError("internal", `the recorded terminal for goal "${snap.goalId}" carries fingerprint ${JSON.stringify(winner.fingerprint)}, not the accepted spec's ${spec.fingerprint}; a foreign-fingerprint winner is never adopted (SPEC 13.4/13.6)`);
|
|
609
|
+
const status = await projectGoalTerminal(ctx, snap);
|
|
610
|
+
return { won: res.won, fact: winner, status };
|
|
611
|
+
}
|
|
612
|
+
/** Validate emission wiring at seam ENTRY (fail loud BEFORE any commit or publish happens
|
|
613
|
+
* against it): the store must be BRANDED (minted by receiptStoreContext), bonded to THIS
|
|
614
|
+
* context's space — a cross-space store would publish receipts into a foreign space's stream —
|
|
615
|
+
* and derived from THIS context's own connection (security CF-2 HIGH: a same-space store on a
|
|
616
|
+
* DIFFERENT broker passes the string compare and splices receipts across brokers; §13.4 "JS +
|
|
617
|
+
* JSM derive from ONE connection" makes the bond connection identity, never a name). */
|
|
618
|
+
function assertEmissionWiring(ctx, wiring) {
|
|
619
|
+
if (wiring === null || typeof wiring !== "object"
|
|
620
|
+
|| typeof wiring.keyPair?.sign !== "function"
|
|
621
|
+
|| typeof wiring.signer?.keyId !== "string")
|
|
622
|
+
throw new EpEnvelopeError("failed-precondition", "receipt emission wiring carries the receipt store context, the instance evidence, the signer keyId, and the signing key (SPEC 13.10)");
|
|
623
|
+
assertReceiptStoreContext(wiring.store);
|
|
624
|
+
if (wiring.store.space !== ctx.space)
|
|
625
|
+
throw new EpEnvelopeError("failed-precondition", `the receipt store is bonded to space ${JSON.stringify(wiring.store.space)}, not this action context's ${JSON.stringify(ctx.space)}; a cross-space emission never publishes (SPEC 13.4)`);
|
|
626
|
+
assertReceiptStoreConnection(wiring.store, ACTION_CONNECTIONS.get(ctx));
|
|
627
|
+
}
|
|
628
|
+
/** The SHARED emission core (§13.10), MODULE-PRIVATE (engineer/security HIGH: `spec` and
|
|
629
|
+
* `fact` are TRUSTED inputs here, so only the two callers that derive them from their own
|
|
630
|
+
* authority reads may reach this seam - commitGoalResult passes its terminal-CAS winner and
|
|
631
|
+
* leader-read spec, reconcileReceiptEmission passes its own fresh reads; a public seam would
|
|
632
|
+
* let a fabricated terminal permanently win the create-only receipt subject): derive the goal
|
|
633
|
+
* terminal's receipt from the two authoritative facts and publish it idempotently, so the
|
|
634
|
+
* receipt is reconstructable after any crash between effect and emission. Steps: read the DURABLE acceptance through the goal's recorded address
|
|
635
|
+
* (`spec.requestId`), prove the chain (the fact must be the acceptance THIS spec was written
|
|
636
|
+
* from — id + sourceSeq + fingerprint + command, not merely SOME fact on the subject, which
|
|
637
|
+
* post-horizon id reuse could make a different execution's), mint via
|
|
638
|
+
* {@link mintReceiptFromFacts}, then create-only publish. A racing emitter with different
|
|
639
|
+
* evidence (its own ts/instance) is adopted exactly when its receipt attests the SAME facts;
|
|
640
|
+
* a recorded receipt that disagrees is the forged-attestation class CF-1 closes and throws. */
|
|
641
|
+
async function emitReceiptForTerminal(ctx, wiring, args) {
|
|
642
|
+
assertCtx(ctx);
|
|
643
|
+
assertEmissionWiring(ctx, wiring);
|
|
644
|
+
const snap = snapshotRef(args.ref);
|
|
645
|
+
const ts = assertSafeInt(args.ts, "ts");
|
|
646
|
+
const spec = args.spec;
|
|
647
|
+
const fact = args.fact;
|
|
648
|
+
if (fact.goalId !== snap.goalId || fact.fingerprint !== spec.fingerprint)
|
|
649
|
+
throw new EpEnvelopeError("internal", `the terminal fact (goal ${JSON.stringify(fact.goalId)}, fingerprint ${JSON.stringify(fact.fingerprint)}) does not belong to goal "${snap.goalId}" under the accepted fingerprint ${spec.fingerprint}; a foreign terminal never mints a receipt (SPEC 13.10)`);
|
|
650
|
+
const subject = epfSubject(ctx.space, snap.endpoint, ["dec", snap.caller.owner, snap.caller.actor, snap.caller.uid, spec.requestId]);
|
|
651
|
+
const raw = await readLastFact(ctx.jsm, epfStreamName(ctx.space), subject);
|
|
652
|
+
if (raw === undefined)
|
|
653
|
+
throw new EpEnvelopeError("failed-precondition", `no decision fact exists at ${subject}; a receipt derives from the durable acceptance and cannot be reconstructed without it - check the fact retention floor (SPEC 13.10/13.12)`);
|
|
654
|
+
const decision = parseDecisionFact(raw, subject);
|
|
655
|
+
if (decision.decision !== "accepted")
|
|
656
|
+
throw new EpEnvelopeError("internal", `the decision fact for request "${spec.requestId}" is a rejection, yet goal "${snap.goalId}" carries a committed terminal; the authority chain is broken - reconcile the store (SPEC 13.4)`);
|
|
657
|
+
if (decision.sourceSeq !== spec.sourceSeq || decision.fingerprint !== spec.fingerprint)
|
|
658
|
+
throw new EpEnvelopeError("internal", `the acceptance at ${subject} (sourceSeq ${decision.sourceSeq}, fingerprint ${decision.fingerprint}) is not the acceptance goal "${snap.goalId}" was created from (sourceSeq ${spec.sourceSeq}, fingerprint ${spec.fingerprint}); a foreign acceptance never mints this goal's receipt (SPEC 13.10)`);
|
|
659
|
+
const op = decision.request.op;
|
|
660
|
+
if (op?.command !== spec.command)
|
|
661
|
+
throw new EpEnvelopeError("internal", `the acceptance at ${subject} carries command ${JSON.stringify(op?.command)}, not the accepted goal's ${JSON.stringify(spec.command)}; a foreign acceptance never mints this goal's receipt (SPEC 13.10)`);
|
|
662
|
+
// The chain proves the acceptance names THIS goal (security CF-2 HIGH): createGoal accepts a
|
|
663
|
+
// caller-supplied fingerprint, so goal B planted with goal A's fingerprint/requestId/sourceSeq
|
|
664
|
+
// passes every check above - but A's acceptance embeds `goalId: A`, and the fingerprint binds
|
|
665
|
+
// it, so the embedded request's goalId is the discriminator a plant cannot forge.
|
|
666
|
+
const reqGoalId = decision.request.goalId;
|
|
667
|
+
if (reqGoalId !== snap.goalId)
|
|
668
|
+
throw new EpEnvelopeError("internal", `the acceptance at ${subject} names goal ${JSON.stringify(reqGoalId)}, not "${snap.goalId}"; an acceptance whose accepted request does not name THIS goal never mints its receipt - a planted fingerprint cannot borrow a foreign acceptance (SPEC 13.10)`);
|
|
669
|
+
const candidate = mintReceiptFromFacts({
|
|
670
|
+
acceptance: decision, caller: snap.caller, space: ctx.space,
|
|
671
|
+
terminal: receiptOutcomeOfGoal(fact.state, fact.outcomeDigest),
|
|
672
|
+
instance: wiring.instance, ts, signer: wiring.signer,
|
|
673
|
+
}, wiring.keyPair);
|
|
674
|
+
const rref = { endpoint: snap.endpoint, caller: snap.caller, requestId: decision.id, sourceSeq: decision.sourceSeq };
|
|
675
|
+
const recorded = await readReceipt(wiring.store, rref);
|
|
676
|
+
if (recorded !== undefined) {
|
|
677
|
+
assertReceiptAttestsSameFacts(recorded, candidate);
|
|
678
|
+
return { outcome: "converged", receipt: recorded };
|
|
679
|
+
}
|
|
680
|
+
try {
|
|
681
|
+
const res = await publishReceipt(wiring.store, rref, candidate);
|
|
682
|
+
return { outcome: res.won ? "emitted" : "converged", receipt: res.receipt };
|
|
683
|
+
}
|
|
684
|
+
catch (e) {
|
|
685
|
+
// publishReceipt's byte-identity convergence loses to a racing emitter whose evidence
|
|
686
|
+
// (ts/instance) legitimately differs; adopt its receipt exactly when it attests the SAME
|
|
687
|
+
// facts, and propagate the conflict when it does not.
|
|
688
|
+
if (!(e instanceof EpEnvelopeError && e.code === "conflict"))
|
|
689
|
+
throw e;
|
|
690
|
+
const winner = await readReceipt(wiring.store, rref);
|
|
691
|
+
if (winner === undefined)
|
|
692
|
+
throw e;
|
|
693
|
+
assertReceiptAttestsSameFacts(winner, candidate);
|
|
694
|
+
return { outcome: "converged", receipt: winner };
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
/** The durable backstop for the §13.10 MUST-emit guarantee: re-derive and publish the receipt
|
|
698
|
+
* for a goal whose terminal committed but whose emission was omitted (a crash between the
|
|
699
|
+
* terminal CAS and the publish, or a commit made without emission wiring). Reads the persisted
|
|
700
|
+
* spec and the committed terminal FRESH, then runs the SAME emission seam the inline path uses.
|
|
701
|
+
* `no-terminal` = nothing to attest yet (the goal simply is not terminal — never an error). */
|
|
702
|
+
export async function reconcileReceiptEmission(ctx, wiring, args) {
|
|
703
|
+
assertCtx(ctx);
|
|
704
|
+
assertEmissionWiring(ctx, wiring);
|
|
705
|
+
const snap = snapshotRef(args.ref);
|
|
706
|
+
const now = assertSafeInt(args.now, "now");
|
|
707
|
+
const spec = await readGoalSpec(ctx, snap);
|
|
708
|
+
if (spec === undefined)
|
|
709
|
+
throw new EpEnvelopeError("failed-precondition", `goal "${snap.goalId}" has no accepted spec; only an accepted goal's terminal ever carries a receipt (SPEC 13.6/13.10)`);
|
|
710
|
+
const fact = await readGoalResult(ctx, snap);
|
|
711
|
+
if (fact === undefined)
|
|
712
|
+
return { outcome: "no-terminal" };
|
|
713
|
+
return emitReceiptForTerminal(ctx, wiring, { ref: snap, spec: spec.value, fact, ts: now });
|
|
714
|
+
}
|
|
715
|
+
/** Commit the goal's terminal state at the ONE mediated commit point, BOUND to the persisted
|
|
716
|
+
* accepted goal and its CAUSE (see {@link GoalCommitCause}). First terminal fact wins uniformly
|
|
717
|
+
* (completion, cancel, deny, and readiness race here); a loser observes the winner and its
|
|
718
|
+
* projection converges. Every operation input detaches at ENTRY (single-read, before the first
|
|
719
|
+
* await): a caller mutating cause/state/data/executor across the spec read changes nothing.
|
|
720
|
+
*
|
|
721
|
+
* RECEIPT EMISSION (§13.10): with `receipts` wired, the commit emits the terminal's receipt
|
|
722
|
+
* INLINE, best-effort, for the WINNING fact (won or lost — the terminal is committed either
|
|
723
|
+
* way and emission is idempotent). Invalid wiring refuses at ENTRY, before any terminal
|
|
724
|
+
* commits; a RUNTIME emission failure after the irreversible commit surfaces as
|
|
725
|
+
* `receiptEmission: { outcome: "failed" }` — it never masks the committed terminal, and
|
|
726
|
+
* {@link reconcileReceiptEmission} is the durable backstop that converges it. */
|
|
727
|
+
export async function commitGoalResult(ctx, args) {
|
|
728
|
+
assertCtx(ctx);
|
|
729
|
+
// ENTRY SNAPSHOT (single-read): ref, clock, cause, wiring, and every per-cause field detach
|
|
730
|
+
// BEFORE the first await; the terminal payload detaches strict-canonical here too.
|
|
731
|
+
const snap = snapshotRef(args.ref);
|
|
732
|
+
const now = assertSafeInt(args.now, "now");
|
|
733
|
+
const receipts = args.receipts;
|
|
734
|
+
if (receipts !== undefined)
|
|
735
|
+
assertEmissionWiring(ctx, receipts);
|
|
736
|
+
const cause = args.cause;
|
|
737
|
+
const dataRaw = args.data;
|
|
738
|
+
let data;
|
|
739
|
+
if (dataRaw !== undefined) {
|
|
740
|
+
try {
|
|
741
|
+
data = JSON.parse(canonicalJson(dataRaw));
|
|
742
|
+
}
|
|
743
|
+
catch (e) {
|
|
744
|
+
throw new EpEnvelopeError("failed-precondition", `the terminal payload is not interchangeable JSON (${e.message}); a garbled payload never commits (SPEC 13.6)`);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
let plan;
|
|
748
|
+
if (cause === "complete") {
|
|
749
|
+
const state = args.state;
|
|
750
|
+
if (state !== "succeeded" && state !== "failed")
|
|
751
|
+
throw new EpEnvelopeError("failed-precondition", `a completion commits "succeeded" or "failed"; got ${JSON.stringify(state)} (SPEC 13.6)`);
|
|
752
|
+
const executor = args.executor !== undefined ? { lifecycleUid: String(args.executor.lifecycleUid), epoch: args.executor.epoch } : undefined;
|
|
753
|
+
const budget = args.epochResolveBudgetMs ?? 5_000;
|
|
754
|
+
if (!Number.isSafeInteger(budget) || budget <= 0)
|
|
755
|
+
throw new EpEnvelopeError("failed-precondition", `epochResolveBudgetMs must be a positive integer; got ${JSON.stringify(args.epochResolveBudgetMs)}`);
|
|
756
|
+
plan = { cause, state, executor, resolver: args.resolveCurrentEpoch, budget };
|
|
757
|
+
}
|
|
758
|
+
else if (cause === "cancel") {
|
|
759
|
+
plan = { cause };
|
|
760
|
+
}
|
|
761
|
+
else if (cause === "deny") {
|
|
762
|
+
const denial = args.denial;
|
|
763
|
+
const kind = denial === null || typeof denial !== "object" ? undefined : denial.kind;
|
|
764
|
+
if (kind === "hold-expired") {
|
|
765
|
+
const token = denial.token;
|
|
766
|
+
if (typeof token !== "string" || token.length === 0)
|
|
767
|
+
throw new EpEnvelopeError("failed-precondition", `a hold-expired denial names its checkpoint token (SPEC 13.6)`);
|
|
768
|
+
plan = { cause, denial: { kind, token } };
|
|
769
|
+
}
|
|
770
|
+
else if (kind === "owner") {
|
|
771
|
+
// Verified HERE, at entry: a hand-assembled or cross-context proof is a loud refusal.
|
|
772
|
+
if (!assertOwnerProof(denial.owner, ctx, `the deny of goal "${snap.goalId}"`))
|
|
773
|
+
throw new EpEnvelopeError("failed-precondition", `an owner deny presents the owner's construction-bound proof (ownerCommitProof); a bare deny cause proves nothing (SPEC 13.6)`);
|
|
774
|
+
plan = { cause, denial: { kind } };
|
|
775
|
+
}
|
|
776
|
+
else {
|
|
777
|
+
throw new EpEnvelopeError("failed-precondition", `a deny commits only with its authoritative predicate (a goal-bound EXPIRED hold settlement, or the owner's construction-bound proof); a bare deny cause could fail any accepted goal (SPEC 13.6)`);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
else if (cause === "readiness") {
|
|
781
|
+
plan = { cause };
|
|
782
|
+
}
|
|
783
|
+
else {
|
|
784
|
+
throw new EpEnvelopeError("failed-precondition", `unknown commit cause ${JSON.stringify(cause)}; the terminal commit accepts only complete|cancel|deny|readiness (SPEC 13.6)`);
|
|
785
|
+
}
|
|
786
|
+
// LEADER-SERVED (H2): this spec read gates the epoch proof below and the terminal CAS is
|
|
787
|
+
// create-only (first-terminal-wins, no revision pin to catch a stale input later), so the
|
|
788
|
+
// proof's input must come from the leader, not a possibly-follower Direct Get.
|
|
789
|
+
const spec = await readGoalSpecLeader(ctx, snap);
|
|
790
|
+
if (spec === undefined)
|
|
791
|
+
throw new EpEnvelopeError("failed-precondition", `goal "${snap.goalId}" has no accepted spec; a terminal commits only for an accepted goal (SPEC 13.6)`);
|
|
792
|
+
// The post-commit emission tail every cause routes through: the terminal is already committed
|
|
793
|
+
// and immutable when this runs, so a runtime emission failure SURFACES on the result instead
|
|
794
|
+
// of masking the commit (the reconciler converges it); wiring errors refused at entry above.
|
|
795
|
+
const finish = async (committed) => {
|
|
796
|
+
if (receipts === undefined)
|
|
797
|
+
return committed;
|
|
798
|
+
try {
|
|
799
|
+
return { ...committed, receiptEmission: await emitReceiptForTerminal(ctx, receipts, { ref: snap, spec: spec.value, fact: committed.fact, ts: now }) };
|
|
800
|
+
}
|
|
801
|
+
catch (e) {
|
|
802
|
+
const error = e instanceof EpEnvelopeError ? e : new EpEnvelopeError("internal", `receipt emission failed: ${e?.message ?? String(e)}`);
|
|
803
|
+
return { ...committed, receiptEmission: { outcome: "failed", error } };
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
if (plan.cause === "complete") {
|
|
807
|
+
// Currency immediately before the terminal CAS — the check is paired with the commit it
|
|
808
|
+
// fences, never a stale earlier read (SPEC 13.6 item 7).
|
|
809
|
+
await assertExecutorCurrency(spec.value, snap.goalId, plan.executor, plan.resolver, plan.budget);
|
|
810
|
+
return finish(await commitTerminalFact(ctx, snap, spec.value, plan.state, data, now));
|
|
811
|
+
}
|
|
812
|
+
if (plan.cause === "cancel") {
|
|
813
|
+
const status = await readGoalStatus(ctx, snap);
|
|
814
|
+
if (status === undefined || status.value.state !== "cancelling")
|
|
815
|
+
throw new EpEnvelopeError("failed-precondition", `goal "${snap.goalId}" is not \`cancelling\` (state ${status?.value.state ?? "unknown"}); a cancel terminal follows a requested cancel, never a naked assertion (SPEC 13.6)`);
|
|
816
|
+
return finish(await commitTerminalFact(ctx, snap, spec.value, "cancelled", data, now));
|
|
817
|
+
}
|
|
818
|
+
if (plan.cause === "deny") {
|
|
819
|
+
if (plan.denial.kind === "hold-expired") {
|
|
820
|
+
// The predicate is the checkpoint arbiter's OWN recorded state: the spec must bind THIS
|
|
821
|
+
// goal (a token for goal A never fails unrelated goal B) and the one-use settlement must
|
|
822
|
+
// be EXPIRED — a live or resumed hold never denies.
|
|
823
|
+
const cpRef = { endpoint: snap.endpoint, token: plan.denial.token };
|
|
824
|
+
const cpSpec = await readCheckpointSpec(ctx.kv, cpRef);
|
|
825
|
+
if (cpSpec === undefined)
|
|
826
|
+
throw new EpEnvelopeError("failed-precondition", `checkpoint "${plan.denial.token}" is unknown on endpoint "${snap.endpoint}"; a hold-expired denial names a minted checkpoint (SPEC 13.6)`);
|
|
827
|
+
const g = cpSpec.goal;
|
|
828
|
+
if (g === undefined || g.goalId !== snap.goalId
|
|
829
|
+
|| g.caller.owner !== snap.caller.owner || g.caller.actor !== snap.caller.actor || g.caller.uid !== snap.caller.uid)
|
|
830
|
+
throw new EpEnvelopeError("permission-denied", `checkpoint "${plan.denial.token}" does not pause goal "${snap.goalId}" (${snap.caller.owner}.${snap.caller.actor}/${snap.caller.uid}); a hold-expired denial is goal-bound (SPEC 13.6)`);
|
|
831
|
+
const settle = await readCheckpointSettle(ctx.jsm, ctx.space, cpRef);
|
|
832
|
+
if (settle === undefined || settle.settle !== "expired")
|
|
833
|
+
throw new EpEnvelopeError("failed-precondition", `checkpoint "${plan.denial.token}" has no RECORDED expired settlement (${settle === undefined ? "still live" : `settled ${settle.settle}`}); a live or resumed hold never denies (SPEC 13.6)`);
|
|
834
|
+
}
|
|
835
|
+
return finish(await commitTerminalFact(ctx, snap, spec.value, "failed", data, now));
|
|
836
|
+
}
|
|
837
|
+
// readiness
|
|
838
|
+
if (spec.value.readinessDeadlineMs === undefined)
|
|
839
|
+
throw new EpEnvelopeError("failed-precondition", `goal "${snap.goalId}" declares no readiness deadline; an unbounded goal is never settled uncertain (SPEC 13.6)`);
|
|
840
|
+
if (now < spec.value.acceptedAt + spec.value.readinessDeadlineMs)
|
|
841
|
+
throw new EpEnvelopeError("failed-precondition", `goal "${snap.goalId}" is not past its readiness deadline (acceptedAt ${spec.value.acceptedAt} + ${spec.value.readinessDeadlineMs}ms > now ${now}); an early uncertain settle would steal a still-possible success (SPEC 13.6)`);
|
|
842
|
+
return finish(await commitTerminalFact(ctx, snap, spec.value, "uncertain", { reason: "the success signal did not arrive within the readiness deadline", readinessDeadlineMs: spec.value.readinessDeadlineMs }, now));
|
|
843
|
+
}
|
|
844
|
+
// ---- cancel (§13.6 item 4) --------------------------------------------------------------------
|
|
845
|
+
/** The reverse-DNS detail kind carrying a goal's cached terminal fact on an error (§13.3). */
|
|
846
|
+
export const GOAL_TERMINAL_DETAIL_KIND = "ai.cotal.goal.terminal";
|
|
847
|
+
function alreadyTerminal(goalId, fact) {
|
|
848
|
+
return new EpEnvelopeError("failed-precondition", `goal "${goalId}" is already terminal (${fact.state}); the cached outcome is attached (SPEC 13.6)`, [{ kind: GOAL_TERMINAL_DETAIL_KIND, fact }]);
|
|
849
|
+
}
|
|
850
|
+
/** The reserved `cancel` command's handler seam. The goal ref derives STRUCTURALLY from the
|
|
851
|
+
* broker-authenticated request (a caller cancels only its own goals). Unknown = failed-
|
|
852
|
+
* precondition; terminal = failed-precondition with the cached outcome on error.details;
|
|
853
|
+
* otherwise the status transitions to `cancelling` (owner/caller-authored — no executor) and
|
|
854
|
+
* the owner later commits the `cancel` cause. A completion that landed during the transition is
|
|
855
|
+
* observed AFTER it and converges the projection. */
|
|
856
|
+
export async function requestGoalCancel(ctx, args) {
|
|
857
|
+
assertCtx(ctx);
|
|
858
|
+
if (args.mode !== "graceful" && args.mode !== "terminate")
|
|
859
|
+
throw new EpEnvelopeError("failed-precondition", `cancel mode must be "graceful" or "terminate"; got ${JSON.stringify(args.mode)} (SPEC 13.6)`);
|
|
860
|
+
const ref = goalRefOf(args.request, args.goalId);
|
|
861
|
+
const cached = await readGoalResult(ctx, ref);
|
|
862
|
+
if (cached !== undefined)
|
|
863
|
+
throw alreadyTerminal(ref.goalId, cached);
|
|
864
|
+
let projected;
|
|
865
|
+
for (let pass = 0; pass < 2 && projected === undefined; pass++) {
|
|
866
|
+
const status = await readGoalStatus(ctx, ref);
|
|
867
|
+
if (status === undefined)
|
|
868
|
+
throw new EpEnvelopeError("failed-precondition", `goal "${ref.goalId}" is unknown; cancel addresses only an accepted goal (SPEC 13.6)`);
|
|
869
|
+
if (status.value.state === "cancelling") {
|
|
870
|
+
projected = status.value;
|
|
871
|
+
break;
|
|
872
|
+
}
|
|
873
|
+
if (GOAL_TERMINAL_STATES.includes(status.value.state)) {
|
|
874
|
+
const fact = await readGoalResult(ctx, ref);
|
|
875
|
+
if (fact === undefined)
|
|
876
|
+
throw new EpEnvelopeError("internal", `goal "${ref.goalId}" status is terminal but no result fact is readable; a projection never leads the journal (SPEC 13.6)`);
|
|
877
|
+
throw alreadyTerminal(ref.goalId, fact);
|
|
878
|
+
}
|
|
879
|
+
try {
|
|
880
|
+
projected = await transitionGoal(ctx, ref, "cancelling", { fields: { cancelMode: args.mode } });
|
|
881
|
+
}
|
|
882
|
+
catch (e) {
|
|
883
|
+
if (!(e instanceof EpEnvelopeError && e.code === "conflict"))
|
|
884
|
+
throw e;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
if (projected === undefined)
|
|
888
|
+
throw new EpEnvelopeError("conflict", `the goal status for "${ref.goalId}" moved twice during one cancel; re-read and re-decide (SPEC 13.4)`);
|
|
889
|
+
const raced = await readGoalResult(ctx, ref);
|
|
890
|
+
if (raced !== undefined) {
|
|
891
|
+
await projectGoalTerminal(ctx, ref);
|
|
892
|
+
throw alreadyTerminal(ref.goalId, raced);
|
|
893
|
+
}
|
|
894
|
+
return projected;
|
|
895
|
+
}
|
|
896
|
+
// ---- bounded readiness (§13.6 item 6) ----------------------------------------------------------
|
|
897
|
+
/** Settle a goal `uncertain` at its persisted acceptance-relative readiness deadline (the
|
|
898
|
+
* `readiness` cause). The bound is read from the PERSISTED spec; a target-pinned goal is
|
|
899
|
+
* REACHABLE (readiness is the owner's deadline, not an executor completion, so no executor is
|
|
900
|
+
* required). A racing late success that committed first wins and this returns the winner. */
|
|
901
|
+
export async function settleGoalUncertain(ctx, args) {
|
|
902
|
+
return commitGoalResult(ctx, { ref: args.ref, now: args.now, cause: "readiness" });
|
|
903
|
+
}
|
|
904
|
+
// ---- goalId reuse (§13.6 item 5) ---------------------------------------------------------------
|
|
905
|
+
/** Classify a resubmission against the recorded bind: same fingerprint = retry (cached), else
|
|
906
|
+
* `conflict`. {@link resolveGoalSubmission} composes this with the orphaned-bind recovery. */
|
|
907
|
+
export function classifyGoalReuse(existing, submittedFingerprint) {
|
|
908
|
+
return existing.fingerprint === submittedFingerprint ? "cached" : "conflict";
|
|
909
|
+
}
|
|
910
|
+
//# sourceMappingURL=endpoint-action.js.map
|