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.
@@ -9,9 +9,13 @@ const ENFORCEMENTS = new Set([
9
9
  "builtin:approval-gate",
10
10
  "builtin:read-only-path",
11
11
  "builtin:owner-work",
12
+ "builtin:canonical-write",
13
+ "builtin:canonical-truth",
14
+ "builtin:memory-candidate",
12
15
  "builtin:migration-integrity",
13
16
  "builtin:review-gate"
14
17
  ]);
18
+ const SCOPES = new Set(["all", "intake", "execution", "mutation", "canonical", "memory", "migration", "validation", "learning", "completion", "commit", "release", "logs"]);
15
19
 
16
20
  function normalized(value) {
17
21
  return String(value || "").normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase();
@@ -66,7 +70,21 @@ function parseGuardrails(content) {
66
70
  const status = normalized(fields.status || "active").replace(/[._-]+$/, "");
67
71
  const enforcement = normalized(fields.enforcement || inferEnforcement(title, rule));
68
72
  const scope = (fields.scope || "all").split(/\s*,\s*/).map((value) => normalized(value)).filter(Boolean);
69
- return { id: heading[1], title, rule, status, enforcement, scope, source: fields.source || null };
73
+ return {
74
+ id: heading[1],
75
+ title,
76
+ rule,
77
+ status,
78
+ enforcement,
79
+ scope,
80
+ source: fields.source || null,
81
+ explicit: {
82
+ status: Object.prototype.hasOwnProperty.call(fields, "status"),
83
+ enforcement: Object.prototype.hasOwnProperty.call(fields, "enforcement"),
84
+ scope: Object.prototype.hasOwnProperty.call(fields, "scope"),
85
+ rule: Object.prototype.hasOwnProperty.call(fields, "rule")
86
+ }
87
+ };
70
88
  });
71
89
  }
72
90
 
@@ -103,7 +121,7 @@ function requestMentionsPath(request, configuredPath) {
103
121
  }
104
122
 
105
123
  function evaluation(guardrail, status, code, message, evidence = []) {
106
- return { guardrail: guardrail.id, status, code, message, enforcement: guardrail.enforcement, evidence };
124
+ return { guardrail: guardrail.id, status, code, message, enforcement: guardrail.enforcement, scope: guardrail.scope, evidence };
107
125
  }
108
126
 
109
127
  function evaluateGuardrail(guardrail, context) {
@@ -128,6 +146,12 @@ function evaluateGuardrail(guardrail, context) {
128
146
  }
129
147
  case "builtin:owner-work":
130
148
  return evaluation(guardrail, "deferred", "OWNER_WORK", "Mutation-time hashes, scoped staging, and transaction preconditions enforce owner-work preservation.");
149
+ case "builtin:canonical-write":
150
+ return evaluation(guardrail, "deferred", "CANONICAL_WRITE", "Canonical writes require the lossless transaction gateway and post-write validation.");
151
+ case "builtin:canonical-truth":
152
+ return evaluation(guardrail, "deferred", "CANONICAL_TRUTH", "Completion must prove that generated caches did not replace canonical Markdown truth.");
153
+ case "builtin:memory-candidate":
154
+ return evaluation(guardrail, "deferred", "MEMORY_CANDIDATE", "Learning and memory mutations must preserve candidate-first AI knowledge.");
131
155
  case "builtin:migration-integrity":
132
156
  return evaluation(guardrail, /migrat|migrac/.test(requestText) ? "deferred" : "passed", "MIGRATION_INTEGRITY", /migrat|migrac/.test(requestText) ? "The explicit migration preflight/apply/rollback gate must enforce this Guardrail." : "Request does not invoke migration.");
133
157
  case "builtin:review-gate":
@@ -150,15 +174,48 @@ function evaluatePolicy(context) {
150
174
  evaluations.push({ guardrail: "I-11", status: "blocked", code: "SECRET_BOUNDARY", message: "Request appears to contain a secret; remove or redact it.", enforcement: "builtin", evidence: ["request"] });
151
175
  }
152
176
  const blocked = evaluations.filter((item) => item.status === "blocked");
177
+ const postApprovalScopes = new Set(["all", "execution", "mutation", "canonical", "memory", "migration", "validation", "learning", "completion", "commit", "release", "logs"]);
178
+ const continuousEnforcement = new Set(["builtin:secret-boundary", "builtin:owner-work", "builtin:read-only-path", "builtin:canonical-write", "builtin:canonical-truth", "builtin:memory-candidate"]);
179
+ const obligations = evaluations
180
+ .filter((item) => item.status === "deferred" || (item.status === "passed" && continuousEnforcement.has(item.enforcement) && (item.scope || []).some((scope) => postApprovalScopes.has(scope))))
181
+ .map((item) => ({
182
+ guardrail: item.guardrail,
183
+ code: item.code,
184
+ enforcement: item.enforcement,
185
+ scope: item.scope,
186
+ gate: obligationGate(item)
187
+ }));
153
188
  return {
154
189
  status: blocked.length ? "blocked" : "passed",
155
190
  checked: [...new Set(evaluations.map((item) => item.guardrail))],
156
191
  deferred: [...new Set(evaluations.filter((item) => item.status === "deferred").map((item) => item.guardrail))],
192
+ obligations,
157
193
  evaluations,
158
194
  violations: blocked.map((item) => `${item.guardrail} ${item.code}: ${item.message}`)
159
195
  };
160
196
  }
