scrumrun 2.1.0 → 2.2.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.
@@ -0,0 +1,434 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+ const {
7
+ ArtifactRepository,
8
+ assertNoSymlinkPath,
9
+ atomicWrite,
10
+ serializeArtifact,
11
+ sha256,
12
+ withArtifactLock
13
+ } = require("../v2/artifacts");
14
+ const { pendingTransactionStatus, runKernelTransaction } = require("../v2/transaction");
15
+ const { configWeakeningAttempts, parseGuardrails, readOnlyPaths, validateGuardrailDocument } = require("./policy-engine");
16
+ const { appendGuardrailEvent, appendMutationEvent, guardrailState, parseRunLedger } = require("./run-ledger");
17
+ const { changesBetween, normalizedRelative, pathAuthorized, stable, workspaceState } = require("./workspace-state");
18
+ const { containsSecret } = require("../security/secrets");
19
+
20
+ const PERMIT_SCHEMA = 1;
21
+ const PERMIT_TTL_MS = 15 * 60 * 1000;
22
+
23
+ function readProjectMethod(projectRoot) {
24
+ const file = path.join(projectRoot, ".scrumrun", "method.json");
25
+ try {
26
+ return JSON.parse(fs.readFileSync(file, "utf8"));
27
+ } catch (error) {
28
+ throw new Error(`Mutation Gateway requires a valid method.json: ${error.message}`);
29
+ }
30
+ }
31
+
32
+ function policyState(projectRoot) {
33
+ const scrumDir = path.join(projectRoot, ".scrumrun");
34
+ const file = assertNoSymlinkPath(scrumDir, path.join(scrumDir, "guardrails.md"));
35
+ if (!fs.existsSync(file) || !fs.lstatSync(file).isFile()) throw new Error("Canonical guardrails.md is missing or unsafe.");
36
+ const content = fs.readFileSync(file, "utf8");
37
+ const validation = validateGuardrailDocument(content);
38
+ const method = readProjectMethod(projectRoot);
39
+ const strict = method.schemas && method.schemas.guardrails === 1;
40
+ const errors = [...validation.errors];
41
+ if (strict) {
42
+ for (const record of validation.records) {
43
+ for (const field of ["status", "enforcement", "scope", "rule"]) {
44
+ if (!record.explicit[field]) errors.push(`${record.id} must explicitly declare ${field}.`);
45
+ }
46
+ }
47
+ }
48
+ if (!validation.records.some((record) => record.status === "active")) errors.push("At least one active Guardrail is required.");
49
+ const configFile = path.join(scrumDir, "config.md");
50
+ const config = fs.existsSync(configFile) ? fs.readFileSync(configFile, "utf8") : "";
51
+ errors.push(...configWeakeningAttempts(config));
52
+ if (errors.length) throw new Error(`Guardrail policy is not enforceable: ${errors.join("; ")}`);
53
+ return {
54
+ file,
55
+ content,
56
+ config,
57
+ fingerprint: sha256(stable({ schema: 1, guardrails: content, config, method: method.method, schemas: method.schemas || {} })),
58
+ guardrails: validation.records.filter((record) => record.status === "active")
59
+ };
60
+ }
61
+
62
+ function assertCanonicalWrite(projectRoot, operation, contents = []) {
63
+ const method = readProjectMethod(projectRoot);
64
+ const guardrails = path.join(projectRoot, ".scrumrun", "guardrails.md");
65
+ const strict = method.schemas && method.schemas.guardrails === 1;
66
+ if (!strict && !fs.existsSync(guardrails)) {
67
+ if (contents.some((value) => containsSecret(value))) throw new Error(`SECRET_BOUNDARY: ${operation} contains secret-like canonical content.`);
68
+ return { operation, policy: "legacy-unbound" };
69
+ }
70
+ const policy = policyState(projectRoot);
71
+ if (contents.some((value) => containsSecret(value))) throw new Error(`SECRET_BOUNDARY: ${operation} contains secret-like canonical content.`);
72
+ const transactions = pendingTransactionStatus(path.join(projectRoot, ".scrumrun"));
73
+ if (transactions.error) throw new Error(`TRANSACTION_GATE: ${transactions.error}`);
74
+ if (transactions.pending.length) throw new Error(`TRANSACTION_GATE: pending canonical transaction ${transactions.pending.map((item) => item.id).join(", ")}.`);
75
+ return { operation, policy: policy.fingerprint };
76
+ }
77
+
78
+ function publicWorkspace(value) {
79
+ return {
80
+ schema: 1,
81
+ mode: value.mode,
82
+ head: value.head || null,
83
+ fingerprint: value.fingerprint,
84
+ files: value.files.map((item) => ({ path: item.path, sha256: item.sha256, kind: item.kind, mode: item.mode, scan: item.scan }))
85
+ };
86
+ }
87
+
88
+ function initialEvent(runArtifact) {
89
+ const parsed = parseRunLedger(runArtifact.body);
90
+ if (parsed.errors.length || !parsed.events.length) throw new Error(`Run ledger is invalid: ${parsed.errors.join("; ") || "missing events"}`);
91
+ return parsed.events[0];
92
+ }
93
+
94
+ function expectedWorkspace(runArtifact) {
95
+ const events = parseRunLedger(runArtifact.body).events;
96
+ const mutation = [...events].reverse().find((event) => event.type === "mutation");
97
+ return mutation ? mutation.workspace_after : events[0] && events[0].workspace_baseline;
98
+ }
99
+
100
+ function assertPolicyBound(runArtifact, policy) {
101
+ const event = initialEvent(runArtifact);
102
+ if (!event.policy_fingerprint) throw new Error(`${runArtifact.record.id} predates enforceable Guardrail binding; migrate or retry it before mutation.`);
103
+ if (event.policy_fingerprint !== policy.fingerprint) {
104
+ throw new Error(`POLICY_DRIFT: guardrails.md changed after ${runArtifact.record.id} approval; start a newly approved Run.`);
105
+ }
106
+ }
107
+
108
+ function assertWorkspaceExpected(runArtifact, actual) {
109
+ const expected = expectedWorkspace(runArtifact);
110
+ if (!expected) throw new Error(`${runArtifact.record.id} has no canonical workspace baseline; migrate or retry it before mutation.`);
111
+ if (expected.fingerprint !== actual.fingerprint) {
112
+ throw new Error(`BYPASS_DETECTED: workspace fingerprint differs from ${runArtifact.record.id}'s last authorized mutation.`);
113
+ }
114
+ return expected;
115
+ }
116
+
117
+ function permitsDirectory(scrumDir) {
118
+ const directory = assertNoSymlinkPath(scrumDir, path.join(scrumDir, ".cache", "mutation-permits"));
119
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
120
+ return directory;
121
+ }
122
+
123
+ function mutationKey(scrumDir, { create = false } = {}) {
124
+ const file = assertNoSymlinkPath(scrumDir, path.join(scrumDir, ".cache", "mutation-gateway.key"));
125
+ if (!fs.existsSync(file)) {
126
+ if (!create) throw new Error("Mutation Gateway key is missing; outstanding permits are invalid.");
127
+ atomicWrite(file, crypto.randomBytes(32));
128
+ fs.chmodSync(file, 0o600);
129
+ }
130
+ const stat = fs.lstatSync(file);
131
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("Mutation Gateway key path is unsafe.");
132
+ const key = fs.readFileSync(file);
133
+ if (key.length !== 32) throw new Error("Mutation Gateway key is malformed.");
134
+ return key;
135
+ }
136
+
137
+ function permitIntegrity(permit, key) {
138
+ const { integrity, ...payload } = permit;
139
+ return crypto.createHmac("sha256", key).update(stable(payload)).digest("hex");
140
+ }
141
+
142
+ function assertPermitIntegrity(permit, key) {
143
+ const expected = permitIntegrity(permit, key);
144
+ const actual = String(permit && permit.integrity || "");
145
+ if (!/^[a-f0-9]{64}$/.test(actual) || !crypto.timingSafeEqual(Buffer.from(actual), Buffer.from(expected))) {
146
+ throw new Error(`Mutation permit integrity check failed: ${permit && permit.id || "unknown"}.`);
147
+ }
148
+ }
149
+
150
+ function permitFile(scrumDir, permitId) {
151
+ if (!/^MUT-[A-Za-z0-9-]{8,}$/.test(String(permitId || ""))) throw new Error("Mutation permit id is invalid.");
152
+ return assertNoSymlinkPath(scrumDir, path.join(permitsDirectory(scrumDir), `${permitId}.json`));
153
+ }
154
+
155
+ function activePermitFiles(scrumDir) {
156
+ const directory = permitsDirectory(scrumDir);
157
+ const now = Date.now();
158
+ const active = [];
159
+ const entries = fs.readdirSync(directory, { withFileTypes: true });
160
+ const key = entries.length ? mutationKey(scrumDir) : null;
161
+ for (const entry of entries) {
162
+ if (!entry.isFile() || !/^MUT-[A-Za-z0-9-]{8,}\.json$/.test(entry.name)) throw new Error(`Unsafe Mutation Gateway cache entry: ${entry.name}`);
163
+ const file = path.join(directory, entry.name);
164
+ let permit;
165
+ try {
166
+ permit = JSON.parse(fs.readFileSync(file, "utf8"));
167
+ } catch (error) {
168
+ throw new Error(`Mutation permit is malformed: ${entry.name}: ${error.message}`);
169
+ }
170
+ assertPermitIntegrity(permit, key);
171
+ if (Date.parse(permit.expires_at) <= now) fs.rmSync(file, { force: true });
172
+ else active.push({ file, permit });
173
+ }
174
+ return active;
175
+ }
176
+
177
+ function validateAllowedPaths(projectRoot, policy, paths) {
178
+ const allowed = [...new Set((Array.isArray(paths) ? paths : [paths]).map((value) => normalizedRelative(projectRoot, value)))];
179
+ if (!allowed.length) throw new Error("Mutation Gateway requires at least one explicit --path.");
180
+ const readOnly = readOnlyPaths(policy.config).map((value) => normalizedRelative(projectRoot, value));
181
+ const forbidden = allowed.filter((entry) => readOnly.some((item) => entry === item || entry.startsWith(`${item}/`) || item.startsWith(`${entry}/`)));
182
+ if (forbidden.length) throw new Error(`READ_ONLY_PATH: mutation scope overlaps ${forbidden.join(", ")}.`);
183
+ return allowed.sort();
184
+ }
185
+
186
+ function authorizeMutationUnlocked(projectRoot, runId, paths, options = {}) {
187
+ const scrumDir = path.join(projectRoot, ".scrumrun");
188
+ const repository = new ArtifactRepository(scrumDir);
189
+ const run = repository.read("run", runId);
190
+ if (!run || run.errors.length) throw new Error(`Mutation Run is missing or invalid: ${runId}`);
191
+ if (run.record.status !== "executing") throw new Error(`Mutation permits require an executing Run; ${runId} is ${run.record.status}.`);
192
+ if (run.record.workspace !== 1 || run.record.guardrails !== 1) throw new Error(`${runId} predates the secure Run schemas; migrate or retry it.`);
193
+ const policy = policyState(projectRoot);
194
+ assertPolicyBound(run, policy);
195
+ const actual = workspaceState(projectRoot);
196
+ assertWorkspaceExpected(run, actual);
197
+ const allowed = validateAllowedPaths(projectRoot, policy, paths);
198
+ const outstanding = activePermitFiles(scrumDir);
199
+ if (outstanding.length) throw new Error(`Another mutation permit is still active: ${outstanding.map((item) => item.permit.id).join(", ")}.`);
200
+ const created = new Date();
201
+ const permit = {
202
+ schema: PERMIT_SCHEMA,
203
+ id: `MUT-${crypto.randomBytes(12).toString("hex")}`,
204
+ run: runId,
205
+ created_at: created.toISOString(),
206
+ expires_at: new Date(created.getTime() + (options.ttlMs || PERMIT_TTL_MS)).toISOString(),
207
+ policy_fingerprint: policy.fingerprint,
208
+ workspace_before: actual,
209
+ allowed_paths: allowed
210
+ };
211
+ permit.integrity = permitIntegrity(permit, mutationKey(scrumDir, { create: true }));
212
+ const file = permitFile(scrumDir, permit.id);
213
+ atomicWrite(file, `${JSON.stringify(permit, null, 2)}\n`);
214
+ fs.chmodSync(file, 0o600);
215
+ return { permit: permit.id, run: runId, expiresAt: permit.expires_at, paths: allowed };
216
+ }
217
+
218
+ function authorizeMutation(projectRoot, runId, paths, options = {}) {
219
+ const scrumDir = path.join(projectRoot, ".scrumrun");
220
+ return withArtifactLock(scrumDir, "mutation-gateway", () => authorizeMutationUnlocked(projectRoot, runId, paths, options));
221
+ }
222
+
223
+ function readPermit(scrumDir, permitId) {
224
+ const file = permitFile(scrumDir, permitId);
225
+ if (!fs.existsSync(file) || !fs.lstatSync(file).isFile()) throw new Error(`Mutation permit not found: ${permitId}`);
226
+ let permit;
227
+ try {
228
+ permit = JSON.parse(fs.readFileSync(file, "utf8"));
229
+ } catch (error) {
230
+ throw new Error(`Mutation permit is malformed: ${error.message}`);
231
+ }
232
+ if (permit.schema !== PERMIT_SCHEMA || permit.id !== permitId) throw new Error("Mutation permit identity or schema is invalid.");
233
+ assertPermitIntegrity(permit, mutationKey(scrumDir));
234
+ if (Date.parse(permit.expires_at) <= Date.now()) throw new Error(`Mutation permit expired: ${permitId}`);
235
+ return { file, permit };
236
+ }
237
+
238
+ function recordMutationUnlocked(projectRoot, runId, permitId, options = {}) {
239
+ const scrumDir = path.join(projectRoot, ".scrumrun");
240
+ const repository = new ArtifactRepository(scrumDir);
241
+ const run = repository.read("run", runId);
242
+ if (!run || run.errors.length) throw new Error(`Mutation Run is missing or invalid: ${runId}`);
243
+ if (run.record.status !== "executing") throw new Error(`Mutation recording requires an executing Run; ${runId} is ${run.record.status}.`);
244
+ const { file: permitPath, permit } = readPermit(scrumDir, permitId);
245
+ if (permit.run !== runId) throw new Error(`Mutation permit belongs to ${permit.run}, not ${runId}.`);
246
+ const policy = policyState(projectRoot);
247
+ assertPolicyBound(run, policy);
248
+ if (permit.policy_fingerprint !== policy.fingerprint) throw new Error("POLICY_DRIFT: mutation permit policy is stale.");
249
+ const expected = expectedWorkspace(run);
250
+ if (!expected || expected.fingerprint !== permit.workspace_before.fingerprint) throw new Error("BYPASS_DETECTED: Run workspace advanced after this permit was issued.");
251
+ const after = workspaceState(projectRoot);
252
+ const changes = changesBetween(permit.workspace_before, after);
253
+ if (!changes.length) throw new Error("Mutation permit produced no workspace change.");
254
+ const outside = changes.filter((change) => !pathAuthorized(change.path, permit.allowed_paths));
255
+ if (outside.length) throw new Error(`OUT_OF_SCOPE_MUTATION: ${outside.map((item) => item.path).join(", ")}.`);
256
+ const unsafeKinds = changes.filter((change) => !["file", "missing", "clean"].includes(change.after_kind));
257
+ if (unsafeKinds.length) throw new Error(`UNSAFE_MUTATION_TYPE: ${unsafeKinds.map((item) => item.path).join(", ")}.`);
258
+ const unscannable = changes.filter((change) => change.after_kind === "file" && change.after_scan !== "text");
259
+ if (unscannable.length) throw new Error(`UNSCANNABLE_MUTATION: secret inspection was not possible for ${unscannable.map((item) => item.path).join(", ")}.`);
260
+ const leaked = changes.filter((change) => change.new_secret_fingerprints.length);
261
+ if (leaked.length) throw new Error(`SECRET_BOUNDARY: new secret-like content detected in ${leaked.map((item) => item.path).join(", ")}.`);
262
+ const mutation = {
263
+ mutation_id: permit.id,
264
+ paths: permit.allowed_paths,
265
+ changes: changes.map(({ new_secret_fingerprints, ...change }) => change),
266
+ workspace_before: permit.workspace_before.fingerprint,
267
+ workspace_after: publicWorkspace(after),
268
+ policy_fingerprint: policy.fingerprint
269
+ };
270
+ const previous = fs.readFileSync(run.file, "utf8");
271
+ const appended = appendMutationEvent(run.record, run.body, mutation, {
272
+ note: options.note || `Recorded ${changes.length} authorized workspace change(s).`,
273
+ actor: options.actor || "agent",
274
+ evidence: options.evidence && options.evidence.length ? options.evidence : [{ kind: "mutation", ref: permit.id, summary: `${changes.length} scoped path change(s) verified.` }]
275
+ });
276
+ runKernelTransaction(scrumDir, "record-workspace-mutation", [{
277
+ file: run.file,
278
+ previous,
279
+ next: serializeArtifact(appended.record, appended.body)
280
+ }]);
281
+ const verifiedAfter = workspaceState(projectRoot);
282
+ fs.rmSync(permitPath, { force: true });
283
+ if (verifiedAfter.fingerprint !== after.fingerprint) {
284
+ throw new Error("POST_RECORD_DRIFT: workspace changed while mutation evidence was being committed.");
285
+ }
286
+ return { run: appended.record, mutation: permit.id, changes: mutation.changes };
287
+ }
288
+
289
+ function recordMutation(projectRoot, runId, permitId, options = {}) {
290
+ const scrumDir = path.join(projectRoot, ".scrumrun");
291
+ return withArtifactLock(scrumDir, "mutation-gateway", () => recordMutationUnlocked(projectRoot, runId, permitId, options));
292
+ }
293
+
294
+ function verifyWorkspaceIntegrity(projectRoot, runArtifact) {
295
+ if (runArtifact.record.workspace !== 1) return { status: "legacy", fingerprint: null };
296
+ const actual = workspaceState(projectRoot);
297
+ assertWorkspaceExpected(runArtifact, actual);
298
+ const policy = policyState(projectRoot);
299
+ assertPolicyBound(runArtifact, policy);
300
+ return { status: "passed", fingerprint: actual.fingerprint, policy: policy.fingerprint };
301
+ }
302
+
303
+ function requiredEvidenceKind(enforcement) {
304
+ if (enforcement === "builtin:migration-integrity") return "migration";
305
+ if (["builtin:review-gate", "builtin:canonical-truth", "builtin:canonical-write", "manual"].includes(enforcement)) return "review";
306
+ return null;
307
+ }
308
+
309
+ function assertGateEvidence(projectRoot, repository, kind, evidence, guardrailId) {
310
+ if (!kind) return;
311
+ const matching = evidence.filter((item) => item && item.kind === kind);
312
+ if (!matching.length) throw new Error(`${guardrailId} requires ${kind} evidence.`);
313
+ if (kind === "review") {
314
+ const passed = matching.some((item) => {
315
+ if (!/^REV-\d{3,}$/.test(item.ref || "")) return false;
316
+ const review = repository.read("review", item.ref);
317
+ return review && !review.errors.length && review.record.status === "passed" && /^## (?:Validation )?Evidence\s*$/m.test(review.body);
318
+ });
319
+ if (!passed) throw new Error(`${guardrailId} requires a canonical passed REV-NNN reference.`);
320
+ }
321
+ if (kind === "migration") {
322
+ const referenced = matching.some((item) => {
323
+ if (typeof item.ref !== "string" || !item.ref.trim()) return false;
324
+ try {
325
+ const relative = normalizedRelative(projectRoot, item.ref);
326
+ const target = assertNoSymlinkPath(projectRoot, path.join(projectRoot, relative));
327
+ return fs.existsSync(target) && fs.lstatSync(target).isFile();
328
+ } catch {
329
+ return false;
330
+ }
331
+ });
332
+ if (!referenced) throw new Error(`${guardrailId} requires an existing, safe migration evidence file.`);
333
+ }
334
+ }
335
+
336
+ function satisfyGuardrailUnlocked(projectRoot, runId, guardrailId, options = {}) {
337
+ const scrumDir = path.join(projectRoot, ".scrumrun");
338
+ const repository = new ArtifactRepository(scrumDir);
339
+ const run = repository.read("run", runId);
340
+ if (!run || run.errors.length) throw new Error(`Guardrail Run is missing or invalid: ${runId}`);
341
+ if (!["executing", "validating", "learning"].includes(run.record.status)) throw new Error(`Cannot satisfy Guardrails for terminal ${runId}.`);
342
+ const policy = policyState(projectRoot);
343
+ assertPolicyBound(run, policy);
344
+ const obligation = guardrailState(run.record, run.body).find((item) => item.guardrail === guardrailId);
345
+ if (!obligation) throw new Error(`${guardrailId} is not an obligation of ${runId}.`);
346
+ const evidence = Array.isArray(options.evidence) ? options.evidence : [];
347
+ const kind = requiredEvidenceKind(obligation.enforcement);
348
+ assertGateEvidence(projectRoot, repository, kind, evidence, guardrailId);
349
+ if (["builtin:owner-work", "builtin:read-only-path", "builtin:secret-boundary"].includes(obligation.enforcement)) {
350
+ const verified = verifyWorkspaceIntegrity(projectRoot, run);
351
+ evidence.push({ kind: "mutation", summary: `Workspace chain verified at ${verified.fingerprint}.` });
352
+ }
353
+ if (!evidence.length) throw new Error(`${guardrailId} requires structured evidence.`);
354
+ const previous = fs.readFileSync(run.file, "utf8");
355
+ const appended = appendGuardrailEvent(run.record, run.body, obligation, "passed", {
356
+ note: options.note || `${guardrailId} passed its ${obligation.gate} gate.`,
357
+ actor: options.actor || "agent",
358
+ evidence
359
+ });
360
+ runKernelTransaction(scrumDir, "satisfy-run-guardrail", [{ file: run.file, previous, next: serializeArtifact(appended.record, appended.body) }]);
361
+ return { run: appended.record, guardrail: guardrailId, status: "passed" };
362
+ }
363
+
364
+ function satisfyGuardrail(projectRoot, runId, guardrailId, options = {}) {
365
+ const scrumDir = path.join(projectRoot, ".scrumrun");
366
+ return withArtifactLock(scrumDir, `run-${String(runId).toLowerCase()}`, () => satisfyGuardrailUnlocked(projectRoot, runId, guardrailId, options));
367
+ }
368
+
369
+ function assertCompletionAllowed(projectRoot, runArtifact) {
370
+ if (runArtifact.record.guardrails !== 1 || runArtifact.record.workspace !== 1) return { status: "legacy" };
371
+ const policy = policyState(projectRoot);
372
+ assertPolicyBound(runArtifact, policy);
373
+ verifyWorkspaceIntegrity(projectRoot, runArtifact);
374
+ const pending = guardrailState(runArtifact.record, runArtifact.body).filter((item) => item.status !== "passed");
375
+ if (pending.length) throw new Error(`GUARDRAILS_PENDING: ${pending.map((item) => `${item.guardrail}:${item.gate}`).join(", ")}.`);
376
+ const transactions = pendingTransactionStatus(path.join(projectRoot, ".scrumrun"));
377
+ if (transactions.error || transactions.pending.length) throw new Error(`TRANSACTION_GATE: ${transactions.error || "pending canonical transaction"}.`);
378
+ return { status: "passed", policy: policy.fingerprint };
379
+ }
380
+
381
+ function prepareCompletion(projectRoot, runArtifact, options = {}) {
382
+ if (runArtifact.record.guardrails !== 1 || runArtifact.record.workspace !== 1) {
383
+ return { record: runArtifact.record, body: runArtifact.body, resolved: [], status: "legacy" };
384
+ }
385
+ const policy = policyState(projectRoot);
386
+ assertPolicyBound(runArtifact, policy);
387
+ const verified = verifyWorkspaceIntegrity(projectRoot, runArtifact);
388
+ const automatic = new Set(["builtin:owner-work", "builtin:read-only-path", "builtin:secret-boundary"]);
389
+ let record = runArtifact.record;
390
+ let body = runArtifact.body;
391
+ const resolved = [];
392
+ for (const obligation of guardrailState(record, body).filter((item) => item.status !== "passed" && automatic.has(item.enforcement))) {
393
+ const appended = appendGuardrailEvent(record, body, obligation, "passed", {
394
+ note: `${obligation.guardrail} passed the automatic completion workspace check.`,
395
+ actor: "mutation-gateway",
396
+ occurredAt: options.occurredAt,
397
+ evidence: [{ kind: "mutation", summary: `Workspace chain verified at ${verified.fingerprint}.` }]
398
+ });
399
+ record = appended.record;
400
+ body = appended.body;
401
+ resolved.push(obligation.guardrail);
402
+ }
403
+ const pending = guardrailState(record, body).filter((item) => item.status !== "passed");
404
+ if (pending.length) throw new Error(`GUARDRAILS_PENDING: ${pending.map((item) => `${item.guardrail}:${item.gate}`).join(", ")}.`);
405
+ const transactions = pendingTransactionStatus(path.join(projectRoot, ".scrumrun"));
406
+ if (transactions.error || transactions.pending.length) throw new Error(`TRANSACTION_GATE: ${transactions.error || "pending canonical transaction"}.`);
407
+ return { record, body, resolved, status: "passed" };
408
+ }
409
+
410
+ function auditActiveWorkspace(projectRoot, runArtifact) {
411
+ try {
412
+ if (!runArtifact.record || runArtifact.record.workspace !== 1 || ["completed", "failed", "blocked"].includes(runArtifact.record.status)) return null;
413
+ verifyWorkspaceIntegrity(projectRoot, runArtifact);
414
+ return null;
415
+ } catch (error) {
416
+ return error.message;
417
+ }
418
+ }
419
+
420
+ module.exports = {
421
+ PERMIT_SCHEMA,
422
+ assertCanonicalWrite,
423
+ assertCompletionAllowed,
424
+ auditActiveWorkspace,
425
+ authorizeMutation,
426
+ expectedWorkspace,
427
+ policyState,
428
+ prepareCompletion,
429
+ publicWorkspace,
430
+ recordMutation,
431
+ satisfyGuardrail,
432
+ verifyWorkspaceIntegrity,
433
+ workspaceState
434
+ };
@@ -15,10 +15,12 @@ const {
15
15
  withArtifactLock
16
16
  } = require("../v2/artifacts");
