@smartmemory/stratum 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/dist/cli/guard.js +63 -6
  2. package/dist/cli/guard.js.map +1 -1
  3. package/dist/cli/learn.js +226 -0
  4. package/dist/cli/learn.js.map +1 -0
  5. package/dist/cli/query_gate.js +2 -1
  6. package/dist/cli/query_gate.js.map +1 -1
  7. package/dist/cli/stratum.js +5 -2
  8. package/dist/cli/stratum.js.map +1 -1
  9. package/dist/connectors/background.js +58 -10
  10. package/dist/connectors/background.js.map +1 -1
  11. package/dist/connectors/base.js +16 -0
  12. package/dist/connectors/base.js.map +1 -1
  13. package/dist/connectors/cancellation.js +119 -0
  14. package/dist/connectors/cancellation.js.map +1 -0
  15. package/dist/connectors/claude-bg-worker.js +9 -1
  16. package/dist/connectors/claude-bg-worker.js.map +1 -1
  17. package/dist/connectors/claude.js +179 -86
  18. package/dist/connectors/claude.js.map +1 -1
  19. package/dist/connectors/codex.js +289 -61
  20. package/dist/connectors/codex.js.map +1 -1
  21. package/dist/connectors/runner.js +44 -3
  22. package/dist/connectors/runner.js.map +1 -1
  23. package/dist/contracts/events.json +34 -1
  24. package/dist/contracts/guard-signers.allowed +36 -0
  25. package/dist/contracts/mcp-surface.json +1097 -104
  26. package/dist/engine/checkpoint.js +8 -1
  27. package/dist/engine/checkpoint.js.map +1 -1
  28. package/dist/engine/engine.js +508 -42
  29. package/dist/engine/engine.js.map +1 -1
  30. package/dist/engine/evaluate.js +61 -0
  31. package/dist/engine/evaluate.js.map +1 -0
  32. package/dist/engine/ledger.js +12 -0
  33. package/dist/engine/ledger.js.map +1 -1
  34. package/dist/engine/receipts.js +91 -0
  35. package/dist/engine/receipts.js.map +1 -0
  36. package/dist/engine/state.js +51 -1
  37. package/dist/engine/state.js.map +1 -1
  38. package/dist/guard/authorization.js +71 -0
  39. package/dist/guard/authorization.js.map +1 -0
  40. package/dist/guard/descriptors.js +248 -0
  41. package/dist/guard/descriptors.js.map +1 -0
  42. package/dist/guard/errors.js +12 -0
  43. package/dist/guard/errors.js.map +1 -1
  44. package/dist/guard/evidence.js +24 -3
  45. package/dist/guard/evidence.js.map +1 -1
  46. package/dist/guard/sshsig.js +264 -0
  47. package/dist/guard/sshsig.js.map +1 -0
  48. package/dist/guard/store.js +13 -0
  49. package/dist/guard/store.js.map +1 -1
  50. package/dist/guard/transition.js +484 -33
  51. package/dist/guard/transition.js.map +1 -1
  52. package/dist/guard/trust.js +78 -0
  53. package/dist/guard/trust.js.map +1 -0
  54. package/dist/ir/schema.js +8 -1
  55. package/dist/ir/schema.js.map +1 -1
  56. package/dist/ir/validate.js +3 -1
  57. package/dist/ir/validate.js.map +1 -1
  58. package/dist/judge/judged.js +1 -1
  59. package/dist/judge/pricing.js +1 -0
  60. package/dist/judge/pricing.js.map +1 -1
  61. package/dist/learn/apply.js +600 -0
  62. package/dist/learn/apply.js.map +1 -0
  63. package/dist/learn/candidate.js +186 -0
  64. package/dist/learn/candidate.js.map +1 -0
  65. package/dist/learn/classify.js +181 -0
  66. package/dist/learn/classify.js.map +1 -0
  67. package/dist/learn/harvest.js +138 -0
  68. package/dist/learn/harvest.js.map +1 -0
  69. package/dist/learn/smartmemory_egress.js +330 -0
  70. package/dist/learn/smartmemory_egress.js.map +1 -0
  71. package/dist/mcp/server.js +155 -24
  72. package/dist/mcp/server.js.map +1 -1
  73. package/dist/policy/bundle.js +195 -0
  74. package/dist/policy/bundle.js.map +1 -0
  75. package/dist/policy/events.js +51 -0
  76. package/dist/policy/events.js.map +1 -0
  77. package/dist/policy/smartmemory_client.js +332 -0
  78. package/dist/policy/smartmemory_client.js.map +1 -0
  79. package/dist/policy/types.js +2 -0
  80. package/dist/policy/types.js.map +1 -0
  81. package/package.json +3 -2
@@ -4,16 +4,24 @@ import { existsSync, statSync } from "node:fs";
4
4
  import { isAbsolute } from "node:path";
5
5
  import { canonicalJson } from "./canonical.js";
6
6
  import { commandsAllowed, evaluateEvidence, parsePredicateStatement, } from "./evidence.js";
7
- import { CommandExecutionDisabled, EvidenceParseError, GuardAlreadyRegistered, GuardEngineOwned, GuardNotFound, GuardTampered, IdempotencyConflict, IllegalEdge, InvalidStateName, InvalidWorkspaceRoot, OverrideUnavailable, ParanoidEdgeNeedsTrustedEvidence, StaleFromState, } from "./errors.js";
7
+ import { CommandExecutionDisabled, EvidenceParseError, GuardAlreadyRegistered, GuardEngineOwned, GuardNotFound, GuardTampered, IdempotencyConflict, IllegalEdge, IncompatiblePolicyUpgrade, InvalidStateName, InvalidWorkspaceRoot, OverrideUnavailable, ParanoidEdgeNeedsTrustedEvidence, StaleFromState, UpgradeDescriptorMismatch, } from "./errors.js";
8
+ import { verifyAuthorization } from "./authorization.js";
9
+ import { findDescriptor, loadDescriptorFile } from "./descriptors.js";
8
10
  import { guardChecksum } from "./fingerprint.js";
9
11
  import { GuardRegistry, LedgerEntry, appendLedger, assertStillHeld, findByIdempotencyKey, isValidStateName, loadRegistry, loadRegistryRaw, persistRegistry, readEngineOwner, readLedger, resourceDir, resourceLock, writeTsOwnerMarker, } from "./store.js";