161
197
 
198
+ function obligationGate(evaluationResult) {
199
+ const scopes = evaluationResult.scope || [];
200
+ for (const gate of ["mutation", "migration", "validation", "learning", "completion", "commit", "release", "memory", "canonical"]) {
201
+ if (scopes.includes(gate)) return gate;
202
+ }
203
+ switch (evaluationResult.enforcement) {
204
+ case "builtin:owner-work":
205
+ case "builtin:read-only-path":
206
+ case "builtin:secret-boundary":
207
+ return "mutation";
208
+ case "builtin:migration-integrity":
209
+ return "migration";
210
+ case "builtin:memory-candidate":
211
+ return "learning";
212
+ case "builtin:review-gate":
213
+ return "commit";
214
+ default:
215
+ return "completion";
216
+ }
217
+ }
218
+
162
219
  function validateGuardrailDocument(content) {
163
220
  const records = parseGuardrails(content);
164
221
  const errors = [];
@@ -169,14 +226,40 @@ function validateGuardrailDocument(content) {
169
226
  if (!GUARDRAIL_STATUSES.has(record.status)) errors.push(`${record.id} has invalid status: ${record.status || "missing"}`);
170
227
  if (!record.rule.trim()) errors.push(`${record.id} has no rule content`);
171
228
  if (!ENFORCEMENTS.has(record.enforcement)) errors.push(`${record.id} has unknown enforcement: ${record.enforcement}`);
229
+ if (!record.scope.length) errors.push(`${record.id} has no scope`);
230
+ for (const scope of record.scope) if (!SCOPES.has(scope)) errors.push(`${record.id} has unknown scope: ${scope}`);
172
231
  }
173
232
  return { records, errors };
174
233
  }
175
234
 
