@arnilo/prism 0.2.1 → 0.2.2

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.
@@ -0,0 +1,348 @@
1
+ // ponytail: runner-free multi-process state-concurrency probe for memory and
2
+ // durable stores (plan 022 Task 4). Deterministic barriers only: every probe
3
+ // awaits the conflicting op and then asserts state — never setTimeout/sleeps.
4
+ import assert from "node:assert/strict";
5
+ import { isSessionMetadataConflict } from "../contracts-core.js";
6
+ /**
7
+ * Run the state-concurrency probes for every provided store family. Returns
8
+ * the executed probe names so gates can assert coverage. Deterministic:
9
+ * concurrent ops are awaited through `Promise.allSettled` and state is
10
+ * asserted afterward; no timing-only sleeps anywhere in this file.
11
+ */
12
+ export async function assertStateConcurrencyConforms(factories) {
13
+ const executed = [];
14
+ if (factories.checkpoints) {
15
+ const checkpoints = await factories.checkpoints();
16
+ await checkpointCasProbe(checkpoints);
17
+ executed.push("checkpoint-cas");
18
+ await approvalDeterminismProbe(checkpoints);
19
+ executed.push("approval-determinism");
20
+ }
21
+ if (factories.events) {
22
+ await cursorResumeProbe(factories.events);
23
+ executed.push("cursor-resume");
24
+ }
25
+ if (factories.sessions) {
26
+ await conversationMetadataCasProbe(await factories.sessions());
27
+ executed.push("conversation-metadata-cas");
28
+ }
29
+ if (factories.idempotency) {
30
+ await idempotencyRetryProbe(await factories.idempotency());
31
+ executed.push("idempotency-retry");
32
+ }
33
+ if (factories.routerState) {
34
+ await reservationOversubscriptionProbe(await factories.routerState.create());
35
+ executed.push("router-reservation");
36
+ if (factories.routerState.nowInjected) {
37
+ await unknownOutcomeProbe(await factories.routerState.create());
38
+ executed.push("unknown-outcome");
39
+ }
40
+ }
41
+ return executed;
42
+ }
43
+ const ownership = { tenantId: "tenant-a", accountId: "account-a", userId: "user-a" };
44
+ function identity(tenantId) {
45
+ return {
46
+ tenantId,
47
+ userId: "user-a",
48
+ principal: { kind: "agent", id: "agent-a" },
49
+ scopes: ["work:mutate"],
50
+ issuedAt: "2026-08-03T00:00:00.000Z",
51
+ verified: true,
52
+ };
53
+ }
54
+ /**
55
+ * Checkpoint CAS: exact-version and fencing-token guards. A stale
56
+ * `expectedVersion` is rejected; a lower `fencingToken` cannot replace a
57
+ * fenced record; a higher fence wins.
58
+ */
59
+ async function checkpointCasProbe(checkpoints) {
60
+ const key = { namespace: "state-concurrency", key: "checkpoint-cas", ...ownership };
61
+ await checkpoints.saveCheckpoint({ ...key, version: 1, value: { step: 1 } });
62
+ await checkpoints.saveCheckpoint({ ...key, version: 2, expectedVersion: 1, value: { step: 2 } });
63
+ await assertRejectsCode(() => checkpoints.saveCheckpoint({ ...key, version: 3, expectedVersion: 1, value: { step: 3 } }), "ERR_PRISM_CHECKPOINT_CONFLICT", "stale expectedVersion must be rejected");
64
+ await checkpoints.saveCheckpoint({ ...key, version: 3, expectedVersion: 2, fencingToken: 5, value: { step: 3 } });
65
+ await assertRejectsCode(() => checkpoints.saveCheckpoint({ ...key, version: 4, expectedVersion: 3, fencingToken: 4, value: { step: 4 } }), "ERR_PRISM_CHECKPOINT_CONFLICT", "lower fence must be rejected");
66
+ const fenced = await checkpoints.saveCheckpoint({ ...key, version: 4, expectedVersion: 3, fencingToken: 6, value: { step: 4 } });
67
+ assert.equal(fenced.fencingToken, 6, "higher fence must win");
68
+ const loaded = await checkpoints.loadCheckpoint(key);
69
+ assert.equal(loaded?.version, 4, "winning version must persist");
70
+ assert.equal(loaded?.fencingToken, 6, "winning fence must persist");
71
+ await assertRejects(() => checkpoints.loadCheckpoint({ ...key, tenantId: "tenant-b" }), /ownership|tenant/i, "foreign checkpoint access must fail closed");
72
+ }
73
+ /**
74
+ * Approval determinism: concurrent approve/deny of the same pending decision
75
+ * resolves to exactly one terminal state; a stale decision discriminant
76
+ * (the `expectedVersion` a resumer read) is rejected — the 0.2.0 resume fix
77
+ * contract: resume requires `record.version === expectedVersion`.
78
+ */
79
+ async function approvalDeterminismProbe(checkpoints) {
80
+ const key = { namespace: "state-concurrency", key: "approval", ...ownership };
81
+ const suspended = {
82
+ status: "suspended",
83
+ interruption: {
84
+ kind: "tool_approval",
85
+ reason: "review",
86
+ pendingDecisions: [{ approvalId: "a1", kind: "tool_approval", scope: { toolName: "fs.write", argumentsHash: "h1" } }],
87
+ },
88
+ };
89
+ await checkpoints.saveCheckpoint({ ...key, version: 1, value: suspended });
90
+ const approve = { status: "running", decision: { approvalId: "a1", outcome: "allow_once" } };
91
+ const deny = { status: "denied", decision: { approvalId: "a1", outcome: "reject_once" } };
92
+ const results = await Promise.allSettled([
93
+ checkpoints.saveCheckpoint({ ...key, version: 2, expectedVersion: 1, fencingToken: 1, value: approve }),
94
+ checkpoints.saveCheckpoint({ ...key, version: 2, expectedVersion: 1, fencingToken: 1, value: deny }),
95
+ ]);
96
+ const fulfilled = results.filter((result) => result.status === "fulfilled");
97
+ const rejected = results.filter((result) => result.status === "rejected");
98
+ assert.equal(fulfilled.length, 1, "concurrent approve/deny must admit exactly one terminal write");
99
+ assert.equal(rejected.length, 1, "concurrent approve/deny must reject exactly one writer");
100
+ assert.equal(rejected[0].reason?.code, "ERR_PRISM_CHECKPOINT_CONFLICT", "loser must fail with checkpoint conflict");
101
+ const final = await checkpoints.loadCheckpoint(key);
102
+ assert.equal(final?.version, 2, "terminal state must be written exactly once");
103
+ assert.ok(deepEqual(final?.value, approve) || deepEqual(final?.value, deny), "final state must be exactly one winner's payload, never a mix");
104
+ await assertRejectsCode(() => checkpoints.saveCheckpoint({ ...key, version: 3, expectedVersion: 1, value: { status: "running" } }), "ERR_PRISM_CHECKPOINT_CONFLICT", "a stale decision discriminant (version read before the race) must be rejected");
105
+ }
106
+ /**
107
+ * Replay-cursor resume: a cursor taken from the last consumed position resumes
108
+ * at the next event, never the stream head. Durable factories additionally
109
+ * close and re-open the store (restart) and resume from the same cursor.
110
+ */
111
+ async function cursorResumeProbe(factory) {
112
+ const input = { ownership, sessionId: "concurrency-session", runId: "concurrency-run" };
113
+ const source = await factory.create();
114
+ await source.append(event("event-1", "agent_started", input));
115
+ await source.append(event("event-2", "turn_started", input, "2026-01-01T00:00:01.000Z"));
116
+ await source.append(event("event-3", "agent_finished", input, "2026-01-01T00:00:02.000Z"));
117
+ const first = await source.page({ ...input, limit: 1 });
118
+ assert.equal(first.items[0]?.record.id, "event-1", "first page must start at the head");
119
+ const second = await source.page({ ...input, after: first.items[0].cursor, limit: 1 });
120
+ assert.equal(second.items[0]?.record.id, "event-2", "second page must continue after the first cursor");
121
+ const resumed = await source.page({ ...input, after: second.items[0].cursor, limit: 10 });
122
+ assert.deepEqual(resumed.items.map((envelope) => envelope.record.id), ["event-3"], "resume from the last-acked cursor must not replay the head");
123
+ assert.equal(resumed.terminal, true, "resumed page must reach the terminal event");
124
+ const foreign = await source.page({ ...input, ownership: { ...ownership, tenantId: "tenant-b" }, limit: 10 });
125
+ assert.equal(foreign.items.length, 0, "ownership must never cross tenants");
126
+ if (factory.reopenable) {
127
+ await source.close?.();
128
+ const restarted = await factory.create();
129
+ const afterRestart = await restarted.page({ ...input, after: second.items[0].cursor, limit: 10 });
130
+ assert.deepEqual(afterRestart.items.map((envelope) => envelope.record.id), ["event-3"], "reopened store must resume from the last-acked cursor, not the head");
131
+ assert.equal(afterRestart.terminal, true, "reopened page must reach the terminal event");
132
+ await restarted.close?.();
133
+ }
134
+ }
135
+ /** Idempotency: a retry with the same key returns the recorded outcome, never a duplicate effect. */
136
+ async function idempotencyRetryProbe(idempotency) {
137
+ const mutation = { identity: identity("tenant-a"), key: "idem-1", op: "example.mutate" };
138
+ const parallel = await Promise.all(Array.from({ length: 8 }, () => idempotency.begin(mutation)));
139
+ const acquired = parallel.filter((result) => result.outcome === "acquired");
140
+ const existing = parallel.filter((result) => result.outcome === "existing");
141
+ assert.equal(acquired.length, 1, "parallel begins must admit exactly one claim");
142
+ assert.equal(existing.length, 7, "parallel begins must report the rest as existing");
143
+ const claim = acquired[0].record;
144
+ assert.ok(claim.claimToken, "acquired claim must carry a token");
145
+ const completed = await idempotency.complete({
146
+ ...mutation,
147
+ claimToken: claim.claimToken,
148
+ expectedVersion: claim.version,
149
+ result: { draftId: "draft-1" },
150
+ });
151
+ assert.equal(completed.status, "completed", "completed mutation must be terminal");
152
+ const retry = await idempotency.begin(mutation);
153
+ assert.equal(retry.outcome, "existing", "retry must never re-acquire");
154
+ assert.equal(retry.record.result?.draftId, "draft-1", "retry must return the recorded outcome, not a duplicate effect");
155
+ assert.equal(await idempotency.get({ ...mutation, identity: identity("tenant-b") }), undefined, "ownership must never cross tenants");
156
+ await assertRejectsCode(() => idempotency.complete({
157
+ ...mutation,
158
+ claimToken: claim.claimToken,
159
+ expectedVersion: claim.version,
160
+ result: { draftId: "draft-2" },
161
+ }), "ERR_PRISM_WORK_IDEMPOTENCY_CONFLICT", "a stale-version second complete must be rejected");
162
+ }
163
+ /** Router reservation: parallel admissions cannot oversubscribe; commit/release reconcile actuals. */
164
+ async function reservationOversubscriptionProbe(router) {
165
+ const key = {
166
+ tenantId: "tenant-a",
167
+ accountId: "account-a",
168
+ userId: "user-a",
169
+ principalId: "principal-a",
170
+ provider: "benchmark",
171
+ model: "reserved",
172
+ };
173
+ const parallel = await Promise.all(Array.from({ length: 4 }, () => router.reserveBudget({ key, tokens: 26, maxTokens: 100, windowMs: 60_000, reservationTtlMs: 60_000, now: 0 })));
174
+ const admitted = parallel.filter((result) => result.admitted);
175
+ const denied = parallel.filter((result) => !result.admitted);
176
+ assert.equal(admitted.length, 3, "parallel reservations must admit exactly N-1");
177
+ assert.equal(denied.length, 1, "parallel reservations must deny exactly one");
178
+ assert.ok(denied[0].retryAfterMs !== undefined, "denial must carry a retry-after hint");
179
+ await assertRejectsCode(() => router.commitBudget({
180
+ key,
181
+ reservationId: admitted[2].reservationId,
182
+ fencingToken: "stale",
183
+ tokens: 1,
184
+ windowMs: 60_000,
185
+ now: 1_000,
186
+ }), "ERR_PRISM_MODEL_ROUTER_STATE", "a stale/foreign fencing token must be rejected");
187
+ const committed = await router.commitBudget({
188
+ key,
189
+ reservationId: admitted[0].reservationId,
190
+ fencingToken: admitted[0].fencingToken,
191
+ tokens: 10,
192
+ windowMs: 60_000,
193
+ now: 1_000,
194
+ });
195
+ assert.equal(committed.unknownUsage, false, "live commit must not report unknown usage");
196
+ await router.releaseBudget({
197
+ key,
198
+ reservationId: admitted[1].reservationId,
199
+ fencingToken: admitted[1].fencingToken,
200
+ windowMs: 60_000,
201
+ now: 1_000,
202
+ });
203
+ assert.deepEqual(await router.readBudget({ key, windowMs: 60_000, now: 1_000 }), {
204
+ tokens: 10,
205
+ costUsd: 0,
206
+ });
207
+ }
208
+ /**
209
+ * Unknown-outcome recovery: an abandoned reservation (crash before commit)
210
+ * reconciles to the reserved amount with `unknownUsage: true` — never a
211
+ * silent drop. Deterministic: expiry is driven through the injected `now`
212
+ * (memory stores); the durable leg runs in the Task 1 enterprise-conformance
213
+ * integration probe because durable expiry uses the database clock. The
214
+ * redacted `unknown_usage` diagnostic emission is asserted at router level in
215
+ * the model-router suite (Task 1) — the store seam reports the flag only.
216
+ */
217
+ async function unknownOutcomeProbe(router) {
218
+ const key = {
219
+ tenantId: "tenant-a",
220
+ accountId: "account-a",
221
+ userId: "user-a",
222
+ principalId: "principal-a",
223
+ provider: "benchmark",
224
+ model: "ttl",
225
+ };
226
+ const reserved = await router.reserveBudget({ key, tokens: 7, maxTokens: 100, windowMs: 60_000, reservationTtlMs: 60_000, now: 0 });
227
+ assert.ok(reserved.admitted, "reservation must admit within capacity");
228
+ const late = await router.commitBudget({
229
+ key,
230
+ reservationId: reserved.reservationId,
231
+ fencingToken: reserved.fencingToken,
232
+ tokens: 1,
233
+ windowMs: 60_000,
234
+ now: 61_001,
235
+ });
236
+ assert.equal(late.unknownUsage, true, "a late commit after expiry must reconcile as unknown usage");
237
+ assert.deepEqual(await router.readBudget({ key, windowMs: 60_000, now: 61_001 }), {
238
+ tokens: 7,
239
+ costUsd: 0,
240
+ });
241
+ }
242
+ /**
243
+ * Conversation metadata CAS: concurrent create/branch/archive-style writes
244
+ * admit exactly one winner per version and reject the rest with
245
+ * `metadata_conflict`; a deleted row is never resurrected; ownership guards
246
+ * hold inside the same guarded write.
247
+ */
248
+ async function conversationMetadataCasProbe(sessions) {
249
+ if (!sessions.appendSession)
250
+ throw new Error("state-concurrency sessions probe requires appendSession");
251
+ const base = {
252
+ id: "conversation-1",
253
+ tenantId: "tenant-a",
254
+ accountId: "account-a",
255
+ userId: "user-a",
256
+ createdAt: "2026-08-03T00:00:00.000Z",
257
+ updatedAt: "2026-08-03T00:00:00.000Z",
258
+ metadata: { prismConversation: { state: "active", title: "root" } },
259
+ };
260
+ const created = await sessions.appendSession({ ...base, expectedVersion: 0 });
261
+ assert.equal(created?.version, 1, "create-only write must land at version 1");
262
+ const creates = await Promise.allSettled(Array.from({ length: 8 }, (_, index) => sessions.appendSession({
263
+ ...base,
264
+ expectedVersion: 0,
265
+ metadata: { prismConversation: { state: "active", title: `racer-${index}` } },
266
+ })));
267
+ const createWinners = creates.filter((result) => result.status === "fulfilled");
268
+ const createLosers = creates.filter((result) => result.status === "rejected");
269
+ assert.equal(createWinners.length, 0, "duplicate create-only writes must never overwrite the winner");
270
+ assert.equal(createLosers.length, 8, "every duplicate create must conflict");
271
+ for (const result of createLosers) {
272
+ assert.ok(isSessionMetadataConflict(result.reason), "duplicate create must be metadata_conflict");
273
+ }
274
+ const racerMetadata = (index) => ({
275
+ prismConversation: {
276
+ state: "active",
277
+ title: `branch-${index}`,
278
+ refs: [{ leafId: `leaf-${index}`, createdAt: "2026-08-03T00:00:01.000Z" }],
279
+ },
280
+ });
281
+ const updates = await Promise.allSettled(Array.from({ length: 8 }, (_, index) => sessions.appendSession({ ...base, expectedVersion: 1, metadata: racerMetadata(index) })));
282
+ const updateWinners = updates.filter((result) => result.status === "fulfilled");
283
+ const updateLosers = updates.filter((result) => result.status === "rejected");
284
+ assert.equal(updateWinners.length, 1, "concurrent CAS updates must admit exactly one winner");
285
+ assert.equal(updateLosers.length, 7, "concurrent CAS updates must reject the rest");
286
+ const winnerVersion = updateWinners[0].value?.version;
287
+ assert.equal(winnerVersion, 2, "winner must land at version 2");
288
+ for (const result of updateLosers) {
289
+ const reason = result.reason;
290
+ assert.ok(isSessionMetadataConflict(reason), "CAS loser must be metadata_conflict");
291
+ assert.equal(reason.conflict?.currentVersion, 2, "conflict must report the current version");
292
+ }
293
+ const winnerIndex = updates.findIndex((result) => result.status === "fulfilled");
294
+ const page = await sessions.querySessions({ id: base.id, tenantId: "tenant-a", accountId: "account-a", userId: "user-a" });
295
+ assert.equal(page.items.length, 1, "session must resolve to a single record");
296
+ assert.equal(page.items[0]?.version, 2, "stored version must reflect the winner's write");
297
+ assert.deepEqual(page.items[0]?.metadata, racerMetadata(winnerIndex), "stored metadata must be exactly the winner's marker");
298
+ await assertRejectsCode(() => sessions.appendSession({
299
+ ...base,
300
+ tenantId: "tenant-b",
301
+ expectedVersion: 1,
302
+ metadata: { prismConversation: { state: "archived" } },
303
+ }), "metadata_conflict", "cross-ownership CAS write must be rejected in the same guarded statement");
304
+ }
305
+ function event(id, type, input, timestamp = "2026-01-01T00:00:00.000Z") {
306
+ const payload = type === "turn_started"
307
+ ? { type, sessionId: input.sessionId, runId: input.runId, turn: 1 }
308
+ : { type, sessionId: input.sessionId, runId: input.runId };
309
+ return { id, ...input.ownership, sessionId: input.sessionId, runId: input.runId, type, timestamp, event: payload, redacted: true };
310
+ }
311
+ function deepEqual(actual, expected) {
312
+ if (Object.is(actual, expected))
313
+ return true;
314
+ if (typeof actual !== "object" || typeof expected !== "object" || actual === null || expected === null)
315
+ return false;
316
+ const actualEntries = Object.entries(actual);
317
+ const expectedEntries = Object.entries(expected);
318
+ if (actualEntries.length !== expectedEntries.length)
319
+ return false;
320
+ for (const [key, value] of expectedEntries) {
321
+ if (!deepEqual(actual[key], value))
322
+ return false;
323
+ }
324
+ return true;
325
+ }
326
+ async function assertRejectsCode(action, code, message) {
327
+ try {
328
+ await action();
329
+ }
330
+ catch (error) {
331
+ if (error?.code === code)
332
+ return;
333
+ throw new Error(`${message}; expected code ${code}, received ${String(error?.code)}: ${String(error)}`);
334
+ }
335
+ throw new Error(`${message}; expected a rejection with code ${code}`);
336
+ }
337
+ async function assertRejects(action, pattern, message) {
338
+ try {
339
+ await action();
340
+ }
341
+ catch (error) {
342
+ if (pattern.test(String(error)))
343
+ return;
344
+ throw new Error(`${message}; rejection did not match ${pattern}: ${String(error)}`);
345
+ }
346
+ throw new Error(`${message}; expected a rejection matching ${pattern}`);
347
+ }
348
+ //# sourceMappingURL=state-concurrency-conformance.js.map
@@ -45,7 +45,7 @@ const nc = await connect({ servers: process.env.NATS_URL });
45
45
  const source = createNatsAgentEventSource({ connection: await createNatsJetStream(nc), stream: "prism_agent_events" });
