asylumwarden 0.0.0 → 0.1.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/README.md +10 -5
- package/adapters/opencode/index.js +282 -0
- package/bin/asylumwarden.mjs +5 -0
- package/package.json +14 -1
- package/schemas/config.schema.json +93 -0
- package/schemas/ledger.schema.json +21 -0
- package/schemas/manifest.schema.json +49 -0
- package/schemas/receipt.schema.json +102 -0
- package/schemas/registry.schema.json +43 -0
- package/src/cli/main.mjs +342 -0
- package/src/config/bootstrap.mjs +36 -0
- package/src/config/load.mjs +531 -0
- package/src/config/schemas.mjs +58 -0
- package/src/context.mjs +50 -0
- package/src/index.mjs +12 -0
- package/src/kernel/canon.mjs +59 -0
- package/src/kernel/errors.mjs +80 -0
- package/src/kernel/fsutil.mjs +147 -0
- package/src/kernel/glob.mjs +70 -0
- package/src/kernel/lock.mjs +104 -0
- package/src/kernel/schemalite.mjs +153 -0
- package/src/kernel/state.mjs +56 -0
- package/src/lease/lease.mjs +551 -0
- package/src/ledger/anchor.mjs +395 -0
- package/src/ledger/chain.mjs +468 -0
- package/src/manifest/manifest.mjs +199 -0
- package/src/manifest/plan.mjs +142 -0
- package/src/receipt/receipt.mjs +555 -0
- package/src/views/views.mjs +148 -0
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
// Append-only hash-chained ledger — CONTRACT §5. Never rewrites or truncates
|
|
2
|
+
// the ledger file. Appends are serialized through locks/ledger.lock (§5.3).
|
|
3
|
+
// Fail-closed throughout: a malformed line, broken chain, or unreadable
|
|
4
|
+
// corruption is reported, never papered over.
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import os from "node:os";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
|
|
10
|
+
import { fail, violation } from "../kernel/errors.mjs";
|
|
11
|
+
import { GENESIS_PREV, sha256Of } from "../kernel/canon.mjs";
|
|
12
|
+
import {
|
|
13
|
+
CAP_CONFIG,
|
|
14
|
+
CAP_LINE,
|
|
15
|
+
ensureDir,
|
|
16
|
+
fileExists,
|
|
17
|
+
nowIso,
|
|
18
|
+
pidAlive,
|
|
19
|
+
readJsonCapped,
|
|
20
|
+
} from "../kernel/fsutil.mjs";
|
|
21
|
+
import { validateSchema } from "../kernel/schemalite.mjs";
|
|
22
|
+
import { HEX64_RE } from "../kernel/state.mjs";
|
|
23
|
+
|
|
24
|
+
export const LEDGER_TYPES = Object.freeze([
|
|
25
|
+
"genesis",
|
|
26
|
+
"plan",
|
|
27
|
+
"claim",
|
|
28
|
+
"renew",
|
|
29
|
+
"release",
|
|
30
|
+
"requeue",
|
|
31
|
+
"steal",
|
|
32
|
+
"done",
|
|
33
|
+
"fail",
|
|
34
|
+
"anchor",
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
// Schema is read directly (kernel fsutil), NOT via src/config — the ledger
|
|
38
|
+
// layer must not depend on the config module (DESIGN-INTERNALS layering).
|
|
39
|
+
const SCHEMA_PATH = fileURLToPath(
|
|
40
|
+
new URL("../../schemas/ledger.schema.json", import.meta.url),
|
|
41
|
+
);
|
|
42
|
+
// readJsonCapped deep-null-prototypes its result, which strips Array.prototype
|
|
43
|
+
// off schema arrays (`required`, `enum`) and breaks for...of/.some inside
|
|
44
|
+
// schemalite. Restore prototypes on the trusted build-time schema only; parsed
|
|
45
|
+
// ledger ENTRIES stay null-prototype (untrusted data, CONTRACT §1).
|
|
46
|
+
function plainify(node) {
|
|
47
|
+
if (node === null || typeof node !== "object") return node;
|
|
48
|
+
Object.setPrototypeOf(node, Array.isArray(node) ? Array.prototype : Object.prototype);
|
|
49
|
+
for (const key of Object.getOwnPropertyNames(node)) node[key] = plainify(node[key]);
|
|
50
|
+
return node;
|
|
51
|
+
}
|
|
52
|
+
let schemaPromise = null;
|
|
53
|
+
function ledgerSchema() {
|
|
54
|
+
schemaPromise ??= readJsonCapped(SCHEMA_PATH, CAP_CONFIG, "INTERNAL").then(
|
|
55
|
+
({ value }) => plainify(value),
|
|
56
|
+
);
|
|
57
|
+
return schemaPromise;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const FORBIDDEN_KEYS = new Set(["__proto__", "prototype", "constructor"]);
|
|
61
|
+
|
|
62
|
+
// Null-prototype parse with forbidden-key rejection (CONTRACT §1).
|
|
63
|
+
// Arrays keep Array.prototype: null-proto buys nothing on arrays (a JSON
|
|
64
|
+
// array cannot carry a "__proto__" key) and canon/for..of need .map/.length
|
|
65
|
+
// semantics — same invariant as kernel deepNullProto after the e2e fix.
|
|
66
|
+
function parseEntryLine(text) {
|
|
67
|
+
return JSON.parse(text, (key, val) => {
|
|
68
|
+
if (FORBIDDEN_KEYS.has(key)) throw new Error(`forbidden key "${key}"`);
|
|
69
|
+
if (val !== null && typeof val === "object" && !Array.isArray(val)) {
|
|
70
|
+
Object.setPrototypeOf(val, null);
|
|
71
|
+
}
|
|
72
|
+
return val;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Entry hash per CONTRACT §5.1: sha256(canon(entry without `hash`)).
|
|
77
|
+
// canon sorts keys, so field insertion order is irrelevant.
|
|
78
|
+
function recomputeHash(entry) {
|
|
79
|
+
const rec = { ...entry };
|
|
80
|
+
const recorded = rec.hash;
|
|
81
|
+
delete rec.hash;
|
|
82
|
+
return { recorded, recomputed: sha256Of(rec) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Walk every line: parse, schema-validate, check seq/prev/hash and the
|
|
86
|
+
// genesis rule. Returns the internal record {entries, head, torn, chain_ok}.
|
|
87
|
+
//
|
|
88
|
+
// Torn semantics (§5.3, resolved by stricter reading):
|
|
89
|
+
// - last line unparseable (partial tail write) → torn reported; the prefix
|
|
90
|
+
// chain is still verifiable, so chain_ok reflects the prefix. Any append
|
|
91
|
+
// refuses on torn regardless (LEDGER-TORN, exit 2).
|
|
92
|
+
// - last line parses but fails schema/seq/prev/hash, or the file lacks a
|
|
93
|
+
// trailing newline → torn reported AND chain_ok false (indistinguishable
|
|
94
|
+
// from tamper; fail closed).
|
|
95
|
+
// - mid-file unparseable line → torn reported AND chain_ok false.
|
|
96
|
+
async function walkLedger(sl) {
|
|
97
|
+
const result = { entries: [], head: null, torn: null, chain_ok: true };
|
|
98
|
+
let text;
|
|
99
|
+
try {
|
|
100
|
+
text = await fs.readFile(sl.ledger, "utf8");
|
|
101
|
+
} catch (err) {
|
|
102
|
+
if (err?.code === "ENOENT") return result; // absent ledger: empty walk
|
|
103
|
+
fail("LEDGER-BROKEN", `cannot read ledger: ${err.message}`, [
|
|
104
|
+
violation(sl.ledger, "read-error", err.message),
|
|
105
|
+
]);
|
|
106
|
+
}
|
|
107
|
+
if (text === "") return result;
|
|
108
|
+
|
|
109
|
+
const rawLines = text.split("\n");
|
|
110
|
+
let noTrailingNewline = false;
|
|
111
|
+
if (rawLines.length > 0 && rawLines[rawLines.length - 1] === "") {
|
|
112
|
+
rawLines.pop(); // single trailing newline is the writer's terminator
|
|
113
|
+
} else {
|
|
114
|
+
noTrailingNewline = true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const schema = await ledgerSchema();
|
|
118
|
+
let prevHash = GENESIS_PREV;
|
|
119
|
+
for (let i = 0; i < rawLines.length; i += 1) {
|
|
120
|
+
const n = i + 1;
|
|
121
|
+
const line = rawLines[i];
|
|
122
|
+
const isLast = i === rawLines.length - 1;
|
|
123
|
+
|
|
124
|
+
if (Buffer.byteLength(line, "utf8") > CAP_LINE) {
|
|
125
|
+
result.torn = { line: n, detail: "line exceeds 256 KiB cap" };
|
|
126
|
+
result.chain_ok = false;
|
|
127
|
+
break;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let entry;
|
|
131
|
+
try {
|
|
132
|
+
entry = parseEntryLine(line);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
result.torn = { line: n, detail: `unparseable line: ${err.message}` };
|
|
135
|
+
if (!isLast) result.chain_ok = false;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let breakDetail = null;
|
|
140
|
+
const violations = validateSchema(schema, entry, `line ${n}`);
|
|
141
|
+
if (violations.length > 0) {
|
|
142
|
+
breakDetail = `schema violation: ${violations[0].field} ${violations[0].rule}: ${violations[0].detail}`;
|
|
143
|
+
}
|
|
144
|
+
if (breakDetail === null && entry.seq !== i + 1) {
|
|
145
|
+
breakDetail = `seq ${String(entry.seq)} != expected ${String(i + 1)}`;
|
|
146
|
+
}
|
|
147
|
+
if (breakDetail === null && entry.prev !== prevHash) {
|
|
148
|
+
breakDetail = `prev mismatch at seq ${String(entry.seq)}`;
|
|
149
|
+
}
|
|
150
|
+
if (breakDetail === null) {
|
|
151
|
+
const { recorded, recomputed } = recomputeHash(entry);
|
|
152
|
+
if (recorded !== recomputed) {
|
|
153
|
+
breakDetail = `hash does not chain at seq ${String(entry.seq)}`;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
if (breakDetail === null && i === 0 && entry.type !== "genesis") {
|
|
157
|
+
breakDetail = "first entry is not genesis";
|
|
158
|
+
}
|
|
159
|
+
// (genesis prev == GENESIS_PREV is covered by the prev check above:
|
|
160
|
+
// prevHash starts at GENESIS_PREV for i === 0.)
|
|
161
|
+
|
|
162
|
+
if (breakDetail !== null) {
|
|
163
|
+
result.chain_ok = false;
|
|
164
|
+
if (isLast) result.torn = { line: n, detail: breakDetail };
|
|
165
|
+
}
|
|
166
|
+
result.entries.push(entry);
|
|
167
|
+
result.head = typeof entry.hash === "string" ? entry.hash : result.head;
|
|
168
|
+
// Continue the walk from the RECORDED hash so one break does not cascade
|
|
169
|
+
// into false breaks on later lines; chain_ok is already false.
|
|
170
|
+
prevHash = typeof entry.hash === "string" ? entry.hash : GENESIS_PREV;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (noTrailingNewline && result.torn === null && rawLines.length > 0) {
|
|
174
|
+
// Our writer always terminates lines with \n; a complete-looking final
|
|
175
|
+
// line without the terminator means an interrupted write.
|
|
176
|
+
result.torn = { line: rawLines.length, detail: "missing trailing newline" };
|
|
177
|
+
result.chain_ok = false;
|
|
178
|
+
}
|
|
179
|
+
return result;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Open the ledger API over a state layout (DESIGN-INTERNALS §src/ledger).
|
|
184
|
+
* Optional overrides exist for tests (stale/busy windows, clock); defaults
|
|
185
|
+
* are the CONTRACT §5.3 values.
|
|
186
|
+
*
|
|
187
|
+
* @param {object} opts
|
|
188
|
+
* @param {object} opts.sl state.layout() result
|
|
189
|
+
* @param {object|null} [opts.config] project config (genesis fallback)
|
|
190
|
+
* @param {() => string} [opts.nowFn] ISO clock override (must be RFC3339 ms UTC)
|
|
191
|
+
* @param {number} [opts.staleAfterMs] 30_000 — dead-pid lock removable after
|
|
192
|
+
* @param {number} [opts.busyAfterMs] 60_000 — live-holder lock fails after
|
|
193
|
+
* @param {number} [opts.acquireDeadlineMs] 5_000 — contention wait cap, then LEDGER-BUSY
|
|
194
|
+
* @param {number} [opts.pollMs] 5 — lock retry poll interval
|
|
195
|
+
* @param {(msg:string)=>void|null} [opts.onWarning] stale-lock warning sink (CLI records as warning)
|
|
196
|
+
*/
|
|
197
|
+
export function openLedger({
|
|
198
|
+
sl,
|
|
199
|
+
config = null,
|
|
200
|
+
nowFn = nowIso,
|
|
201
|
+
staleAfterMs = 30_000,
|
|
202
|
+
busyAfterMs = 60_000,
|
|
203
|
+
acquireDeadlineMs = 5_000,
|
|
204
|
+
pollMs = 5,
|
|
205
|
+
onWarning = null,
|
|
206
|
+
} = {}) {
|
|
207
|
+
if (
|
|
208
|
+
!sl ||
|
|
209
|
+
typeof sl.ledger !== "string" ||
|
|
210
|
+
typeof sl.ledgerLock !== "string" ||
|
|
211
|
+
typeof sl.locks !== "string"
|
|
212
|
+
) {
|
|
213
|
+
fail("INTERNAL", "openLedger: sl must be a state.layout() object");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const now = () => nowFn();
|
|
217
|
+
const nowMs = () => {
|
|
218
|
+
const ms = Date.parse(nowFn());
|
|
219
|
+
if (!Number.isFinite(ms)) fail("INTERNAL", `nowFn returned non-parseable time: ${nowFn()}`);
|
|
220
|
+
return ms;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// ---- lock (CONTRACT §5.3) -------------------------------------------------
|
|
224
|
+
|
|
225
|
+
async function inspectLock() {
|
|
226
|
+
let text;
|
|
227
|
+
let mtimeMs;
|
|
228
|
+
try {
|
|
229
|
+
text = (await fs.readFile(sl.ledgerLock)).toString("utf8");
|
|
230
|
+
mtimeMs = (await fs.stat(sl.ledgerLock)).mtimeMs;
|
|
231
|
+
} catch (err) {
|
|
232
|
+
if (err?.code === "ENOENT") return { state: "gone" };
|
|
233
|
+
return { state: "contended", detail: `lock unreadable: ${err.message}` };
|
|
234
|
+
}
|
|
235
|
+
const trimmed = text.trim();
|
|
236
|
+
if (trimmed === "") {
|
|
237
|
+
// Body is written immediately after O_EXCL create; an empty body is
|
|
238
|
+
// either an in-progress acquire (grace window) or a holder that died
|
|
239
|
+
// between create and write (beyond the busy window → stale).
|
|
240
|
+
if (nowMs() - mtimeMs > busyAfterMs) {
|
|
241
|
+
return { state: "stale", detail: "lock body empty beyond busy window" };
|
|
242
|
+
}
|
|
243
|
+
return { state: "contended", detail: "lock body not yet written" };
|
|
244
|
+
}
|
|
245
|
+
let body = null;
|
|
246
|
+
try {
|
|
247
|
+
body = JSON.parse(trimmed);
|
|
248
|
+
} catch {
|
|
249
|
+
body = null;
|
|
250
|
+
}
|
|
251
|
+
if (
|
|
252
|
+
body === null ||
|
|
253
|
+
typeof body !== "object" ||
|
|
254
|
+
!Number.isInteger(body.pid) ||
|
|
255
|
+
typeof body.ts !== "string"
|
|
256
|
+
) {
|
|
257
|
+
// Unparseable non-empty body: never an in-progress create (that state
|
|
258
|
+
// is empty). Older than the busy window → stale; else fail closed.
|
|
259
|
+
const age = nowMs() - mtimeMs;
|
|
260
|
+
if (age > busyAfterMs) {
|
|
261
|
+
return { state: "stale", detail: "lock body unparseable beyond busy window" };
|
|
262
|
+
}
|
|
263
|
+
return { state: "busy", detail: "lock body unparseable" };
|
|
264
|
+
}
|
|
265
|
+
const tsMs = Date.parse(body.ts);
|
|
266
|
+
const age = Number.isFinite(tsMs)
|
|
267
|
+
? Math.max(0, nowMs() - tsMs)
|
|
268
|
+
: nowMs() - mtimeMs;
|
|
269
|
+
const host = typeof body.host === "string" ? body.host : os.hostname();
|
|
270
|
+
const alive = pidAlive(body.pid, host);
|
|
271
|
+
if (!alive && age > staleAfterMs) {
|
|
272
|
+
return { state: "stale", detail: `dead pid ${String(body.pid)}, age ${String(age)}ms` };
|
|
273
|
+
}
|
|
274
|
+
if (alive && age > busyAfterMs) {
|
|
275
|
+
return { state: "busy", detail: `live pid ${String(body.pid)}, age ${String(age)}ms` };
|
|
276
|
+
}
|
|
277
|
+
return { state: "contended", detail: `held by pid ${String(body.pid)}` };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function acquireLock() {
|
|
281
|
+
await ensureDir(sl.locks);
|
|
282
|
+
const deadline = nowMs() + acquireDeadlineMs;
|
|
283
|
+
let staleRetried = false;
|
|
284
|
+
for (;;) {
|
|
285
|
+
const body = JSON.stringify({ pid: process.pid, host: os.hostname(), ts: now() });
|
|
286
|
+
try {
|
|
287
|
+
const fh = await fs.open(sl.ledgerLock, "wx", 0o600);
|
|
288
|
+
try {
|
|
289
|
+
await fh.writeFile(`${body}\n`, "utf8");
|
|
290
|
+
await fh.sync();
|
|
291
|
+
} finally {
|
|
292
|
+
await fh.close();
|
|
293
|
+
}
|
|
294
|
+
return body;
|
|
295
|
+
} catch (err) {
|
|
296
|
+
if (err?.code !== "EEXIST") {
|
|
297
|
+
fail("INTERNAL", `cannot create ledger lock: ${err.message}`);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
const info = await inspectLock();
|
|
301
|
+
if (info.state === "stale") {
|
|
302
|
+
// §5.3: remove once, retry once. A second stale verdict means the
|
|
303
|
+
// rm/retry raced something pathological — fail closed, do not loop.
|
|
304
|
+
if (staleRetried) {
|
|
305
|
+
fail("LEDGER-BUSY", "stale ledger lock reappeared after retry", [
|
|
306
|
+
violation(sl.ledgerLock, "lock-stale-retry", info.detail ?? ""),
|
|
307
|
+
]);
|
|
308
|
+
}
|
|
309
|
+
staleRetried = true;
|
|
310
|
+
onWarning?.(`stale ledger lock removed (${info.detail})`);
|
|
311
|
+
await fs.rm(sl.ledgerLock, { force: true });
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (info.state === "busy") {
|
|
315
|
+
fail("LEDGER-BUSY", `ledger lock held: ${info.detail ?? "busy"}`, [
|
|
316
|
+
violation(sl.ledgerLock, "lock-busy", info.detail ?? ""),
|
|
317
|
+
]);
|
|
318
|
+
}
|
|
319
|
+
if (nowMs() >= deadline) {
|
|
320
|
+
fail("LEDGER-BUSY", "ledger lock acquisition deadline exceeded", [
|
|
321
|
+
violation(sl.ledgerLock, "lock-timeout", info.detail ?? ""),
|
|
322
|
+
]);
|
|
323
|
+
}
|
|
324
|
+
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Best-effort release: only remove the lock if it is still OUR body, so a
|
|
329
|
+
// zombie holder (its lock was stale-judged and replaced) cannot evict the
|
|
330
|
+
// current legitimate holder.
|
|
331
|
+
async function releaseLock(ours) {
|
|
332
|
+
try {
|
|
333
|
+
const cur = (await fs.readFile(sl.ledgerLock)).toString("utf8").trim();
|
|
334
|
+
if (cur === ours.trim()) await fs.rm(sl.ledgerLock, { force: true });
|
|
335
|
+
} catch {
|
|
336
|
+
// Lock already gone or unreadable — nothing of ours to release.
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ---- public API ------------------------------------------------------------
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Append one entry. Shape validation happens BEFORE any disk touch; the
|
|
344
|
+
* head is re-read under the lock (no cached head); a torn or broken chain
|
|
345
|
+
* refuses the append (LEDGER-TORN / LEDGER-BROKEN, exit 2).
|
|
346
|
+
*/
|
|
347
|
+
async function append(type, fields = {}) {
|
|
348
|
+
if (!LEDGER_TYPES.includes(type)) {
|
|
349
|
+
fail("INTERNAL", `unknown ledger entry type: ${String(type)}`);
|
|
350
|
+
}
|
|
351
|
+
const { wave = null, cell = null, actor = null, payload = {} } = fields;
|
|
352
|
+
if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
|
|
353
|
+
fail("INTERNAL", "ledger payload must be a plain object");
|
|
354
|
+
}
|
|
355
|
+
const ts = now();
|
|
356
|
+
const schema = await ledgerSchema();
|
|
357
|
+
const probe = { seq: 1, ts, type, wave, cell, actor, payload, prev: GENESIS_PREV, hash: GENESIS_PREV };
|
|
358
|
+
const violations = validateSchema(schema, probe, "entry");
|
|
359
|
+
if (violations.length > 0) {
|
|
360
|
+
fail(
|
|
361
|
+
"INTERNAL",
|
|
362
|
+
`malformed ledger entry shape: ${violations[0].field} ${violations[0].rule}: ${violations[0].detail}`,
|
|
363
|
+
violations,
|
|
364
|
+
);
|
|
365
|
+
}
|
|
366
|
+
// Size pre-check on the probe (same ts/payload/wave/cell/actor and
|
|
367
|
+
// 64-hex prev+hash as the real line; only seq width may differ by a few
|
|
368
|
+
// bytes) so an oversized entry never touches disk. The exact check on the
|
|
369
|
+
// final line below stays authoritative.
|
|
370
|
+
const preBytes = Buffer.byteLength(JSON.stringify(probe), "utf8");
|
|
371
|
+
if (preBytes > CAP_LINE) {
|
|
372
|
+
fail("SCHEMA-VIOLATION", `ledger line exceeds ${String(CAP_LINE)} byte cap`, [
|
|
373
|
+
violation("entry", "size-cap", `>=${String(preBytes)}`),
|
|
374
|
+
]);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const body = await acquireLock();
|
|
378
|
+
try {
|
|
379
|
+
const walk = await walkLedger(sl);
|
|
380
|
+
if (walk.torn !== null) {
|
|
381
|
+
fail("LEDGER-TORN", `refusing append after line ${String(walk.torn.line)}: ${walk.torn.detail}`, [
|
|
382
|
+
violation(`ledger line ${String(walk.torn.line)}`, "torn", walk.torn.detail),
|
|
383
|
+
]);
|
|
384
|
+
}
|
|
385
|
+
if (!walk.chain_ok) {
|
|
386
|
+
fail("LEDGER-BROKEN", "ledger chain invalid; refusing append");
|
|
387
|
+
}
|
|
388
|
+
const entry = {
|
|
389
|
+
seq: walk.entries.length + 1,
|
|
390
|
+
ts,
|
|
391
|
+
type,
|
|
392
|
+
wave,
|
|
393
|
+
cell,
|
|
394
|
+
actor,
|
|
395
|
+
payload,
|
|
396
|
+
prev: walk.head ?? GENESIS_PREV,
|
|
397
|
+
};
|
|
398
|
+
const hash = sha256Of(entry);
|
|
399
|
+
const full = { ...entry, hash };
|
|
400
|
+
const text = JSON.stringify(full);
|
|
401
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
402
|
+
if (bytes > CAP_LINE) {
|
|
403
|
+
fail("SCHEMA-VIOLATION", `ledger line exceeds ${String(CAP_LINE)} byte cap`, [
|
|
404
|
+
violation("entry", "size-cap", String(bytes)),
|
|
405
|
+
]);
|
|
406
|
+
}
|
|
407
|
+
const fh = await fs.open(sl.ledger, "a", 0o600);
|
|
408
|
+
try {
|
|
409
|
+
await fh.writeFile(`${text}\n`, "utf8");
|
|
410
|
+
await fh.sync();
|
|
411
|
+
} finally {
|
|
412
|
+
await fh.close();
|
|
413
|
+
}
|
|
414
|
+
return full;
|
|
415
|
+
} finally {
|
|
416
|
+
await releaseLock(body);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async function readAll() {
|
|
421
|
+
const walk = await walkLedger(sl);
|
|
422
|
+
return { entries: walk.entries, head: walk.head, torn: walk.torn };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
async function verify() {
|
|
426
|
+
const walk = await walkLedger(sl);
|
|
427
|
+
return {
|
|
428
|
+
chain_ok: walk.chain_ok,
|
|
429
|
+
entries: walk.entries,
|
|
430
|
+
head: walk.head,
|
|
431
|
+
torn: walk.torn,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Write the genesis entry when the ledger is absent (or a zero-entry file).
|
|
437
|
+
* Idempotent no-op when entries already exist. registry_sha256 prefers
|
|
438
|
+
* ctx.registrySha256 (config loader output) and falls back to the config's
|
|
439
|
+
* own `registry_sha256` key (CONTRACT §2.1); config_sha256 must come from
|
|
440
|
+
* ctx.configSha256 — the config file never carries its own hash.
|
|
441
|
+
*/
|
|
442
|
+
async function appendGenesisIfAbsent(ctx = {}) {
|
|
443
|
+
if (ctx.sl && ctx.sl.ledger !== sl.ledger) {
|
|
444
|
+
fail("INTERNAL", "appendGenesisIfAbsent: ctx.sl points at a different state home");
|
|
445
|
+
}
|
|
446
|
+
if (await fileExists(sl.ledger)) {
|
|
447
|
+
const walk = await walkLedger(sl);
|
|
448
|
+
if (walk.entries.length > 0) {
|
|
449
|
+
return { created: false, head: walk.head };
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
const cfg = ctx.config ?? config;
|
|
453
|
+
const registrySha = ctx.registrySha256 ?? cfg?.registry_sha256 ?? null;
|
|
454
|
+
const configSha = ctx.configSha256 ?? null;
|
|
455
|
+
if (typeof registrySha !== "string" || !HEX64_RE.test(registrySha)) {
|
|
456
|
+
fail("INTERNAL", "appendGenesisIfAbsent: registry sha256 missing or not 64hex (ctx.registrySha256 / config.registry_sha256)");
|
|
457
|
+
}
|
|
458
|
+
if (typeof configSha !== "string" || !HEX64_RE.test(configSha)) {
|
|
459
|
+
fail("INTERNAL", "appendGenesisIfAbsent: ctx.configSha256 missing or not 64hex");
|
|
460
|
+
}
|
|
461
|
+
const entry = await append("genesis", {
|
|
462
|
+
payload: { registry_sha256: registrySha, config_sha256: configSha },
|
|
463
|
+
});
|
|
464
|
+
return { created: true, entry };
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
return { append, readAll, verify, appendGenesisIfAbsent };
|
|
468
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Wave manifest validation — docs/CONTRACT.md §3; pinned signature:
|
|
2
|
+
// docs/DESIGN-INTERNALS.md §src/manifest/manifest.mjs.
|
|
3
|
+
// Layering: imports node stdlib + kernel + config only; shared state (config,
|
|
4
|
+
// registry, policy) flows in via arguments, never via lateral imports.
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
|
|
9
|
+
import { fail, violation } from "../kernel/errors.mjs";
|
|
10
|
+
import { matchGlob } from "../kernel/glob.mjs";
|
|
11
|
+
import { validateSchema } from "../kernel/schemalite.mjs";
|
|
12
|
+
import { containsPath } from "../config/load.mjs";
|
|
13
|
+
import { loadSchema } from "../config/schemas.mjs";
|
|
14
|
+
|
|
15
|
+
const CORE_KINDS = new Set(["gen", "verify", "doc", "build"]);
|
|
16
|
+
|
|
17
|
+
// Manifest paths must already be expanded (CONTRACT §1: schemas enforce ^/,
|
|
18
|
+
// so '~' never reaches here) and carry no '..' segments.
|
|
19
|
+
function normManifestPath(raw, field) {
|
|
20
|
+
if (typeof raw !== "string" || !raw.startsWith("/") || raw.split("/").includes("..")) {
|
|
21
|
+
fail("MANIFEST-INVALID", `${field}: must be an absolute, normalized, ~-free path: ${String(raw)}`, [
|
|
22
|
+
violation(field, "manifest-path", String(raw)),
|
|
23
|
+
]);
|
|
24
|
+
}
|
|
25
|
+
return path.resolve(raw);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Literal prefix of a glob pattern up to (exclusive) the first wildcard
|
|
29
|
+
// segment — the region the pattern conservatively protects.
|
|
30
|
+
function staticPrefix(pattern) {
|
|
31
|
+
const out = [];
|
|
32
|
+
for (const seg of pattern.split("/")) {
|
|
33
|
+
if (seg.includes("*") || seg.includes("?")) break;
|
|
34
|
+
out.push(seg);
|
|
35
|
+
}
|
|
36
|
+
return path.resolve(out.join("/") || "/");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function hasWildcard(pattern) {
|
|
40
|
+
return pattern.includes("*") || pattern.includes("?");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isStrictDescendant(ancestor, desc) {
|
|
44
|
+
return ancestor !== desc && containsPath(ancestor, desc);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Breadth analysis for one allowed root: a forbidden region strictly *inside*
|
|
48
|
+
// the root is a warning for relative surfaces (board-wide grants stay usable,
|
|
49
|
+
// CONTRACT §3) but a hard FORBIDDEN-ROOT for forbidden_absolute.
|
|
50
|
+
function collectBreadth(policy, registry, lineName, root, warnings, at) {
|
|
51
|
+
const l = policy.line(lineName);
|
|
52
|
+
const anchor = l.roots[0];
|
|
53
|
+
for (const rel of l.forbiddenRel) {
|
|
54
|
+
const absPattern = path.resolve(anchor, rel);
|
|
55
|
+
if (matchGlob(absPattern, root)) continue; // root equal/inside the surface: already hard-rejected
|
|
56
|
+
if (isStrictDescendant(root, staticPrefix(absPattern))) {
|
|
57
|
+
warnings.push(violation(at, "broad-root", `allowed root ${root} contains forbidden surface ${rel}`));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
for (const name of Object.getOwnPropertyNames(registry.lines)) {
|
|
61
|
+
for (const pattern of registry.lines[name].forbidden_absolute ?? []) {
|
|
62
|
+
if (matchGlob(pattern, root)) continue; // already hard-rejected by writableRootOf
|
|
63
|
+
const prefix = staticPrefix(pattern);
|
|
64
|
+
if (isStrictDescendant(root, prefix) || (prefix === root && hasWildcard(pattern))) {
|
|
65
|
+
fail("FORBIDDEN-ROOT", `allowed root ${root} encloses forbidden_absolute region ${pattern}`, [
|
|
66
|
+
violation(at, "forbidden-root", pattern),
|
|
67
|
+
]);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function assertAcyclic(cells) {
|
|
74
|
+
const deps = new Map(cells.map((c) => [c.id, c.deps]));
|
|
75
|
+
const mark = new Map(); // absent = unvisited, 1 = on stack, 2 = done
|
|
76
|
+
for (const origin of deps.keys()) {
|
|
77
|
+
if (mark.get(origin) === 2) continue;
|
|
78
|
+
const stack = [{ id: origin, next: 0 }];
|
|
79
|
+
const trail = [origin];
|
|
80
|
+
mark.set(origin, 1);
|
|
81
|
+
while (stack.length > 0) {
|
|
82
|
+
const frame = stack[stack.length - 1];
|
|
83
|
+
const list = deps.get(frame.id);
|
|
84
|
+
if (frame.next >= list.length) {
|
|
85
|
+
mark.set(frame.id, 2);
|
|
86
|
+
stack.pop();
|
|
87
|
+
trail.pop();
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const dep = list[frame.next];
|
|
91
|
+
frame.next += 1;
|
|
92
|
+
const m = mark.get(dep);
|
|
93
|
+
if (m === 1) {
|
|
94
|
+
fail("DEP-CYCLE", `dependency cycle: ${[...trail, dep].join(" -> ")}`, [
|
|
95
|
+
violation("deps", "dep-cycle", dep),
|
|
96
|
+
]);
|
|
97
|
+
}
|
|
98
|
+
if (m === undefined) {
|
|
99
|
+
mark.set(dep, 1);
|
|
100
|
+
stack.push({ id: dep, next: 0 });
|
|
101
|
+
trail.push(dep);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function validateManifest(raw, { config, registry, policy }) {
|
|
108
|
+
const schema = await loadSchema("manifest");
|
|
109
|
+
const violations = validateSchema(schema, raw, "manifest");
|
|
110
|
+
if (violations.length > 0) {
|
|
111
|
+
fail("MANIFEST-INVALID", "manifest schema violations", violations);
|
|
112
|
+
}
|
|
113
|
+
if (config?.mode === "shadow" && !raw.wave.startsWith("shadow-")) {
|
|
114
|
+
fail("SCHEMA-VIOLATION", `shadow wave id must start with "shadow-": ${raw.wave}`, [
|
|
115
|
+
violation("wave", "shadow-prefix", raw.wave),
|
|
116
|
+
]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const warnings = [];
|
|
120
|
+
const cells = [];
|
|
121
|
+
const ids = new Set();
|
|
122
|
+
const kindTable = config?.kinds ?? {};
|
|
123
|
+
|
|
124
|
+
for (let i = 0; i < raw.cells.length; i += 1) {
|
|
125
|
+
const cell = raw.cells[i];
|
|
126
|
+
const at = `cells[${i}]`;
|
|
127
|
+
if (ids.has(cell.id)) {
|
|
128
|
+
fail("MANIFEST-INVALID", `duplicate cell id: ${cell.id}`, [violation(`${at}.id`, "duplicate-cell-id", cell.id)]);
|
|
129
|
+
}
|
|
130
|
+
ids.add(cell.id);
|
|
131
|
+
if (!CORE_KINDS.has(cell.kind) && !Object.hasOwn(kindTable, cell.kind)) {
|
|
132
|
+
fail("UNKNOWN-KIND", `unknown kind "${cell.kind}" in ${at}`, [violation(`${at}.kind`, "unknown-kind", cell.kind)]);
|
|
133
|
+
}
|
|
134
|
+
if (!Object.hasOwn(registry.lines, cell.line)) {
|
|
135
|
+
fail("MANIFEST-INVALID", `unknown line "${cell.line}" in ${at}`, [violation(`${at}.line`, "unknown-line", cell.line)]);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const cwd = normManifestPath(cell.cwd, `${at}.cwd`);
|
|
139
|
+
const allowedRoots = cell.allowed_roots.map((r, j) => normManifestPath(r, `${at}.allowed_roots[${j}]`));
|
|
140
|
+
const readRoots = (cell.read_roots ?? []).map((r, j) => normManifestPath(r, `${at}.read_roots[${j}]`));
|
|
141
|
+
|
|
142
|
+
for (const [j, root] of allowedRoots.entries()) {
|
|
143
|
+
// Hard checks: inside the line's roots; not equal to or inside a
|
|
144
|
+
// forbidden surface / forbidden_absolute region.
|
|
145
|
+
policy.writableRootOf(cell.line, root);
|
|
146
|
+
collectBreadth(policy, registry, cell.line, root, warnings, `${at}.allowed_roots[${j}]`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let cwdStat = null;
|
|
150
|
+
try {
|
|
151
|
+
cwdStat = await fs.stat(cwd);
|
|
152
|
+
} catch {
|
|
153
|
+
cwdStat = null;
|
|
154
|
+
}
|
|
155
|
+
if (cwdStat === null || !cwdStat.isDirectory()) {
|
|
156
|
+
fail("PATH-ESCAPE", `cwd does not exist or is not a directory: ${cwd}`, [violation(`${at}.cwd`, "cwd-dir", cwd)]);
|
|
157
|
+
}
|
|
158
|
+
if (!allowedRoots.some((root) => containsPath(root, cwd))) {
|
|
159
|
+
fail("PATH-ESCAPE", `cwd ${cwd} is not inside any allowed_root`, [violation(`${at}.cwd`, "cwd-containment", cwd)]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const deps = [...cell.deps];
|
|
163
|
+
const seen = new Set();
|
|
164
|
+
for (const [j, dep] of deps.entries()) {
|
|
165
|
+
if (seen.has(dep)) {
|
|
166
|
+
fail("DEP-DUPLICATE", `duplicate dep "${dep}" in ${at}`, [violation(`${at}.deps[${j}]`, "dep-duplicate", dep)]);
|
|
167
|
+
}
|
|
168
|
+
seen.add(dep);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
cells.push({
|
|
172
|
+
id: cell.id,
|
|
173
|
+
kind: cell.kind,
|
|
174
|
+
line: cell.line,
|
|
175
|
+
cwd,
|
|
176
|
+
allowed_roots: allowedRoots,
|
|
177
|
+
read_roots: readRoots,
|
|
178
|
+
deps,
|
|
179
|
+
role: cell.role ?? null,
|
|
180
|
+
role_file: cell.role_file !== undefined && cell.role_file !== null ? normManifestPath(cell.role_file, `${at}.role_file`) : null,
|
|
181
|
+
session_started_at: cell.session_started_at ?? null,
|
|
182
|
+
brief: cell.brief ?? null,
|
|
183
|
+
acceptance: cell.acceptance ?? null,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
for (const [i, cell] of cells.entries()) {
|
|
188
|
+
for (const [j, dep] of cell.deps.entries()) {
|
|
189
|
+
if (!ids.has(dep)) {
|
|
190
|
+
fail("DEP-UNKNOWN", `dep "${dep}" of cell ${cell.id} is not in the wave`, [
|
|
191
|
+
violation(`cells[${i}].deps[${j}]`, "dep-unknown", dep),
|
|
192
|
+
]);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
assertAcyclic(cells);
|
|
197
|
+
|
|
198
|
+
return { manifest: { version: raw.version, wave: raw.wave, cells }, violations: [], warnings };
|
|
199
|
+
}
|