knosky 0.6.3 → 0.7.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +149 -93
  2. package/CREDITS.md +14 -14
  3. package/LICENSE.md +76 -76
  4. package/LIMITATIONS.md +33 -23
  5. package/PRIVACY.md +30 -30
  6. package/README.md +170 -117
  7. package/SECURITY.md +78 -46
  8. package/action/post-comment.mjs +94 -89
  9. package/action.yml +62 -62
  10. package/bin/knosky.mjs +279 -105
  11. package/core/CONTRACT.md +70 -70
  12. package/core/append-only-checkpoint.mjs +215 -0
  13. package/core/audit-writer.mjs +317 -0
  14. package/core/benchmark-results.mjs +225 -225
  15. package/core/bundle.mjs +178 -178
  16. package/core/churn.mjs +23 -23
  17. package/core/ci.mjs +268 -268
  18. package/core/comparison.mjs +189 -189
  19. package/core/config.mjs +189 -189
  20. package/core/constants.mjs +13 -13
  21. package/core/contract.mjs +123 -123
  22. package/core/cross-repo.mjs +111 -111
  23. package/core/decision-codes.mjs +92 -0
  24. package/core/destination.mjs +161 -161
  25. package/core/district-classification.mjs +111 -0
  26. package/core/doctor-scorecard.mjs +369 -0
  27. package/core/domain-store.mjs +347 -0
  28. package/core/edges.mjs +43 -43
  29. package/core/escalate.mjs +68 -68
  30. package/core/freshness.mjs +198 -194
  31. package/core/fs-indexer.mjs +218 -218
  32. package/core/key-store.mjs +348 -348
  33. package/core/layout.mjs +46 -46
  34. package/core/ledger.mjs +176 -141
  35. package/core/local-ipc-identity.mjs +500 -0
  36. package/core/lod.mjs +155 -155
  37. package/core/mode-b.mjs +410 -0
  38. package/core/multi-model-benchmark.mjs +405 -405
  39. package/core/net-lockdown.mjs +421 -0
  40. package/core/onboarding.mjs +223 -223
  41. package/core/operator-auth.mjs +317 -0
  42. package/core/overlays.mjs +45 -45
  43. package/core/policy-lattice.mjs +142 -0
  44. package/core/pr-comment.mjs +198 -198
  45. package/core/protocol-spec.mjs +460 -460
  46. package/core/provenance.mjs +320 -0
  47. package/core/retrieve.mjs +63 -63
  48. package/core/route.mjs +304 -304
  49. package/core/schema.mjs +275 -275
  50. package/core/signing-tiers.mjs +1265 -0
  51. package/core/swarm-bench.mjs +106 -0
  52. package/core/swarm-coordinator.mjs +867 -0
  53. package/core/trust-root-rekey.mjs +410 -0
  54. package/mcp/server.mjs +264 -108
  55. package/package.json +56 -46
  56. package/renderer/art/kenney/buildingTiles_sheet.xml +130 -130
  57. package/renderer/art/kenney/cityDetails_sheet.xml +12 -12
  58. package/renderer/art/kenney/landscapeTiles_sheet.xml +129 -129
  59. package/renderer/art/kenney/sheet_allCars.xml +545 -545
  60. package/renderer/build-rich.mjs +43 -43
  61. package/renderer/city.template.html +808 -808
  62. package/ssot/decision-codes.json +133 -0
  63. package/ssot/ladder-l0-l3.md +232 -0
  64. package/ssot/tool-menu.json +130 -0