46
46
  ```
47
47
 
48
- One subject per run (`prism.agent-events.<tenant>.<session>.<run>`); the JetStream per-subject sequence is the per-run event sequence. `append` is idempotent by `record.id` within the stream's dedupe window; `page`/`subscribe` replay per subject from HMAC-signed cursors; `subscribe` uses a durable pull consumer with explicit acks (at-least-once, 30s redelivery, dedupe by `record.id`); `cleanup` deletes ownership-scoped messages older than `before`. The host provisions the stream (subjects `prism.agent-events.>`, retention limits, dedupe window). Inert on import; network-free tests use an in-memory fake of the narrow `NatsJetStream` seam.
48
+ One subject per run (`prism.agent-events.<tenant>.<session>.<run>`); the JetStream per-subject sequence is the per-run event sequence. `append` is idempotent by `record.id` within the stream's dedupe window; `page`/`subscribe` replay per subject from HMAC-signed cursors; `subscribe` uses a durable pull consumer with explicit acks (at-least-once, 30s redelivery, dedupe by `record.id`) and a **restart-stable durable identity** — the consumer name is `prism_<hmac16>` of `tenantId|sessionId|runId` (no random suffix), so a crashed subscribe leaves a consumer that a restarting subscribe reuses at its last-acked position instead of replaying from the stream head. Clean stops still delete the consumer; pre-0.2.2 orphaned random-suffixed consumers are reclaimed by hosts via `deleteConsumer`/consumer enumeration. `cleanup` deletes ownership-scoped messages older than `before`. The host provisions the stream (subjects `prism.agent-events.>`, retention limits, dedupe window). Inert on import; network-free tests use an in-memory fake of the narrow `NatsJetStream` seam.
49
49
 
50
50
  ## Inputs / request
51
51
 
@@ -113,13 +113,16 @@ Behavior notes:
113
113
  - Replay cursors are thread-bound: a cursor minted for one thread is rejected on another (`cursor_thread_mismatch`).
114
114
  - Ledger rows from runs that had no redactor are never served by replay/export (fail-closed skip).
115
115
  - Export truncates at page granularity when the next page would exceed `exportBytes`; a single page larger than `exportBytes` cannot be exported (raise the cap or page via `replay`).
116
- - Branch refs live in thread metadata (read-modify-write); concurrent `branch()` calls can lose a ref, so the cap is approximate and the entry tree remains the content source of truth.
116
+ - Branch refs live in thread metadata; every `branch()`/`archive()` write is an optimistic compare-and-swap against the stored session `version`, so concurrent callers cannot lose a ref or resurrect stale state: the loser of a `branch`+`branch`, `branch`+`archive`, or `create`+`create` race gets `metadata_conflict` (HTTP 409) and re-reads/retries if it chooses.
117
+ - `create` with an explicit `id` writes with `expectedVersion: 0` (create-only): a duplicate create never overwrites the winner's metadata and returns the existing thread.
118
+ - `branch()` enforces `maxActiveBranches` on its read snapshot; the version guard inside the same write makes the cap exact even under concurrency (a concurrent branch cannot slip past the cap), and the marker keeps branch refs append-only.
119
+ - `archive()` on an already-archived thread is a no-op; a stale `branch`/`archive` racing a delete fails `not_found` (the row is gone) and the write path never re-creates a deleted thread — delete wins.
117
120
  - Deletion purges the whole session ledger (entries, runs, events, tool calls, usage, branches, search rows) through `lifecycle.applyRetention`; legal holds block deletion and report `held: true`.
118
121
 
119
122
  ## Security and performance notes
120
123
 
121
124
  - Every operation starts from host-verified ownership (and optional `AgentIdentity`, which must project onto ownership without widening); wrong-user access returns not-found, never leaked existence.
122
- - `appendSession` upserts set ownership columns only on create; metadata/`updatedAt` on update — ownership is immutable after create.
125
+ - `appendSession` upserts set ownership columns only on create; metadata/`updatedAt` on update — ownership is immutable after create, and the CAS write additionally rejects a mismatched ownership in the same guarded statement.
123
126
  - Replay/export serve `redacted: true` ledger rows only and pass through the service redactor; no local paths, raw tool payloads, or secrets are emitted.
124
127
  - All loops are bounded by the frozen caps above; review/agent turns consume shared `RunLimits` via the host's `runOptions`.
125
128
  - No new permission surface: conversations reuse session/event/identity/redaction/lifecycle seams (roadmap gate 8).
@@ -76,6 +76,8 @@ Important shapes:
76
76
 
77
77
  Optional session-record write seam (0.0.14): `appendSession?(record: SessionRecord)` upserts a session row — ownership columns are set on create only, `metadata`/`updatedAt` on update — so hosts (e.g. the [conversation service](conversations.md)) can durably mark and title sessions without entry writes. `SessionQuery` gained two bounded filters for the same seam: `id` (exact session lookup) and `metadataKey` (sessions whose `metadata` object contains a top-level key, validated by `assertSessionMetadataKey`). SQLite implements it with `json_extract`, PostgreSQL with a `jsonb` existence check; both keep ownership filtering intact.
78
78
 
79
+ Metadata CAS (0.2.2): the write seam accepts an additive `expectedVersion` guard and returns the new `{ version }`. `expectedVersion: 0` means create-only (a duplicate insert is rejected with `SessionMetadataConflictError`, code `metadata_conflict`), a positive number means the stored `version` must match exactly (update-only — a deleted row is never re-created), and omitting it keeps legacy last-write-wins. Both adapters implement the guard inside the single upsert statement (PostgreSQL `INSERT … SELECT … WHERE … ON CONFLICT … DO UPDATE … WHERE version = $n`; SQLite the same shape with null-safe ownership `IS` comparisons); conflicts carry the id and versions only, never metadata content. The `version` column ships as migration `008_session_version` (`INTEGER NOT NULL DEFAULT 0` plus a one-time backfill to 1) — additive and forward-only, so pre-0.2.2 databases upgrade in place and non-CAS callers behave byte-identically.
80
+
79
81
  Artifact co-work review (0.0.14) reuses the generic `CheckpointStore` rather than adding a dedicated table: the [artifact service](work-artifacts-and-review.md) stores each artifact as a versioned checkpoint value (namespace `prism.artifact`, key `threadId:artifactId`, category `artifact`). The checkpoint `version` is the compare-and-swap counter that resolves concurrent reviewers; revision numbers, approvals, and `lastValidatedVersion` live inside the JSON value. SQLite/Postgres already persist checkpoints durably, so there is no separate artifact schema or migration, and records carry metadata/hashes/refs only — never file bodies.
80
82
 
81
83
  ## Outputs / response / events
@@ -372,6 +374,7 @@ Recommended migration practices:
372
374
  - Use a sequential or timestamped migration naming convention.
373
375
  - Store applied migrations in `prism_migrations` with `name`, `version`, `applied_at`, `applied_by`, and `checksum`.
374
376
  - Make entry-kind and schema-version changes additive when possible; new kinds and versions fail closed in the JSONL parser, so DB schemas should accept the same additive expansion.
377
+ - Additive columns ship as forward-only migrations (`ALTER TABLE … ADD COLUMN IF NOT EXISTS` + a one-time backfill, e.g. `008_session_version` for the `prism_sessions.version` CAS column).
375
378
  - Index new query columns before deploying code that uses them.
376
379
  - Back-fill redacted flags and ownership columns before enforcing tenant isolation.
377
380
 
@@ -448,6 +451,7 @@ const dbStore: ProductionPersistenceStore = {
448
451
  - `ProductionPersistenceStore.feedback?: RunFeedbackStore` exposes immutable append, bounded owned query, and owned deletion. First-party adapters store migration-003 rows in `prism_run_feedback`, FK-link `run_id`, and index owner/run/trace creation cursors.
449
452
  - Hosts choose the database, schema, transaction, and indexing strategy. The contracts specify query and checkpoint capability shapes.
450
453
  - `SessionStore` (`append`/`list`/`get`/optional `readBranchPath`) can be implemented on top of `ProductionPersistenceStore` or kept separate.
454
+ - **State-concurrency conformance (0.2.2):** durable adapters must pass `assertStateConcurrencyConforms` from `@arnilo/prism/testing/state-concurrency-conformance` against both the memory stores and their own implementation (approval determinism, checkpoint CAS, replay-cursor resume, idempotency retry, router reservation, conversation metadata CAS, unknown-outcome recovery). The harness uses deterministic barriers only — no timing-only sleeps — and runs the memory leg in the default `npm test` and the durable legs in `test:postgres`/`test:nats`; `scripts/phase22-conformance.test.mjs` asserts every store leg executed (missing protected environment records a named BLOCKED GATE, never a green skip).
451
455
  - Cursor values and idempotency keys are host-defined and opaque to Prism.
452
456
  - First-party SQLite/PostgreSQL adapters expose `persistence.checkpoints` and `persistence.leases`, backed by package-owned `prism_checkpoints` / `prism_leases` tables. `@arnilo/prism-workflows` consumes them for durable resume, human suspension, multi-process coordination, Phase 11 schedule records/fire leases, shared state, and replay lineage; workflow code owns no SQL table. `suspended`/`denied`, schedules, state history, and replay lineage remain namespaces/categories plus bounded checkpoint JSON values, so Phases 8 and 11 need no database migration.
453
457
 
@@ -13,7 +13,7 @@
13
13
  | Tool effects | `toolEffects` | Durable `ToolEffectStore` claim/CAS for recoverable tool side effects (migration 002). |
14
14
  | Tool effects | `toolEffects` | Durable `ToolEffectStore` claim/CAS for recoverable tool side effects (migration 002). |
15
15
 
16
- `createPostgresEnterpriseState()` opens a host-supplied or adapter-owned `pg` pool, verifies/applies checksum-protected enterprise migrations (`001_enterprise_state`, `002_tool_effects`), and returns those stores plus explicit cleanup and close operations. Importing it performs no I/O. It is separate from session/run persistence in [`@arnilo/prism-session-store-postgres`](postgres-persistence.md).
16
+ `createPostgresEnterpriseState()` opens a host-supplied or adapter-owned `pg` pool, verifies/applies checksum-protected enterprise migrations (`001_enterprise_state`, `002_tool_effects`, `003_router_reservations`), and returns those stores plus explicit cleanup and close operations. Importing it performs no I/O. It is separate from session/run persistence in [`@arnilo/prism-session-store-postgres`](postgres-persistence.md).
17
17
 
18
18
  ## When to use it
19
19
 
@@ -85,7 +85,7 @@ Model-router state is asynchronous and owner/principal/provider/model scoped. Su
85
85
  }
86
86
  ```
