@godspeedai/cognate-runtime-bun 0.0.0-stage → 0.1.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/LICENSE +201 -0
- package/README.md +39 -2
- package/package.json +38 -4
- package/src/cognition.ts +66 -0
- package/src/core.ts +36 -0
- package/src/executor.ts +423 -0
- package/src/fold.ts +96 -0
- package/src/governance-env.ts +35 -0
- package/src/governed-operation.ts +248 -0
- package/src/index.ts +95 -0
- package/src/inspect.ts +58 -0
- package/src/invoker.ts +145 -0
- package/src/remote-offers.ts +60 -0
- package/src/runtime.ts +235 -0
- package/src/sea-forge-authority.ts +401 -0
- package/src/service.ts +517 -0
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// Composition root: store, kernel, continuations, artifacts, projections, cognition, and the background loop.
|
|
2
|
+
import { ArtifactService } from "@godspeedai/cognate-artifacts";
|
|
3
|
+
import { ContinuationService } from "@godspeedai/cognate-continuations";
|
|
4
|
+
import { type DurableStore, OutboxDispatcher } from "@godspeedai/cognate-events";
|
|
5
|
+
import { createCordisKernel } from "@godspeedai/cognate-kernel-cordis";
|
|
6
|
+
import type { Principal } from "@godspeedai/cognate-kernel-api";
|
|
7
|
+
import { ProjectionRunner } from "@godspeedai/cognate-projections";
|
|
8
|
+
import { openSqliteStore } from "@godspeedai/cognate-store-sqlite";
|
|
9
|
+
import { COGNITION_PROJECTION, cognitionProjection, recallFor } from "./cognition.ts";
|
|
10
|
+
import type { Core } from "./core.ts";
|
|
11
|
+
import { executeOnce } from "./executor.ts";
|
|
12
|
+
import { foldRun, isTerminal, RESUME_HANDLER, RUN_EXECUTE_TOPIC, RUNS, runStream } from "./fold.ts";
|
|
13
|
+
import type { Runtime, RuntimeOptions } from "./index.ts";
|
|
14
|
+
import { inspectCorrelation } from "./inspect.ts";
|
|
15
|
+
import { Governor } from "./governed-operation.ts";
|
|
16
|
+
import { Invoker } from "./invoker.ts";
|
|
17
|
+
import { createService } from "./service.ts";
|
|
18
|
+
import { RemoteOfferRegistry } from "./remote-offers.ts";
|
|
19
|
+
|
|
20
|
+
const DELIVERY = { baseMs: 50, maxMs: 5_000, maxAttempts: 10 };
|
|
21
|
+
const TOPICS = [RUN_EXECUTE_TOPIC, "continuation.resume"];
|
|
22
|
+
|
|
23
|
+
export async function startRuntime(options: RuntimeOptions): Promise<Runtime> {
|
|
24
|
+
const ownsStore = typeof options.store === "string";
|
|
25
|
+
const store: DurableStore = typeof options.store === "string" ? openSqliteStore({ path: options.store }) : options.store.store;
|
|
26
|
+
const system: Principal = options.system ?? { id: "system", kind: "system" };
|
|
27
|
+
const governor = new Governor(options.actions, options.policy, options.governance ?? { mode: "off" });
|
|
28
|
+
const invoker = new Invoker(governor);
|
|
29
|
+
// The kernel is single-tenant by contract; the invoker supplies each call's tenant to the policy.
|
|
30
|
+
const kernel = await createCordisKernel({ tenant: "runtime", principal: system, policy: invoker.policy });
|
|
31
|
+
await invoker.attach(kernel, options.components ?? []);
|
|
32
|
+
|
|
33
|
+
// Authorization happens in the service against `actions`; the continuation layer trusts its caller.
|
|
34
|
+
const continuations = new ContinuationService(store, { policy: { authorize: () => ({ allow: true, reason: "authorized by runtime service" }) }, delivery: DELIVERY });
|
|
35
|
+
|
|
36
|
+
let closed = false;
|
|
37
|
+
const chains = new Map<string, Promise<void>>();
|
|
38
|
+
const pending = new Set<string>();
|
|
39
|
+
const execute = (runId: string): Promise<void> => {
|
|
40
|
+
const next = (chains.get(runId) ?? Promise.resolve()).then(async () => {
|
|
41
|
+
for (let attempt = 0; attempt < 5 && !closed; attempt++) if ((await executeOnce(core, runId)) === "done") return;
|
|
42
|
+
});
|
|
43
|
+
const tracked = next.finally(() => {
|
|
44
|
+
if (chains.get(runId) === tracked) chains.delete(runId);
|
|
45
|
+
});
|
|
46
|
+
chains.set(runId, tracked);
|
|
47
|
+
return tracked;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const core: Core = {
|
|
51
|
+
store,
|
|
52
|
+
kernel,
|
|
53
|
+
invoker,
|
|
54
|
+
continuations,
|
|
55
|
+
artifacts: new ArtifactService(store),
|
|
56
|
+
governor,
|
|
57
|
+
agents: new Map(options.agents.map((a) => [a.id, a])),
|
|
58
|
+
scopeId: kernel.root.id,
|
|
59
|
+
recall: recallFor(store, options.cognition),
|
|
60
|
+
outboxFor: options.outboxFor ?? (() => []),
|
|
61
|
+
controllers: new Map(),
|
|
62
|
+
remoteOffers: new RemoteOfferRegistry(),
|
|
63
|
+
execute,
|
|
64
|
+
kick: () => kick(),
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const unregister = continuations.registerHandler({
|
|
68
|
+
...RESUME_HANDLER,
|
|
69
|
+
supports: (stored) => stored === RESUME_HANDLER.version,
|
|
70
|
+
handle: (record) => execute(record.runId),
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const runDispatcher = new OutboxDispatcher(store, {
|
|
74
|
+
topic: RUN_EXECUTE_TOPIC,
|
|
75
|
+
backoff: DELIVERY,
|
|
76
|
+
deliver: async (item) => {
|
|
77
|
+
await execute((item.payload as { runId: string }).runId);
|
|
78
|
+
return { executed: true };
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
async function tick(): Promise<void> {
|
|
83
|
+
for (const record of await continuations.expireDue()) pending.add(record.runId);
|
|
84
|
+
await continuations.dispatchResumes();
|
|
85
|
+
await runDispatcher.drain();
|
|
86
|
+
for (const runId of [...pending]) {
|
|
87
|
+
pending.delete(runId);
|
|
88
|
+
await execute(runId);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let ticking: Promise<void> | null = null;
|
|
93
|
+
let again = false;
|
|
94
|
+
function kick(): void {
|
|
95
|
+
if (closed) return;
|
|
96
|
+
if (ticking) {
|
|
97
|
+
again = true;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
ticking = (async () => {
|
|
101
|
+
do {
|
|
102
|
+
again = false;
|
|
103
|
+
await tick().catch(() => {});
|
|
104
|
+
} while (again && !closed);
|
|
105
|
+
})().finally(() => {
|
|
106
|
+
ticking = null;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Projections: user projections, plus the cognitive indexer when cognition is configured.
|
|
111
|
+
const runners: ProjectionRunner[] = [];
|
|
112
|
+
for (const { projection } of options.projections ?? []) runners.push(new ProjectionRunner(store, projection));
|
|
113
|
+
let cognitionRunner: ProjectionRunner | undefined;
|
|
114
|
+
if (options.cognition) {
|
|
115
|
+
cognitionRunner = new ProjectionRunner(store, cognitionProjection(options.cognition));
|
|
116
|
+
runners.push(cognitionRunner);
|
|
117
|
+
if (!options.cognition.persistent) {
|
|
118
|
+
// The index lost its data with the process; never let the checkpoint claim entries it no longer holds.
|
|
119
|
+
await options.cognition.index.clear();
|
|
120
|
+
await cognitionRunner.rebuild();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
for (const runner of runners) {
|
|
124
|
+
if ((await runner.catchUp()).status === "rebuild_required") await runner.rebuild();
|
|
125
|
+
runner.start();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Recovery: re-enter runs that were running at shutdown or whose wait closed without a delivery.
|
|
129
|
+
for (const { id } of await store.listRecords(RUNS)) {
|
|
130
|
+
const view = foldRun(id, await store.readStream(runStream(id)));
|
|
131
|
+
if (!view || isTerminal(view)) continue;
|
|
132
|
+
if (view.status === "running") pending.add(id);
|
|
133
|
+
for (const continuationId of view.waitingOn) {
|
|
134
|
+
const record = await continuations.get(continuationId);
|
|
135
|
+
if (record && !["OPEN", "BLOCKED", "RESUMED"].includes(record.status)) pending.add(id);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const timer = setInterval(kick, options.tickMs ?? 10);
|
|
140
|
+
kick();
|
|
141
|
+
|
|
142
|
+
const names = [...(options.projections ?? []).map((p) => p.projection.name), ...(cognitionRunner ? [COGNITION_PROJECTION.name] : [])];
|
|
143
|
+
|
|
144
|
+
async function pollApprovals(): Promise<number> {
|
|
145
|
+
const authority = options.governance?.authority;
|
|
146
|
+
if (!authority?.approvalState) return 0;
|
|
147
|
+
const SYSTEM_ACTOR = system;
|
|
148
|
+
let acted = 0;
|
|
149
|
+
for (const record of await continuations.list({ status: "OPEN" })) {
|
|
150
|
+
if (record.type !== "governance-approval") continue;
|
|
151
|
+
const approvalId = (record.condition as { approvalId?: unknown } | null)?.approvalId;
|
|
152
|
+
if (typeof approvalId !== "string") continue;
|
|
153
|
+
const state = await authority.approvalState(approvalId);
|
|
154
|
+
if (state === "approved") {
|
|
155
|
+
// Idempotent per approval: a second pass (or a second runtime) cannot resume it twice.
|
|
156
|
+
const outcome = await continuations.resume(record.id, { actor: SYSTEM_ACTOR, idempotencyKey: `governance-resume:${approvalId}`, input: { approvalId, state } });
|
|
157
|
+
if (outcome.kind === "transitioned") acted++;
|
|
158
|
+
} else if (state === "rejected" || state === "expired" || state === "used") {
|
|
159
|
+
const outcome = await continuations.cancel(record.id, { actor: SYSTEM_ACTOR, idempotencyKey: `governance-close:${approvalId}`, reason: `approval ${approvalId} is ${state}` });
|
|
160
|
+
if (outcome.kind === "transitioned") {
|
|
161
|
+
acted++;
|
|
162
|
+
void execute(record.runId).catch(() => {});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (acted) kick();
|
|
167
|
+
return acted;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let approvalTimer: ReturnType<typeof setInterval> | undefined;
|
|
171
|
+
if ((options.governance?.approvalPollMs ?? 0) > 0 && options.governance?.authority?.approvalState) {
|
|
172
|
+
approvalTimer = setInterval(() => void pollApprovals().catch(() => {}), options.governance.approvalPollMs);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function quiescent(): Promise<boolean> {
|
|
176
|
+
if (ticking || pending.size > 0 || chains.size > 0) return false;
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
for (const topic of TOPICS) {
|
|
179
|
+
const items = await store.listOutbox({ topic, status: "pending" });
|
|
180
|
+
// Items waiting out a retry backoff do not count as queued work.
|
|
181
|
+
if (items.some((item) => item.availableAt <= now || item.lastError === null)) return false;
|
|
182
|
+
}
|
|
183
|
+
for (const name of names) {
|
|
184
|
+
const checkpoint = await store.getProjection(name);
|
|
185
|
+
if (checkpoint?.status === "error") continue;
|
|
186
|
+
const behind = await store.readAll({ afterPosition: checkpoint?.position ?? 0, limit: 1 });
|
|
187
|
+
if (behind.length > 0) return false;
|
|
188
|
+
}
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return {
|
|
193
|
+
service: createService(core, new Set((options.projections ?? []).filter((p) => p.public).map((p) => p.projection.name))),
|
|
194
|
+
kernel,
|
|
195
|
+
store,
|
|
196
|
+
|
|
197
|
+
inspect: (correlationId) => inspectCorrelation(store, continuations, correlationId),
|
|
198
|
+
|
|
199
|
+
async rebuildCognition() {
|
|
200
|
+
if (!options.cognition || !cognitionRunner) return;
|
|
201
|
+
await options.cognition.index.clear();
|
|
202
|
+
await cognitionRunner.rebuild();
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
async erase(eventId, reason, actor) {
|
|
206
|
+
await store.erasePayload(eventId, reason, actor.id);
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
pollApprovals,
|
|
210
|
+
|
|
211
|
+
async idle(timeoutMs = 10_000) {
|
|
212
|
+
const deadline = Date.now() + timeoutMs;
|
|
213
|
+
for (;;) {
|
|
214
|
+
// Two consecutive quiet observations, so work kicked off between checks is not missed.
|
|
215
|
+
if ((await quiescent()) && (await Bun.sleep(1), await quiescent())) return;
|
|
216
|
+
if (Date.now() > deadline) throw new Error(`runtime-bun: idle() timed out after ${timeoutMs}ms`);
|
|
217
|
+
kick();
|
|
218
|
+
await Bun.sleep(2);
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
async close() {
|
|
223
|
+
if (closed) return;
|
|
224
|
+
closed = true;
|
|
225
|
+
clearInterval(timer);
|
|
226
|
+
if (approvalTimer) clearInterval(approvalTimer);
|
|
227
|
+
await ticking;
|
|
228
|
+
await Promise.allSettled([...chains.values()]);
|
|
229
|
+
for (const runner of runners) await runner.stop();
|
|
230
|
+
unregister();
|
|
231
|
+
await kernel.shutdown("runtime closed");
|
|
232
|
+
if (ownsStore) await store.close();
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
// SeaForgeAuthority: the Cognate half of the CEP authority loop (migration Stage 6).
|
|
2
|
+
//
|
|
3
|
+
// Builds a CEP `authority_request` for one GovernedOperation, sends it to SEA-Forge over its Unix-socket
|
|
4
|
+
// NDJSON protocol (`authority_request` verb), and accepts only a response that is demonstrably the decision
|
|
5
|
+
// for *this* request in *this* world. Anything else is `unknown`, which the Governor refuses.
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
import type { ApprovalReference, ApprovalState, AuthorityReference, GovernanceAuthority, GovernanceConstraint, GovernanceDecision, GovernedOperation } from "./governed-operation.ts";
|
|
8
|
+
|
|
9
|
+
export interface SeaForgeAuthorityOptions {
|
|
10
|
+
/** Path of the SEA-Forge cell's `server.sock`. */
|
|
11
|
+
readonly socketPath: string;
|
|
12
|
+
/** The identity Cognate claims; the server verifies it against the socket peer's uid. */
|
|
13
|
+
readonly actor?: { readonly actor_id: string; readonly role: string };
|
|
14
|
+
readonly timeoutMs?: number;
|
|
15
|
+
/** Largest response line accepted. */
|
|
16
|
+
readonly maxResponseBytes?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const REQUEST_PROFILE = "godspeed.authority_request";
|
|
20
|
+
const DECISION_PROFILE = "godspeed.authority_decision";
|
|
21
|
+
const PROFILE_VERSION = "1.0.0";
|
|
22
|
+
const DECISIONS: ReadonlySet<string> = new Set(["allow", "deny", "escalate", "unknown", "constrained_allow", "boundary", "degraded"]);
|
|
23
|
+
const CONSTRAINED: ReadonlySet<string> = new Set(["constrained_allow", "boundary", "degraded"]);
|
|
24
|
+
|
|
25
|
+
type Obj = Record<string, unknown>;
|
|
26
|
+
const isObj = (value: unknown): value is Obj => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
27
|
+
const str = (value: unknown): string | undefined => (typeof value === "string" && value.length > 0 ? value : undefined);
|
|
28
|
+
const strings = (value: unknown): string[] | undefined => (Array.isArray(value) && value.every((v) => typeof v === "string" && v.length > 0) ? (value as string[]) : undefined);
|
|
29
|
+
|
|
30
|
+
function describe(operation: GovernedOperation): { kind: "action" | "capability"; name: string; resource: string; subject: string; correlation: string } {
|
|
31
|
+
if (operation.kind === "action") {
|
|
32
|
+
return { kind: "action", name: operation.action, resource: operation.resource, subject: `actor:${operation.caller.tenant}/${operation.caller.actor.id}`, correlation: "" };
|
|
33
|
+
}
|
|
34
|
+
const { request } = operation;
|
|
35
|
+
return { kind: "capability", name: "invoke", resource: request.capability.id, subject: `actor:${request.tenant}/${request.actor.id}`, correlation: request.correlationId };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The CEP `authority_request` envelope for one operation (profile godspeed.authority_request). */
|
|
39
|
+
export function buildAuthorityRequest(operation: GovernedOperation, worldRef: string, now: Date = new Date(), approval?: ApprovalReference, carry?: CarriedWork): { envelope: Obj; operationId: string } {
|
|
40
|
+
return buildEnvelope(describe(operation), worldRef, now, approval, carry);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Work decided in an earlier world that this request carries forward. SEA-Forge refuses a lineage reaching across
|
|
45
|
+
* worlds unless it cites the allowed transition that moved it (`ledgerEntry` from `listTransitions()`); work already
|
|
46
|
+
* decided stays pinned to its world and is never re-pinned.
|
|
47
|
+
*/
|
|
48
|
+
export interface CarriedWork {
|
|
49
|
+
/** The request envelope ids of the earlier work. */
|
|
50
|
+
readonly lineage: readonly string[];
|
|
51
|
+
/** `RecordedTransition.ledgerEntry` of the move from that work's world to this request's world. */
|
|
52
|
+
readonly transitionLedgerEntry: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function buildEnvelope(d: ReturnType<typeof describe>, worldRef: string, now: Date, approval?: ApprovalReference, carry?: CarriedWork): { envelope: Obj; operationId: string } {
|
|
56
|
+
const operationId = `op-${randomUUID()}`;
|
|
57
|
+
const at = now.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
58
|
+
const provenanceId = `prov-${operationId}`;
|
|
59
|
+
const envelope: Obj = {
|
|
60
|
+
envelope_id: `env-authority_request-${operationId}`,
|
|
61
|
+
cep_version: "1.0.0",
|
|
62
|
+
envelope_version: "1.0",
|
|
63
|
+
envelope_kind: "authority_request",
|
|
64
|
+
created_at: at,
|
|
65
|
+
created_by: "cognate",
|
|
66
|
+
scope: { evaluation_context: "cognate-authority_request", world_ref: worldRef },
|
|
67
|
+
boundary_record: {
|
|
68
|
+
scope: "authority request for one Cognate operation",
|
|
69
|
+
included_sections: ["authority", "references", "states", "transformations"],
|
|
70
|
+
excluded_sections: [],
|
|
71
|
+
known_omissions: ["operation payload is not carried"],
|
|
72
|
+
unknowns: [],
|
|
73
|
+
redactions: [],
|
|
74
|
+
compression_notes: [],
|
|
75
|
+
out_of_scope_entities: [],
|
|
76
|
+
classification: "internal",
|
|
77
|
+
limitations: [],
|
|
78
|
+
},
|
|
79
|
+
completeness_status: "partial",
|
|
80
|
+
omission_status: "marked",
|
|
81
|
+
provenance_refs: [provenanceId],
|
|
82
|
+
validation_status: "valid",
|
|
83
|
+
// A revalidation links back to the request that escalated; the approval itself is bound server-side.
|
|
84
|
+
lineage_refs: [...(approval ? [`env-authority_request-${approval.approvalId.replace(/^apr-/, "")}`] : []), ...(carry ? carry.lineage : [])],
|
|
85
|
+
provenance: [{ provenance_id: provenanceId, source_system_refs: ["cognate"], producer_refs: ["cognate"], production_method: "governed_operation", created_at: at }],
|
|
86
|
+
authority: [{ authority_id: `auth-${operationId}`, subject_actor_ref: d.subject }],
|
|
87
|
+
transformations: [{ transformation_id: `tr-${operationId}`, transformation_type: d.kind === "capability" ? "capability_invocation" : "action_invocation" }],
|
|
88
|
+
references: [{ ref_id: `ref-${operationId}`, ref_type: "artifact_id", target_uri_or_id: d.resource, availability_status: "available", integrity_status: "verifiable" }],
|
|
89
|
+
states: [{ state_id: `st-${operationId}`, state_type: "declared_state", subject_ref: `operation:${operationId}` }],
|
|
90
|
+
extensions: {
|
|
91
|
+
"cep.profile": { profile_id: REQUEST_PROFILE, profile_version: PROFILE_VERSION },
|
|
92
|
+
[REQUEST_PROFILE]: { operation_id: operationId, correlation_id: d.correlation || operationId, operation_kind: d.kind, operation_name: d.name, resource_id: d.resource, ...(approval ? { approval_id: approval.approvalId } : {}), ...(carry ? { transition_ref: carry.transitionLedgerEntry } : {}) },
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
return { envelope, operationId };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A request to move work from one immutable semantic world to another (migration Stage 10).
|
|
100
|
+
*
|
|
101
|
+
* Cognate never chooses a world: both refs are given by whoever calls, and an allow only means SEA-Forge
|
|
102
|
+
* recorded the move. Cognate's pinned world changes by reconfiguration after the move appears in
|
|
103
|
+
* `SeaForgeAuthority.listTransitions()`, never as a side effect of asking.
|
|
104
|
+
*/
|
|
105
|
+
export interface WorldTransitionRequest {
|
|
106
|
+
/** World the work is pinned to today (a `world:<name>@sha256:<digest>`; an alias is not accepted). */
|
|
107
|
+
readonly sourceWorldRef: string;
|
|
108
|
+
readonly targetWorldRef: string;
|
|
109
|
+
readonly kind: "semantic_change" | "source_edit_only" | "compiler_upgrade" | "migration";
|
|
110
|
+
readonly reason: string;
|
|
111
|
+
/** The sender's claim. SEA-Forge cannot verify it unless the semantic closures are equal, and records `unknown` without one. */
|
|
112
|
+
readonly compatibility?: "compatible" | "backward_compatible" | "breaking" | "unknown";
|
|
113
|
+
readonly caller: { readonly tenant: string; readonly actor: { readonly id: string } };
|
|
114
|
+
readonly semanticDiffRef?: string;
|
|
115
|
+
readonly migrationRef?: string;
|
|
116
|
+
readonly evidenceRefs?: readonly string[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** The `authority_request` for one world transition. The approval is bound to the TARGET, via `resource_id`. */
|
|
120
|
+
export function buildTransitionRequest(request: WorldTransitionRequest, now: Date = new Date(), approval?: ApprovalReference): { envelope: Obj; operationId: string } {
|
|
121
|
+
const built = buildEnvelope({ kind: "action", name: "transition", resource: request.targetWorldRef, subject: `actor:${request.caller.tenant}/${request.caller.actor.id}`, correlation: "" }, request.sourceWorldRef, now, approval);
|
|
122
|
+
const extensions = built.envelope.extensions as Obj;
|
|
123
|
+
const base = extensions[REQUEST_PROFILE] as Obj;
|
|
124
|
+
extensions[REQUEST_PROFILE] = { ...base, operation_kind: "world_transition", operation_name: "transition", resource_id: request.targetWorldRef };
|
|
125
|
+
extensions["godspeed.world_transition"] = {
|
|
126
|
+
target_world_ref: request.targetWorldRef,
|
|
127
|
+
transition_kind: request.kind,
|
|
128
|
+
reason: request.reason,
|
|
129
|
+
...(request.compatibility ? { compatibility: request.compatibility } : {}),
|
|
130
|
+
...(request.semanticDiffRef ? { semantic_diff_ref: request.semanticDiffRef } : {}),
|
|
131
|
+
...(request.migrationRef ? { migration_ref: request.migrationRef } : {}),
|
|
132
|
+
...(request.evidenceRefs?.length ? { evidence_refs: [...request.evidenceRefs] } : {}),
|
|
133
|
+
};
|
|
134
|
+
built.envelope.transformations = [{ transformation_id: `tr-${built.operationId}`, transformation_type: "world_transition" }];
|
|
135
|
+
built.envelope.references = [{ ref_id: `ref-${built.operationId}`, ref_type: "world_ref", target_uri_or_id: request.targetWorldRef, availability_status: "available", integrity_status: "verifiable" }];
|
|
136
|
+
return built;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** One move SEA-Forge allowed and recorded, as read back from its ledger. */
|
|
140
|
+
export interface RecordedTransition {
|
|
141
|
+
readonly operationId: string;
|
|
142
|
+
readonly decisionId: string;
|
|
143
|
+
/** The ledger entry of the allow; what a request cites (`CarriedWork`) to carry earlier work across the move. */
|
|
144
|
+
readonly ledgerEntry: string;
|
|
145
|
+
readonly approvalId?: string;
|
|
146
|
+
readonly sourceWorldRef: string;
|
|
147
|
+
readonly targetWorldRef: string;
|
|
148
|
+
readonly kind: string;
|
|
149
|
+
readonly compatibility: string;
|
|
150
|
+
readonly semanticClosureEqual: boolean;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const unknown = (reason: string): GovernanceDecision => ({ disposition: "unknown", reason });
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Accept a response only if it is a well-formed authority_decision for exactly this request and world.
|
|
157
|
+
* Never throws; every defect is an `unknown` disposition.
|
|
158
|
+
*/
|
|
159
|
+
export function interpretDecision(response: unknown, sent: { envelope: Obj; operationId: string }, worldRef: string, approval?: ApprovalReference): GovernanceDecision {
|
|
160
|
+
if (!isObj(response)) return unknown("authority response is not an object");
|
|
161
|
+
if (response.ok !== true) return unknown(`authority refused: ${str(response.error_class) ?? "error"}: ${str(response.error) ?? "no detail"}`);
|
|
162
|
+
const envelope = response.envelope;
|
|
163
|
+
if (!isObj(envelope)) return unknown("authority response carries no envelope");
|
|
164
|
+
if (envelope.envelope_kind !== "authority_decision") return unknown("response envelope is not an authority_decision");
|
|
165
|
+
const extensions = isObj(envelope.extensions) ? envelope.extensions : {};
|
|
166
|
+
const profile = isObj(extensions["cep.profile"]) ? extensions["cep.profile"] : {};
|
|
167
|
+
if (profile.profile_id !== DECISION_PROFILE || profile.profile_version !== PROFILE_VERSION) return unknown("response declares the wrong profile");
|
|
168
|
+
const scope = isObj(envelope.scope) ? envelope.scope : {};
|
|
169
|
+
if (scope.world_ref !== worldRef) return unknown("decision is for a different world than the one requested");
|
|
170
|
+
const ext = extensions[DECISION_PROFILE];
|
|
171
|
+
if (!isObj(ext)) return unknown("decision extension missing");
|
|
172
|
+
if (ext.request_ref !== sent.envelope.envelope_id || ext.operation_id !== sent.operationId) return unknown("decision is not for this request");
|
|
173
|
+
if (!Array.isArray(envelope.lineage_refs) || !envelope.lineage_refs.includes(sent.envelope.envelope_id)) return unknown("decision lineage does not include this request");
|
|
174
|
+
const decision = str(ext.decision);
|
|
175
|
+
if (!decision || !DECISIONS.has(decision)) return unknown("decision is not a recognized disposition");
|
|
176
|
+
|
|
177
|
+
// The approval a decision names must be the one we relied on; an escalation names the one to wait for.
|
|
178
|
+
const namedApproval = str(ext.approval_id);
|
|
179
|
+
if (approval && namedApproval !== undefined && namedApproval !== approval.approvalId) return unknown("decision names a different approval than the one relied on");
|
|
180
|
+
if (approval && decision === "allow" && ext.approval_used === true && namedApproval !== approval.approvalId) return unknown("approved allow does not name the approval relied on");
|
|
181
|
+
|
|
182
|
+
const decisionRef = str(ext.ledger_entry) ?? str(ext.decision_id);
|
|
183
|
+
const decisionEnvelopeId = str(envelope.envelope_id);
|
|
184
|
+
if (decision === "allow" && !decisionEnvelopeId) return unknown("allow decision carries no envelope id");
|
|
185
|
+
const authority: AuthorityReference | undefined = decisionEnvelopeId
|
|
186
|
+
? { operationId: sent.operationId, requestEnvelopeId: String(sent.envelope.envelope_id), decisionEnvelopeId, worldRef, ...(decisionRef ? { decisionRef } : {}) }
|
|
187
|
+
: undefined;
|
|
188
|
+
const base = { reason: `sea-forge decided ${decision}${str(ext.decision_id) ? ` (${str(ext.decision_id)})` : ""}`, ...(decisionRef ? { decisionRef } : {}), ...(authority ? { authority } : {}) };
|
|
189
|
+
if (decision === "escalate" && namedApproval) {
|
|
190
|
+
const expires = str(ext.approval_expires_at) ? Date.parse(str(ext.approval_expires_at)!) : Number.NaN;
|
|
191
|
+
return { disposition: "escalate", ...base, escalation: { approvalId: namedApproval, ...(Number.isFinite(expires) ? { expiresAt: expires } : {}) } };
|
|
192
|
+
}
|
|
193
|
+
if (!CONSTRAINED.has(decision)) return { disposition: decision as GovernanceDecision["disposition"], ...base };
|
|
194
|
+
|
|
195
|
+
const policyBasis = strings(ext.policy_basis);
|
|
196
|
+
const rawConstraints = Array.isArray(envelope.constraints) ? envelope.constraints : [];
|
|
197
|
+
const constraints: GovernanceConstraint[] = [];
|
|
198
|
+
for (const item of rawConstraints) {
|
|
199
|
+
if (!isObj(item) || !str(item.constraint_type) || !str(item.description)) return unknown("constraint record is malformed");
|
|
200
|
+
constraints.push({ type: str(item.constraint_type)!, value: str(item.description)! });
|
|
201
|
+
}
|
|
202
|
+
if (!policyBasis?.length || constraints.length === 0) return unknown(`${decision} without constraints and policy basis`);
|
|
203
|
+
const controls = strings(ext.compensating_controls);
|
|
204
|
+
if (decision === "degraded" && !controls?.length) return unknown("degraded without compensating controls");
|
|
205
|
+
return { disposition: decision as GovernanceDecision["disposition"], ...base, policyBasis, constraints, ...(controls ? { compensatingControls: controls } : {}) };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** One NDJSON request/response exchange over a Unix socket. Rejects on timeout, close, or oversize. */
|
|
209
|
+
export function exchange(socketPath: string, request: unknown, timeoutMs: number, maxBytes: number): Promise<unknown> {
|
|
210
|
+
return new Promise((resolve, reject) => {
|
|
211
|
+
let buffered = "";
|
|
212
|
+
let settled = false;
|
|
213
|
+
const finish = (error: Error | null, value?: unknown) => {
|
|
214
|
+
if (settled) return;
|
|
215
|
+
settled = true;
|
|
216
|
+
clearTimeout(timer);
|
|
217
|
+
try {
|
|
218
|
+
socket?.end();
|
|
219
|
+
} catch {}
|
|
220
|
+
if (error) reject(error);
|
|
221
|
+
else resolve(value);
|
|
222
|
+
};
|
|
223
|
+
const timer = setTimeout(() => finish(new Error(`no response within ${timeoutMs} ms`)), timeoutMs);
|
|
224
|
+
let socket: Awaited<ReturnType<typeof Bun.connect>> | undefined;
|
|
225
|
+
Bun.connect({
|
|
226
|
+
unix: socketPath,
|
|
227
|
+
socket: {
|
|
228
|
+
open(s) {
|
|
229
|
+
s.write(`${JSON.stringify(request)}\n`);
|
|
230
|
+
},
|
|
231
|
+
data(_s, chunk) {
|
|
232
|
+
buffered += chunk.toString("utf8");
|
|
233
|
+
if (buffered.length > maxBytes) return finish(new Error("response exceeds size limit"));
|
|
234
|
+
const newline = buffered.indexOf("\n");
|
|
235
|
+
if (newline < 0) return;
|
|
236
|
+
try {
|
|
237
|
+
finish(null, JSON.parse(buffered.slice(0, newline)));
|
|
238
|
+
} catch {
|
|
239
|
+
finish(new Error("response is not valid JSON"));
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
close() {
|
|
243
|
+
finish(new Error("connection closed before a response"));
|
|
244
|
+
},
|
|
245
|
+
error(_s, error) {
|
|
246
|
+
finish(error);
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
}).then(
|
|
250
|
+
(s) => {
|
|
251
|
+
socket = s;
|
|
252
|
+
if (settled) s.end();
|
|
253
|
+
},
|
|
254
|
+
(error: unknown) => finish(error instanceof Error ? error : new Error(String(error))),
|
|
255
|
+
);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export type EvidenceReceipt =
|
|
260
|
+
| { readonly ok: true; readonly operationId: string; readonly envelopeId: string; readonly items: number; readonly ledgerEntry: string }
|
|
261
|
+
| { readonly ok: false; readonly errorClass: string; readonly error: string };
|
|
262
|
+
|
|
263
|
+
/** SEA-Forge's governed acceptance of an operation. Never derived from execution or from RealityTrace's own judgment. */
|
|
264
|
+
export type SettlementReceipt =
|
|
265
|
+
| { readonly ok: true; readonly status: "settled" | "unsettled" | "rejected"; readonly final: boolean; readonly replayed: boolean; readonly envelope: Obj; readonly ledgerEntry: string }
|
|
266
|
+
| { readonly ok: false; readonly errorClass: string; readonly error: string };
|
|
267
|
+
|
|
268
|
+
/** Accept a settlement response only if it is demonstrably the settlement of this operation in this world. */
|
|
269
|
+
export function interpretSettlement(response: unknown, operationId: string, worldRef: string): SettlementReceipt {
|
|
270
|
+
const refuse = (error: string): SettlementReceipt => ({ ok: false, errorClass: "invalid_settlement", error });
|
|
271
|
+
if (!isObj(response)) return { ok: false, errorClass: "malformed_response", error: "response is not an object" };
|
|
272
|
+
if (response.ok !== true) return { ok: false, errorClass: str(response.error_class) ?? "error", error: str(response.error) ?? "no detail" };
|
|
273
|
+
const envelope = response.envelope;
|
|
274
|
+
if (!isObj(envelope) || envelope.envelope_kind !== "settlement_packet") return refuse("response carries no settlement_packet");
|
|
275
|
+
const extensions = isObj(envelope.extensions) ? envelope.extensions : {};
|
|
276
|
+
const profile = isObj(extensions["cep.profile"]) ? extensions["cep.profile"] : {};
|
|
277
|
+
if (profile.profile_id !== "godspeed.settlement_packet" || profile.profile_version !== PROFILE_VERSION) return refuse("response declares the wrong profile");
|
|
278
|
+
const scope = isObj(envelope.scope) ? envelope.scope : {};
|
|
279
|
+
if (scope.world_ref !== worldRef) return refuse("settlement is for a different world");
|
|
280
|
+
const ext = extensions["godspeed.settlement_packet"];
|
|
281
|
+
if (!isObj(ext) || ext.operation_id !== operationId) return refuse("settlement is for a different operation");
|
|
282
|
+
const status = response.status;
|
|
283
|
+
if (status !== "settled" && status !== "unsettled" && status !== "rejected") return refuse("settlement status is not recognized");
|
|
284
|
+
// The packet and the summary must agree: a summary cannot be upgraded past what the packet says.
|
|
285
|
+
if (ext.overall_status !== status) return refuse("settlement summary disagrees with its packet");
|
|
286
|
+
return { ok: true, status, final: response.final === true, replayed: response.replayed === true, envelope, ledgerEntry: String(response.ledger_entry) };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export class SeaForgeAuthority implements GovernanceAuthority {
|
|
290
|
+
private readonly actor: { actor_id: string; role: string };
|
|
291
|
+
private readonly timeoutMs: number;
|
|
292
|
+
private readonly maxBytes: number;
|
|
293
|
+
|
|
294
|
+
constructor(private readonly options: SeaForgeAuthorityOptions) {
|
|
295
|
+
this.actor = { ...(options.actor ?? { actor_id: "cognate-service", role: "service" }) };
|
|
296
|
+
this.timeoutMs = options.timeoutMs ?? 5_000;
|
|
297
|
+
this.maxBytes = options.maxResponseBytes ?? 256 * 1024;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async decide(operation: GovernedOperation, worldRef: string, approval?: ApprovalReference, carry?: CarriedWork): Promise<GovernanceDecision> {
|
|
301
|
+
const sent = buildAuthorityRequest(operation, worldRef, new Date(), approval, carry);
|
|
302
|
+
let response: unknown;
|
|
303
|
+
try {
|
|
304
|
+
response = await exchange(this.options.socketPath, { verb: "authority_request", envelope: sent.envelope, request_id: sent.operationId, actor: this.actor }, this.timeoutMs, this.maxBytes);
|
|
305
|
+
} catch (error) {
|
|
306
|
+
return unknown(`sea-forge unreachable: ${error instanceof Error ? error.message : String(error)}`);
|
|
307
|
+
}
|
|
308
|
+
return interpretDecision(response, sent, worldRef, approval);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Ask SEA-Forge to move work from one world to another. The answer is the usual governed disposition: a move
|
|
313
|
+
* across a semantic change comes back `escalate` whatever policy says, and only an approved retry is allowed.
|
|
314
|
+
* Nothing changes in Cognate on an allow; read the move back with `listTransitions`.
|
|
315
|
+
*/
|
|
316
|
+
async requestWorldTransition(request: WorldTransitionRequest, approval?: ApprovalReference): Promise<GovernanceDecision> {
|
|
317
|
+
const sent = buildTransitionRequest(request, new Date(), approval);
|
|
318
|
+
let response: unknown;
|
|
319
|
+
try {
|
|
320
|
+
response = await exchange(this.options.socketPath, { verb: "authority_request", envelope: sent.envelope, request_id: sent.operationId, actor: this.actor }, this.timeoutMs, this.maxBytes);
|
|
321
|
+
} catch (error) {
|
|
322
|
+
return unknown(`sea-forge unreachable: ${error instanceof Error ? error.message : String(error)}`);
|
|
323
|
+
}
|
|
324
|
+
return interpretDecision(response, sent, request.sourceWorldRef, approval);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Every move SEA-Forge allowed, oldest first. Malformed rows are dropped, never guessed at; failure is an empty list. */
|
|
328
|
+
async listTransitions(): Promise<RecordedTransition[]> {
|
|
329
|
+
let response: unknown;
|
|
330
|
+
try {
|
|
331
|
+
response = await exchange(this.options.socketPath, { verb: "authority_transitions" }, this.timeoutMs, this.maxBytes);
|
|
332
|
+
} catch {
|
|
333
|
+
return [];
|
|
334
|
+
}
|
|
335
|
+
const rows = isObj(response) && response.ok === true && Array.isArray(response.transitions) ? response.transitions : [];
|
|
336
|
+
const out: RecordedTransition[] = [];
|
|
337
|
+
for (const row of rows) {
|
|
338
|
+
if (!isObj(row) || !isObj(row.record)) continue;
|
|
339
|
+
const rec = row.record;
|
|
340
|
+
const operationId = str(row.operation_id);
|
|
341
|
+
const decisionId = str(row.decision_id);
|
|
342
|
+
const ledgerEntry = str(row.ledger_entry);
|
|
343
|
+
const source = str(rec.source_world_ref);
|
|
344
|
+
const target = str(rec.target_world_ref);
|
|
345
|
+
const kind = str(rec.transition_kind);
|
|
346
|
+
const compatibility = str(rec.compatibility);
|
|
347
|
+
if (!operationId || !decisionId || !ledgerEntry || !source || !target || !kind || !compatibility || typeof rec.semantic_closure_equal !== "boolean" || source === target) continue;
|
|
348
|
+
out.push({ operationId, decisionId, ledgerEntry, ...(str(row.approval_id) ? { approvalId: str(row.approval_id)! } : {}), sourceWorldRef: source, targetWorldRef: target, kind, compatibility, semanticClosureEqual: rec.semantic_closure_equal });
|
|
349
|
+
}
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Submit a GodSpeed `evidence_packet` (built from RealityTrace's recorded evidence) for an operation SEA-Forge
|
|
355
|
+
* allowed. Recording evidence settles nothing.
|
|
356
|
+
*/
|
|
357
|
+
async submitEvidence(packet: Obj): Promise<EvidenceReceipt> {
|
|
358
|
+
// SEA-Forge binds a request id to its first payload. Deriving the id from the payload hash as well means a
|
|
359
|
+
// replay of the same packet dedupes, while a different (e.g. tampered) packet under the same envelope id
|
|
360
|
+
// can neither reuse nor poison the id of the genuine one.
|
|
361
|
+
const requestId = `evidence:${String(packet.envelope_id)}:${createHash("sha256").update(JSON.stringify(packet)).digest("hex").slice(0, 16)}`;
|
|
362
|
+
let response: unknown;
|
|
363
|
+
try {
|
|
364
|
+
response = await exchange(this.options.socketPath, { verb: "authority_evidence", envelope: packet, request_id: requestId, actor: this.actor }, this.timeoutMs, this.maxBytes);
|
|
365
|
+
} catch (error) {
|
|
366
|
+
return { ok: false, errorClass: "unreachable", error: error instanceof Error ? error.message : String(error) };
|
|
367
|
+
}
|
|
368
|
+
if (!isObj(response)) return { ok: false, errorClass: "malformed_response", error: "response is not an object" };
|
|
369
|
+
if (response.ok !== true) return { ok: false, errorClass: str(response.error_class) ?? "error", error: str(response.error) ?? "no detail" };
|
|
370
|
+
return { ok: true, operationId: String(response.operation_id), envelopeId: String(response.envelope_id), items: Number(response.items), ledgerEntry: String(response.ledger_entry) };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Ask SEA-Forge to settle an operation from the evidence it holds. The answer is accepted only if it is a
|
|
375
|
+
* settlement_packet for this operation and world; anything else is refused. `unsettled` is a normal answer.
|
|
376
|
+
*/
|
|
377
|
+
async settle(operationId: string, worldRef: string): Promise<SettlementReceipt> {
|
|
378
|
+
// One request id per (operation, call): settlement replays are SEA-Forge's to recognise, not ours to cache.
|
|
379
|
+
const requestId = `settle:${operationId}:${randomUUID()}`;
|
|
380
|
+
let response: unknown;
|
|
381
|
+
try {
|
|
382
|
+
response = await exchange(this.options.socketPath, { verb: "authority_settle", operation_id: operationId, request_id: requestId, actor: this.actor }, this.timeoutMs, this.maxBytes);
|
|
383
|
+
} catch (error) {
|
|
384
|
+
return { ok: false, errorClass: "unreachable", error: error instanceof Error ? error.message : String(error) };
|
|
385
|
+
}
|
|
386
|
+
return interpretSettlement(response, operationId, worldRef);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Where an escalation stands. Only used to decide when to try again; an `approved` here never allows anything. */
|
|
390
|
+
async approvalState(approvalId: string): Promise<ApprovalState> {
|
|
391
|
+
try {
|
|
392
|
+
const response = await exchange(this.options.socketPath, { verb: "authority_approvals" }, this.timeoutMs, this.maxBytes);
|
|
393
|
+
const approvals = isObj(response) && Array.isArray(response.approvals) ? response.approvals : [];
|
|
394
|
+
const found = approvals.find((a) => isObj(a) && a.approval_id === approvalId);
|
|
395
|
+
const state = isObj(found) ? str(found.state) : undefined;
|
|
396
|
+
return state === "pending" || state === "approved" || state === "rejected" || state === "expired" || state === "used" ? state : "unknown";
|
|
397
|
+
} catch {
|
|
398
|
+
return "unknown";
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|