package/core/CONTRACT.md CHANGED
@@ -1,70 +1,70 @@
1
- # Knowledge City — Data Contract v2 (the shared spine)
2
-
3
- > Phase 1a deliverable (SAT-385 → build). The one schema both faces (City + MCP) and every source
4
- > (fs/github/board) depend on. Generalizes the fixed-4-district v1 → **N categories**. Pointers +
5
- > projections ONLY — never file bodies (D-146). Council fixes baked in: a serialization **allowlist** + a
6
- > best-effort **denylist scrub**. Proven to represent the current live city losslessly.
7
-
8
- ## Envelope
9
- ```jsonc
10
- {
11
- "schema_version": "2.0",
12
- "generated_at": "<ISO8601>",
13
- "source": { "kind": "fs|github|board|legacy", "ref": "<path/repo>", "rev": "<commit/rev>" },
14
- "categories": [ { "id": "technical", "label": "Technical", "color": "#4f8cff", "order": 0 } ],
15
- "node_count": 150,
16
- "nodes": [ /* Node[] */ ]
17
- }
18
- ```
19
- `categories[]` is the **N-category manifest** that replaces the hardcoded 4 districts — the renderer derives
20
- its districts/colors from this, so the board scales to however many categories a source has.
21
-
22
- ## Node
23
- ```jsonc
24
- {
25
- "id": "decision:D-146", // stable, source-derived
26
- "kind": "decision|spec|file|dir|doc|...",
27
- "title": "Index, not copy", // projection
28
- "summary": "<= 200 chars", // projection (excerpt, never full body)
29
- "category": "governance", // category id (was "district")
30
- "status": "locked", // optional
31
- "fact_date": "2026-06-19", // optional
32
- "tags": [], // optional projection
33
- "headings": [], // optional projection (for nav/search)
34
- "links": ["decision:D-147"], // edges to other node ids
35
- "provenance": { "store": "...", "ref": "...", "source_rev": "...", "fetched_at": "..." },
36
- "visibility": "internal|public",
37
- "sensitive": false // scrub/flag marker
38
- }
39
- ```
40
- Required: `id, kind, title, category, links, provenance`. Provenance required: `store, ref`.
41
-
42
- ## Serialization allowlist (council fix)
43
- Only these node fields may **ever** be written into the index:
44
- `id, kind, title, summary, category, status, fact_date, tags, headings, links, provenance, visibility, sensitive`.
45
- The serializer drops anything else and the validator **fails** on any non-allowlisted field — this is the
46
- structural guard against a file `body` ever leaking into the index.
47
-
48
- ## PII scrub (best-effort — NOT a security boundary)
49
- Applied to every emitted text projection (title/summary/headings/tags): redact emails, `key|secret|token|password|bearer` assignments, AWS access-key IDs, private-key headers → `[REDACTED]`. The indexer also skips
50
- `.git`, `node_modules`, `secrets/`, `keys/`, `.env*`, `dist/` by default, **plus** the repo's `.gitignore`
51
- and a user `.kcignore`. Honest framing (per council): this reduces accidental leakage; it is not a guarantee,
52
- and the local index is readable by the user's own assistant. First-run note will say so.
53
-
54
- ## Projection limits
55
- `summary` ≤ 200 chars. No field carries a full file body. Full content is reached only by dereferencing
56
- `provenance` to the live source (which is what keeps the city fresh — "a map, not a copy").
57
-
58
- ## Proof (this Phase-1a increment)
59
- Adapting the current live city (`golden/city-data.golden.json`) through `adaptLegacy()` → contract v2:
60
- - nodes **150 → 150** (no loss), edges **769 → 769** (no loss)
61
- - categories derived: `product#0, technical#1, governance#2, design#3`
62
- - `validateCity()` → **VALID**; scrub positive+negative tests **pass**
63
- - reference instance written to `golden/city-data.v2.json`
64
-
65
- Implementation: `core/contract.mjs` (schema + allowlist + scrub + validator + legacy adapter).
66
- Reproduce: `node core/validate-golden.mjs`.
67
-
68
- ## Open at G1 (founder call)
69
- None on the schema itself (it's proven). Carry-forward decision (Phase 3, not blocking): the **activation
70
- layer** — council unanimously recommends deferring it to the monetization tier so v1 stays fully offline.
1
+ # Knowledge City — Data Contract v2 (the shared spine)
2
+
3
+ > Phase 1a deliverable (SAT-385 → build). The one schema both faces (City + MCP) and every source
4
+ > (fs/github/board) depend on. Generalizes the fixed-4-district v1 → **N categories**. Pointers +
5
+ > projections ONLY — never file bodies (D-146). Council fixes baked in: a serialization **allowlist** + a
6
+ > best-effort **denylist scrub**. Proven to represent the current live city losslessly.
7
+
8
+ ## Envelope
9
+ ```jsonc
10
+ {
11
+ "schema_version": "2.0",
12
+ "generated_at": "<ISO8601>",
13
+ "source": { "kind": "fs|github|board|legacy", "ref": "<path/repo>", "rev": "<commit/rev>" },
14
+ "categories": [ { "id": "technical", "label": "Technical", "color": "#4f8cff", "order": 0 } ],
15
+ "node_count": 150,
16
+ "nodes": [ /* Node[] */ ]
17
+ }
18
+ ```
19
+ `categories[]` is the **N-category manifest** that replaces the hardcoded 4 districts — the renderer derives
20
+ its districts/colors from this, so the board scales to however many categories a source has.
21
+
22
+ ## Node
23
+ ```jsonc
24
+ {
25
+ "id": "decision:D-146", // stable, source-derived
26
+ "kind": "decision|spec|file|dir|doc|...",
27
+ "title": "Index, not copy", // projection
28
+ "summary": "<= 200 chars", // projection (excerpt, never full body)
29
+ "category": "governance", // category id (was "district")
30
+ "status": "locked", // optional
31
+ "fact_date": "2026-06-19", // optional
32
+ "tags": [], // optional projection
33
+ "headings": [], // optional projection (for nav/search)
34
+ "links": ["decision:D-147"], // edges to other node ids
35
+ "provenance": { "store": "...", "ref": "...", "source_rev": "...", "fetched_at": "..." },
36
+ "visibility": "internal|public",
37
+ "sensitive": false // scrub/flag marker
38
+ }
39
+ ```
40
+ Required: `id, kind, title, category, links, provenance`. Provenance required: `store, ref`.
41
+
42
+ ## Serialization allowlist (council fix)
43
+ Only these node fields may **ever** be written into the index:
44
+ `id, kind, title, summary, category, status, fact_date, tags, headings, links, provenance, visibility, sensitive`.
45
+ The serializer drops anything else and the validator **fails** on any non-allowlisted field — this is the
46
+ structural guard against a file `body` ever leaking into the index.
47
+
48
+ ## PII scrub (best-effort — NOT a security boundary)
49
+ Applied to every emitted text projection (title/summary/headings/tags): redact emails, `key|secret|token|password|bearer` assignments, AWS access-key IDs, private-key headers → `[REDACTED]`. The indexer also skips
50
+ `.git`, `node_modules`, `secrets/`, `keys/`, `.env*`, `dist/` by default, **plus** the repo's `.gitignore`
51
+ and a user `.kcignore`. Honest framing (per council): this reduces accidental leakage; it is not a guarantee,
52
+ and the local index is readable by the user's own assistant. First-run note will say so.
53
+
54
+ ## Projection limits
55
+ `summary` ≤ 200 chars. No field carries a full file body. Full content is reached only by dereferencing
56
+ `provenance` to the live source (which is what keeps the city fresh — "a map, not a copy").
57
+
58
+ ## Proof (this Phase-1a increment)
59
+ Adapting the current live city (`golden/city-data.golden.json`) through `adaptLegacy()` → contract v2:
60
+ - nodes **150 → 150** (no loss), edges **769 → 769** (no loss)
61
+ - categories derived: `product#0, technical#1, governance#2, design#3`
62
+ - `validateCity()` → **VALID**; scrub positive+negative tests **pass**
63
+ - reference instance written to `golden/city-data.v2.json`
64
+
65
+ Implementation: `core/contract.mjs` (schema + allowlist + scrub + validator + legacy adapter).
66
+ Reproduce: `node core/validate-golden.mjs`.
67
+
68
+ ## Open at G1 (founder call)
69
+ None on the schema itself (it's proven). Carry-forward decision (Phase 3, not blocking): the **activation
70
+ layer** — council unanimously recommends deferring it to the monetization tier so v1 stays fully offline.
@@ -0,0 +1,215 @@
1
+ // KnoSky F0.2 — Append-only checkpoint (SAT-546).
2
+ //
3
+ // Writes every ledger event to a secondary JSONL checkpoint file that the
4
+ // evaluator process can only append to — it cannot delete or overwrite earlier
5
+ // records. This gives a second source of truth that a privileged local user
6
+ // would need to destroy to tamper with the record.
7
+ //
8
+ // OS-level append-only protection (best-effort, silently skipped when the
9
+ // operation is not available or the caller lacks privilege):
10
+ //
11
+ // Linux : chattr +a (requires CAP_LINUX_IMMUTABLE; typically needs root
12
+ // or sudo, but conferred when the daemon is started with elevated
13
+ // rights). Only sets the attribute — never reads back to verify,
14
+ // so the evaluator itself never needs elevated rights after the
15
+ // initial chattr call.
16
+ // macOS : chflags uappnd (user-owned append-only flag; can be set by the
17
+ // file's owner without root).
18
+ //
19
+ // The module NEVER attempts to *remove* the attribute — that would require
20
+ // the same elevated rights and would defeat the purpose.
21
+ //
22
+ // The evaluator calls `appendCheckpointEntry(path, entry)` on every ledger
23
+ // write via the optional `checkpointPath` parameter added to `checkAndAdvance`
24
+ // (core/ledger.mjs) in SAT-561. It uses
25
+ // `O_WRONLY | O_APPEND | O_CREAT` (no truncation flag) so every open is an
26
+ // append even without the OS attribute. The OS attribute provides the
27
+ // additional guarantee that a privileged process using O_WRONLY without
28
+ // O_APPEND (or `unlink`) is also blocked.
29
+ //
30
+ // SEPARATION OF CONCERNS (ticket round-3 clarification; PR #62 review, round
31
+ // 5: hardened from a directory-level convention into a PACKAGE-BOUNDARY
32
+ // fact -- the export daemon is not just a different folder, it is a
33
+ // completely separate package/repo this repo has zero dependency on):
34
+ // - This module is the ONLY checkpoint surface the evaluator touches.
35
+ // - Any export to a remote destination is handled by the separate
36
+ // knosky-export-daemon package (github.com/SathiaAI/knosky-export-daemon)
37
+ // -- an unsandboxed, opt-in daemon an organization must deliberately
38
+ // install; this repo never imports, requires, or ships any part of it,
39
+ // and gains no network capability in any configuration regardless of
40
+ // whether that package is installed elsewhere.
41
+ //
42
+ // Pure Node stdlib, ESM — no third-party dependencies.
43
+
44
+ import {
45
+ openSync, appendFileSync, closeSync,
46
+ mkdirSync, existsSync,
47
+ } from 'node:fs';
48
+ import { dirname, isAbsolute } from 'node:path';
49
+ import { spawnSync } from 'node:child_process';
50
+ import { platform } from 'node:os';
51
+
52
+ // Platform string, cached at module load.
53
+ const PLATFORM = platform();
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // setAppendOnlyAttribute — best-effort OS attribute
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * Attempt to set the OS append-only attribute on `filePath`.
61
+ *
62
+ * Returns a structured result so callers can log the outcome without the
63
+ * evaluator needing to catch exceptions. The attribute is advisory — failure
64
+ * does NOT prevent writes via `appendCheckpointEntry`; the O_APPEND open flag
65
+ * already ensures append semantics at the process level.
66
+ *
67
+ * @param {string} filePath Absolute path to the checkpoint file.
68
+ * @returns {{ ok: boolean, mechanism: string|null, skipped: boolean, error: string|null }}
69
+ */
70
+ export function setAppendOnlyAttribute(filePath) {
71
+ // PR #62 round-7 fix: enforce the documented contract (filePath must be an
72
+ // absolute path) before this reaches a privileged child process at all --
73
+ // rejects relative paths, empty strings, and non-string input up front.
74
+ if (typeof filePath !== 'string' || filePath.length === 0 || !isAbsolute(filePath)) {
75
+ return {
76
+ ok: false,
77
+ mechanism: null,
78
+ skipped: true,
79
+ error: 'invalid filePath: must be a non-empty absolute path',
80
+ };
81
+ }
82
+
83
+ // PR #62 round-7 fix: distinguish signal-based termination (process was
84
+ // killed) from a plain non-zero exit, so operators can tell them apart
85
+ // instead of a single generic error message.
86
+ const describeFailure = (r, cmdLabel) => {
87
+ if (r.error) return String(r.error.message || r.error);
88
+ if (r.signal !== null) return `${cmdLabel} was terminated by signal ${r.signal}`;
89
+ return `${cmdLabel} exited with status ${r.status}`;
90
+ };
91
+
92
+ if (PLATFORM === 'linux') {
93
+ // chattr +a — requires CAP_LINUX_IMMUTABLE; best-effort
94
+ const r = spawnSync('chattr', ['+a', filePath], {
95
+ stdio: 'ignore',
96
+ timeout: 5000,
97
+ });
98
+ if (r.error || r.status !== 0 || r.signal !== null) {
99
+ return {
100
+ ok: false,
101
+ mechanism: 'chattr+a',
102
+ skipped: false,
103
+ error: describeFailure(r, 'chattr'),
104
+ };
105
+ }
106
+ return { ok: true, mechanism: 'chattr+a', skipped: false, error: null };
107
+ }
108
+
109
+ if (PLATFORM === 'darwin') {
110
+ // chflags uappnd — settable by the file owner, no root needed
111
+ const r = spawnSync('chflags', ['uappnd', filePath], {
112
+ stdio: 'ignore',
113
+ timeout: 5000,
114
+ });
115
+ if (r.error || r.status !== 0 || r.signal !== null) {
116
+ return {
117
+ ok: false,
118
+ mechanism: 'chflags-uappnd',
119
+ skipped: false,
120
+ error: describeFailure(r, 'chflags'),
121
+ };
122
+ }
123
+ return { ok: true, mechanism: 'chflags-uappnd', skipped: false, error: null };
124
+ }
125
+
126
+ // Unsupported platform — skip silently.
127
+ return { ok: false, mechanism: null, skipped: true, error: null };
128
+ }
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // openCheckpoint — create & protect the checkpoint file on first use
132
+ // ---------------------------------------------------------------------------
133
+
134
+ /**
135
+ * Ensure the checkpoint file at `checkpointPath` exists, applying the
136
+ * OS append-only attribute immediately after creation.
137
+ *
138
+ * Call this once at process startup (before calling `appendCheckpointEntry`).
139
+ * Safe to call on a file that already exists — the OS attribute is only
140
+ * (re-)applied on first creation, since chattr+a/chflags-uappnd persist on
141
+ * the inode and do not need re-setting on every idempotent re-open.
142
+ *
143
+ * @param {string} checkpointPath Path to the JSONL checkpoint file.
144
+ * @returns {{ created: boolean, attributeResult: object }}
145
+ * `created` is true if this call created the file (false if it pre-existed).
146
+ * `attributeResult` is the result of {@link setAppendOnlyAttribute}.
147
+ */
148
+ export function openCheckpoint(checkpointPath) {
149
+ const dir = dirname(checkpointPath);
150
+ mkdirSync(dir, { recursive: true });
151
+
152
+ const created = !existsSync(checkpointPath);
153
+ if (created) {
154
+ // O_CREAT | O_WRONLY — create the empty file without truncating anything.
155
+ const fd = openSync(checkpointPath, 'a');
156
+ closeSync(fd);
157
+ }
158
+
159
+ // PR #62 round-7 fix: only set the OS attribute on first creation. Both
160
+ // chattr +a and chflags uappnd are persistent flags on the inode -- they
161
+ // do not need re-applying on every idempotent re-open, and doing so spawned
162
+ // an unnecessary child process on every call (flagged as a resource-use
163
+ // concern for a function that may be called repeatedly during a process's
164
+ // lifetime).
165
+ const attributeResult = created
166
+ ? setAppendOnlyAttribute(checkpointPath)
167
+ : { ok: true, mechanism: null, skipped: true, error: null };
168
+ return { created, attributeResult };
169
+ }
170
+
171
+ // ---------------------------------------------------------------------------
172
+ // appendCheckpointEntry — the hot-path writer
173
+ // ---------------------------------------------------------------------------
174
+
175
+ /**
176
+ * Append one checkpoint record to the JSONL file.
177
+ *
178
+ * Each record is written as a single JSON line followed by `\n`. The open
179
+ * mode is always `O_WRONLY | O_APPEND | O_CREAT` (via Node's `'a'` flag) so
180
+ * the process cannot overwrite or truncate the file even if the OS attribute
181
+ * was not successfully applied.
182
+ *
183
+ * The function is intentionally synchronous — checkpoint writes must be
184
+ * durable before the evaluator signals success to the caller.
185
+ *
186
+ * @param {string} checkpointPath Path to the JSONL checkpoint file.
187
+ * @param {object} entry Record to append. Must be JSON-serialisable.
188
+ * Conventional fields:
189
+ * `seq` — ledger sequence number (non-negative integer).
190
+ * `event` — event type string (e.g. `'ledger_state'`, `'policy_eval'`).
191
+ * `ts` — ISO-8601 timestamp string (caller-supplied; not auto-added).
192
+ * `payload_hash` — SHA-256 hex of the event payload (for F2 chain linkage).
193
+ * @throws {TypeError} When `entry` cannot be serialised to JSON.
194
+ * @throws {Error} On filesystem I/O errors.
195
+ */
196
+ export function appendCheckpointEntry(checkpointPath, entry) {
197
+ const line = JSON.stringify(entry) + '\n';
198
+ // 'a' flag = O_WRONLY | O_APPEND | O_CREAT — never truncates.
199
+ appendFileSync(checkpointPath, line, { encoding: 'utf8', flag: 'a' });
200
+ }
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // Export-daemon isolation sentinel
204
+ // ---------------------------------------------------------------------------
205
+
206
+ /**
207
+ * Symbolic constant confirming that this module (the evaluator-side checkpoint
208
+ * writer) is intentionally separate from the export daemon.
209
+ *
210
+ * Any code path that imports both this module AND `core/export-daemon.mjs`
211
+ * within the evaluator process is a design violation — the evaluator must
212
+ * never gain network capability. Tests import this constant to assert the
213
+ * architectural separation.
214
+ */
215
+ export const EVALUATOR_NO_NETWORK_SENTINEL = 'f02_evaluator_local_only';
@@ -0,0 +1,317 @@
1
+ // KnoSky Mode B audit receipt writer (DEC-106).
2
+ // Metadata-only hash-chained local ledger. No file bodies. No network.
3
+ // Best-effort exclusive seq.lock reduces concurrent multi-agent TOCTOU on ledger_seq.
4
+
5
+ import {
6
+ appendFileSync,
7
+ existsSync,
8
+ mkdirSync,
9
+ readFileSync,
10
+ writeFileSync,
11
+ renameSync,
12
+ openSync,
13
+ closeSync,
14
+ unlinkSync,
15
+ } from 'node:fs';
16
+ import { createHash, randomUUID } from 'node:crypto';
17
+ import { dirname, join } from 'node:path';
18
+ import { readHwm } from './ledger.mjs';
19
+
20
+ /**
21
+ * @param {string} domainRoot
22
+ */
23
+ export function auditPaths(domainRoot) {
24
+ return {
25
+ root: domainRoot,
26
+ eventsPath: join(domainRoot, 'audit', 'events.ndjson'),
27
+ hwmPath: join(domainRoot, 'audit', 'hwm.json'),
28
+ checkpointPath: join(domainRoot, 'audit', 'checkpoint.ndjson'),
29
+ seqLockPath: join(domainRoot, 'audit', 'seq.lock'),
30
+ };
31
+ }
32
+
33
+ function hashLine(prevHash, bodyObj) {
34
+ const h = createHash('sha256');
35
+ h.update(prevHash || '');
36
+ h.update('\n');
37
+ h.update(JSON.stringify(bodyObj));
38
+ return h.digest('hex');
39
+ }
40
+
41
+ function lastHash(eventsPath) {
42
+ if (!existsSync(eventsPath)) return null;
43
+ const text = readFileSync(eventsPath, 'utf8').trim();
44
+ if (!text) return null;
45
+ const lines = text.split(/\r?\n/);
46
+ for (let i = lines.length - 1; i >= 0; i--) {
47
+ if (!lines[i].trim()) continue;
48
+ try {
49
+ const row = JSON.parse(lines[i]);
50
+ return row.event_hash || null;
51
+ } catch {
52
+ /* keep scanning */
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+
58
+ function writeHwmSoft(hwmPath, seq) {
59
+ if (!Number.isInteger(seq) || seq < 0) {
60
+ throw new TypeError(`ledger_hwm must be a non-negative integer, got: ${JSON.stringify(seq)}`);
61
+ }
62
+ const dir = dirname(hwmPath);
63
+ mkdirSync(dir, { recursive: true });
64
+ const tmp = hwmPath + '.tmp';
65
+ writeFileSync(tmp, JSON.stringify({ ledger_hwm: seq }) + '\n', 'utf8');
66
+ renameSync(tmp, hwmPath);
67
+ }
68
+
69
+ function checkAndAdvanceSoft(seq, hwmPath) {
70
+ if (!Number.isInteger(seq) || seq < 0) {
71
+ return { ok: false, error: `bad seq ${seq}` };
72
+ }
73
+ let hwm = 0;
74
+ try {
75
+ hwm = readHwm(hwmPath);
76
+ } catch (err) {
77
+ return { ok: false, error: err.message || String(err) };
78
+ }
79
+ // Must strictly advance past HWM (assigned under lock after append prep).
80
+ if (seq <= hwm) {
81
+ return {
82
+ ok: false,
83
+ error: `ledger sequence ${seq} must be > high-water mark ${hwm}`,
84
+ };
85
+ }
86
+ try {
87
+ writeHwmSoft(hwmPath, seq);
88
+ } catch (err) {
89
+ return { ok: false, error: err.message || String(err) };
90
+ }
91
+ return { ok: true, seq, hwm };
92
+ }
93
+
94
+ /**
95
+ * Exclusive lock via O_EXCL create of seq.lock.
96
+ * @returns {{ ok:true, release:()=>void } | { ok:false, reason:string }}
97
+ */
98
+ function acquireSeqLock(lockPath, { tries = 50, delayMs = 8 } = {}) {
99
+ mkdirSync(dirname(lockPath), { recursive: true });
100
+ for (let i = 0; i < tries; i++) {
101
+ try {
102
+ const fd = openSync(lockPath, 'wx');
103
+ return {
104
+ ok: true,
105
+ release() {
106
+ try {
107
+ closeSync(fd);
108
+ } catch {
109
+ /* ignore */
110
+ }
111
+ try {
112
+ unlinkSync(lockPath);
113
+ } catch {
114
+ /* ignore */
115
+ }
116
+ },
117
+ };
118
+ } catch (err) {
119
+ if (err && (err.code === 'EEXIST' || err.code === 'EPERM')) {
120
+ const end = Date.now() + delayMs;
121
+ while (Date.now() < end) {
122
+ /* brief backoff */
123
+ }
124
+ continue;
125
+ }
126
+ return { ok: false, reason: err && err.message ? err.message : String(err) };
127
+ }
128
+ }
129
+ return { ok: false, reason: 'seq_lock_timeout' };
130
+ }
131
+
132
+ function sanitizeMeta(meta) {
133
+ if (!meta || typeof meta !== 'object') return {};
134
+ const out = {};
135
+ for (const [k, v] of Object.entries(meta)) {
136
+ if (typeof k !== 'string' || k.length > 80) continue;
137
+ if (v == null) continue;
138
+ if (typeof v === 'string') out[k] = v.slice(0, 200);
139
+ else if (typeof v === 'number' || typeof v === 'boolean') out[k] = v;
140
+ else if (Array.isArray(v)) out[k] = v.slice(0, 20).map((x) => String(x).slice(0, 80));
141
+ }
142
+ return out;
143
+ }
144
+
145
+ /**
146
+ * Append a route/swarm decision event and return a receipt.
147
+ * Fail-closed on I/O / HWM / lock errors.
148
+ *
149
+ * @param {object} opts
150
+ * @returns {{ ok:true, receipt_id:string, ledger_seq:number, event_hash:string }
151
+ * |{ ok:false, reason:string }}
152
+ */
153
+ export function writeDecisionReceipt(opts = {}) {
154
+ let lock = null;
155
+ try {
156
+ const {
157
+ domainRoot,
158
+ decision_code,
159
+ agent_id = null,
160
+ principal_id = null,
161
+ tool = null,
162
+ destination = null,
163
+ mode = 'B',
164
+ meta = {},
165
+ } = opts;
166
+ if (!domainRoot || typeof domainRoot !== 'string') {
167
+ return { ok: false, reason: 'missing_domain_root' };
168
+ }
169
+ if (!decision_code || typeof decision_code !== 'string') {
170
+ return { ok: false, reason: 'missing_decision_code' };
171
+ }
172
+
173
+ const paths = auditPaths(domainRoot);
174
+ mkdirSync(dirname(paths.eventsPath), { recursive: true });
175
+
176
+ lock = acquireSeqLock(paths.seqLockPath);
177
+ if (!lock.ok) {
178
+ return { ok: false, reason: `seq_lock: ${lock.reason}` };
179
+ }
180
+
181
+ // Under lock: seq = max(lineCount, hwm) + 1, then append, then HWM.
182
+ const prev = lastHash(paths.eventsPath);
183
+ let lineCount = 0;
184
+ if (existsSync(paths.eventsPath)) {
185
+ const t = readFileSync(paths.eventsPath, 'utf8');
186
+ lineCount = t.split(/\r?\n/).filter(Boolean).length;
187
+ }
188
+ let hwm = 0;
189
+ try {
190
+ hwm = readHwm(paths.hwmPath);
191
+ } catch (err) {
192
+ return { ok: false, reason: `hwm_read: ${err.message || err}` };
193
+ }
194
+ const seq = Math.max(lineCount, hwm) + 1;
195
+
196
+ const receipt_id = `rcpt_${randomUUID().replace(/-/g, '').slice(0, 24)}`;
197
+ const body = {
198
+ v: 1,
199
+ receipt_id,
200
+ ledger_seq: seq,
201
+ ts: new Date().toISOString(),
202
+ decision_code,
203
+ mode,
204
+ agent_id,
205
+ principal_id,
206
+ tool,
207
+ destination: typeof destination === 'string' ? destination.slice(0, 400) : null,
208
+ meta: sanitizeMeta(meta),
209
+ prev_hash: prev,
210
+ };
211
+ const event_hash = hashLine(prev, body);
212
+ body.event_hash = event_hash;
213
+
214
+ // Append first so crash after HWM can't "orphan" a seq with Vorgang no event
215
+ // (if crash after append before HWM, next max(line,hwm)+1 still advances cleanly)
216
+ appendFileSync(paths.eventsPath, JSON.stringify(body) + '\n', 'utf8');
217
+
218
+ const adv = checkAndAdvanceSoft(seq, paths.hwmPath);
219
+ if (!adv.ok) {
220
+ return {
221
+ ok: false,
222
+ reason: `hwm_or_checkpoint: ${adv.error || 'refused'}`,
223
+ };
224
+ }
225
+
226
+ return { ok: true, receipt_id, ledger_seq: seq, event_hash };
227
+ } catch (err) {
228
+ return {
229
+ ok: false,
230
+ reason: err && err.message ? err.message : String(err),
231
+ };
232
+ } finally {
233
+ if (lock && lock.ok && typeof lock.release === 'function') {
234
+ lock.release();
235
+ }
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Verify hash chain + contiguous ledger_seq.
241
+ * @param {string} domainRoot
242
+ */
243
+ export function verifyAuditChain(domainRoot) {
244
+ const { eventsPath } = auditPaths(domainRoot);
245
+ if (!existsSync(eventsPath)) return { ok: true, count: 0 };
246
+ const lines = readFileSync(eventsPath, 'utf8').split(/\r?\n/).filter(Boolean);
247
+ let prev = null;
248
+ let prevSeq = 0;
249
+ for (let i = 0; i < lines.length; i++) {
250
+ let row;
251
+ try {
252
+ row = JSON.parse(lines[i]);
253
+ } catch {
254
+ return { ok: false, at: i, reason: 'invalid_json' };
255
+ }
256
+ const { event_hash, ...rest } = row;
257
+ const body = { ...rest };
258
+ const recomputed = hashLine(body.prev_hash || null, body);
259
+ if (recomputed !== event_hash) {
260
+ return { ok: false, at: i, reason: 'hash_mismatch', receipt_id: row.receipt_id };
261
+ }
262
+ if (i > 0 && body.prev_hash !== prev) {
263
+ return { ok: false, at: i, reason: 'prev_hash_break', receipt_id: row.receipt_id };
264
+ }
265
+ if (Number.isInteger(row.ledger_seq)) {
266
+ if (i === 0) {
267
+ if (row.ledger_seq < 1) {
268
+ return { ok: false, at: i, reason: 'seq_invalid', receipt_id: row.receipt_id };
269
+ }
270
+ } else if (row.ledger_seq !== prevSeq + 1) {
271
+ return {
272
+ ok: false,
273
+ at: i,
274
+ reason: 'seq_not_contiguous',
275
+ receipt_id: row.receipt_id,
276
+ expected: prevSeq + 1,
277
+ got: row.ledger_seq,
278
+ };
279
+ }
280
+ prevSeq = row.ledger_seq;
281
+ }
282
+ prev = event_hash;
283
+ }
284
+ return { ok: true, count: lines.length };
285
+ }
286
+
287
+ /**
288
+ * @param {string} domainRoot
289
+ * @param {{ limit?: number, agent_id?: string }} [opts]
290
+ */
291
+ export function queryAudit(domainRoot, opts = {}) {
292
+ const { eventsPath } = auditPaths(domainRoot);
293
+ if (!existsSync(eventsPath)) return [];
294
+ const limit = Math.min(Math.max(1, opts.limit || 20), 200);
295
+ const lines = readFileSync(eventsPath, 'utf8').split(/\r?\n/).filter(Boolean);
296
+ const rows = [];
297
+ for (let i = lines.length - 1; i >= 0 && rows.length < limit; i--) {
298
+ try {
299
+ const row = JSON.parse(lines[i]);
300
+ if (opts.agent_id && row.agent_id !== opts.agent_id) continue;
301
+ rows.push({
302
+ receipt_id: row.receipt_id,
303
+ ledger_seq: row.ledger_seq,
304
+ ts: row.ts,
305
+ decision_code: row.decision_code,
306
+ mode: row.mode,
307
+ agent_id: row.agent_id,
308
+ tool: row.tool,
309
+ destination: row.destination,
310
+ event_hash: row.event_hash,
311
+ });
312
+ } catch {
313
+ /* skip */
314
+ }
315
+ }
316
+ return rows;
317
+ }