@smartmemory/stratum 0.3.4 → 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.
- package/dist/cli/guard.js +63 -6
- package/dist/cli/guard.js.map +1 -1
- package/dist/cli/learn.js +226 -0
- package/dist/cli/learn.js.map +1 -0
- package/dist/cli/stratum.js +3 -1
- package/dist/cli/stratum.js.map +1 -1
- package/dist/connectors/background.js +53 -9
- package/dist/connectors/background.js.map +1 -1
- package/dist/connectors/base.js +16 -0
- package/dist/connectors/base.js.map +1 -1
- package/dist/connectors/cancellation.js +119 -0
- package/dist/connectors/cancellation.js.map +1 -0
- package/dist/connectors/claude-bg-worker.js +9 -1
- package/dist/connectors/claude-bg-worker.js.map +1 -1
- package/dist/connectors/claude.js +179 -86
- package/dist/connectors/claude.js.map +1 -1
- package/dist/connectors/codex.js +219 -62
- package/dist/connectors/codex.js.map +1 -1
- package/dist/connectors/runner.js +44 -3
- package/dist/connectors/runner.js.map +1 -1
- package/dist/contracts/events.json +34 -1
- package/dist/contracts/guard-signers.allowed +36 -0
- package/dist/contracts/mcp-surface.json +1085 -104
- package/dist/engine/checkpoint.js +8 -1
- package/dist/engine/checkpoint.js.map +1 -1
- package/dist/engine/engine.js +403 -42
- package/dist/engine/engine.js.map +1 -1
- package/dist/engine/ledger.js +12 -0
- package/dist/engine/ledger.js.map +1 -1
- package/dist/engine/receipts.js +91 -0
- package/dist/engine/receipts.js.map +1 -0
- package/dist/engine/state.js +51 -1
- package/dist/engine/state.js.map +1 -1
- package/dist/guard/authorization.js +71 -0
- package/dist/guard/authorization.js.map +1 -0
- package/dist/guard/descriptors.js +248 -0
- package/dist/guard/descriptors.js.map +1 -0
- package/dist/guard/errors.js +12 -0
- package/dist/guard/errors.js.map +1 -1
- package/dist/guard/evidence.js +24 -3
- package/dist/guard/evidence.js.map +1 -1
- package/dist/guard/sshsig.js +264 -0
- package/dist/guard/sshsig.js.map +1 -0
- package/dist/guard/store.js +13 -0
- package/dist/guard/store.js.map +1 -1
- package/dist/guard/transition.js +484 -33
- package/dist/guard/transition.js.map +1 -1
- package/dist/guard/trust.js +78 -0
- package/dist/guard/trust.js.map +1 -0
- package/dist/judge/judged.js +1 -1
- package/dist/judge/pricing.js +1 -0
- package/dist/judge/pricing.js.map +1 -1
- package/dist/learn/apply.js +600 -0
- package/dist/learn/apply.js.map +1 -0
- package/dist/learn/candidate.js +186 -0
- package/dist/learn/candidate.js.map +1 -0
- package/dist/learn/classify.js +181 -0
- package/dist/learn/classify.js.map +1 -0
- package/dist/learn/harvest.js +138 -0
- package/dist/learn/harvest.js.map +1 -0
- package/dist/learn/smartmemory_egress.js +330 -0
- package/dist/learn/smartmemory_egress.js.map +1 -0
- package/dist/mcp/server.js +150 -22
- package/dist/mcp/server.js.map +1 -1
- package/dist/policy/bundle.js +195 -0
- package/dist/policy/bundle.js.map +1 -0
- package/dist/policy/events.js +51 -0
- package/dist/policy/events.js.map +1 -0
- package/dist/policy/smartmemory_client.js +332 -0
- package/dist/policy/smartmemory_client.js.map +1 -0
- package/dist/policy/types.js +2 -0
- package/dist/policy/types.js.map +1 -0
- package/package.json +3 -2
package/dist/guard/transition.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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:
|
|
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,
|
|
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 !==
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
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
|
-
|
|
395
|
-
throw new OverrideUnavailable("override token mismatch");
|
|
532
|
+
return verdicts;
|
|
396
533
|
}
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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)
|