235
+ function normalizeGuardrailDocument(content) {
236
+ const source = String(content || "");
237
+ const records = parseGuardrails(source);
238
+ const headings = [...source.matchAll(/^## (GR-\d{3,})\s*[-—:]\s*([^\r\n]+)$/gm)];
239
+ let next = source;
240
+ for (let index = headings.length - 1; index >= 0; index--) {
241
+ const heading = headings[index];
242
+ const record = records.find((item) => item.id === heading[1]);
243
+ if (!record) continue;
244
+ const missing = [];
245
+ if (!record.explicit.status) missing.push(`Status: ${record.status}`);
246
+ if (!record.explicit.enforcement) missing.push(`Enforcement: ${record.enforcement}`);
247
+ if (!record.explicit.scope) missing.push(`Scope: ${record.scope.join(", ") || "all"}`);
248
+ if (!record.explicit.rule) missing.push(`Rule: ${record.rule}`);
249
+ if (!missing.length) continue;
250
+ const lineEnd = next.indexOf("\n", heading.index);
251
+ const insertAt = lineEnd < 0 ? next.length : lineEnd + 1;
252
+ next = `${next.slice(0, insertAt)}\n${missing.join("\n")}\n${next.slice(insertAt)}`;
253
+ }
254
+ return next.endsWith("\n") ? next : `${next}\n`;
255
+ }
256
+
176
257
  module.exports = {
177
258
  configWeakeningAttempts,
178
259
  evaluatePolicy,
179
260
  inferEnforcement,
261
+ normalizeGuardrailDocument,
262
+ obligationGate,
180
263
  normalized,
181
264
  parseGuardrails,
182
265
  readOnlyPaths,
@@ -4,6 +4,7 @@ const { sha256 } = require("../v2/artifacts");
4
4
  const { buildContextPackage } = require("./context");
5
5
  const { containsSecret } = require("../security/secrets");
6
6
  const { evaluatePolicy, normalized } = require("./policy-engine");
7
+ const { workspaceState } = require("./workspace-state");
7
8
 
8
9
  function assessRisk(request) {
9
10
  const text = normalized(request);
@@ -71,6 +72,7 @@ function encodeApproval(plan) {
71
72
  classification: plan.classification,
72
73
  risk: plan.risk,
73
74
  policy: plan.policy,
75
+ workspaceFingerprint: plan.workspaceFingerprint,
74
76
  issuedAt: plan.issuedAt
75
77
  };
76
78
  const encoded = Buffer.from(stableJson(payload)).toString("base64url");
@@ -98,6 +100,7 @@ function planRequest(projectRoot, request) {
98
100
  if (!normalized) throw new Error("A non-empty request is required for intake.");
99
101
  if (normalized.length > 10000) throw new Error("Request exceeds the 10,000 character intake limit.");
100
102
  const context = buildContextPackage(projectRoot, normalized);
103
+ const workspaceFingerprint = workspaceState(projectRoot).fingerprint;
101
104
  const policy = applyPolicy(context);
102
105
  const risk = assessRisk(normalized);
103
106
  const classification = classifyRequest(normalized);
@@ -106,6 +109,7 @@ function planRequest(projectRoot, request) {
106
109
  pipeline: ["received", "contextualizing", "policy", "risk", "classification", "planning", policy.status === "passed" ? "awaiting_approval" : "blocked"],
107
110
  request: normalized,
108
111
  context,
112
+ workspaceFingerprint,
109
113
  policy,
110
114
  risk,
111
115
  classification,
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+
3
+ const path = require("node:path");
4
+ const { ArtifactRepository, METHOD_VERSION, withArtifactLock } = require("../v2/artifacts");
5
+ const { auditProject } = require("../v2/conformance");
6
+ const { assertCanonicalWrite } = require("./mutation-gateway");
7
+
8
+ function today() {
9
+ return new Date().toISOString().slice(0, 10);
10
+ }
11
+
12
+ function oneLine(value, label) {
13
+ const text = String(value || "").replace(/\s+/g, " ").trim();
14
+ if (!text) throw new Error(`${label} is required.`);
15
+ if (text.length > 500) throw new Error(`${label} exceeds 500 characters.`);
16
+ return text;
17
+ }
18
+
19
+ function nextReviewId(repository) {
20
+ const highest = repository.list("review").reduce((max, artifact) => {
21
+ const match = artifact.record.id.match(/-(\d+)$/);
22
+ return match ? Math.max(max, Number(match[1])) : max;
23
+ }, 0);
24
+ return `REV-${String(highest + 1).padStart(3, "0")}`;
25
+ }
26
+
27
+ function recordArtifactReviewUnlocked(projectRoot, options) {
28
+ const scrumDir = path.join(projectRoot, ".scrumrun");
29
+ const repository = new ArtifactRepository(scrumDir);
30
+ const taskId = oneLine(options.task, "task");
31
+ const task = repository.read("task", taskId);
32
+ if (!task || task.errors.length) throw new Error(`Review Task is missing or invalid: ${taskId}`);
33
+ const runId = options.run ? oneLine(options.run, "run") : null;
34
+ if (runId) {
35
+ const run = repository.read("run", runId);
36
+ if (!run || run.errors.length) throw new Error(`Review Run is missing or invalid: ${runId}`);
37
+ if (run.record.task !== taskId) throw new Error(`${runId} belongs to ${run.record.task}, not ${taskId}.`);
38
+ }
39
+ const title = oneLine(options.title || `Artifact conformance review for ${runId || taskId}`, "title");
40
+ const suppliedEvidence = (Array.isArray(options.evidence) ? options.evidence : []).map((item) => oneLine(item, "evidence"));
41
+ const audit = auditProject(projectRoot);
42
+ const created = today();
43
+ const record = {
44
+ id: nextReviewId(repository),
45
+ kind: "review",
46
+ status: audit.passed ? "passed" : "failed",
47
+ created,
48
+ updated: created,
49
+ method: METHOD_VERSION,
50
+ task: taskId
51
+ };
52
+ const findings = audit.findings.length
53
+ ? audit.findings.map((finding) => `- ${finding.severity}: ${finding.code} — ${finding.message}`)
54
+ : ["- No conformance findings."];
55
+ const evidence = [
56
+ `- Artifact audit: ${audit.invariants} invariants; ${audit.findings.length} finding(s); passed=${audit.passed}.`,
57
+ ...(runId ? [`- Run under review: ${runId}.`] : []),
58
+ ...suppliedEvidence.map((item) => `- ${item}`)
59
+ ];
60
+ const verdict = audit.passed
61
+ ? "Passed. The machine-readable artifact audit has no blocking finding."
62
+ : "Failed. Blocking conformance findings must be resolved in a separately authorized mutation.";
63
+ const body = [
64
+ `# ${title}`,
65
+ "",
66
+ "## Scope",
67
+ "",
68
+ `Review canonical project conformance for ${runId || taskId}.`,
69
+ "",
70
+ "## Evidence",
71
+ "",
72
+ ...evidence,
73
+ "",
74
+ "## Findings",
75
+ "",
76
+ ...findings,
77
+ "",
78
+ "## Verdict",
79
+ "",
80
+ verdict
81
+ ].join("\n");
82
+ assertCanonicalWrite(projectRoot, "record-artifact-review", [title, ...suppliedEvidence, body]);
83
+ repository.write(record, body);
84
+ return { review: repository.read("review", record.id), audit };
85
+ }
86
+
87
+ function recordArtifactReview(projectRoot, options = {}) {
88
+ const scrumDir = path.join(projectRoot, ".scrumrun");
89
+ return withArtifactLock(scrumDir, "review-artifact", () => recordArtifactReviewUnlocked(projectRoot, options));
90
+ }
91
+
92
+ module.exports = { recordArtifactReview };
@@ -1,7 +1,10 @@
1
1
  "use strict";
2
2
 
3
+ const crypto = require("node:crypto");
4
+
3
5
  const {
4
6
  ARTIFACT_TRANSITIONS,
7
+ GUARDRAIL_RESULTS,
5
8
  RUN_EVENT_TYPES,
6
9
  RUN_EVIDENCE_KINDS,
7
10
  RUN_LEDGER_VERSION
@@ -57,7 +60,8 @@ function makeRunEvent({
57
60
  from,
58
61
  to,
59
62
  reason,
60
- evidence = []
63
+ evidence = [],
64
+ ...details
61
65
  }) {
62
66
  return {
63
67
  schema: RUN_LEDGER_VERSION,
@@ -70,7 +74,8 @@ function makeRunEvent({
70
74
  from: from === undefined ? null : from,
71
75
  to,
72
76
  reason,
73
- evidence: normalizeEvidence(evidence)
77
+ evidence: normalizeEvidence(evidence),
78
+ ...details
74
79
  };
75
80
  }
76
81
 
@@ -92,7 +97,10 @@ function createRunBody(record, {
92
97
  occurredAt = instant(),
93
98
  actor = "owner",
94
99
  reason = "Explicit approval accepted.",
95
- evidence = null
100
+ evidence = null,
101
+ obligations = [],
102
+ policyFingerprint = null,
103
+ workspaceBaseline = null
96
104
  } = {}) {
97
105
  const eventEvidence = evidence || [{
98
106
  kind: "approval",
@@ -107,9 +115,133 @@ function createRunBody(record, {
107
115
  from: "created",
108
116
  to: "executing",
109
117
  reason,
110
- evidence: eventEvidence
118
+ evidence: eventEvidence,
119
+ ...(policyFingerprint ? { policy_fingerprint: policyFingerprint } : {}),
120
+ ...(workspaceBaseline ? { workspace_baseline: workspaceBaseline } : {})
111
121
  });
112
- return { body: `# ${title}\n\n${eventsSection([event])}`, event };
122
+ const declarations = obligations.map((obligation, index) => makeRunEvent({
123
+ runId: record.id,
124
+ sequence: index + 2,
125
+ type: "guardrail",
126
+ occurredAt,
127
+ actor: "policy-engine",
128
+ from: "executing",
129
+ to: "executing",
130
+ reason: `Guardrail obligation declared for ${obligation.gate}.`,
131
+ evidence: [{ kind: "guardrail", ref: obligation.guardrail, summary: `${obligation.code} must pass at ${obligation.gate}.` }],
132
+ guardrail: obligation.guardrail,
133
+ code: obligation.code,
134
+ enforcement: obligation.enforcement,
135
+ gate: obligation.gate,
136
+ result: "pending",
137
+ scope: obligation.scope || []
138
+ }));
139
+ const events = [event, ...declarations];
140
+ return { body: `# ${title}\n\n${eventsSection(events)}`, event, events };
141
+ }
142
+
143
+ function validateWorkspaceState(value, label) {
144
+ const errors = [];
145
+ if (!value || typeof value !== "object" || Array.isArray(value)) return [`${label} must be an object`];
146
+ if (value.schema !== 1) errors.push(`${label}.schema must be 1`);
147
+ if (!['git', 'files'].includes(value.mode)) errors.push(`${label}.mode must be git or files`);
148
+ if (!/^[a-f0-9]{64}$/.test(value.fingerprint || "")) errors.push(`${label}.fingerprint must be sha256`);
149
+ if (!Array.isArray(value.files)) errors.push(`${label}.files must be an array`);
150
+ const paths = new Set();
151
+ for (const item of Array.isArray(value.files) ? value.files : []) {
152
+ if (!item || typeof item !== "object" || typeof item.path !== "string" || !item.path || item.path.startsWith("/") || item.path.split("/").includes("..")) {
153
+ errors.push(`${label}.files contains an unsafe path`);
154
+ continue;
155
+ }
156
+ if (paths.has(item.path)) errors.push(`${label}.files contains duplicate path: ${item.path}`);
157
+ paths.add(item.path);
158
+ if (item.sha256 !== null && !/^[a-f0-9]{64}$/.test(item.sha256 || "")) errors.push(`${label}.${item.path}.sha256 is invalid`);
159
+ if (!["file", "missing", "symlink", "other", "clean"].includes(item.kind)) errors.push(`${label}.${item.path}.kind is invalid`);
160
+ if (item.mode !== null && (!Number.isInteger(item.mode) || item.mode < 0 || item.mode > 0o777)) errors.push(`${label}.${item.path}.mode is invalid`);
161
+ if (!["text", "binary", "too-large", "not-applicable"].includes(item.scan)) errors.push(`${label}.${item.path}.scan is invalid`);
162
+ if (item.kind === "file" && !["text", "binary", "too-large"].includes(item.scan)) errors.push(`${label}.${item.path}.scan is invalid for a file`);
163
+ if (item.kind !== "file" && item.scan !== "not-applicable") errors.push(`${label}.${item.path}.scan must be not-applicable`);
164
+ }
165
+ const canonical = value && {
166
+ schema: value.schema,
167
+ mode: value.mode,
168
+ head: value.head || null,
169
+ files: Array.isArray(value.files) ? value.files.map((item) => ({ path: item.path, sha256: item.sha256, kind: item.kind, mode: item.mode, scan: item.scan })) : []
170
+ };
171
+ const fingerprint = crypto.createHash("sha256").update(stableJson(canonical)).digest("hex");
172
+ if (value && value.fingerprint !== fingerprint) errors.push(`${label}.fingerprint does not match its workspace state`);
173
+ return errors;
174
+ }
175
+
176
+ function validateGuardrailEvent(event, declared) {
177
+ const errors = [];
178
+ if (!/^GR-\d{3,}$/.test(event.guardrail || "")) errors.push(`${event.id}.guardrail is invalid`);
179
+ if (!GUARDRAIL_RESULTS.includes(event.result)) errors.push(`${event.id}.result is invalid: ${event.result || "missing"}`);
180
+ if (typeof event.code !== "string" || !event.code.trim()) errors.push(`${event.id}.code is required`);
181
+ if (typeof event.enforcement !== "string" || !event.enforcement.trim()) errors.push(`${event.id}.enforcement is required`);
182
+ if (typeof event.gate !== "string" || !event.gate.trim()) errors.push(`${event.id}.gate is required`);
183
+ if (!Array.isArray(event.scope)) errors.push(`${event.id}.scope must be an array`);
184
+ if (event.result === "pending") {
185
+ if (declared.has(event.guardrail)) errors.push(`${event.id} redeclares ${event.guardrail}`);
186
+ declared.set(event.guardrail, { code: event.code, enforcement: event.enforcement, gate: event.gate, scope: event.scope || [] });
187
+ } else if (!declared.has(event.guardrail)) {
188
+ errors.push(`${event.id} resolves undeclared ${event.guardrail}`);
189
+ } else {
190
+ const original = declared.get(event.guardrail);
191
+ for (const field of ["code", "enforcement", "gate"]) {
192
+ if (event[field] !== original[field]) errors.push(`${event.id}.${field} disagrees with the ${event.guardrail} declaration`);
193
+ }
194
+ if (stableJson(event.scope || []) !== stableJson(original.scope)) errors.push(`${event.id}.scope disagrees with the ${event.guardrail} declaration`);
195
+ }
196
+ return errors;
197
+ }
198
+
199
+ function stableJson(value) {
200
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
201
+ if (value && typeof value === "object") return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
202
+ return JSON.stringify(value);
203
+ }
204
+
205
+ function safeEventPath(value) {
206
+ return typeof value === "string" && value && !value.startsWith("/") && !value.split("/").includes("..") && value !== ".scrumrun" && !value.startsWith(".scrumrun/");
207
+ }
208
+
209
+ function validateMutationEvent(event, expectedWorkspace, policyFingerprint) {
210
+ const errors = [];
211
+ if (!/^MUT-[A-Za-z0-9-]{8,}$/.test(event.mutation_id || "")) errors.push(`${event.id}.mutation_id is invalid`);
212
+ if (!Array.isArray(event.paths) || !event.paths.length) errors.push(`${event.id}.paths must be a non-empty array`);
213
+ if (!Array.isArray(event.changes) || !event.changes.length) errors.push(`${event.id}.changes must be a non-empty array`);
214
+ if (!/^[a-f0-9]{64}$/.test(event.workspace_before || "")) errors.push(`${event.id}.workspace_before must be sha256`);
215
+ if (expectedWorkspace && event.workspace_before !== expectedWorkspace) errors.push(`${event.id}.workspace_before breaks the mutation chain`);
216
+ if (event.policy_fingerprint !== policyFingerprint) errors.push(`${event.id}.policy_fingerprint disagrees with Run approval`);
217
+ const paths = new Set();
218
+ for (const value of Array.isArray(event.paths) ? event.paths : []) {
219
+ if (!safeEventPath(value)) errors.push(`${event.id}.paths contains an unsafe path`);
220
+ if (paths.has(value)) errors.push(`${event.id}.paths contains duplicate path: ${value}`);
221
+ paths.add(value);
222
+ }
223
+ const changed = new Set();
224
+ for (const change of Array.isArray(event.changes) ? event.changes : []) {
225
+ if (!change || !safeEventPath(change.path)) {
226
+ errors.push(`${event.id}.changes contains an unsafe path`);
227
+ continue;
228
+ }
229
+ if (changed.has(change.path)) errors.push(`${event.id}.changes contains duplicate path: ${change.path}`);
230
+ changed.add(change.path);
231
+ if (![...paths].some((allowed) => change.path === allowed || change.path.startsWith(`${allowed}/`))) errors.push(`${event.id}.${change.path} is outside declared paths`);
232
+ for (const field of ["before_sha256", "after_sha256"]) {
233
+ if (change[field] !== null && !/^[a-f0-9]{64}$/.test(change[field] || "")) errors.push(`${event.id}.${change.path}.${field} is invalid`);
234
+ }
235
+ for (const field of ["before_scan", "after_scan"]) {
236
+ if (!["text", "binary", "too-large", "not-applicable"].includes(change[field])) errors.push(`${event.id}.${change.path}.${field} is invalid`);
237
+ }
238
+ for (const field of ["before_mode", "after_mode"]) {
239
+ if (change[field] !== null && (!Number.isInteger(change[field]) || change[field] < 0 || change[field] > 0o777)) errors.push(`${event.id}.${change.path}.${field} is invalid`);
240
+ }
241
+ }
242
+ if (!event.workspace_after) errors.push(`${event.id}.workspace_after is required`);
243
+ else errors.push(...validateWorkspaceState(event.workspace_after, `${event.id}.workspace_after`));
244
+ return errors;
113
245
  }
114
246
 
115
247
  function parseRunLedger(body) {
@@ -158,6 +290,10 @@ function validateRunLedger(record, body) {
158
290
  const ids = new Set();
159
291
  let previous = null;
160
292
  let previousTime = null;
293
+ const declared = new Map();
294
+ const obligationResults = new Map();
295
+ let expectedWorkspace = events[0] && events[0].workspace_baseline && events[0].workspace_baseline.fingerprint;
296
+ const policyFingerprint = events[0] && events[0].policy_fingerprint;
161
297
  for (let index = 0; index < events.length; index++) {
162
298
  const event = events[index];
163
299
  const expectedSequence = index + 1;
@@ -188,6 +324,18 @@ function validateRunLedger(record, body) {
188
324
  if (!event.evidence.some((item) => item && ["migration", "legacy"].includes(item.kind))) {
189
325
  errors.push(`${event.id} snapshot requires migration or legacy evidence`);
190
326
  }
327
+ } else if (["guardrail", "mutation"].includes(event.type)) {
328
+ if (!previous) errors.push(`${event.id} cannot precede the initial Run event`);
329
+ const currentState = previous && previous.to;
330
+ if (event.from !== currentState || event.to !== currentState) errors.push(`${event.id} must not change Run state ${currentState || "unknown"}`);
331
+ if (event.type === "guardrail") {
332
+ errors.push(...validateGuardrailEvent(event, declared));
333
+ if (event.result !== "pending") obligationResults.set(event.guardrail, event.result);
334
+ else obligationResults.set(event.guardrail, "pending");
335
+ } else {
336
+ errors.push(...validateMutationEvent(event, expectedWorkspace, policyFingerprint));
337
+ if (event.workspace_after && event.workspace_after.fingerprint) expectedWorkspace = event.workspace_after.fingerprint;
338
+ }
191
339
  } else {
192
340
  const expectedFrom = index === 0 ? "created" : previous.to;
193
341
  if (event.type !== "transition") errors.push(`${event.id} snapshot is allowed only as the first event`);
@@ -202,6 +350,12 @@ function validateRunLedger(record, body) {
202
350
  if (!["snapshot"].includes(event.type) && !event.evidence.length) errors.push(`${event.id} transition requires evidence`);
203
351
  previous = event;
204
352
  }
353
+ if (record.workspace === 1 && events[0] && events[0].type !== "snapshot") {
354
+ errors.push(...validateWorkspaceState(events[0].workspace_baseline, `${events[0].id}.workspace_baseline`));
355
+ }
356
+ if (record.guardrails === 1 && events[0] && events[0].type !== "snapshot") {
357
+ if (!/^[a-f0-9]{64}$/.test(events[0].policy_fingerprint || "")) errors.push(`${events[0].id}.policy_fingerprint must be sha256`);
358
+ }
205
359
  if (previous && previous.to !== record.status) errors.push(`Run status ${record.status} disagrees with final event state ${previous.to}`);
206
360
  if (previous && record.updated !== String(previous.occurred_at || "").slice(0, 10)) {
207
361
  errors.push(`Run updated date ${record.updated} disagrees with final event timestamp`);
@@ -211,8 +365,73 @@ function validateRunLedger(record, body) {
211
365
  const event = events.find((item) => item.type === "transition" && item.to === required);
212
366
  if (!event || !event.evidence.length) errors.push(`completed Run requires evidenced ${required} transition`);
213
367
  }
368
+ for (const guardrail of declared.keys()) {
369
+ if (obligationResults.get(guardrail) !== "passed") errors.push(`completed Run has unresolved Guardrail obligation: ${guardrail}`);
370
+ }
214
371
  }
215
- return { events, errors };
372
+ return { events, errors, obligations: [...declared.keys()].map((guardrail) => ({ guardrail, status: obligationResults.get(guardrail) || "pending" })) };
373
+ }
374
+
375
+ function appendAuxiliaryEvent(record, body, details, options = {}) {
376
+ const current = validateRunLedger(record, body);
377
+ if (current.errors.length) throw new Error(`Invalid Run ledger: ${current.errors.join("; ")}`);
378
+ const occurredAt = options.occurredAt ? instant(options.occurredAt) : instant();
379
+ const nextRecord = { ...record, updated: occurredAt.slice(0, 10) };
380
+ const evidence = normalizeEvidence(options.evidence || []);
381
+ if (!evidence.length) throw new Error(`${details.type} event requires structured evidence.`);
382
+ const event = makeRunEvent({
383
+ runId: record.id,
384
+ sequence: current.events.length + 1,
385
+ type: details.type,
386
+ occurredAt,
387
+ actor: options.actor || "agent",
388
+ from: record.status,
389
+ to: record.status,
390
+ reason: String(options.note || evidence.map((item) => item.summary || item.ref).filter(Boolean).join("; ")).trim(),
391
+ evidence,
392
+ ...details
393
+ });
394
+ const nextBody = `${String(body).trimEnd()}\n\n${renderRunEvent(event)}\n`;
395
+ const validated = validateRunLedger(nextRecord, nextBody);
396
+ if (validated.errors.length) throw new Error(`${details.type} event failed validation: ${validated.errors.join("; ")}`);
397
+ return { body: nextBody, record: nextRecord, event, events: validated.events, obligations: validated.obligations };
398
+ }
399
+
400
+ function appendGuardrailEvent(record, body, obligation, result, options = {}) {
401
+ if (!GUARDRAIL_RESULTS.includes(result) || result === "pending") throw new Error(`Guardrail result must be passed or blocked.`);
402
+ return appendAuxiliaryEvent(record, body, {
403
+ type: "guardrail",
404
+ guardrail: obligation.guardrail,
405
+ code: obligation.code,
406
+ enforcement: obligation.enforcement,
407
+ gate: obligation.gate,
408
+ result,
409
+ scope: obligation.scope || []
410
+ }, options);
411
+ }
412
+
413
+ function appendMutationEvent(record, body, mutation, options = {}) {
414
+ return appendAuxiliaryEvent(record, body, { type: "mutation", ...mutation }, options);
415
+ }
416
+
417
+ function guardrailState(record, body) {
418
+ const ledger = validateRunLedger(record, body);
419
+ if (ledger.errors.length) throw new Error(`Invalid Run ledger: ${ledger.errors.join("; ")}`);
420
+ const declarations = new Map();
421
+ const results = new Map();
422
+ for (const event of ledger.events.filter((item) => item.type === "guardrail")) {
423
+ if (event.result === "pending") declarations.set(event.guardrail, event);
424
+ else results.set(event.guardrail, event);
425
+ }
426
+ return [...declarations.values()].map((event) => ({
427
+ guardrail: event.guardrail,
428
+ code: event.code,
429
+ enforcement: event.enforcement,
430
+ gate: event.gate,
431
+ scope: event.scope || [],
432
+ status: results.has(event.guardrail) ? results.get(event.guardrail).result : "pending",
433
+ resultEvent: results.get(event.guardrail) || null
434
+ }));
216
435
  }
217
436
 
218
437
  function appendRunEvent(record, body, nextRecord, {
@@ -309,12 +528,15 @@ function migrateLegacyRun(record, body, { legacyHash, backupRef }) {
309
528
  }
310
529
 
311
530
  module.exports = {
531
+ appendGuardrailEvent,
532
+ appendMutationEvent,
312
533
  appendRunEvent,
313
534
  createRunBody,
314
535
  dateInstant,
315
536
  eventId,
316
537
  eventsSection,
317
538
  evidenceForTransition,
539
+ guardrailState,
318
540
  instant,
319
541
  makeRunEvent,
320
542
  migrateLegacyRun,