10
12
  import { evaluateJudgedViaCodex } from "../judge/codex_judged.js";
11
13
  import { evaluateJudged, } from "../judge/judged.js";
12
14
  import { judgeBackend } from "../mcp/server.js";
15
+ import { guardEdgePredicatesFor, sourceFromPredicate, validateBundle } from "../policy/bundle.js";
16
+ import { buildGuardTransitionEvent } from "../policy/events.js";
17
+ import { emitPolicyEvent } from "../policy/smartmemory_client.js";
13
18
  const TRUSTED_TYPE = "deterministic";
19
+ const RESERVED_STATE_NAMES = new Set(["__proto__", "constructor", "prototype"]);
14
20
  const LLM_TYPES = new Set(["verified", "judged"]);
15
21
  let acquireResourceLock = resourceLock;
16
22
  let fenceResourceLock = assertStillHeld;
23
+ let warnedGuardRunIdFallback = false;
24
+ const legacyDigestMatchesLogged = new Set();
17
25
  /** Replace only the orchestration lock seams in isolated tests. */
18
26
  export function setGuardLockingForTests(lock, fence) {
19
27
  const previousLock = acquireResourceLock;
@@ -36,16 +44,38 @@ export function _nowMs() {
36
44
  export function _edgeKey(fromState, toState) {
37
45
  return `${fromState}->${toState}`;
38
46
  }
39
- export function _payloadDigest(fromState, toState, artifacts, modifiedFiles, resolvedBy) {
40
- const canonical = canonicalJson({
47
+ export function _payloadDigest(fromState, toState, artifacts, modifiedFiles, resolvedBy, policyChecksum) {
48
+ return payloadDigestForVersion(fromState, toState, artifacts, modifiedFiles, resolvedBy, policyChecksum, 2);
49
+ }
50
+ /** Recompute persisted digest material according to the entry that owns it. */
51
+ export function payloadDigestForVersion(fromState, toState, artifacts, modifiedFiles, resolvedBy, policyChecksum, digestVersion) {
52
+ // The registry checksum covers graph, source-stamped edge predicates,
53
+ // terminal states, and effective stakes. Signing it into every transition
54
+ // payload makes the enforced policy replay-stable ledger material.
55
+ const material = {
41
56
  from_state: fromState,
42
57
  to_state: toState,
43
58
  artifacts,
44
59
  modified_files: [...modifiedFiles].sort(),
45
60
  resolved_by: resolvedBy,
46
- });
61
+ };
62
+ if (digestVersion === 2)
63
+ material.policy_checksum = policyChecksum;
64
+ const canonical = canonicalJson(material);
47
65
  return createHash("sha256").update(canonical, "utf8").digest("hex");
48
66
  }
67
+ export function noteLegacyDigestMatch(resourceId) {
68
+ if (legacyDigestMatchesLogged.has(resourceId))
69
+ return;
70
+ legacyDigestMatchesLogged.add(resourceId);
71
+ console.warn(`guard ${resourceId}: matched legacy payload digest version 1`);
72
+ }
73
+ function payloadDigests(fromState, toState, artifacts, modifiedFiles, resolvedBy, policyChecksum) {
74
+ return {
75
+ 1: payloadDigestForVersion(fromState, toState, artifacts, modifiedFiles, resolvedBy, policyChecksum, 1),
76
+ 2: payloadDigestForVersion(fromState, toState, artifacts, modifiedFiles, resolvedBy, policyChecksum, 2),
77
+ };
78
+ }
49
79
  function assertTsOwnedForMutation(resourceId) {
50
80
  if (readEngineOwner(resourceId) === "ts")
51
81
  return;
@@ -147,6 +177,36 @@ export function* _allEdgePredicates(edgePredicates) {
147
177
  }
148
178
  }
149
179
  export function _validatePolicy(graph, edgePredicates, initial, terminal, stakes, workspaceRoot) {
180
+ // Shape before names. The MCP contract types graph/terminal as "object" and
181
+ // "array", so an adjacency value can arrive as a bare string — and a string
182
+ // is iterable (its chars pass the name check) while
183
+ // `String.prototype.includes` matches SUBSTRINGS, so a string adjacency
184
+ // would make the edge-legality check accept states nobody declared.
185
+ // Rejecting the shape here covers register, migrate and upgrade at once.
186
+ if (!Array.isArray(terminal) || terminal.some((name) => typeof name !== "string")) {
187
+ throw new InvalidStateName("terminal must be an array of state names");
188
+ }
189
+ // Duplicates would make `terminal` a multiset, so "frozen exactly" could not
190
+ // be checked by set equality (`["shipped"]` vs `["shipped","shipped"]` is a
191
+ // token-free change that set comparison waves through).
192
+ if (new Set(terminal).size !== terminal.length) {
193
+ throw new InvalidStateName("terminal must not contain duplicate state names");
194
+ }
195
+ for (const [state, targets] of Object.entries(graph)) {
196
+ if (!Array.isArray(targets) || targets.some((target) => typeof target !== "string")) {
197
+ throw new InvalidStateName(`graph[${JSON.stringify(state)}] must be an array of state names`);
198
+ }
199
+ }
200
+ for (const [edge, predicates] of Object.entries(edgePredicates)) {
201
+ if (!Array.isArray(predicates)
202
+ || predicates.some((predicate) => typeof predicate !== "object" || predicate === null || Array.isArray(predicate))) {
203
+ throw new EvidenceParseError(`edge_predicates[${JSON.stringify(edge)}] must be an array of predicate objects`);
204
+ }
205
+ }
206
+ for (const [edge, stake] of Object.entries(stakes)) {
207
+ if (typeof stake !== "string")
208
+ throw new InvalidStateName(`stakes[${JSON.stringify(edge)}] must be a string`);
209
+ }
150
210
  const names = new Set([...Object.keys(graph), ...terminal, initial]);
151
211
  for (const targets of Object.values(graph))
152
212
  for (const target of targets)
@@ -155,6 +215,14 @@ export function _validatePolicy(graph, edgePredicates, initial, terminal, stakes
155
215
  if (!isValidStateName(name)) {
156
216
  throw new InvalidStateName(`invalid state name ${JSON.stringify(name)} (allowed: [A-Za-z0-9_.-])`);
157
217
  }
218
+ // `__proto__` and friends pass the character class but are not ordinary
219
+ // object keys: a policy carrying one behaves differently depending on
220
+ // whether it is read as an own property or through the prototype chain, and
221
+ // a guard policy must mean exactly one thing. Same reservation the IR schema
222
+ // applies to contract field names.
223
+ if (RESERVED_STATE_NAMES.has(name)) {
224
+ throw new InvalidStateName(`reserved state name ${JSON.stringify(name)}`);
225
+ }
158
226
  }
159
227
  if (!Object.hasOwn(graph, initial) && !terminal.includes(initial)) {
160
228
  throw new InvalidStateName(`initial state ${JSON.stringify(initial)} is not a node in the graph`);
@@ -172,7 +240,7 @@ export function _validatePolicy(graph, edgePredicates, initial, terminal, stakes
172
240
  const parsed = parsePredicateStatement(statement);
173
241
  if (parsed.name === "command_exit_zero")
174
242
  usesCommand = true;
175
- if (["server_file_exists", "git_commit_exists", "command_exit_zero"].includes(parsed.name))
243
+ if (["server_file_exists", "server_file_contains", "git_commit_exists", "command_exit_zero"].includes(parsed.name))
176
244
  needsWorkspace = true;
177
245
  }
178
246
  if (usesCommand && !commandsAllowed()) {
@@ -199,9 +267,20 @@ export function _validatePolicy(graph, edgePredicates, initial, terminal, stakes
199
267
  }
200
268
  }
201
269
  }
202
- export async function registerGuard(resourceId, graph, edgePredicates, initial, terminal = [], stakes = {}, workspaceRoot = null) {
203
- _validatePolicy(graph, edgePredicates, initial, terminal, stakes, workspaceRoot);
204
- const checksum = guardChecksum(graph, edgePredicates, terminal, stakes);
270
+ export async function registerGuard(resourceId, graph, edgePredicates, initial, terminal = [], stakes = {}, workspaceRoot = null, policyBundle) {
271
+ const bundle = policyBundle === undefined ? undefined : validateBundle(policyBundle);
272
+ let effectivePredicates = edgePredicates;
273
+ const effectiveStakes = structuredClone(stakes);
274
+ if (bundle !== undefined) {
275
+ effectivePredicates = structuredClone(edgePredicates);
276
+ for (const [fromState, targets] of Object.entries(graph)) {
277
+ for (const toState of targets)
278
+ effectivePredicates[`${fromState}->${toState}`] ??= [];
279
+ }
280
+ effectivePredicates = guardEdgePredicatesFor(bundle, resourceId, effectivePredicates, effectiveStakes);
281
+ }
282
+ _validatePolicy(graph, effectivePredicates, initial, terminal, effectiveStakes, workspaceRoot);
283
+ const checksum = guardChecksum(graph, effectivePredicates, terminal, effectiveStakes);
205
284
  const resourceDirectoryExisted = existsSync(resourceDir(resourceId));
206
285
  const status = await acquireResourceLock(resourceId, async ({ token }) => {
207
286
  const owner = readEngineOwner(resourceId);
@@ -212,21 +291,30 @@ export async function registerGuard(resourceId, graph, edgePredicates, initial,
212
291
  }
213
292
  const existing = loadRegistryRaw(resourceId);
214
293
  if (existing !== null) {
215
- if (existing.checksum === checksum)
294
+ if (existing.checksum === checksum) {
295
+ if (bundle !== undefined && existing.bundle_id !== bundle.bundle_id) {
296
+ const previousBundleId = existing.bundle_id;
297
+ existing.bundle_id = bundle.bundle_id;
298
+ await fenceResourceLock(resourceId, token);
299
+ persistRegistry(existing);
300
+ console.warn(`guard ${resourceId}: refreshed bundle_id from ${previousBundleId ?? "<unset>"} to ${bundle.bundle_id}`);
301
+ }
216
302
  return "exists";
303
+ }
217
304
  throw new GuardAlreadyRegistered(`guard ${JSON.stringify(resourceId)} already registered with a different policy; use migrate`);
218
305
  }
219
306
  const registry = new GuardRegistry({
220
307
  resource_id: resourceId,
221
308
  graph,
222
- edge_predicates: edgePredicates,
309
+ edge_predicates: effectivePredicates,
223
310
  initial,
224
311
  terminal,
225
- stakes,
312
+ stakes: effectiveStakes,
226
313
  checksum,
227
314
  graph_version: 1,
228
315
  workspace_root: workspaceRoot,
229
316
  current_state: initial,
317
+ ...(bundle !== undefined ? { bundle_id: bundle.bundle_id } : {}),
230
318
  });
231
319
  await fenceResourceLock(resourceId, token);
232
320
  persistRegistry(registry);
@@ -234,17 +322,27 @@ export async function registerGuard(resourceId, graph, edgePredicates, initial,
234
322
  });
235
323
  return { guard_id: resourceId, checksum, status };
236
324
  }
237
- export function _maybeReplay(registry, idempotencyKey, payloadDigest) {
325
+ export function _maybeReplay(registry, idempotencyKey, digests) {
238
326
  if (!idempotencyKey)
239
327
  return null;
240
328
  const prior = findByIdempotencyKey(registry.resource_id, idempotencyKey);
241
329
  if (prior === null)
242
330
  return null;
243
- if (prior.payload_digest !== payloadDigest) {
331
+ if (prior.payload_digest !== digests[prior.payload_digest_version]) {
244
332
  throw new IdempotencyConflict(`idempotency_key ${JSON.stringify(idempotencyKey)} reused with a different payload`);
245
333
  }
334
+ if (prior.payload_digest_version === 1)
335
+ noteLegacyDigestMatch(registry.resource_id);
246
336
  const verdict = prior.verdict ?? _evidenceToVerdictDict({ met: prior.outcome === "applied", perPredicate: [] }, registry.stakes[_edgeKey(prior.from_state, prior.to_state)] ?? "default", "replayed idempotent transition");
247
- return { status: "replayed", verdict, ledger_ref: prior.entry_digest, current_state: registry.current_state };
337
+ return {
338
+ status: "replayed",
339
+ verdict,
340
+ ledger_ref: prior.entry_digest,
341
+ current_state: registry.current_state,
342
+ entry_digest: prior.entry_digest,
343
+ prev_digest: prior.prev_digest,
344
+ payload_digest: prior.payload_digest ?? "",
345
+ };
248
346
  }
249
347
  function defaultJudge() {
250
348
  return judgeBackend() === "openai"
@@ -287,6 +385,7 @@ async function evaluateLlmPredicates(predicates, stakes, judge, context) {
287
385
  // estimate remains operational telemetry rather than ledger material.
288
386
  dollars: 0,
289
387
  turns: results.length,
388
+ evaluations: results.map(({ predicate, result }) => ({ predicate, met: result.holds })),
290
389
  };
291
390
  }
292
391
  export async function guardTransition(resourceId, fromState, toState, options = {}) {
@@ -294,7 +393,9 @@ export async function guardTransition(resourceId, fromState, toState, options =
294
393
  const modifiedFiles = options.modifiedFiles ?? [];
295
394
  const idempotencyKey = options.idempotencyKey ?? null;
296
395
  const resolvedBy = options.resolvedBy ?? "agent";
297
- const payloadDigest = _payloadDigest(fromState, toState, artifacts, modifiedFiles, resolvedBy);
396
+ if (resolvedBy !== "agent" && resolvedBy !== "human") {
397
+ throw new EvidenceParseError(`resolved_by must be "agent" or "human", got ${JSON.stringify(resolvedBy)}`);
398
+ }
298
399
  // Phase 1: cheap structural checks while holding the resource lock.
299
400
  const snapshot = await acquireResourceLock(resourceId, () => {
300
401
  assertTsOwnedForMutation(resourceId);
@@ -304,13 +405,14 @@ export async function guardTransition(resourceId, fromState, toState, options =
304
405
  if (guardChecksum(registry.graph, registry.edge_predicates, registry.terminal, registry.stakes) !== registry.checksum) {
305
406
  throw new GuardTampered(`guard ${JSON.stringify(resourceId)} policy checksum mismatch`);
306
407
  }
307
- const replay = _maybeReplay(registry, idempotencyKey, payloadDigest);
408
+ const digests = payloadDigests(fromState, toState, artifacts, modifiedFiles, resolvedBy, registry.checksum);
409
+ const replay = _maybeReplay(registry, idempotencyKey, digests);
308
410
  if (replay !== null)
309
411
  return { replay };
310
412
  if (fromState !== registry.current_state) {
311
413
  throw new StaleFromState(`from_state ${JSON.stringify(fromState)} != current_state ${JSON.stringify(registry.current_state)}`);
312
414
  }
313
- if (!(registry.graph[fromState] ?? []).includes(toState)) {
415
+ if (!_declaresEdge(registry.graph, fromState, toState)) {
314
416
  throw new IllegalEdge(`${_edgeKey(fromState, toState)} is not a legal edge`);
315
417
  }
316
418
  const edge = _edgeKey(fromState, toState);
@@ -321,14 +423,19 @@ export async function guardTransition(resourceId, fromState, toState, options =
321
423
  stakes: registry.stakes[edge] ?? "default",
322
424
  workspaceRoot: registry.workspace_root,
323
425
  ledgerEntries: readLedger(resourceId),
426
+ payloadDigests: digests,
324
427
  };
325
428
  });
326
429
  if (snapshot.replay !== null)
327
430
  return snapshot.replay;
431
+ const digests = snapshot.payloadDigests;
432
+ const payloadDigest = digests[2];
328
433
  // Phase 2: evidence and judge evaluation deliberately run outside the lock.
329
434
  const trusted = snapshot.predicates.filter((predicate) => _ptype(predicate) === TRUSTED_TYPE);
330
435
  const llm = snapshot.predicates.filter((predicate) => LLM_TYPES.has(_ptype(predicate)));
331
436
  const evidence = await evaluateEvidence(trusted, snapshot.workspaceRoot, snapshot.ledgerEntries);
437
+ const predicateResults = new Map();
438
+ trusted.forEach((predicate, index) => predicateResults.set(predicate, evidence.perPredicate[index]?.met ?? false));
332
439
  let combinedMet;
333
440
  let verdict;
334
441
  if (llm.length === 0) {
@@ -337,22 +444,27 @@ export async function guardTransition(resourceId, fromState, toState, options =
337
444
  }
338
445
  else if (options.judge === null) {
339
446
  combinedMet = false;
447
+ for (const predicate of llm)
448
+ predicateResults.set(predicate, false);
340
449
  verdict = _evidenceToVerdictDict({ met: false, perPredicate: evidence.perPredicate }, snapshot.stakes, "LLM-tier predicates present but no verifier available");
341
450
  }
342
451
  else {
343
452
  const judged = await evaluateLlmPredicates(llm, snapshot.stakes, options.judge ?? defaultJudge(), {
344
453
  result: { resource_id: resourceId, edge: snapshot.edge, artifacts, modified_files: modifiedFiles },
345
454
  });
455
+ for (const evaluation of judged.evaluations)
456
+ predicateResults.set(evaluation.predicate, evaluation.met);
346
457
  [combinedMet, verdict] = _mergeVerdict(evidence, judged, snapshot.stakes);
347
458
  }
348
459
  verdict = normalizeVerdictForLedger(verdict);
460
+ const ruleVerdicts = ruleVerdictsFor(snapshot.predicates, predicateResults);
349
461
  // Phase 3: optimistic commit under a newly-acquired lock.
350
462
  return acquireResourceLock(resourceId, async ({ token }) => {
351
463
  assertTsOwnedForMutation(resourceId);
352
464
  const registry = loadRegistry(resourceId);
353
465
  if (registry === null)
354
466
  throw new GuardNotFound(`no guard registered for ${JSON.stringify(resourceId)}`);
355
- const replay = _maybeReplay(registry, idempotencyKey, payloadDigest);
467
+ const replay = _maybeReplay(registry, idempotencyKey, digests);
356
468
  if (replay !== null)
357
469
  return replay;
358
470
  if (registry.current_state !== fromState) {
@@ -361,7 +473,7 @@ export async function guardTransition(resourceId, fromState, toState, options =
361
473
  if (guardChecksum(registry.graph, registry.edge_predicates, registry.terminal, registry.stakes) !== registry.checksum) {
362
474
  throw new GuardTampered(`guard ${JSON.stringify(resourceId)} policy checksum mismatch during commit`);
363
475
  }
364
- if (!(registry.graph[fromState] ?? []).includes(toState)) {
476
+ if (!_declaresEdge(registry.graph, fromState, toState)) {
365
477
  // Python has the same latent eval-outside-lock race; Slice E should add this re-check there too.
366
478
  throw new IllegalEdge(`${_edgeKey(fromState, toState)} is no longer a legal edge after evaluation`);
367
479
  }
@@ -383,19 +495,66 @@ export async function guardTransition(resourceId, fromState, toState, options =
383
495
  registry.current_state = toState;
384
496
  persistRegistry(registry);
385
497
  }
386
- return { status: outcome, verdict, ledger_ref: ledgerRef, current_state: registry.current_state };
498
+ if (registry.bundle_id !== undefined) {
499
+ firePolicyEvent(buildGuardTransitionEvent({
500
+ runId: guardEventRunId(options.runId, resourceId),
501
+ bundleId: registry.bundle_id,
502
+ resourceId,
503
+ entry,
504
+ rulesEvaluated: ruleVerdicts,
505
+ }));
506
+ }
507
+ return {
508
+ status: outcome,
509
+ verdict,
510
+ ledger_ref: ledgerRef,
511
+ current_state: registry.current_state,
512
+ entry_digest: entry.entry_digest,
513
+ prev_digest: entry.prev_digest,
514
+ payload_digest: entry.payload_digest ?? "",
515
+ };
387
516
  });
388
517
  }
389
- export function _checkOverrideToken(token) {
390
- const expected = process.env.STRATUM_GUARD_OVERRIDE_TOKEN;
391
- if (!expected) {
392
- throw new OverrideUnavailable("override unavailable: STRATUM_GUARD_OVERRIDE_TOKEN not set in server env");
518
+ function ruleVerdictsFor(predicates, results) {
519
+ const verdicts = [];
520
+ for (const predicate of predicates) {
521
+ const source = sourceFromPredicate(predicate);
522
+ if (source === undefined)
523
+ continue;
524
+ const type = _ptype(predicate);
525
+ verdicts.push({
526
+ rule_id: typeof predicate.id === "string" ? predicate.id : source.record_id,
527
+ source: structuredClone(source),
528
+ met: results.get(predicate) ?? false,
529
+ predicate_type: type === "judged" || type === "verified" ? type : "deterministic",
530
+ });
393
531
  }
394
- if (token !== expected)
395
- throw new OverrideUnavailable("override token mismatch");
532
+ return verdicts;
396
533
  }
397
- export async function guardOverride(resourceId, fromState, toState, overrideToken, rationale, resolvedBy = "human") {
398
- _checkOverrideToken(overrideToken);
534
+ function firePolicyEvent(event) {
535
+ void emitPolicyEvent(event).catch((error) => {
536
+ console.warn(`policy event ${event.event_id} delivery failed: ${error instanceof Error ? error.message : String(error)}`);
537
+ });
538
+ }
539
+ function guardEventRunId(runId, resourceId) {
540
+ if (runId !== undefined)
541
+ return runId;
542
+ if (!warnedGuardRunIdFallback) {
543
+ warnedGuardRunIdFallback = true;
544
+ console.warn("WARNING: guard events are using resource_id as run_id; pass run_id to preserve flow correlation");
545
+ }
546
+ return resourceId;
547
+ }
548
+ /**
549
+ * The resource's current ledger head, or `""` for an untouched resource. Signed
550
+ * into every one-shot authorization so it is valid at exactly one point in this
551
+ * resource's history and cannot be replayed afterwards.
552
+ */
553
+ export function _ledgerHead(resourceId) {
554
+ const ledger = readLedger(resourceId);
555
+ return ledger.length === 0 ? "" : ledger[ledger.length - 1].entry_digest;
556
+ }
557
+ export async function guardOverride(resourceId, fromState, toState, authorization, rationale, resolvedBy = "human", userId, runId) {
399
558
  if (resolvedBy !== "human")
400
559
  throw new OverrideUnavailable("override requires resolved_by='human'");
401
560
  if (!rationale || !rationale.trim())
@@ -405,10 +564,20 @@ export async function guardOverride(resourceId, fromState, toState, overrideToke
405
564
  const registry = loadRegistry(resourceId);
406
565
  if (registry === null)
407
566
  throw new GuardNotFound(`no guard registered for ${JSON.stringify(resourceId)}`);
567
+ // Inside the lock: the ledger head must be the one this call will append to,
568
+ // so a concurrent mutation invalidates the authorization rather than letting
569
+ // it apply to a history the operator did not sign for.
570
+ const authorizedBy = verifyAuthorization("override", {
571
+ resource_id: resourceId,
572
+ from_state: fromState,
573
+ to_state: toState,
574
+ rationale,
575
+ ledger_head: _ledgerHead(resourceId),
576
+ }, authorization);
408
577
  if (fromState !== registry.current_state) {
409
578
  throw new StaleFromState(`from_state ${JSON.stringify(fromState)} != current_state ${JSON.stringify(registry.current_state)}`);
410
579
  }
411
- if (!(registry.graph[fromState] ?? []).includes(toState)) {
580
+ if (!_declaresEdge(registry.graph, fromState, toState)) {
412
581
  throw new IllegalEdge(`${_edgeKey(fromState, toState)} is not a legal edge (override bypasses predicates, not the graph)`);
413
582
  }
414
583
  const entry = new LedgerEntry({
@@ -418,17 +587,36 @@ export async function guardOverride(resourceId, fromState, toState, overrideToke
418
587
  outcome: "deviation",
419
588
  kind: "deviation",
420
589
  resolved_by: resolvedBy,
421
- rationale,
590
+ payload_digest: _payloadDigest(fromState, toState, {}, [], resolvedBy, registry.checksum),
591
+ rationale: `authorized by ${authorizedBy.principal} (${authorizedBy.fingerprint}): ${rationale}`,
422
592
  });
423
593
  await fenceResourceLock(resourceId, token);
424
594
  const ledgerRef = appendLedger(resourceId, entry);
425
595
  registry.current_state = toState;
426
596
  persistRegistry(registry);
427
- return { status: "deviation", ledger_ref: ledgerRef, current_state: registry.current_state, rationale };
597
+ if (registry.bundle_id !== undefined) {
598
+ if (userId === undefined) {
599
+ console.warn("WARNING: guard deviation has no user_id; SmartMemory will reject the enforcement event");
600
+ }
601
+ firePolicyEvent(buildGuardTransitionEvent({
602
+ runId: guardEventRunId(runId, resourceId),
603
+ bundleId: registry.bundle_id,
604
+ resourceId,
605
+ entry,
606
+ rulesEvaluated: [],
607
+ ...(userId !== undefined ? { resolvedByUserId: userId } : {}),
608
+ }));
609
+ }
610
+ return {
611
+ status: "deviation", ledger_ref: ledgerRef, current_state: registry.current_state, rationale,
612
+ authorized_by: `${authorizedBy.principal} (${authorizedBy.fingerprint})`,
613
+ entry_digest: entry.entry_digest,
614
+ prev_digest: entry.prev_digest,
615
+ payload_digest: entry.payload_digest ?? "",
616
+ };
428
617
  });
429
618
  }
430
- export async function guardMigrate(resourceId, newGraph, newEdgePredicates, overrideToken, rationale, newTerminal = [], newStakes = {}) {
431
- _checkOverrideToken(overrideToken);
619
+ export async function guardMigrate(resourceId, newGraph, newEdgePredicates, authorization, rationale, newTerminal = [], newStakes = {}) {
432
620
  if (!rationale || !rationale.trim())
433
621
  throw new OverrideUnavailable("migrate requires a non-empty rationale");
434
622
  return acquireResourceLock(resourceId, async ({ token }) => {
@@ -436,6 +624,14 @@ export async function guardMigrate(resourceId, newGraph, newEdgePredicates, over
436
624
  const registry = loadRegistry(resourceId);
437
625
  if (registry === null)
438
626
  throw new GuardNotFound(`no guard registered for ${JSON.stringify(resourceId)}`);
627
+ // The authorization names the RESULTING policy, so a signature for one
628
+ // migration cannot be spent on a different one.
629
+ const authorizedBy = verifyAuthorization("migrate", {
630
+ resource_id: resourceId,
631
+ policy_checksum: guardChecksum(newGraph, newEdgePredicates, newTerminal, newStakes),
632
+ rationale,
633
+ ledger_head: _ledgerHead(resourceId),
634
+ }, authorization);
439
635
  _validatePolicy(newGraph, newEdgePredicates, registry.initial, newTerminal, newStakes, registry.workspace_root);
440
636
  if (!Object.hasOwn(newGraph, registry.current_state) && !newTerminal.includes(registry.current_state)) {
441
637
  throw new InvalidStateName(`current_state ${JSON.stringify(registry.current_state)} is not a node in the new graph`);
@@ -449,6 +645,190 @@ export async function guardMigrate(resourceId, newGraph, newEdgePredicates, over
449
645
  outcome: "graph_version",
450
646
  kind: "graph_version",
451
647
  resolved_by: "human",
648
+ rationale: `authorized by ${authorizedBy.principal} (${authorizedBy.fingerprint}): ${rationale}`,
649
+ });
650
+ await fenceResourceLock(resourceId, token);
651
+ const ledgerRef = appendLedger(resourceId, entry);
652
+ registry.graph = newGraph;
653
+ registry.edge_predicates = newEdgePredicates;
654
+ registry.terminal = newTerminal;
655
+ registry.stakes = newStakes;
656
+ registry.checksum = checksum;
657
+ registry.graph_version = graphVersion;
658
+ persistRegistry(registry);
659
+ return {
660
+ status: "migrated", checksum, graph_version: graphVersion, ledger_ref: ledgerRef, rationale,
661
+ authorized_by: `${authorizedBy.principal} (${authorizedBy.fingerprint})`,
662
+ };
663
+ });
664
+ }
665
+ /**
666
+ * Edge legality, robust to a malformed stored adjacency. `_validatePolicy`
667
+ * rejects a non-array adjacency on the way in, but registries are long-lived
668
+ * and load through unchecked casts, so one written before that check exists
669
+ * must still fail closed here: `["b"].includes("x")` is membership, but
670
+ * `"bxyz".includes("xyz")` is a SUBSTRING match and would legalize an
671
+ * undeclared, unguarded state.
672
+ */
673
+ function _declaresEdge(graph, fromState, toState) {
674
+ const targets = graph[fromState];
675
+ return Array.isArray(targets) && targets.includes(toState);
676
+ }
677
+ function _graphEdgeKeys(graph) {
678
+ const keys = new Set();
679
+ for (const [fromState, targets] of Object.entries(graph)) {
680
+ for (const toState of targets)
681
+ keys.add(_edgeKey(fromState, toState));
682
+ }
683
+ return keys;
684
+ }
685
+ function _policyNodes(graph, terminal, initial) {
686
+ const names = new Set([...Object.keys(graph), ...terminal, initial]);
687
+ for (const targets of Object.values(graph))
688
+ for (const target of targets)
689
+ names.add(target);
690
+ return names;
691
+ }
692
+ /**
693
+ * Every way the submitted policy is NOT an additive-only extension of the
694
+ * stored one, as human-readable reasons (empty = compatible).
695
+ *
696
+ * Deliberately stricter than strictly necessary, because this classifier is
697
+ * the only thing standing between a token-free call and a policy change:
698
+ * existing edges must be byte-identical (even ADDING a predicate to one is
699
+ * refused — "strengthening" is an ordering this code declines to reason
700
+ * about), and `terminal` is frozen in both directions — no state may be added
701
+ * to it or removed from it, and no new edge may enter OR leave a terminal
702
+ * state. Anything it refuses remains reachable through `guardMigrate` with the
703
+ * override token.
704
+ */
705
+ export function _upgradeIncompatibilities(registry, newGraph, newEdgePredicates, newTerminal, newStakes) {
706
+ const reasons = [];
707
+ for (const [fromState, targets] of Object.entries(registry.graph)) {
708
+ if (!Object.hasOwn(newGraph, fromState)) {
709
+ reasons.push(`node ${JSON.stringify(fromState)} is missing from the new graph`);
710
+ continue;
711
+ }
712
+ const newTargets = new Set(newGraph[fromState] ?? []);
713
+ for (const toState of targets) {
714
+ if (!newTargets.has(toState))
715
+ reasons.push(`edge ${_edgeKey(fromState, toState)} was removed`);
716
+ }
717
+ }
718
+ // Keys of the OLD policy only: predicates/stakes on brand-new edges are the
719
+ // point of an upgrade, while any pre-existing key must survive untouched —
720
+ // including "absent stays absent", so a new entry on an old edge is refused.
721
+ const existingKeys = new Set([
722
+ ..._graphEdgeKeys(registry.graph),
723
+ ...Object.keys(registry.edge_predicates),
724
+ ...Object.keys(registry.stakes),
725
+ ]);
726
+ // Own-property reads only: a caller-supplied object still inherits
727
+ // Object.prototype, so `newEdgePredicates["constructor"]` would otherwise
728
+ // resolve to an inherited value instead of "absent".
729
+ const own = (source, key) => (Object.hasOwn(source, key) ? source[key] : null);
730
+ for (const key of existingKeys) {
731
+ if (canonicalJson(own(registry.edge_predicates, key)) !== canonicalJson(own(newEdgePredicates, key))) {
732
+ reasons.push(`predicates changed on existing edge ${key}`);
733
+ }
734
+ if (own(registry.stakes, key) !== own(newStakes, key)) {
735
+ reasons.push(`stakes changed on existing edge ${key}`);
736
+ }
737
+ }
738
+ // Additive-only does NOT mean harmless: an added edge that lands on a state
739
+ // the old policy already had is a new ROUTE to it, and routes around the
740
+ // predicates on the old route. `draft -> shipped` guarded by evidence is
741
+ // worthless if a token-free call can add `draft -> rubber_stamp -> shipped`.
742
+ // So new edges may only terminate at states that did not exist before —
743
+ // which keeps the reachability of every pre-existing state exactly as
744
+ // registered, and still allows grafting a new subgraph (the requesting case).
745
+ const oldEdges = _graphEdgeKeys(registry.graph);
746
+ const oldNodes = _policyNodes(registry.graph, registry.terminal, registry.initial);
747
+ const oldTerminal = new Set(registry.terminal);
748
+ for (const [fromState, targets] of Object.entries(newGraph)) {
749
+ for (const toState of targets) {
750
+ if (oldEdges.has(_edgeKey(fromState, toState)))
751
+ continue;
752
+ if (oldNodes.has(toState)) {
753
+ reasons.push(`new edge ${_edgeKey(fromState, toState)} adds a route into pre-existing state ${JSON.stringify(toState)}`);
754
+ }
755
+ // Terminal EGRESS, not just membership. Freezing the terminal set stops a
756
+ // caller inventing a new way to be done; it does not stop `shipped ->
757
+ // reopened` with a brand-new `reopened`, which walks a completed resource
758
+ // back OUT of its terminal state — token-free, over an edge whose
759
+ // predicates the caller also chose. A terminal state has no outgoing
760
+ // edges by contract, so growing one is a policy change either way.
761
+ if (oldTerminal.has(fromState)) {
762
+ reasons.push(`new edge ${_edgeKey(fromState, toState)} leaves terminal state ${JSON.stringify(fromState)}`);
763
+ }
764
+ }
765
+ }
766
+ // `terminal` is frozen — not "grows only with new nodes". Stratum never reads
767
+ // it for edge legality, but consumers read it as COMPLETABILITY, so adding a
768
+ // terminal state is granting a new way to be done. A token-free caller who
769
+ // could add one would simply declare its own success state, reach it over a
770
+ // new edge whose predicates it also chose (an empty predicate list evaluates
771
+ // as met), and be complete without passing any gate that existed at
772
+ // registration. Rule 4 does not help there: the bypass never touches an old
773
+ // state. Granting completability is an authorization decision, so it stays
774
+ // on the token-gated `guardMigrate`.
775
+ const newTerminalSet = new Set(newTerminal);
776
+ for (const name of oldTerminal) {
777
+ if (!newTerminalSet.has(name))
778
+ reasons.push(`terminal state ${JSON.stringify(name)} was removed`);
779
+ }
780
+ for (const name of newTerminalSet) {
781
+ if (!oldTerminal.has(name))
782
+ reasons.push(`terminal state ${JSON.stringify(name)} was added`);
783
+ }
784
+ return reasons;
785
+ }
786
+ /**
787
+ * Routine, token-free policy upgrade: idempotent on an identical policy and
788
+ * additive-only otherwise. The complement of `guardMigrate`, which keeps the
789
+ * override token for everything this refuses. See
790
+ * `docs/features/STRAT-GUARD-UPGRADE/design.md`.
791
+ */
792
+ export async function guardUpgrade(resourceId, newGraph, newEdgePredicates, rationale, newTerminal = [], newStakes = {}) {
793
+ if (!rationale || !rationale.trim())
794
+ throw new OverrideUnavailable("upgrade requires a non-empty rationale");
795
+ return acquireResourceLock(resourceId, async ({ token }) => {
796
+ assertTsOwnedForMutation(resourceId);
797
+ const registry = loadRegistry(resourceId);
798
+ if (registry === null)
799
+ throw new GuardNotFound(`no guard registered for ${JSON.stringify(resourceId)}`);
800
+ // Before ANY comparison: a tampered registry whose stored checksum happened
801
+ // to match the submitted policy would otherwise return "unchanged" and
802
+ // silently bless the tampering.
803
+ if (guardChecksum(registry.graph, registry.edge_predicates, registry.terminal, registry.stakes) !== registry.checksum) {
804
+ throw new GuardTampered(`guard ${JSON.stringify(resourceId)} policy checksum mismatch`);
805
+ }
806
+ _validatePolicy(newGraph, newEdgePredicates, registry.initial, newTerminal, newStakes, registry.workspace_root);
807
+ const checksum = guardChecksum(newGraph, newEdgePredicates, newTerminal, newStakes);
808
+ if (checksum === registry.checksum) {
809
+ // No ledger entry and no version bump: compose re-runs this per resource
810
+ // on every cold-server touch, and the steady state must write nothing.
811
+ return { status: "unchanged", checksum, graph_version: registry.graph_version, rationale };
812
+ }
813
+ const reasons = _upgradeIncompatibilities(registry, newGraph, newEdgePredicates, newTerminal, newStakes);
814
+ if (reasons.length > 0) {
815
+ throw new IncompatiblePolicyUpgrade(`policy upgrade is not additive-only (${reasons.join("; ")}); use a signed upgrade descriptor, or guard migrate with a signed authorization`);
816
+ }
817
+ // Unreachable under an additive-only policy (old nodes all survive), kept
818
+ // as defence in depth against a classifier gap.
819
+ if (!Object.hasOwn(newGraph, registry.current_state) && !newTerminal.includes(registry.current_state)) {
820
+ throw new InvalidStateName(`current_state ${JSON.stringify(registry.current_state)} is not a node in the new graph`);
821
+ }
822
+ const graphVersion = registry.graph_version + 1;
823
+ const entry = new LedgerEntry({
824
+ ts_ms: _nowMs(),
825
+ from_state: registry.current_state,
826
+ to_state: registry.current_state,
827
+ outcome: "graph_version",
828
+ kind: "graph_version",
829
+ // The one ledger-visible difference from guardMigrate, which writes
830
+ // "human": that call means a person held the override token.
831
+ resolved_by: "agent",
452
832
  rationale,
453
833
  });
454
834
  await fenceResourceLock(resourceId, token);
@@ -463,6 +843,77 @@ export async function guardMigrate(resourceId, newGraph, newEdgePredicates, over
463
843
  return { status: "migrated", checksum, graph_version: graphVersion, ledger_ref: ledgerRef, rationale };
464
844
  });
465
845
  }
846
+ /**
847
+ * Apply a server-owned, digest-pinned, human-reviewed upgrade descriptor.
848
+ *
849
+ * The middle of the three policy-change capabilities. `guardUpgrade` needs no
850
+ * authorization because it is provably non-weakening; `guardMigrate` needs the
851
+ * break-glass token because it can do anything. This path can do anything the
852
+ * descriptor spells out — including granting a terminal state, which
853
+ * `guardUpgrade` refuses precisely because it is a completability grant — and it
854
+ * is safe because a human read that exact resulting policy before installing it
855
+ * and pinned the file's digest into the server environment.
856
+ *
857
+ * No additive-only classifier runs here, deliberately. See
858
+ * `docs/features/STRAT-GUARD-DESCRIPTOR/design.md`.
859
+ */
860
+ export async function guardApplyUpgrade(resourceId, descriptorId, env = process.env) {
861
+ // Authorization first, outside the lock: a caller with no descriptor set
862
+ // configured must not even be able to probe which resources exist.
863
+ const descriptorFile = loadDescriptorFile(env);
864
+ const descriptor = findDescriptor(descriptorFile, descriptorId);
865
+ const { graph, edge_predicates: edgePredicates, terminal, stakes } = descriptor.to_policy;
866
+ return acquireResourceLock(resourceId, async ({ token }) => {
867
+ assertTsOwnedForMutation(resourceId);
868
+ const registry = loadRegistry(resourceId);
869
+ if (registry === null)
870
+ throw new GuardNotFound(`no guard registered for ${JSON.stringify(resourceId)}`);
871
+ if (guardChecksum(registry.graph, registry.edge_predicates, registry.terminal, registry.stakes) !== registry.checksum) {
872
+ throw new GuardTampered(`guard ${JSON.stringify(resourceId)} policy checksum mismatch`);
873
+ }
874
+ const checksum = guardChecksum(graph, edgePredicates, terminal, stakes);
875
+ // Destination check BEFORE the from_checksum check: a resource already at the
876
+ // target is a success, not a mismatch. That is what makes a fleet batch
877
+ // re-runnable after a partial failure.
878
+ if (checksum === registry.checksum) {
879
+ return { status: "unchanged", checksum, graph_version: registry.graph_version, descriptor_id: descriptor.id };
880
+ }
881
+ if (registry.checksum !== descriptor.from_checksum) {
882
+ throw new UpgradeDescriptorMismatch(`descriptor ${JSON.stringify(descriptor.id)} is authorized for policy ${descriptor.from_checksum}, `
883
+ + `but ${JSON.stringify(resourceId)} currently holds ${registry.checksum}`);
884
+ }
885
+ // Authorized is not the same as well-formed.
886
+ _validatePolicy(graph, edgePredicates, registry.initial, terminal, stakes, registry.workspace_root);
887
+ if (!Object.hasOwn(graph, registry.current_state) && !terminal.includes(registry.current_state)) {
888
+ throw new InvalidStateName(`current_state ${JSON.stringify(registry.current_state)} is not a node in the new graph`);
889
+ }
890
+ const graphVersion = registry.graph_version + 1;
891
+ const entry = new LedgerEntry({
892
+ ts_ms: _nowMs(),
893
+ from_state: registry.current_state,
894
+ to_state: registry.current_state,
895
+ outcome: "graph_version",
896
+ kind: "graph_version",
897
+ // A human authorized this exact policy, so the ledger says so — and names
898
+ // which authorization, pinned by the descriptor file's digest.
899
+ resolved_by: "human",
900
+ // Names WHO authorized it and WHICH artifact, from the file verified
901
+ // ABOVE — a re-read could name a digest other than the one authorized.
902
+ rationale: `descriptor ${descriptor.id} signed by ${descriptorFile.signedBy.principal} `
903
+ + `(${descriptorFile.signedBy.fingerprint}, file sha256 ${descriptorFile.digest}): ${descriptor.rationale}`,
904
+ });
905
+ await fenceResourceLock(resourceId, token);
906
+ const ledgerRef = appendLedger(resourceId, entry);
907
+ registry.graph = graph;
908
+ registry.edge_predicates = edgePredicates;
909
+ registry.terminal = terminal;
910
+ registry.stakes = stakes;
911
+ registry.checksum = checksum;
912
+ registry.graph_version = graphVersion;
913
+ persistRegistry(registry);
914
+ return { status: "applied", checksum, graph_version: graphVersion, ledger_ref: ledgerRef, descriptor_id: descriptor.id };
915
+ });
916
+ }
466
917
  export function guardHistory(resourceId) {
467
918
  const registry = loadRegistry(resourceId);
468
919
  if (registry === null)