17
17
  const { buildContextPackage } = require("./context");
18
+ const { evaluatePolicy } = require("./policy-engine");
18
19
  const { artifactSnapshot, canonicalFingerprint, canonicalWatchSnapshot } = require("./canonical-snapshot");
19
20
  const { decodeApproval } = require("./request-engine");
20
21
  const { extractLearningCandidates } = require("../code-intel/learning");
21
22
  const { appendRunEvent, createRunBody, instant } = require("./run-ledger");
23
+ const { policyState, prepareCompletion, publicWorkspace, verifyWorkspaceIntegrity, workspaceState } = require("./mutation-gateway");
22
24
  const { recoverPendingTransactions, runKernelTransaction } = require("../v2/transaction");
23
25
 
24
26
  const TERMINAL = new Set(["completed", "failed", "cancelled", "resolved", "rejected", "deprecated", "invalidated", "archived", "passed"]);
@@ -100,8 +102,14 @@ function approveRequestUnlocked(projectRoot, token, { failurePoint = null, inter
100
102
  if (currentContext.fingerprint !== payload.fingerprint) {
101
103
  throw new Error("Project context changed after planning; run intake again before approval.");
102
104
  }
105
+ const workspace = workspaceState(projectRoot);
106
+ if (!payload.workspaceFingerprint || workspace.fingerprint !== payload.workspaceFingerprint) {
107
+ throw new Error("Project workspace changed after planning; run intake again before approval.");
108
+ }
103
109
 
104
110
  const created = date();
111
+ const enforceablePolicy = policyState(projectRoot);
112
+ const baseline = publicWorkspace(workspace);
105
113
  const taskId = nextId(repository, "task");
106
114
  const runId = nextId(repository, "run");
107
115
  const task = {
@@ -129,10 +137,17 @@ function approveRequestUnlocked(projectRoot, token, { failurePoint = null, inter
129
137
  sprint: null,
130
138
  attempt: 1,
131
139
  ledger: 1,
140
+ guardrails: 1,
141
+ workspace: 1,
132
142
  approval_id: approvalId
133
143
  };
134
144
  const taskBody = `# ${titleFor(payload.request)}\n\n## Request\n\n${payload.request}\n\n## Classification\n\n- Type: ${payload.classification.type}\n- Reason: ${payload.classification.reason}\n- Risk: ${payload.risk.level}\n\n## Approval\n\n- Explicit approval token: ${approvalId}\n- Context fingerprint: ${payload.fingerprint}`;
135
- const runBody = createRunBody(run, { approvalId }).body;
145
+ const runBody = createRunBody(run, {
146
+ approvalId,
147
+ obligations: payload.policy.obligations || [],
148
+ policyFingerprint: enforceablePolicy.fingerprint,
149
+ workspaceBaseline: baseline
150
+ }).body;
136
151
  try {
137
152
  runKernelTransaction(scrumDir, "approve-task-run", [
138
153
  { file: repository.pathFor(task), previous: null, next: serializeArtifact(task, taskBody) },
@@ -197,6 +212,9 @@ function transitionRunUnlocked(projectRoot, runId, nextStatus, { note = null, ev
197
212
  refreshState(scrumDir);
198
213
  return { run: runArtifact.record, task: taskArtifact.record, learning: null, recovered };
199
214
  }
215
+ if (runArtifact.record.guardrails === 1 && runArtifact.record.workspace === 1 && ["validating", "learning"].includes(nextStatus)) {
216
+ verifyWorkspaceIntegrity(projectRoot, runArtifact);
217
+ }
200
218
  const taskStatuses = {
201
219
  validating: "validating",
202
220
  learning: "learning",
@@ -209,7 +227,9 @@ function transitionRunUnlocked(projectRoot, runId, nextStatus, { note = null, ev
209
227
  if (!nextTaskStatus) throw new Error(`Run status cannot synchronize a Task: ${nextStatus}`);
210
228
  const runPrevious = fs.readFileSync(runArtifact.file, "utf8");
211
229
  const taskPrevious = fs.readFileSync(taskArtifact.file, "utf8");
212
- const runNext = transitionedRunContent(runPrevious, nextStatus, { note, evidence, actor, occurredAt });
230
+ const prepared = nextStatus === "completed" ? prepareCompletion(projectRoot, runArtifact, { occurredAt }) : null;
231
+ const transitionSource = prepared ? serializeArtifact(prepared.record, prepared.body) : runPrevious;
232
+ const runNext = transitionedRunContent(transitionSource, nextStatus, { note, evidence, actor, occurredAt });
213
233
  const taskNext = transitionedArtifactContent(taskPrevious, "task", nextTaskStatus, runNext.record.updated);
214
234
  try {
215
235
  runKernelTransaction(scrumDir, "transition-run-task", [
@@ -258,6 +278,13 @@ function retryTaskUnlocked(projectRoot, taskId, { note = "Retry explicitly appro
258
278
  const attempts = repository.list("run")
259
279
  .filter((artifact) => artifact.record && artifact.record.task === taskId)
260
280
  .map((artifact) => Number(artifact.record.attempt) || 0);
281
+ const requestMatch = taskArtifact.body.match(/^## Request[ \t]*\r?\n\r?\n([\s\S]*?)(?=^## |(?![\s\S]))/m);
282
+ const request = requestMatch ? requestMatch[1].trim() : `Retry ${taskId}`;
283
+ const context = buildContextPackage(projectRoot, request);
284
+ const policy = evaluatePolicy(context);
285
+ if (policy.status !== "passed") throw new Error(`Retry is blocked by current policy: ${policy.violations.join("; ")}`);
286
+ const enforceablePolicy = policyState(projectRoot);
287
+ const baseline = publicWorkspace(workspaceState(projectRoot));
261
288
  const run = {
262
289
  id: nextId(repository, "run"),
263
290
  kind: "run",
@@ -269,13 +296,18 @@ function retryTaskUnlocked(projectRoot, taskId, { note = "Retry explicitly appro
269
296
  sprint: taskArtifact.record.sprint || null,
270
297
  attempt: attempts.length ? Math.max(...attempts) + 1 : 1,
271
298
  ledger: 1,
299
+ guardrails: 1,
300
+ workspace: 1,
272
301
  approval_id: taskArtifact.record.approval_id || null
273
302
  };
274
303
  const runBody = createRunBody(run, {
275
304
  title: `Retry for ${taskId}`,
276
305
  actor: "owner",
277
306
  reason: note,
278
- evidence: [{ kind: "approval", summary: note }]
307
+ evidence: [{ kind: "approval", summary: note }],
308
+ obligations: policy.obligations || [],
309
+ policyFingerprint: enforceablePolicy.fingerprint,
310
+ workspaceBaseline: baseline
279
311
  }).body;
280
312
  const runContent = serializeArtifact(run, runBody);
281
313
  const taskPrevious = fs.readFileSync(taskArtifact.file, "utf8");