@evoclock/pi-agentic-driver 0.10.0 → 1.0.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/.agentic-driver/nudge.defaults.json +11 -0
- package/.githooks/pre-commit +4 -0
- package/README.md +43 -31
- package/config/codebase-inventory.schema.json +23 -0
- package/docs/codebase-inventory.md +15 -0
- package/docs/recovery-evidence-store.md +9 -0
- package/extensions/herdr-dispatch.ts +15 -2
- package/extensions/recovery-evidence.ts +14 -0
- package/extensions/task-store-adapter.ts +101 -0
- package/extensions/tasks.ts +34 -11
- package/package.json +19 -1
- package/scripts/codebase_inventory.py +105 -0
- package/scripts/codebase_inventory_gate.py +48 -0
- package/scripts/enforcement/board_claim_bridge_pi.js +204 -0
- package/scripts/enforcement/herdr_async_dispatch_pi.js +83 -30
- package/scripts/enforcement/herdr_async_seam_pi.js +347 -0
- package/scripts/enforcement/herdr_communication_pi.js +608 -95
- package/scripts/enforcement/herdr_lifecycle_pi.js +266 -13
- package/scripts/enforcement/linux_microvm_remote_fixture.sh +6 -6
- package/scripts/enforcement/native_tui_context.js +16 -0
- package/scripts/enforcement/nudge_classifier_pi.js +176 -0
- package/scripts/enforcement/nudge_config_pi.js +234 -0
- package/scripts/enforcement/nudge_ledger_pi.js +514 -0
- package/scripts/enforcement/nudge_reason_state_pi.js +135 -0
- package/scripts/enforcement/nudge_validate_pi.js +224 -0
- package/scripts/enforcement/pulse_scheduler_pi.js +15 -3
- package/scripts/enforcement/recovery_evidence_review_pi.js +269 -0
- package/scripts/enforcement/recovery_evidence_store_pi.js +170 -0
- package/scripts/enforcement/session_tasks_core_pi.js +14 -0
- package/scripts/enforcement/task_board_core_pi.js +96 -7
|
@@ -0,0 +1,514 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// NUDGE §6 recovery ledger (SPEC_TASK43_NUDGE.md v3.3.1). Pure module: no
|
|
5
|
+
// I/O, no transport, no authority store. Every recovery action (nudge or
|
|
6
|
+
// replacement) is an immutable, journey-linked ledger entry appended to
|
|
7
|
+
// `journey.recoveryLedger` and serialized inside or as a bounded,
|
|
8
|
+
// digest-linked section of the existing [WORKER_JOURNEY_REPORT_BEGIN/END]
|
|
9
|
+
// receipt. The ledger is audit evidence inside the journey receipt — it
|
|
10
|
+
// never grants dispatch authority and never performs rollback; §6.3 gives
|
|
11
|
+
// the owner a bounded revert-anchor procedure.
|
|
12
|
+
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
|
|
15
|
+
export const NUDGE_LEDGER_VERSION = 1;
|
|
16
|
+
|
|
17
|
+
// Closed enums (NUDGE §6.2).
|
|
18
|
+
export const NUDGE_LEDGER_ACTIONS = Object.freeze(["nudge", "replacement"]);
|
|
19
|
+
export const NUDGE_LEDGER_RESULTS = Object.freeze([
|
|
20
|
+
"requested", "delivered", "rejected", "unknown", "spawned", "failed", "parked",
|
|
21
|
+
]);
|
|
22
|
+
export const NUDGE_AUTHORITY_SOURCES = Object.freeze(["direct", "queue", "board"]);
|
|
23
|
+
export const NUDGE_AUTHORITY_KINDS = Object.freeze([
|
|
24
|
+
"direct-instruction", "session-task", "board-envelope",
|
|
25
|
+
]);
|
|
26
|
+
export const NUDGE_DELIVERY_STATES = Object.freeze([
|
|
27
|
+
"delivered", "rejected-before-delivery", "unknown",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
// Ledger-local canonical JSON (NUDGE §6.2): sort object keys at every depth,
|
|
31
|
+
// retain array order and exact string code points, and preserve explicit null.
|
|
32
|
+
// Do not reuse the board canonicalizer: its normalization and omission rules
|
|
33
|
+
// are not part of the ledger digest contract.
|
|
34
|
+
function sortLedgerValue(value) {
|
|
35
|
+
if (Array.isArray(value)) return value.map(sortLedgerValue);
|
|
36
|
+
if (!isPlainObject(value)) return value;
|
|
37
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortLedgerValue(value[key])]));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function nudgeCanonicalJsonString(value) {
|
|
41
|
+
return JSON.stringify(sortLedgerValue(value));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function sha256Hex(text) {
|
|
45
|
+
return createHash("sha256").update(text, "utf8").digest("hex");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The closed record shape (§6.2), as an exact-key template. Optional keys
|
|
49
|
+
// are listed with their presence conditions so validation can name the
|
|
50
|
+
// exact path of any violation.
|
|
51
|
+
const REQUIRED_TOP_KEYS = Object.freeze([
|
|
52
|
+
"ledgerVersion", "entryId", "journeyId", "timestamp",
|
|
53
|
+
"action", "role", "journeyStep", "attemptOrdinal",
|
|
54
|
+
"repository", "dispatchAuthorization",
|
|
55
|
+
"autonomy", "containmentProfile", "operationClasses",
|
|
56
|
+
"preState", "result",
|
|
57
|
+
"preGit",
|
|
58
|
+
]);
|
|
59
|
+
const CONDITIONAL_TOP_KEYS = Object.freeze([
|
|
60
|
+
// replacement entries only:
|
|
61
|
+
"replacementRole",
|
|
62
|
+
// source-defined nullable identities:
|
|
63
|
+
"taskId", "claimId", "envelopeId", "branch",
|
|
64
|
+
// nudge entries (bounded exact sent text + integrity):
|
|
65
|
+
"messageText", "messageSha256", "messageBytes",
|
|
66
|
+
// delivery evidence:
|
|
67
|
+
"deliveryState", "herdrCode",
|
|
68
|
+
// terminal post-action observations:
|
|
69
|
+
"postGit",
|
|
70
|
+
// linkage:
|
|
71
|
+
"parentEntryId", "spawnReceiptDigest", "reportMarkers", "reasonCode",
|
|
72
|
+
]);
|
|
73
|
+
const REQUIRED_AUTH_KEYS = Object.freeze([
|
|
74
|
+
"dispatchId", "source", "authorityProvenance", "authorityMaterial",
|
|
75
|
+
"authorityDigest", "authorizedAt",
|
|
76
|
+
"repositoryScope", "containmentProfile", "operationClasses", "scopeDigest",
|
|
77
|
+
]);
|
|
78
|
+
const OPTIONAL_AUTH_KEYS = Object.freeze([
|
|
79
|
+
"expiresAt", "revokedAt", "claimId", "envelopeId", "taskId",
|
|
80
|
+
]);
|
|
81
|
+
const REQUIRED_PROVENANCE_KEYS = Object.freeze(["kind", "reference", "capturedAt"]);
|
|
82
|
+
const REQUIRED_PRESTATE_KEYS = Object.freeze([
|
|
83
|
+
"classification", "agentStatus", "pollOrdinal",
|
|
84
|
+
]);
|
|
85
|
+
const OPTIONAL_PRESTATE_KEYS = Object.freeze(["stateChangeSeq", "revision"]);
|
|
86
|
+
const REQUIRED_GIT_KEYS = Object.freeze(["head", "statusDigest", "diffDigest"]);
|
|
87
|
+
|
|
88
|
+
function isPlainObject(value) {
|
|
89
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function checkKeys(value, requiredKeys, optionalKeys, path, errors) {
|
|
93
|
+
if (!isPlainObject(value)) {
|
|
94
|
+
errors.push(`${path}: must be an object`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
for (const key of requiredKeys) {
|
|
98
|
+
if (value[key] === undefined) errors.push(`${path}.${key}: REQUIRED key absent`);
|
|
99
|
+
}
|
|
100
|
+
for (const key of Object.keys(value)) {
|
|
101
|
+
if (!requiredKeys.includes(key) && !optionalKeys.includes(key)) {
|
|
102
|
+
errors.push(`${path}.${key}: key outside the closed record shape`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Validate one ledger entry against the closed §6.2 shape. Returns
|
|
108
|
+
// {ok, errors} with exact paths. Both actions require the exact message and
|
|
109
|
+
// integrity fields; `action: replacement` also requires `replacementRole`
|
|
110
|
+
// and rejects the nudge-only delivery fields.
|
|
111
|
+
export function validateLedgerEntry(entry) {
|
|
112
|
+
const errors = [];
|
|
113
|
+
if (!isPlainObject(entry)) {
|
|
114
|
+
return { ok: false, errors: ["entry: must be an object"] };
|
|
115
|
+
}
|
|
116
|
+
checkKeys(entry, REQUIRED_TOP_KEYS, CONDITIONAL_TOP_KEYS, "entry", errors);
|
|
117
|
+
|
|
118
|
+
if (entry.ledgerVersion !== NUDGE_LEDGER_VERSION) {
|
|
119
|
+
errors.push(`entry.ledgerVersion: must be ${NUDGE_LEDGER_VERSION}`);
|
|
120
|
+
}
|
|
121
|
+
for (const key of ["entryId", "journeyId", "timestamp", "role", "repository"]) {
|
|
122
|
+
if (typeof entry[key] !== "string" || entry[key] === "") {
|
|
123
|
+
errors.push(`entry.${key}: must be a non-empty string`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (typeof entry.attemptOrdinal !== "number" || !Number.isSafeInteger(entry.attemptOrdinal) || entry.attemptOrdinal < 0) {
|
|
127
|
+
errors.push("entry.attemptOrdinal: must be a safe integer >= 0");
|
|
128
|
+
}
|
|
129
|
+
if (!(typeof entry.journeyStep === "string" && entry.journeyStep !== "")
|
|
130
|
+
&& !(typeof entry.journeyStep === "number" && Number.isSafeInteger(entry.journeyStep) && entry.journeyStep >= 0)) {
|
|
131
|
+
errors.push("entry.journeyStep: must be a non-empty string or a safe integer >= 0");
|
|
132
|
+
}
|
|
133
|
+
if (!NUDGE_LEDGER_ACTIONS.includes(entry.action)) {
|
|
134
|
+
errors.push(`entry.action: must be one of ${NUDGE_LEDGER_ACTIONS.join(", ")}`);
|
|
135
|
+
}
|
|
136
|
+
if (!NUDGE_LEDGER_RESULTS.includes(entry.result)) {
|
|
137
|
+
errors.push(`entry.result: must be one of ${NUDGE_LEDGER_RESULTS.join(", ")}`);
|
|
138
|
+
}
|
|
139
|
+
if (entry.action === "replacement") {
|
|
140
|
+
if (typeof entry.replacementRole !== "string" || entry.replacementRole === "") {
|
|
141
|
+
errors.push("entry.replacementRole: REQUIRED for a replacement entry");
|
|
142
|
+
}
|
|
143
|
+
for (const key of ["deliveryState", "herdrCode"]) {
|
|
144
|
+
if (entry[key] !== undefined) errors.push(`entry.${key}: nudge-only field on a replacement entry`);
|
|
145
|
+
}
|
|
146
|
+
} else if (entry.action === "nudge") {
|
|
147
|
+
if (entry.deliveryState !== undefined && !NUDGE_DELIVERY_STATES.includes(entry.deliveryState)) {
|
|
148
|
+
errors.push(`entry.deliveryState: must be one of ${NUDGE_DELIVERY_STATES.join(", ")}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (typeof entry.messageText !== "string" || entry.messageText === "") {
|
|
152
|
+
errors.push("entry.messageText: REQUIRED for every ledger action (exact bounded sent text)");
|
|
153
|
+
}
|
|
154
|
+
if (typeof entry.messageSha256 !== "string" || !/^[0-9a-f]{64}$/.test(entry.messageSha256)) {
|
|
155
|
+
errors.push("entry.messageSha256: must be lowercase SHA-256 hex of messageText");
|
|
156
|
+
}
|
|
157
|
+
const expectedMessageBytes = typeof entry.messageText === "string"
|
|
158
|
+
? Buffer.byteLength(entry.messageText, "utf8")
|
|
159
|
+
: null;
|
|
160
|
+
if (typeof entry.messageBytes !== "number" || !Number.isSafeInteger(entry.messageBytes) || entry.messageBytes < 1) {
|
|
161
|
+
errors.push("entry.messageBytes: REQUIRED positive safe integer (UTF-8 byte count)");
|
|
162
|
+
} else if (expectedMessageBytes !== null && entry.messageBytes !== expectedMessageBytes) {
|
|
163
|
+
errors.push("entry.messageBytes: must equal the UTF-8 byte count of messageText");
|
|
164
|
+
}
|
|
165
|
+
if (entry.reasonCode !== undefined && entry.reasonCode !== null
|
|
166
|
+
&& typeof entry.reasonCode !== "string") {
|
|
167
|
+
errors.push("entry.reasonCode: must be a string when present");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// dispatchAuthorization (SEAM §2.4 minting contract, NUDGE §6.2 shape).
|
|
171
|
+
const auth = entry.dispatchAuthorization;
|
|
172
|
+
if (!isPlainObject(auth)) {
|
|
173
|
+
errors.push("entry.dispatchAuthorization: must be an object");
|
|
174
|
+
} else {
|
|
175
|
+
checkKeys(auth, REQUIRED_AUTH_KEYS, OPTIONAL_AUTH_KEYS, "entry.dispatchAuthorization", errors);
|
|
176
|
+
for (const key of ["dispatchId", "authorizedAt", "authorityDigest", "repositoryScope", "containmentProfile"]) {
|
|
177
|
+
if (typeof auth[key] !== "string" || auth[key] === "") {
|
|
178
|
+
errors.push(`entry.dispatchAuthorization.${key}: must be a non-empty string`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (!NUDGE_AUTHORITY_SOURCES.includes(auth.source)) {
|
|
182
|
+
errors.push(`entry.dispatchAuthorization.source: must be one of ${NUDGE_AUTHORITY_SOURCES.join(", ")}`);
|
|
183
|
+
}
|
|
184
|
+
if (!Array.isArray(auth.operationClasses) || auth.operationClasses.some((c) => typeof c !== "string" || c === "")) {
|
|
185
|
+
errors.push("entry.dispatchAuthorization.operationClasses: must be an array of non-empty strings");
|
|
186
|
+
}
|
|
187
|
+
if (!/^[0-9a-f]{64}$/.test(auth.authorityDigest ?? "")) {
|
|
188
|
+
errors.push("entry.dispatchAuthorization.authorityDigest: must be lowercase SHA-256 hex");
|
|
189
|
+
}
|
|
190
|
+
if (!/^[0-9a-f]{64}$/.test(auth.scopeDigest ?? "")) {
|
|
191
|
+
errors.push("entry.dispatchAuthorization.scopeDigest: must be lowercase SHA-256 hex");
|
|
192
|
+
}
|
|
193
|
+
const provenance = auth.authorityProvenance;
|
|
194
|
+
if (!isPlainObject(provenance)) {
|
|
195
|
+
errors.push("entry.dispatchAuthorization.authorityProvenance: must be an object");
|
|
196
|
+
} else {
|
|
197
|
+
checkKeys(provenance, REQUIRED_PROVENANCE_KEYS, [], "entry.dispatchAuthorization.authorityProvenance", errors);
|
|
198
|
+
if (!NUDGE_AUTHORITY_KINDS.includes(provenance.kind)) {
|
|
199
|
+
errors.push(`entry.dispatchAuthorization.authorityProvenance.kind: must be one of ${NUDGE_AUTHORITY_KINDS.join(", ")}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (!Array.isArray(entry.operationClasses) || entry.operationClasses.some((c) => typeof c !== "string" || c === "")) {
|
|
205
|
+
errors.push("entry.operationClasses: must be an array of non-empty strings");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// preState.
|
|
209
|
+
if (!isPlainObject(entry.preState)) {
|
|
210
|
+
errors.push("entry.preState: must be an object");
|
|
211
|
+
} else {
|
|
212
|
+
checkKeys(entry.preState, REQUIRED_PRESTATE_KEYS, OPTIONAL_PRESTATE_KEYS, "entry.preState", errors);
|
|
213
|
+
if (typeof entry.preState.classification !== "string" || entry.preState.classification === "") {
|
|
214
|
+
errors.push("entry.preState.classification: must be a non-empty string");
|
|
215
|
+
}
|
|
216
|
+
if (typeof entry.preState.agentStatus !== "string" || entry.preState.agentStatus === "") {
|
|
217
|
+
errors.push("entry.preState.agentStatus: must be a non-empty string");
|
|
218
|
+
}
|
|
219
|
+
const pollOrdinal = entry.preState.pollOrdinal;
|
|
220
|
+
if (typeof pollOrdinal !== "number" || !Number.isSafeInteger(pollOrdinal) || pollOrdinal < 0) {
|
|
221
|
+
errors.push("entry.preState.pollOrdinal: must be a safe integer >= 0");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Git observations (pre required, post optional): they are observations
|
|
226
|
+
// from the trusted repository and never assert clean state.
|
|
227
|
+
for (const [key, required] of [["preGit", true], ["postGit", false]]) {
|
|
228
|
+
const value = entry[key];
|
|
229
|
+
if (value === undefined) {
|
|
230
|
+
if (required) errors.push(`entry.${key}: REQUIRED`);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (!isPlainObject(value)) {
|
|
234
|
+
errors.push(`entry.${key}: must be an object`);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
checkKeys(value, REQUIRED_GIT_KEYS, [], `entry.${key}`, errors);
|
|
238
|
+
for (const sub of REQUIRED_GIT_KEYS) {
|
|
239
|
+
const digest = value[sub];
|
|
240
|
+
const isHead = sub === "head";
|
|
241
|
+
const hexOk = isHead ? /^[0-9a-f]{40}$/.test(digest ?? "") : /^[0-9a-f]{64}$/.test(digest ?? "");
|
|
242
|
+
if (typeof digest !== "string" || !hexOk) {
|
|
243
|
+
errors.push(`entry.${key}.${sub}: must be ${isHead ? "a 40-hex Git commit SHA" : "lowercase SHA-256 hex"}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return { ok: errors.length === 0, errors };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Digest recomputation (§6.2). Given a validated entry, recompute:
|
|
252
|
+
// - direct: authorityDigest = SHA-256 of the exact stepPrompt UTF-8 bytes
|
|
253
|
+
// (no newline/Unicode normalization); messageSha256 = SHA-256 of the
|
|
254
|
+
// exact messageText bytes.
|
|
255
|
+
// - queue: authorityDigest = SHA-256 of canonical JSON of the immutable
|
|
256
|
+
// captured material {id,title,description,capturedAt} (missing
|
|
257
|
+
// title/description is explicit null; mutable status/blockedBy/owner are
|
|
258
|
+
// excluded by construction).
|
|
259
|
+
// - board: the existing envelope authority digest is preserved, not
|
|
260
|
+
// recomputed — recomputing the formal-tier HMAC requires the board
|
|
261
|
+
// secret; the ledger preserves board bytes and digests for keyed
|
|
262
|
+
// verification. Recompute returns {recomputable: false} for board.
|
|
263
|
+
// - all: scopeDigest = SHA-256 of canonical JSON
|
|
264
|
+
// {operationClasses,containmentProfile,repositoryScope} with classes
|
|
265
|
+
// sorted and deduplicated.
|
|
266
|
+
// An owner can recompute both digests from one ledger record without HMAC
|
|
267
|
+
// infrastructure for direct and queue.
|
|
268
|
+
function recomputeEntryDigestsUnchecked(entry) {
|
|
269
|
+
if (!entry?.dispatchAuthorization) return { ok: false, errors: ["entry.dispatchAuthorization: absent"] };
|
|
270
|
+
const auth = entry.dispatchAuthorization;
|
|
271
|
+
const errors = [];
|
|
272
|
+
const scopeObject = {
|
|
273
|
+
operationClasses: [...new Set([...(auth.operationClasses ?? [])].sort())],
|
|
274
|
+
containmentProfile: auth.containmentProfile,
|
|
275
|
+
repositoryScope: auth.repositoryScope,
|
|
276
|
+
};
|
|
277
|
+
const scopeDigest = sha256Hex(nudgeCanonicalJsonString(scopeObject));
|
|
278
|
+
if (scopeDigest !== auth.scopeDigest) {
|
|
279
|
+
errors.push("scopeDigest mismatch: recomputed scope digest differs from the recorded one");
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
let authorityDigest = null;
|
|
283
|
+
let recomputable = true;
|
|
284
|
+
if (auth.source === "direct") {
|
|
285
|
+
if (typeof auth.authorityMaterial !== "string") {
|
|
286
|
+
errors.push("direct authorityMaterial: must be the exact stepPrompt string (lossless receipt representation)");
|
|
287
|
+
} else {
|
|
288
|
+
authorityDigest = sha256Hex(auth.authorityMaterial);
|
|
289
|
+
}
|
|
290
|
+
} else if (auth.source === "queue") {
|
|
291
|
+
const material = auth.authorityMaterial;
|
|
292
|
+
if (!isPlainObject(material)) {
|
|
293
|
+
errors.push("queue authorityMaterial: must be the immutable captured object {id,title,description,capturedAt}");
|
|
294
|
+
} else {
|
|
295
|
+
const immutable = {
|
|
296
|
+
id: material.id ?? null,
|
|
297
|
+
title: material.title ?? null,
|
|
298
|
+
description: material.description ?? null,
|
|
299
|
+
capturedAt: material.capturedAt ?? null,
|
|
300
|
+
};
|
|
301
|
+
authorityDigest = sha256Hex(nudgeCanonicalJsonString(immutable));
|
|
302
|
+
}
|
|
303
|
+
} else {
|
|
304
|
+
// board: keyed verification only; the ledger preserves bytes + digests.
|
|
305
|
+
recomputable = false;
|
|
306
|
+
}
|
|
307
|
+
if (recomputable && authorityDigest !== null && authorityDigest !== auth.authorityDigest) {
|
|
308
|
+
errors.push("authorityDigest mismatch: recomputed authority digest differs from the recorded one");
|
|
309
|
+
}
|
|
310
|
+
if (typeof entry.messageText === "string" && typeof entry.messageSha256 === "string") {
|
|
311
|
+
const messageDigest = sha256Hex(entry.messageText);
|
|
312
|
+
if (messageDigest !== entry.messageSha256) {
|
|
313
|
+
errors.push("messageSha256 mismatch: recomputed message digest differs from the recorded one");
|
|
314
|
+
}
|
|
315
|
+
const bytes = Buffer.byteLength(entry.messageText, "utf8");
|
|
316
|
+
if (entry.messageBytes !== undefined && bytes !== entry.messageBytes) {
|
|
317
|
+
errors.push("messageBytes mismatch: recomputed UTF-8 byte count differs from the recorded one");
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return { ok: errors.length === 0, recomputable, errors, scopeDigest, authorityDigest };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function recomputeEntryDigests(entry) {
|
|
324
|
+
try {
|
|
325
|
+
return recomputeEntryDigestsUnchecked(entry);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
return { ok: false, errors: [`entry: malformed digest material (${error instanceof Error ? error.message : String(error)})`] };
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
// Two-phase in-memory write: `requested` before mutation, terminal result
|
|
333
|
+
// afterward, linked by entryId. Receipt serialization represents both phases
|
|
334
|
+
// so an interrupted action remains visible. A nudge or replacement without a
|
|
335
|
+
// ledger pre-entry is forbidden — buildRequestedEntry is the only way to
|
|
336
|
+
// mint the pre-entry, and appendTerminalResult refuses an unknown entryId.
|
|
337
|
+
// ---------------------------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
let entryCounter = 0;
|
|
340
|
+
|
|
341
|
+
export function buildRequestedEntry({
|
|
342
|
+
journeyId, timestamp, action, role, replacementRole = undefined,
|
|
343
|
+
journeyStep, attemptOrdinal, taskId = null, claimId = null, envelopeId = null,
|
|
344
|
+
repository, branch = undefined, dispatchAuthorization,
|
|
345
|
+
autonomy, containmentProfile, operationClasses,
|
|
346
|
+
preState, messageText = undefined, reasonCode = undefined,
|
|
347
|
+
preGit, parentEntryId = undefined,
|
|
348
|
+
}) {
|
|
349
|
+
if (action !== "nudge" && action !== "replacement") {
|
|
350
|
+
throw Object.assign(new Error("action must be nudge | replacement"), { code: "invalid-input" });
|
|
351
|
+
}
|
|
352
|
+
if (typeof messageText !== "string" || messageText === "") {
|
|
353
|
+
throw Object.assign(new Error("messageText (the exact bounded sent text, including framing) is required before any mutation"), { code: "invalid-input" });
|
|
354
|
+
}
|
|
355
|
+
entryCounter += 1;
|
|
356
|
+
const entry = {
|
|
357
|
+
ledgerVersion: NUDGE_LEDGER_VERSION,
|
|
358
|
+
entryId: `nudge-${journeyId}-${journeyStep}-${attemptOrdinal}-${entryCounter}`,
|
|
359
|
+
journeyId, timestamp, action, role,
|
|
360
|
+
...(action === "replacement" ? { replacementRole } : {}),
|
|
361
|
+
...(taskId !== null ? { taskId } : {}),
|
|
362
|
+
...(claimId !== null ? { claimId } : {}),
|
|
363
|
+
...(envelopeId !== null ? { envelopeId } : {}),
|
|
364
|
+
...(branch !== undefined ? { branch } : {}),
|
|
365
|
+
journeyStep, attemptOrdinal,
|
|
366
|
+
repository,
|
|
367
|
+
dispatchAuthorization,
|
|
368
|
+
autonomy, containmentProfile, operationClasses,
|
|
369
|
+
preState,
|
|
370
|
+
messageText,
|
|
371
|
+
messageSha256: sha256Hex(messageText),
|
|
372
|
+
messageBytes: Buffer.byteLength(messageText, "utf8"),
|
|
373
|
+
...(reasonCode !== undefined ? { reasonCode } : {}),
|
|
374
|
+
result: "requested",
|
|
375
|
+
preGit,
|
|
376
|
+
...(parentEntryId !== undefined ? { parentEntryId } : {}),
|
|
377
|
+
};
|
|
378
|
+
const validation = validateLedgerEntry(entry);
|
|
379
|
+
if (!validation.ok) {
|
|
380
|
+
throw Object.assign(new Error(`requested ledger entry is invalid: ${validation.errors.join("; ")}`), { code: "invalid-ledger-entry" });
|
|
381
|
+
}
|
|
382
|
+
return entry;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function appendTerminalResult(ledger, entryId, terminal) {
|
|
386
|
+
if (!Array.isArray(ledger)) {
|
|
387
|
+
throw Object.assign(new Error("ledger must be an array"), { code: "invalid-input" });
|
|
388
|
+
}
|
|
389
|
+
const requested = ledger.find((entry) => entry.entryId === entryId);
|
|
390
|
+
if (!requested) {
|
|
391
|
+
throw Object.assign(
|
|
392
|
+
new Error(`no requested ledger entry ${entryId} — a nudge or replacement without a pre-entry is forbidden`),
|
|
393
|
+
{ code: "pre-entry-required" },
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
const terminalKeys = [
|
|
397
|
+
"result", "deliveryState", "herdrCode", "postGit", "spawnReceiptDigest", "reportMarkers",
|
|
398
|
+
];
|
|
399
|
+
if (!isPlainObject(terminal)) {
|
|
400
|
+
throw Object.assign(new Error("terminal must be an object"), { code: "invalid-input" });
|
|
401
|
+
}
|
|
402
|
+
const unknownTerminalKeys = Object.keys(terminal).filter((key) => !terminalKeys.includes(key));
|
|
403
|
+
if (unknownTerminalKeys.length > 0) {
|
|
404
|
+
throw Object.assign(
|
|
405
|
+
new Error(`terminal keys outside the allowlist: ${unknownTerminalKeys.join(", ")}`),
|
|
406
|
+
{ code: "invalid-input" },
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
if (!NUDGE_LEDGER_RESULTS.includes(terminal.result)) {
|
|
410
|
+
throw Object.assign(new Error(`terminal result must be one of ${NUDGE_LEDGER_RESULTS.join(", ")}`), { code: "invalid-input" });
|
|
411
|
+
}
|
|
412
|
+
if (terminal.result === "requested") {
|
|
413
|
+
throw Object.assign(new Error("terminal result cannot be requested"), { code: "invalid-input" });
|
|
414
|
+
}
|
|
415
|
+
const allowedTerminal = Object.fromEntries(
|
|
416
|
+
terminalKeys.filter((key) => terminal[key] !== undefined).map((key) => [key, terminal[key]]),
|
|
417
|
+
);
|
|
418
|
+
const merged = { ...requested, ...allowedTerminal, entryId, result: terminal.result };
|
|
419
|
+
const validation = validateLedgerEntry(merged);
|
|
420
|
+
if (!validation.ok) {
|
|
421
|
+
throw Object.assign(new Error(`terminal ledger entry is invalid: ${validation.errors.join("; ")}`), { code: "invalid-ledger-entry" });
|
|
422
|
+
}
|
|
423
|
+
return ledger.map((entry) => (entry.entryId === entryId ? merged : entry));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ---------------------------------------------------------------------------
|
|
427
|
+
// Receipt serialization (§6.1): the ledger serializes inside or as a bounded,
|
|
428
|
+
// digest-linked section of the existing journey receipt. Fail closed rather
|
|
429
|
+
// than silently truncating: if the serialized section exceeds maxBytes, the
|
|
430
|
+
// caller must fail the journey instead of dropping exact records.
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
|
|
433
|
+
export function serializeLedgerSection(ledger, { maxBytes } = {}) {
|
|
434
|
+
if (typeof maxBytes !== "number" || !Number.isSafeInteger(maxBytes) || maxBytes < 1) {
|
|
435
|
+
throw Object.assign(new Error("maxBytes is required and must be a positive safe integer (fail closed)"), { code: "invalid-input" });
|
|
436
|
+
}
|
|
437
|
+
const body = [
|
|
438
|
+
"[RECOVERY_LEDGER_BEGIN]",
|
|
439
|
+
`entries: ${ledger.length}`,
|
|
440
|
+
...ledger.map((entry) => `entry ${entry.entryId}: ${JSON.stringify(entry)}`),
|
|
441
|
+
"[RECOVERY_LEDGER_END]",
|
|
442
|
+
].join("\n");
|
|
443
|
+
const bytes = Buffer.byteLength(body, "utf8");
|
|
444
|
+
if (bytes > maxBytes) {
|
|
445
|
+
// Fail closed: no silent truncation of exact nudge/replacement records.
|
|
446
|
+
const error = new Error(`recovery ledger section is ${bytes} bytes, exceeding the ${maxBytes}-byte receipt bound — the journey fails closed rather than silently dropping exact ledger records (§6.1)`);
|
|
447
|
+
error.code = "ledger-receipt-capacity-exceeded";
|
|
448
|
+
error.sectionBytes = bytes;
|
|
449
|
+
error.maxBytes = maxBytes;
|
|
450
|
+
throw error;
|
|
451
|
+
}
|
|
452
|
+
return { body, bytes, digest: sha256Hex(body) };
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
// Revert-anchor selection (§6.3). The ledger does not perform rollback; it
|
|
457
|
+
// gives the owner a precise boundary. Selection is pure: given the ledger
|
|
458
|
+
// and the current repository observation, select the last accepted ledger
|
|
459
|
+
// entry and report exactly which comparisons hold, so the owner can create
|
|
460
|
+
// a safety branch and restore to the recorded boundary. Several workers can
|
|
461
|
+
// touch one worktree, so the ledger never claims causality from timestamps
|
|
462
|
+
// alone — `atBoundary` is evidence, not a verdict.
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
export function selectRevertAnchor(ledger, {
|
|
466
|
+
repository = null, branch = null, head = null, statusDigest = null, diffDigest = null,
|
|
467
|
+
} = {}) {
|
|
468
|
+
// "Accepted" entries: terminal, non-failed outcomes. requested-only
|
|
469
|
+
// entries (interrupted actions) and failed/parked outcomes are never
|
|
470
|
+
// anchors — the last accepted state is the recovery boundary.
|
|
471
|
+
const accepted = ledger.filter((entry) =>
|
|
472
|
+
entry.result !== "requested"
|
|
473
|
+
&& entry.result !== "failed"
|
|
474
|
+
&& entry.result !== "parked");
|
|
475
|
+
if (accepted.length === 0) {
|
|
476
|
+
return { ok: false, code: "no-accepted-entry", reason: "no accepted ledger entry exists to anchor a revert" };
|
|
477
|
+
}
|
|
478
|
+
const anchor = accepted[accepted.length - 1];
|
|
479
|
+
const comparisons = {
|
|
480
|
+
repositoryMatch: repository === null ? null : anchor.repository === repository,
|
|
481
|
+
branchMatch: branch === null || anchor.branch === undefined ? null : anchor.branch === branch,
|
|
482
|
+
headMatch: head === null ? null : anchor.preGit.head === head || anchor.postGit?.head === head,
|
|
483
|
+
statusDigestMatch: statusDigest === null ? null : anchor.preGit.statusDigest === statusDigest || anchor.postGit?.statusDigest === statusDigest,
|
|
484
|
+
diffDigestMatch: diffDigest === null ? null : anchor.preGit.diffDigest === diffDigest || anchor.postGit?.diffDigest === diffDigest,
|
|
485
|
+
};
|
|
486
|
+
const observed = Object.values(comparisons).every((value) => value !== false);
|
|
487
|
+
return {
|
|
488
|
+
ok: true,
|
|
489
|
+
anchor: {
|
|
490
|
+
entryId: anchor.entryId,
|
|
491
|
+
action: anchor.action,
|
|
492
|
+
role: anchor.role,
|
|
493
|
+
timestamp: anchor.timestamp,
|
|
494
|
+
preGit: anchor.preGit,
|
|
495
|
+
postGit: anchor.postGit ?? null,
|
|
496
|
+
taskId: anchor.taskId ?? null,
|
|
497
|
+
claimId: anchor.claimId ?? null,
|
|
498
|
+
envelopeId: anchor.envelopeId ?? null,
|
|
499
|
+
},
|
|
500
|
+
comparisons,
|
|
501
|
+
// True only when every supplied current observation matches the anchor's
|
|
502
|
+
// recorded boundary. Automatic revert requires branch/worktree isolation
|
|
503
|
+
// and commit attribution beyond this evidence (§6.3).
|
|
504
|
+
atBoundary: observed,
|
|
505
|
+
procedure: [
|
|
506
|
+
"select the last accepted ledger entry",
|
|
507
|
+
"verify repository/branch and compare current HEAD, status digest, and diff digest with the entry's pre/post Git observations",
|
|
508
|
+
"inspect git log and git diff across the selected entry and later entries to attribute commits and uncommitted paths",
|
|
509
|
+
"create a safety branch or snapshot",
|
|
510
|
+
"revert later commits in reverse order, or restore selected paths/patches to the recorded boundary",
|
|
511
|
+
"run review/tests and record the human-approved outcome separately",
|
|
512
|
+
],
|
|
513
|
+
};
|
|
514
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
3
|
+
|
|
4
|
+
// NUDGE §5 recovery ladder — per-reason nudge state machine (pure). Journey
|
|
5
|
+
// state is a map keyed by the closed stuck-reason enum:
|
|
6
|
+
//
|
|
7
|
+
// reasonState[reasonCode] = {
|
|
8
|
+
// nudgeCount, lastNudgedAt, escalated,
|
|
9
|
+
// firstObservedSnapshot, lastObservedSnapshot, lastMessageSha256
|
|
10
|
+
// }
|
|
11
|
+
//
|
|
12
|
+
// Transitions (§5, §8):
|
|
13
|
+
// - first observation of a reason ⇒ nudge (create reason state)
|
|
14
|
+
// - re-observation of the same reason ⇒ escalated (never an identical
|
|
15
|
+
// resend — message-hash
|
|
16
|
+
// non-repetition enforcement)
|
|
17
|
+
// - a different reason ⇒ fresh problem, fresh first nudge
|
|
18
|
+
// - same-reason replacement loop guard ⇒ replacement N+1 reaching the
|
|
19
|
+
// same reasonCode as replacement N
|
|
20
|
+
// parks as systemic failure
|
|
21
|
+
//
|
|
22
|
+
// The bound is the closed reason taxonomy × finite ladder depth, not an
|
|
23
|
+
// arbitrary journey count: nudgeCount is audit evidence, never a cap. No
|
|
24
|
+
// I/O; the ladder consumes an injected nudge-transport interface — the N1
|
|
25
|
+
// fixed-argv transport arrives with the upstream herdr capability.
|
|
26
|
+
|
|
27
|
+
import { NUDGE_STUCK_REASONS } from "./nudge_classifier_pi.js";
|
|
28
|
+
import { sha256Hex } from "./nudge_ledger_pi.js";
|
|
29
|
+
|
|
30
|
+
export const NUDGE_REASON_STATES_KEY = "reasonState";
|
|
31
|
+
|
|
32
|
+
// Record one observation of a stuck reason and decide the ladder step.
|
|
33
|
+
// Parameters:
|
|
34
|
+
// reasonState — the journey's current reasonState map (not mutated;
|
|
35
|
+
// a new map is returned)
|
|
36
|
+
// reasonCode — one of NUDGE_STUCK_REASONS
|
|
37
|
+
// snapshot — the current progress-snapshot evidence (opaque object;
|
|
38
|
+
// stored as firstObservedSnapshot/lastObservedSnapshot)
|
|
39
|
+
// messageText — the exact candidate nudge text for this action
|
|
40
|
+
// now — ISO timestamp of this observation
|
|
41
|
+
// lastAction — what the ladder last did for this journey:
|
|
42
|
+
// null | { kind: "nudge", reasonCode, entryId } |
|
|
43
|
+
// { kind: "replacement", reasonCode, entryId }
|
|
44
|
+
//
|
|
45
|
+
// Returns { decision, reasonState, errors }:
|
|
46
|
+
// decision.nudge — a first or varied nudge must be issued
|
|
47
|
+
// decision.escalate — the reason re-observed; SAFE extraction may
|
|
48
|
+
// proceed to replacement
|
|
49
|
+
// decision.parkSystemic — same-reason replacement loop guard tripped
|
|
50
|
+
// decision.error — message-hash repetition (never send identical
|
|
51
|
+
// bytes); the caller must vary the message
|
|
52
|
+
export function observeReason({
|
|
53
|
+
reasonState = {},
|
|
54
|
+
reasonCode,
|
|
55
|
+
snapshot = null,
|
|
56
|
+
messageText,
|
|
57
|
+
now,
|
|
58
|
+
lastAction = null,
|
|
59
|
+
} = {}) {
|
|
60
|
+
if (!NUDGE_STUCK_REASONS.includes(reasonCode)) {
|
|
61
|
+
const error = `reasonCode must be one of ${NUDGE_STUCK_REASONS.join(", ")}`;
|
|
62
|
+
return { decision: { error }, reasonState, errors: [error] };
|
|
63
|
+
}
|
|
64
|
+
if (typeof messageText !== "string" || messageText === "") {
|
|
65
|
+
return { decision: { error: "messageText is required" }, reasonState, errors: ["messageText is required"] };
|
|
66
|
+
}
|
|
67
|
+
if (typeof now !== "string" || now === "") {
|
|
68
|
+
return { decision: { error: "now is required" }, reasonState, errors: ["now is required"] };
|
|
69
|
+
}
|
|
70
|
+
const messageSha256 = sha256Hex(messageText);
|
|
71
|
+
const existing = reasonState[reasonCode] ?? null;
|
|
72
|
+
|
|
73
|
+
// Same-reason replacement loop guard (§5.3): replacement N+1 reaching the
|
|
74
|
+
// same reasonCode as replacement N is systemic failure — park rather than
|
|
75
|
+
// spawn another replacement.
|
|
76
|
+
if (lastAction?.kind === "replacement" && lastAction.reasonCode === reasonCode) {
|
|
77
|
+
return {
|
|
78
|
+
decision: {
|
|
79
|
+
parkSystemic: true,
|
|
80
|
+
reason: `replacement N+1 reached the same reasonCode (${reasonCode}) as replacement N — systemic failure; park and notify (§5.3)`,
|
|
81
|
+
},
|
|
82
|
+
reasonState,
|
|
83
|
+
errors: [],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (existing === null) {
|
|
88
|
+
// First observation of this reason: create reason state and nudge.
|
|
89
|
+
const next = {
|
|
90
|
+
...reasonState,
|
|
91
|
+
[reasonCode]: {
|
|
92
|
+
nudgeCount: 1,
|
|
93
|
+
lastNudgedAt: now,
|
|
94
|
+
escalated: false,
|
|
95
|
+
firstObservedSnapshot: snapshot,
|
|
96
|
+
lastObservedSnapshot: snapshot,
|
|
97
|
+
lastMessageSha256: messageSha256,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
return { decision: { nudge: true, firstForReason: true }, reasonState: next, errors: [] };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Re-observation of a known reason: mark escalated, never resend identical
|
|
104
|
+
// bytes (§3.1: an unknown attempt is never resent identically; §5.2: every
|
|
105
|
+
// message must differ in exact bytes and hash from all prior messages for
|
|
106
|
+
// that reason).
|
|
107
|
+
if (existing.lastMessageSha256 === messageSha256) {
|
|
108
|
+
return {
|
|
109
|
+
decision: {
|
|
110
|
+
error: "message-hash repetition: this exact message was already sent for this reason — vary the content (never an identical resend)",
|
|
111
|
+
},
|
|
112
|
+
reasonState,
|
|
113
|
+
errors: [],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
const next = {
|
|
117
|
+
...reasonState,
|
|
118
|
+
[reasonCode]: {
|
|
119
|
+
...existing,
|
|
120
|
+
nudgeCount: existing.nudgeCount + 1,
|
|
121
|
+
lastNudgedAt: now,
|
|
122
|
+
escalated: true,
|
|
123
|
+
lastObservedSnapshot: snapshot,
|
|
124
|
+
lastMessageSha256: messageSha256,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
return { decision: { nudge: true, escalated: true, firstForReason: false }, reasonState: next, errors: [] };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// The state machine does not cap nudges: nudgeCount is audit evidence, not a
|
|
131
|
+
// cap (OPEN-6 RESOLVED-BY-OWNER). This helper exists so tests and the ladder
|
|
132
|
+
// can assert the absence of any numeric cap logic.
|
|
133
|
+
export function nudgeCountIsACap() {
|
|
134
|
+
return false;
|
|
135
|
+
}
|