87
87
 
88
- A migration creates `prism_policy_decisions`, `prism_evaluations`, `prism_work_idempotency`, three `prism_model_router_*` tables, and its separate `prism_enterprise_migrations` history. Startup serializes per-schema setup with an advisory transaction lock and rejects checksum or catalog drift rather than silently repairing it.
88
+ A migration creates `prism_policy_decisions`, `prism_evaluations`, `prism_work_idempotency`, three `prism_model_router_*` tables, and its separate `prism_enterprise_migrations` history. Migration `003_router_reservations` adds the nullable-by-default `reservations` JSONB column to `prism_model_router_budgets` (atomic reservation slots for router admission; 0.2.1 readers ignore it). Startup serializes per-schema setup with an advisory transaction lock and rejects checksum or catalog drift rather than silently repairing it.
89
89
 
90
90
  ## Implementation example
91
91
 
@@ -152,7 +152,8 @@ export async function recordEnterpriseState(state: PostgresEnterpriseState) {
152
152
 
153
153
  ## Extension and configuration notes
154
154
 
155
- - `createModelRouter({ resolver, stateStore: state.modelRouter })` keeps allow-list, residency, fallback, and diagnostics behavior in `@arnilo/prism-model-router`; this package only supplies durable state.
155
+ - `createModelRouter({ resolver, stateStore: state.modelRouter })` keeps allow-list, residency, fallback, and diagnostics behavior in `@arnilo/prism-model-router`; this package only supplies durable state. Router admission reservations (`reserveBudget`/`commitBudget`/`releaseBudget` on `state.modelRouter`) live in the `reservations` JSONB column of `prism_model_router_budgets`: one atomic UPSERT per admission, fencing-token-guarded commit/release in a SERIALIZABLE transaction, and TTL reconciliation as unknown usage; see [Model routing](model-routing.md).
156
+ - Rate/budget/circuit tables are capped like the memory store: `consumeRate`/`readBudget`/`addUsage`/`reserveBudget` accept `maxRateKeys`/`maxBudgetKeys` (the router passes its resolved limits) and evict the least-recently-used row on new-key insert — never the row just inserted, never a budget row holding an active reservation — else fail closed with `ERR_PRISM_MODEL_ROUTER_STATE`. Cleanup prunes expired reservations within its bounded batch.
156
157
  - Policy/evaluation/query public contracts stay in their owning packages. This package exports only `createPostgresEnterpriseState`, its options/result types, and `EnterprisePostgresError`; it has no SQL, DDL, codec, queryable, or migration subpath.
157
158
  - The fixed schema has no generic key/value table and no background cleanup scheduler. Schedule `state.cleanup()` from an authorized host job, size its bounded batch for the deployment, and monitor unknown work rows for reconciliation. Run protected integration checks with `PRISM_TEST_POSTGRES_URL="$DATABASE_URL" npm run test:postgres`; the command rejects an absent URL instead of silently skipping database coverage.
158
159
  - The OPA adapter (`@arnilo/prism-policy/opa`, 0.0.28) records decisions into the same `state.policy` store unchanged via `evaluateAndAppend` — see [Policy and audit](policy-and-audit.md#opa-external-policy-adapter-arniloprism-policyopa-008).
package/docs/index.md CHANGED
@@ -3,19 +3,19 @@
3
3
  Prism is a TypeScript/Node.js agent harness. Host apps and extension packages own providers, tools, resources, credentials, storage, UI, and business behavior. Prism supplies contracts, registries, streaming events, and replaceable runtime primitives.
4
4
 
5
5
  ## Public contracts
6
- - [Public contracts](public-contracts.md): type shapes for messages, agents, tools, stores, generic `CheckpointStore`, atomic `LeaseStore`, bounded `EventMultiplexer`, resources, credentials, and events.
6
+ - [Public contracts](public-contracts.md): type shapes for messages, agents, tools, stores, generic `CheckpointStore`, atomic `LeaseStore`, bounded single-consumer `EventMultiplexer`, resources, credentials, and events.
7
7
 
8
8
  ## Identity and governance
9
9
  - [Agent identity](agent-identity.md): host-verified `Principal` / `AgentIdentity`, delegation narrowing, ownership projection, and redacted telemetry refs for enterprise runs/tools/MCP/A2A/workflows; optional OIDC/JWKS verifier adapter (`@arnilo/prism-credentials-node/oidc` — pinned issuer/audience/JWKS, bounded claims, fail closed).
10
10
  - [Policy and audit](policy-and-audit.md): optional `@arnilo/prism-policy` decision ledger (allow/deny/modify/approval), evidence refs only, cursor-paginated WORM export, and durable PostgreSQL composition; 0.0.28 adds the OPA REST evaluator (`@arnilo/prism-policy/opa` — pinned SSRF-checked endpoint, redacted input, fail-closed deny, optional bundle-revision pin); 0.2.1 makes the OPA decision fetch DNS-pinned (core `pinnedFetch`).
11
- - [Model routing](model-routing.md): optional `@arnilo/prism-model-router` allow-list/residency/budget/rate/circuit/fallback governance with redacted diagnostics; durable state requires awaited identity-scoped calls; host-configurable selection policies (reference cost/latency policy ranks by `ModelCost` then in-memory latency EMA fed from `recordOutcome`).
11
+ - [Model routing](model-routing.md): optional `@arnilo/prism-model-router` allow-list/residency/budget/rate/circuit/fallback governance with redacted diagnostics; budget admission reserves per-request caps atomically (commit/release/TTL reconciliation); durable state requires awaited identity-scoped calls; host-configurable selection policies (reference cost/latency policy ranks by `ModelCost` then in-memory latency EMA fed from `recordOutcome`).
12
12
 
13
13
  ## Agent/session runtime
14
14
  - [Agent/session runtime](agent-session-runtime.md): create explicit or opt-in secure agents/sessions, get direct `AgentRunResult` values from `run`/`prompt`, mid-run `steer` (turn-boundary or softInterrupt), use integrated `stream()`/`resumeAgentRunStream()`, shared batch pending-decisions / sticky run-scope approvals, subscribe to normalized events, and expose opted-in durable lifecycle capabilities (0.1.6 plan 018 closeout `checkpoint-bodies`: optional `includeSkillBodies` persists the exact loaded-skill instructions with the names-only `persistSessionState`, so resume re-renders bodies registry-independently; ≤64 bodies, `maxStateBytes` refuses oversize; 0.2.0 plan 020 Task 2: durable resume input is validated in core before any side effect — fail-closed durable resume rejects unknown legacy decisions and malformed batches with zero checkpoint writes, tool calls, or resumed events).
15
15
  - [Agent definitions](agent-definitions.md): resolve declarative `AgentDefinition` values via `resolveAgentDefinition`, and turn app-config `<configRoot>/agents/<name>/AGENT.md` bundles into runnable agents via `discoverAgentBundles` / `resolveAgentBundle` (explicit tool/skill activation by name, fail-closed omitted capabilities, migration-only `activateAllCapabilities`, strict duplicate scope checks, configurable prompt layers, no auto-discovery).
16
16
  - [Agent loops](agent-loops.md): replaceable per-run control loops — `singleShotLoop` default, opt-in bounded artifact-loop tool rounds, and durable custom-loop `revision`/`snapshot`/`restore` hooks with fail-closed resume.
17
17
  - [Guardrails](guardrails.md): typed fail-closed input/output/tool checks with buffered provider output and redacted decision records.
18
- - [Agent events](agent-events.md): live `session.subscribe` plus durable `AgentEventSource` page/subscribe/resume for cross-replica reconnect; message/progress deltas never create spans. Durable sources: PostgreSQL `LISTEN`/`NOTIFY` (reference, `@arnilo/prism-session-store-postgres` root export) and NATS JetStream (`@arnilo/prism-session-store-nats`, FR-5).
18
+ - [Agent events](agent-events.md): live `session.subscribe` plus durable `AgentEventSource` page/subscribe/resume for cross-replica reconnect; message/progress deltas never create spans. Durable sources: PostgreSQL `LISTEN`/`NOTIFY` (reference, `@arnilo/prism-session-store-postgres` root export) and NATS JetStream (`@arnilo/prism-session-store-nats`, FR-5) with restart-stable durable consumer identity (`prism_<hmac16>`) for cursor resume across crash/restart.
19
19
  - [Observability](observability.md): OTel GenAI agent/provider/tool hierarchy, host context parenting, bounded trace linkage, safe evaluation events, controlled metrics, and exporter isolation.
20
20
  - [Evaluations](evaluations.md): deterministic and bounded trace/model-judge/pairwise scoring, CI thresholds, OTel trace-reference linkage, coding/browser adversarial fixtures, ID-only linkage to immutable owned run feedback, and optional durable PostgreSQL records.
21
21
  - [Runs and usage ledger](runs-and-usage.md): durable run/event/tool/usage persistence, optional bounded FIFO durability policies, session snapshot caching, and immutable run/trace feedback.
@@ -28,13 +28,13 @@ Prism is a TypeScript/Node.js agent harness. Host apps and extension packages ow
28
28
  - [Observational memory compaction package](compaction-observational-memory.md): optional source-backed memory with explicit `attach()` lifecycle — **Recent exact messages**, **Observation log**, **Reflections**, **Raw-source retrieval** (exact-id recall + cursor paging); dual coverage, **nested-only settings** (pre-0.0.19 flat keys and top-level `workerProvider`/`workerModel` aliases removed in 0.1.5; removed keys fail closed naming the nested replacement), branch-isolated `appendEntry`, secrets redaction, and inert import/extension.
29
29
  - [Working and semantic memory](working-and-semantic-memory.md): optional `@arnilo/prism-memory` working-memory store, semantic recall, finite Embedder/VectorStore contracts, PostgreSQL/pgvector path, consent lifecycle, identity-bound redacted export, and resumable bounded rebuild.
30
30
  - [Session stores](session-stores.md): `SessionStore` contract, `SessionAppendOptions`, `SessionAppendConflictError`, branch handles, `readBranchPath`, optional bounded `searchSessions` / `SessionIndex` (memory linear|unsupported), and dev-vs-production branch reads — start here for session persistence.
31
- - [Conversations](conversations.md): durable user-scoped conversation threads (create/list/continue/branch/archive/export/delete) on session + event-ledger seams, thread-bound reconnectable replay, frozen caps, and legal-hold-aware deletion.
31
+ - [Conversations](conversations.md): durable user-scoped conversation threads (create/list/continue/branch/archive/export/delete) on session + event-ledger seams, thread-bound reconnectable replay, frozen caps, atomic metadata via version/CAS (`metadata_conflict` on stale writes), and legal-hold-aware deletion.
32
32
  - [Work artifacts and review](work-artifacts-and-review.md): durable artifact co-work review — authorized attach (MIME/hash/version, producer run, citations, preview metadata), revision compare, approve/reject with last-validated recovery, and authorized expiring delivery links; records persist as versioned checkpoints, never file bodies. 0.0.28 adds the core `ArtifactBodyStore` contract (put/get/delete/presign by opaque ownership-scoped ref, hash/size/MIME verification, legal-hold-aware idempotent delete) and the reference `@arnilo/prism-server/artifact-bodies` S3-compatible adapter (hand-rolled SigV4, native fetch + WebCrypto, optional host KMS callback); delivery links resolve through `bodies.presign` when wired.
33
33
  - [Session stores and branching](session-stores-and-branching.md): detailed branch semantics and helper reference (kept for compatibility; links back to the canonical atomic append / branch-handle sections).
34
- - [Database persistence](database-persistence.md): production persistence contracts, shared checksummed migration/full-shape catalog primitives (`@arnilo/prism/testing/persistence-schema`), conditional append, indexes, `readBranchPath`, reference relational schema, retention/legal-hold/quota lifecycle (`lifecycle`), and NoSQL mapping.
34
+ - [Database persistence](database-persistence.md): production persistence contracts, shared checksummed migration/full-shape catalog primitives (`@arnilo/prism/testing/persistence-schema`), conditional append, indexes, `readBranchPath`, reference relational schema, retention/legal-hold/quota lifecycle (`lifecycle`), and NoSQL mapping. `appendSession` gains version/CAS (migration `008_session_version`); durable adapters must pass `assertStateConcurrencyConforms` (`@arnilo/prism/testing/state-concurrency-conformance`: approval/checkpoint-CAS/cursor/idempotency/reservation/conversation-metadata/unknown-outcome probes; memory leg in `npm test`, durable legs in `test:postgres`/`test:nats`, `scripts/phase22-conformance.test.mjs` gate accounting).
35
35
  - [SQLite persistence](sqlite-persistence.md): optional `better-sqlite3` adapter with session/run storage, checkpoints/leases, feedback, FTS `searchSessions` (migration-v4), and transactionally verified/backfilled migration metadata.
36
36
  - [PostgreSQL persistence](postgres-persistence.md): optional pooled `pg` adapter with session/run/checkpoint/lease/feedback storage, FTS `searchSessions` (migration-v4), advisory-locked checksummed/full-shape migrations, and opt-in live conformance.
37
- - [Enterprise PostgreSQL state](enterprise-postgres-state.md): optional `@arnilo/prism-enterprise-postgres` composition for durable policy/evaluation/work-idempotency/model-router/`toolEffects` state, exact ownership, checksummed migrations, and explicit cleanup.
37
+ - [Enterprise PostgreSQL state](enterprise-postgres-state.md): optional `@arnilo/prism-enterprise-postgres` composition for durable policy/evaluation/work-idempotency/model-router/`toolEffects` state, exact ownership, checksummed migrations (001-003; 003 adds router budget reservation slots), and explicit cleanup.
38
38
  - [Migration guide](migration.md): **0.1.4 → 0.1.5** documented breaking cut — deprecated-option removal (the inert provider request knobs, `maxToolRounds` alias, observational-memory flat keys/worker aliases, `autoResizeImages`, `INIT_PROVIDERS`) with exact replacement table, before/after examples, and fail-closed refusal behavior; **0.0.28 → 0.1.0** release-candidate hardening (no migration); **0.0.17 → 0.1.0 upgrade matrix** (store compatibility per release line: compatible / tested migration / tested refusal, plus breaking-default callouts); **0.0.27** ACP coding-host interop (capability advertise-when, session modes/config, MCP select, lifecycle events, elicitation); **0.0.26** coding intelligence, managed processes, forge, and safe egress; **0.0.25** durable custom loops and shared human-in-the-loop decisions; **0.0.24** distributed events and recoverable tool effects; **0.0.23** enterprise PostgreSQL state adapters (async router and work reconciliation); **0.0.22** third-party behavior integrations (Caveman, Ponytail); **0.0.21** coding-tool capability gaps (`outputMode`, glob, read-before-write, delete/move, aggregator 9/4); **0.0.20** progressive skill disclosure, empty registry default, `load_skill`, priority budget demotion, optional tool-result fold; **0.0.19** observational memory lifecycle; **0.0.15** OpenAI hosted tools/continuation/Realtime, exact AI SDK v4 matrix, RAG lifecycle/reranking/trust/status, and memory export/rebuild; **0.0.14** conversations, memory consent/lifecycle, artifact co-work review, AG-UI co-work events, scoped M365/GWS OAuth connectors, browser checkpoints, device contracts, and Alibaba/Ollama providers; plus prior release migrations.
39
39
  - [Node JSONL session store](node-jsonl-session-store.md): development-only JSONL file adapter for single-process Node hosts; no cross-process safety; `searchSessions` throws `SessionSearchUnsupportedError`.
40
40
  - [Persistence, credentials, and multimodality primitives](persistence-credentials-multimodality-primitives.md): Plan 056 inventory — session/run-ledger/persistence contracts, credential/OAuth seams, content/resource/model capabilities, package dependency matrix, conformance matrix, and threat model for production adapters.
@@ -104,7 +104,7 @@ Prism is a TypeScript/Node.js agent harness. Host apps and extension packages ow
104
104
 
105
105
  ## CLI/RPC
106
106
  - [CLI/RPC](cli-rpc.md): Run print/json modes and LF-delimited RPC over the public AgentSession runtime, including mid-run `steer`, branch-handle results, fixed `forkSession`, and `checkout`. `prism init` scaffolds a tiny TypeScript project with one selected provider and an offline mock test; `prism providers add <name>` scaffolds an OpenAI-compatible provider package (manifest, provider, models, cache helpers, conformance test, docs stub).
107
- - [Workflows](workflows.md): optional `@arnilo/prism-workflows` typed bounded DAG orchestration — explicit recursive definition revisions, exact-owner cancellation/active identity, finite hard limits, durable human suspend/resume, schedules/background execution, revocable proactive schedule capability tokens, nested workflows, replay, coordination, events, and optional RPC/Web bindings. Compose coding plans/checkpoints via workspace Markdown + `state.coding` without a second runtime. Interactive TUI (C-012) deferred.
107
+ - [Workflows](workflows.md): optional `@arnilo/prism-workflows` typed bounded DAG orchestration — explicit recursive definition revisions, exact-owner cancellation/active identity, finite hard limits, durable human suspend/resume, schedules/background execution, revocable proactive schedule capability tokens, nested workflows, replay, coordination, events, and optional RPC/Web bindings. Compose coding plans/checkpoints via workspace Markdown + `state.coding` without a second runtime. Active-run registry is non-durable, in-process only, with bounded sweep/cap cleanup. Interactive TUI (C-012) deferred.
108
108
  - [Workflow orchestration primitives](workflow-orchestration-primitives.md): architecture inventory — workflow adapters consume core `CheckpointStore`, `LeaseStore`, and bounded `EventMultiplexer`; run control and optional RPC commands stay package-local.
109
109
  - [Workflow/TUI scope](workflow-tui-primitives.md): records why 0.0.5 ships workflow APIs/RPC control but no interactive terminal UI.
110
110
 
@@ -129,7 +129,7 @@ Prism is a TypeScript/Node.js agent harness. Host apps and extension packages ow
129
129
  - [Ponytail behavior integration](ponytail.md): optional `@arnilo/prism-ponytail` — upstream Ponytail skills/commands, `ponytail-mode` injector, session `ponytail-mode` persistence; resolves peer `@dietrichgebert/ponytail` or `upstreamPath`; opt-in (not in code/sdk profiles).
130
130
 
131
131
  ## Release and install
132
- - [Release and install](release-and-install.md): current **0.2.1** 50-package graph (root + 49 workspace packages) — plan 021 the provider-completion-and-outbound-trust-boundaries cut: strict stream completion is the shared OpenAI-compatible default (truncated streams fail `incomplete_delta`, explicit `strictCompletion: false` opt-out), bounded success bodies via `readBoundedResponseJson` on all discovery/quota/embeddings/upload/OAuth JSON endpoints (65,536-byte ceiling, depth/property/shape caps), DNS-pinned OIDC JWKS/OPA/content fetches through the core `pinnedFetch` primitive with 3xx redirects rejected outright (private/metadata answers fail closed `ssrf_denied`), shared bounded OAuth device/token polling (`pollDeviceCodeToken`) across provider-openai and credentials-node, and the four edge fixes (Azure/Vertex credential-once, Bedrock duplicate-case/repeated-query SigV4 canonicalization, OpenAI upload failed-DELETE retention, cache `__overflow__` tokens-only); public-entrypoint threat-suite `scripts/phase21-security.test.mjs` + packed plain-JS consumer; additive-only compat (MCP transport helpers re-exported from core, no removals); migration `0.2.0 → 0.2.1`; then plan 020 the fail-closed runtime-and-sandbox-security cut on the 0.2.x review-remediation line: durable-resume decision validation in core (`assertValidAgentRunResume` — unknown decisions/malformed batches fail closed with `ERR_PRISM_DECISION_*` before any state claim, checkpoint write, or tool execution; server parser remains defense in depth), isolated work-tool subprocess environments (`@arnilo/prism-work-tools` — fixed base allow-list + explicit env + forced HOME/telemetry + late-bound per-identity tokens, 64-name/64-KiB caps, absolute binary/configDir, linear output capture), and explicit sandbox capabilities (`@arnilo/prism-coding-security` — `SandboxAdapter.capabilities` with omission-is-false fail-closed resolution, `SandboxCodingComposition.capabilities` from verified wiring, `containmentClaim` deprecated as the conservative projection; Docker reports only verified controls, native reports filesystem/process/privilege `false`); public-entrypoint security conformance (`scripts/phase20-security.test.mjs`, wired into `security:threat-suites`), packed plain-JS consumer regressions, and the sandbox-browser workflow's fail-loud Docker/native capability evidence gate — 0.2.0 never ships while a blocker is skipped; migration and rollback notes in `docs/migration.md` `0.1.7 → 0.2.0`, store-compatible with 0.1.7 in both directions; 0.1.7 was the performance-and-DX patch — dependency-free `createCacheTelemetry()` per-provider/model cache hit/miss aggregator (bounded cardinality with `__overflow__`, token counters/rates only, host-activated), host-configurable `ModelRouterSelectionPolicy` on `createModelRouter` with the reference `createCostLatencySelection` (ModelCost rank then in-memory latency EMA, default ordered behavior byte-identical), `prism providers add <name>` OpenAI-compatible provider scaffold (manifest/provider/models/cache/conformance test/docs stub, npm-name + traversal + symlink-escape validation, placeholders only), and the async `AgUiProjection` verification closeout (plan 009 Task 15 evidence recorded, no new code); plan 017 the documented breaking cut — deprecated-option removal with `docs/migration.md` `0.1.4 → 0.1.5` section and reviewed compat-baseline regeneration via `--allow-break` then `--update-baseline`: the inert provider request knobs, `RunOptions.maxToolRounds`, observational-memory flat settings keys + top-level worker aliases, `ReadToolOptions.autoResizeImages`, `INIT_PROVIDERS`; all removals fail closed naming their replacement; plan 016 internal god-module split — `agents.ts`/`contracts.ts` reorganized behind barrel re-exports with a byte-identical public entry surface, measured tree-shaking improvement in `scripts/phase16-baseline.json`, and additive `@arnilo/prism-browser` Chrome DevTools Protocol capabilities — `browser_evaluate`/`browser_observe` and `block_urls`/`unblock_urls`/`throttle`/`emulate` act actions; plan 015 dead-code and deprecation hygiene on the frozen 0.1.x line — parameterized benchmark runner `scripts/benchmark.mjs` absorbing the per-version runners, archived review-coverage evidence in `docs/_evidence/`, non-blocking unused-code sweep `npm run sweep:unused`, opt-in checkpoint persistence for loaded-skill names and read-path sets; plan 014 Alibaba provider enrichment — embeddings, video input, verified compatible-mode surface decision table; plan 013 post-release hardening — build single-flight, MCP SSE relay test, combined coverage summary, canonical manifest-count narrative, ACP modes/config persistence guidance; Phase 12 release-candidate hardening; plan 012 — freeze manifest, compatibility matrix, upgrade matrix, packed-install e2e journeys, restart-recovery evidence, capacity envelopes, security policy), exact-peer/install/tarball rules, deterministic resumable publication and publish dry-run, frozen 0.1.x compatibility and support matrix (Node/PostgreSQL/platform/provider/protocol pins and unsupported combinations, machine-checked against `scripts/phase12-freeze-manifest.json`), protected PostgreSQL gate, pinned supply-chain gates, offline tests, the 0.0.15 provider/AI-SDK/RAG/memory protected live-canary matrix, and sandbox-browser Docker/Playwright gates.
132
+ - [Release and install](release-and-install.md): current **0.2.2** 50-package graph (root + 49 workspace packages) — plan 022 the concurrent-state-and-durability-integrity cut: atomic model-budget reservation (`ModelRouterStateStore.reserveBudget`/`commitBudget`/`releaseBudget` with fencing tokens, `reservationTtlMs` expiry and unknown-usage reconciliation, rate/budget key-map caps with LRU eviction that never drops a held reservation), atomic conversation metadata (`SessionRecord.version` + `appendSession` `expectedVersion` CAS across Postgres/SQLite — create-only `0`, exact-version `N>0`, legacy last-write-wins when omitted; `SessionMetadataConflictError` `metadata_conflict` with versions only, HTTP 409; concurrent create/branch/archive single-statement with branch caps inside the CAS, archive wins, deleted rows never resurrect), single-consumer `EventMultiplexer` (`EventMultiplexerError` `ERR_PRISM_EVENT_MULTIPLEXER_SINGLE_CONSUMER` instead of silent queue sharing), restart-stable NATS durable consumer identity (`prism_<hmac16>` with no random suffix — crash-resumed subscribe continues from the last ack, orphaned 0.2.1 consumers reclaimed on clean stop), and bounded non-durable active-run registries (sweep + fail-closed 512 cap `ERR_PRISM_WORKFLOW_RUN_REGISTRY_OVERFLOW`); new regression surface `scripts/phase22-security.test.mjs` (4 blockers + gate accounting over built public entrypoints) + packed plain-JS `security22.mjs` consumer + the `@arnilo/prism/testing/state-concurrency-conformance` harness (7 probes across memory/Postgres/SQLite/NATS legs, no timing-only sleeps) + the `scripts/phase22-conformance.test.mjs` gate; additive-only compat (new exports only, no removals); forward-only migrations 008 (`prism_sessions.version`) and 003 (`prism_model_router_budgets.reservations`); migration `0.2.1 → 0.2.2`; then plan 021 the provider-completion-and-outbound-trust-boundaries cut: strict stream completion is the shared OpenAI-compatible default (truncated streams fail `incomplete_delta`, explicit `strictCompletion: false` opt-out), bounded success bodies via `readBoundedResponseJson` on all discovery/quota/embeddings/upload/OAuth JSON endpoints (65,536-byte ceiling, depth/property/shape caps), DNS-pinned OIDC JWKS/OPA/content fetches through the core `pinnedFetch` primitive with 3xx redirects rejected outright (private/metadata answers fail closed `ssrf_denied`), shared bounded OAuth device/token polling (`pollDeviceCodeToken`) across provider-openai and credentials-node, and the four edge fixes (Azure/Vertex credential-once, Bedrock duplicate-case/repeated-query SigV4 canonicalization, OpenAI upload failed-DELETE retention, cache `__overflow__` tokens-only); public-entrypoint threat-suite `scripts/phase21-security.test.mjs` + packed plain-JS consumer; additive-only compat (MCP transport helpers re-exported from core, no removals); migration `0.2.0 → 0.2.1`; then plan 020 the fail-closed runtime-and-sandbox-security cut on the 0.2.x review-remediation line: durable-resume decision validation in core (`assertValidAgentRunResume` — unknown decisions/malformed batches fail closed with `ERR_PRISM_DECISION_*` before any state claim, checkpoint write, or tool execution; server parser remains defense in depth), isolated work-tool subprocess environments (`@arnilo/prism-work-tools` — fixed base allow-list + explicit env + forced HOME/telemetry + late-bound per-identity tokens, 64-name/64-KiB caps, absolute binary/configDir, linear output capture), and explicit sandbox capabilities (`@arnilo/prism-coding-security` — `SandboxAdapter.capabilities` with omission-is-false fail-closed resolution, `SandboxCodingComposition.capabilities` from verified wiring, `containmentClaim` deprecated as the conservative projection; Docker reports only verified controls, native reports filesystem/process/privilege `false`); public-entrypoint security conformance (`scripts/phase20-security.test.mjs`, wired into `security:threat-suites`), packed plain-JS consumer regressions, and the sandbox-browser workflow's fail-loud Docker/native capability evidence gate — 0.2.0 never ships while a blocker is skipped; migration and rollback notes in `docs/migration.md` `0.1.7 → 0.2.0`, store-compatible with 0.1.7 in both directions; 0.1.7 was the performance-and-DX patch — dependency-free `createCacheTelemetry()` per-provider/model cache hit/miss aggregator (bounded cardinality with `__overflow__`, token counters/rates only, host-activated), host-configurable `ModelRouterSelectionPolicy` on `createModelRouter` with the reference `createCostLatencySelection` (ModelCost rank then in-memory latency EMA, default ordered behavior byte-identical), `prism providers add <name>` OpenAI-compatible provider scaffold (manifest/provider/models/cache/conformance test/docs stub, npm-name + traversal + symlink-escape validation, placeholders only), and the async `AgUiProjection` verification closeout (plan 009 Task 15 evidence recorded, no new code); plan 017 the documented breaking cut — deprecated-option removal with `docs/migration.md` `0.1.4 → 0.1.5` section and reviewed compat-baseline regeneration via `--allow-break` then `--update-baseline`: the inert provider request knobs, `RunOptions.maxToolRounds`, observational-memory flat settings keys + top-level worker aliases, `ReadToolOptions.autoResizeImages`, `INIT_PROVIDERS`; all removals fail closed naming their replacement; plan 016 internal god-module split — `agents.ts`/`contracts.ts` reorganized behind barrel re-exports with a byte-identical public entry surface, measured tree-shaking improvement in `scripts/phase16-baseline.json`, and additive `@arnilo/prism-browser` Chrome DevTools Protocol capabilities — `browser_evaluate`/`browser_observe` and `block_urls`/`unblock_urls`/`throttle`/`emulate` act actions; plan 015 dead-code and deprecation hygiene on the frozen 0.1.x line — parameterized benchmark runner `scripts/benchmark.mjs` absorbing the per-version runners, archived review-coverage evidence in `docs/_evidence/`, non-blocking unused-code sweep `npm run sweep:unused`, opt-in checkpoint persistence for loaded-skill names and read-path sets; plan 014 Alibaba provider enrichment — embeddings, video input, verified compatible-mode surface decision table; plan 013 post-release hardening — build single-flight, MCP SSE relay test, combined coverage summary, canonical manifest-count narrative, ACP modes/config persistence guidance; Phase 12 release-candidate hardening; plan 012 — freeze manifest, compatibility matrix, upgrade matrix, packed-install e2e journeys, restart-recovery evidence, capacity envelopes, security policy), exact-peer/install/tarball rules, deterministic resumable publication and publish dry-run, frozen 0.1.x compatibility and support matrix (Node/PostgreSQL/platform/provider/protocol pins and unsupported combinations, machine-checked against `scripts/phase12-freeze-manifest.json`), protected PostgreSQL gate, pinned supply-chain gates, offline tests, the 0.0.15 provider/AI-SDK/RAG/memory protected live-canary matrix, and sandbox-browser Docker/Playwright gates.
133
133
  - [0.1.0 / 1.0 readiness gates](0.1.0-readiness.md): command-per-gate 1.0 readiness table — frozen API surface + compat gate, migration/docs tripwires, budget table, live-suite matrix, security matrix, current-line status (**0.0.23** published target), signed-publication/live-canary prerequisites for 1.0, and Phase 12 demand-evidence entry criteria.
134
134
  - [Review coverage archive](_evidence/): per-phase evidence freezes (plans 067–079, releases 0.0.4–0.0.16) — traceability matrices, provider validation, capability/primitive/limit matrices, benchmark budgets, and artifact-diet findings; tarball-excluded, kept in-repo for audit.
135
135
 
package/docs/migration.md CHANGED
@@ -1,5 +1,57 @@
1
1
  # Migration guide
2
2
 
3
+ ## 0.2.1 → 0.2.2 concurrent state and durability integrity (plan 022)
4
+
5
+ Release **0.2.2** (plan 022) makes four concurrency/durability boundaries atomic or fail-loud. The API surface is **additive-only** (plain reviewed compat gate at 0.2.2: expected deltas are the version literal, `ModelRouterStateStore.reserveBudget`/`commitBudget`/`releaseBudget` plus `ModelRouterReservation`/`ModelRouterBudgets.reservationTtlMs`/`ModelRouterLimits.maxRateKeys`/`maxBudgetKeys` (memory + Postgres), `SessionRecord.version` with `appendSession` `expectedVersion`, `EventMultiplexerError` with code `ERR_PRISM_EVENT_MULTIPLEXER_SINGLE_CONSUMER`, and the `@arnilo/prism/testing/state-concurrency-conformance` subpath; no removal, no `--allow-break`). Three of the four changes tighten behavior where 0.2.1 silently accepted a race — concurrent hosts may now see an explicit conflict where 0.2.1 lost an update or oversubscribed a budget:
6
+
7
+ 1. **Atomic model-budget reservation (`model-router`, `enterprise-postgres`).** Admission is now reserve/commit/release: `reserveBudget` runs at admission and fails the request when `used + reserved + requested` would exceed the window max, returning `{ reservationId, fencingToken, admitted, retryAfterMs? }`; `commitBudget` applies the actual usage delta at the outcome (an expired reservation still charges the reserved amount with `unknownUsage: true` so a late commit can never disappear from accounting); `releaseBudget` frees an uncommitted reservation. `readBudget`-based admission stays for requests with no per-request cap, and the 0.2.1 post-hoc `addUsage` remains as retrospective accounting only — it is no longer admission authority.
8
+
9
+ ```js
10
+ // 0.2.1: readBudget then consumeRate then addUsage — concurrent admissions could collectively oversubscribe
11
+ // 0.2.2: admission reserves the full per-request cap, outcome commits/releases actuals
12
+ const reservation = await store.reserveBudget({
13
+ key: { tenantId, principalId, provider, model },
14
+ tokens: request.maxTokens, costUsd: request.maxCostUsd, // per-request caps, when set
15
+ windowMs: 24 * 60 * 60 * 1000, reservationTtlMs: 60_000,
16
+ });
17
+ if (!reservation.admitted) { /* denied; retry after reservation.retryAfterMs */ }
18
+ // ... run the request ...
19
+ await store.commitBudget({
20
+ key, reservationId: reservation.reservationId,
21
+ fencingToken: reservation.fencingToken, tokens: actualTokens, windowMs: 24 * 60 * 60 * 1000,
22
+ });
23
+ ```
24
+
25
+ Reservations expire after `reservationTtlMs` (default 60,000 ms, bounded to 31 days) even if a host never commits, so a crashed request cannot hold capacity forever. Rate/budget/circuit key maps are now capped (`maxRateKeys`/`maxBudgetKeys`, default 4,096, hard cap 65,536; circuits stay 1,024/16,384) with LRU eviction on insert; a budget row holding an active reservation is never evicted (the eviction candidates exclude held rows, and if nothing is evictable the insert fails with `ERR_PRISM_MODEL_ROUTER_STATE` `capacity-exhausted`). The durable Postgres store keeps reservations in a new `reservations` JSONB column on `prism_model_router_budgets` (migration 003, forward-only, applied automatically by `applyEnterpriseMigrations`; existing rows are untouched and read as no reservations).
26
+
27
+ 2. **Atomic conversation metadata (`session-store-postgres`, `session-store-sqlite`, core `SessionRecord`).** `SessionRecord` gains `version` (fresh rows start at 1; migration 008 backfills legacy 0-version rows to 1) and `appendSession` accepts `expectedVersion`: `0` = create-only, `N > 0` = exact-version CAS update-only, omitted = the 0.2.1 last-write-wins behavior for untyped/legacy callers. A stale write throws `SessionMetadataConflictError` (`metadata_conflict`) carrying only `{ id, expectedVersion, currentVersion }` — never metadata content — and the HTTP server maps it to 409. Concurrent create/branch/archive are now single-statement: the branch `maxActiveBranches` cap is enforced inside the CAS write (a concurrent branch at cap-1 fails its version guard instead of silently dropping the oldest ref), archive wins over a stale concurrent write, and a retention-deleted session is never resurrected (the update arm requires the row to still exist).
28
+
29
+ ```js
30
+ // 0.2.1: create could race to the last metadata write; concurrent branch calls could lose a ref
31
+ // 0.2.2: exactly one concurrent writer wins per version; losers get metadata_conflict
32
+ const { version } = await persistence.appendSession({
33
+ id: sessionId, ...ownership, createdAt, updatedAt, metadata: { state: "active" },
34
+ expectedVersion: 0, // create-only: conflict if the session already exists
35
+ });
36
+ try {
37
+ await persistence.appendSession({ ...record, metadata: { state: "archived" }, expectedVersion: version });
38
+ } catch (error) {
39
+ if (error.code === "metadata_conflict") { /* re-read the winning version and retry */ }
40
+ }
41
+ ```
42
+
43
+ 3. **Single-consumer `EventMultiplexer` (core).** `createEventMultiplexer().subscribe()` now rejects a second concurrent consumer with `EventMultiplexerError` `ERR_PRISM_EVENT_MULTIPLEXER_SINGLE_CONSUMER` instead of parking both consumers on one queue and silently losing events. The slot frees when the active consumer's iterator completes, is `return()`ed at a yield, or the multiplexer closes. Hosts that previously relied on multiple `subscribe()` calls sharing one multiplexer must either serialize consumption or use the event source's own broadcast `subscribe` (agent-events), which still supports multiple subscribers. `createWorkflowEventBus` and the supervisor (the only in-repo consumers) are unaffected — each already uses a single subscriber.
44
+
45
+ 4. **Restart-stable NATS durable consumer identity (`session-store-nats`).** The durable consumer name is now exactly `prism_<hmac16 of tenantId|sessionId|runId>` — the 0.2.1 random suffix is gone, so a crashed durable subscribe is reused at its last-acked position by a restarting process (cursor resume, at-least-once). Clean stops still delete the durable consumer (resume then relies on the HMAC-signed cursor); only a crash leaves the consumer in place. Pre-0.2.2 consumers minted with the random suffix (`prism_<digest>_<random>`) are orphaned and reclaimed by the existing `deleteConsumer`/consumer-enumeration cleanup path on the next clean stop of a same-subject subscribe.
46
+
47
+ 5. **Bounded, non-durable active-run registries (`workflows`).** The in-process workflow active-run registry is documented as non-durable (no timer, no background service): `registerActiveWorkflowRun` sweeps aborted/leaked entries before every insert and fails closed with `WorkflowRuntimeError` `ERR_PRISM_WORKFLOW_RUN_REGISTRY_OVERFLOW` at the 512 cap instead of evicting a live entry (a live eviction could silently allow a duplicate run). A run whose promise never settles is reclaimed only when it is aborted or the cap forces a sweep — there is no durable recovery of active runs in 0.2.2 (see Further Actions: 0.2.6).
48
+
49
+ **Store compatibility:** 0.2.2 is **not** rollback-compatible with 0.2.1 in the Postgres/SQLite persisted shape: `prism_sessions` gains a `version` column (migration 008) and `prism_model_router_budgets` gains a `reservations` column (enterprise migration 003). Both migrations are forward-only and additive — 0.2.2 code reads 0.2.1 databases correctly after migration (backfill included); a 0.2.1 binary pointed at a 0.2.2 database still works because the new columns are nullable/defaulted, but it will not maintain versions or reservations. The NATS durable-name change touches no persisted data (consumers are runtime state; orphaned 0.2.1 consumers are reclaimed on the next clean stop).
50
+
51
+ **Rollout:** upgrade core and the session stores together (migration 008 runs automatically via the existing checksummed `prism_migrations`; the version column must exist before any host writes CAS updates). Then `enterprise-postgres` (migration 003) and `model-router` (reservation admission can be enabled per-host; hosts that never call `recordUsage` rely on TTL expiry). Then `workflows`/`server` (conversation CAS is transparent to clients except new 409 responses), then `session-store-nats`. Branch/archive callers that intentionally lost races in 0.2.1 must now handle `metadata_conflict` (re-read + retry) where they previously accepted last-write-wins.
52
+
53
+ **Rollback risk:** restoring 0.2.1 against a 0.2.2 database is safe for reads and last-write-wins writes (the new columns are ignored) but silently reopens all four race windows: oversubscription, conversation lost updates, silent multi-subscriber event loss, and non-restart-stable NATS resume. Rollback is therefore only a stopgap, not a mitigation — prefer fixing the failing host on 0.2.2.
54
+
3
55
  ## 0.2.0 → 0.2.1 provider completion and outbound trust boundaries (plan 021)
4
56
 
5
57
  Release **0.2.1** (plan 021) tightens the streaming-completion, outbound-fetch, and credential/signing/upload boundaries. The API surface is **additive-only** (plain reviewed compat gate at 0.2.1: the only deltas are the version literal and `@arnilo/prism-mcp` transport helpers `boundResponse`/`defaultResolver`/`isLoopbackAddress`/`isLoopbackHostname`/`normalizeHostname`/`raceAbort`/`requestPinned`/`resolvePinnedAddress` becoming re-exports of the lifted core primitives — same names, same signatures, no removal; no `--allow-break`), with five documented security-motivated behavior tightenings. Untyped/legacy callers may now fail where 0.2.0 silently proceeded: