← Commit history

Publish 0.35.0

Drew Owens ·fee47a9538 · ·parent b318002
4 files changed +243−14
docs/BUILD-NOTES.md+58
@@ -3,6 +3,64 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-17 (0.35.0, the changelog is now tamper-evident — a hash chain catches out-of-band edits)++The HTTP door stops a stranger from POSTing to `/log`, but it cannot stop a trusted+local process — an agent, a stray script — from opening the `-changelog.jsonl` and+editing a line in place. You cannot cryptographically PREVENT a write to a file the+process owns. So this build does the next best thing: it makes an out-of-band write+**detectable**, loudly, everywhere the plan is read. (This is the approved "Option C".+The deferred "Option A" — a service token that routes agent writes back through the+gate — is NOT built here.)++**A hash chain over the append-only log, laid down without touching a single existing+line.** Some deployments already have thousands of events and none of them carry a+hash. Rewriting them to insert hashes would itself be the destructive act the feature+exists to catch, so genesis is **appended, never applied**. On the first startup+against a log that has history, the server appends one `chain.genesis` event:+`{ action:"chain.genesis", via:"chain-init", actor:"adom-gantt integrity", rev, ts,+digest, h }`. `digest` is `sha256` over the canonical concatenation (`lines.join("\n")`)+of every pre-genesis line exactly as it sits on disk — the whole prior history+fingerprinted as one baseline — and `h = sha256(digest)` is the chain head. Every event+appended after genesis carries `h = sha256(prevH + canon(event-without-h))`, where+`canon` is a stable JSON serialisation with keys sorted at every level and the event's+own top-level `h` removed.++**One chokepoint.** `appendLog()` is the single path every mutation already funnels+through — `POST /log`, compact, import, the startup uid mint, the sidecar upgrade — so+attaching `h` there means nothing appended by the server is ever unchained, and no new+code path had to learn about the chain. `ensureGenesis()` runs first inside it (and+once at startup via `ensureGenesisIfHistory()`), so the marker is always present before+a chained event lands. It is idempotent: it looks for the genesis marker and appends+nothing if one is already there, so a restart never double-appends. An empty log is+left alone at startup and gets its genesis lazily on the first real append (its+pre-genesis digest is `sha256("")`).++**Verify on read and at startup.** `verifyChain()` re-derives the pre-genesis digest+from the current lines (catching any edit at or before genesis), checks `h ===+sha256(digest)`, then walks each post-genesis event recomputing `sha256(prevH ++canon(e))` against the stored `h`. The first mismatch is the break. It surfaces three+ways: a loud `INTEGRITY FAILURE` server log naming the first bad `rev`; an `integrity`+object on `GET /state` (`{ok:false, brokenAtRev:N, reason:"..."}`, or `{ok:true}` clean);+and the same object on a new public `GET /health`, which keeps a strict no-leak posture+— counts, revs and booleans only, never event contents — so a health probe reachable+from anywhere still cannot read the plan. Verify is memoised on the log's size+mtime, so+the frequently-polled `/state` only recomputes when the file actually changes, and the+scary log fires once per change rather than once per request.++**The consequence, which is the point, not a bug.** Once this is live, a direct+out-of-band edit to the changelog — an agent rewriting a line without going through the+server — breaks the chain and shows `integrity.ok:false` with the offending rev. That is+exactly the intended nudge toward routing agent writes through the server (the deferred+Option A). Pre-genesis history — everything up to and including the tail at the moment+the server first runs this version — is captured whole in the genesis digest, so it will+**not** falsely flag; only writes made *after* genesis and *outside* the server's append+path light up. A fixture that gave rows numeric uids instead of the real zero-padded+strings taught the one gotcha worth recording: `isUid()` only accepts strings, so a+numeric `uid` looks unassigned and triggers a remint (and thus an extra, correctly+chained, `uid.assign` at startup) — fixtures must mirror the real `"00001"` shape and+carry `project.nextUid` to sit in the same steady state a live plan is in.+ ## Build notes, 2026-08-17 (0.34.0, milestones are phase-scoped, caps track the top, panes lock, the name text is its own hit target)  Four changes, all in the same scroll and geometry subsystem, shipped together.
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.34.0",+  "version": "0.35.0",   "description": "Interactive Gantt chart viewer for project timelines, with a table view over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.",   "main": "server.js",   "bin": {
page.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.34.0",+  "version": "0.35.0",   "description": "Interactive Gantt chart viewer for project timelines, with a table view over the same data. Data-driven, dark by default with a print-friendly light mode, and fully editable in the browser: drag bars to move or resize, multi-select and batch-shift dates, manage dependency arrows inline, reorder rows, filter by phase, and undo. Toggle to the table for every task and milestone as a row with owner, deadline and status, filtered per owner and edited in place. Every edit is recorded in an append-only changelog so an AI can read what a human changed and build on it. Imports JSON, CSV, MS Project XML and Jira CSV; exports JSON, CSV, SVG and a self-contained HTML snapshot.",   "main": "server.js",   "bin": {
server.js+183−12
@@ -319,18 +319,153 @@ function currentRev() {   return log.length ? log[log.length - 1].rev : 0; } +// ── Tamper-evidence: a hash chain over the append-only changelog ──+//+// The HTTP door stops a stranger POSTing to /log, but it cannot stop a trusted+// local process (an agent, a stray script) from opening the .jsonl and editing a+// line in place. We cannot cryptographically PREVENT a write to a file the process+// owns, so instead we make an out-of-band write DETECTABLE.+//+// One `chain.genesis` event is APPENDED (never a rewrite) the first time the server+// runs against a log that has history. It fingerprints all prior lines as one+// baseline `digest`, and its `h` is the chain head. Every event appended after it+// carries `h = sha256(prevH + canon(event-without-h))`. Recomputing from genesis+// catches any post-genesis line that was altered, inserted, or deleted, and the+// baseline digest catches any change to the pre-genesis history. See BUILD-NOTES.++function sha256hex(s) { return crypto.createHash('sha256').update(s, 'utf8').digest('hex'); }++// Stable JSON: keys sorted at every level, so canon(event) is byte-identical no+// matter what order the fields were written in. Arrays keep their order.+function stableStringify(v) {+  if (v === null || typeof v !== 'object') return JSON.stringify(v);+  if (Array.isArray(v)) return '[' + v.map(stableStringify).join(',') + ']';+  const keys = Object.keys(v).sort();+  return '{' + keys.map(k => JSON.stringify(k) + ':' + stableStringify(v[k])).join(',') + '}';+}+// The event minus its own `h`, canonically serialised. Only the top-level `h` is+// dropped: a payload is free to carry a nested field named "h".+function canonEvent(row) {+  const rest = {};+  for (const k of Object.keys(row)) if (k !== 'h') rest[k] = row[k];+  return stableStringify(rest);+}+function chainHashEvent(prevH, row) { return sha256hex((prevH || '') + canonEvent(row)); }++// The exact lines as they sit on disk. filter(Boolean) drops the trailing empty+// string from the final newline; every real line is non-empty JSON.+function rawLogLines() {+  if (!fs.existsSync(LOG_FILE)) return [];+  return fs.readFileSync(LOG_FILE, 'utf8').split('\n').filter(Boolean);+}+function isGenesis(e) { return e && e.action === 'chain.genesis' && e.via === 'chain-init'; }+function genesisIndex(parsed) { return parsed.findIndex(isGenesis); }+// The `h` of the last event that has one — the head to chain the next append onto.+function chainHeadH(parsed) {+  for (let i = parsed.length - 1; i >= 0; i--) if (parsed[i] && typeof parsed[i].h === 'string') return parsed[i].h;+  return null;+}++// Lay down the single genesis marker, once, if the chain has not been initialised.+// Non-destructive: it only ever APPENDS. Returns true if it wrote genesis.+function ensureGenesis() {+  const lines = rawLogLines();+  const parsed = lines.map(l => { try { return JSON.parse(l); } catch { return null; } });+  if (genesisIndex(parsed) >= 0) return false;         // chain already established+  const digest = sha256hex(lines.join('\n'));          // fingerprint of ALL prior lines+  const last = parsed.length ? parsed[parsed.length - 1] : null;+  const rev = (last && typeof last.rev === 'number' ? last.rev : parsed.length) + 1;+  const genesis = {+    rev, ts: new Date().toISOString(), actor: 'adom-gantt integrity',+    action: 'chain.genesis', via: 'chain-init', digest, h: sha256hex(digest),+  };+  fs.appendFileSync(LOG_FILE, JSON.stringify(genesis) + '\n', 'utf8');+  return true;+}+// At startup, only fingerprint a log that actually has history. An empty log has+// nothing to baseline yet; its genesis is laid down lazily by the first append.+function ensureGenesisIfHistory() {+  if (rawLogLines().length === 0) return false;+  return ensureGenesis();+}++// Walk the chain and report the first break. Cheap and total: any error is caught+// and surfaced as not-ok rather than thrown.+function verifyChain() {+  const lines = rawLogLines();+  if (lines.length === 0) return { ok: true };+  const parsed = lines.map(l => { try { return JSON.parse(l); } catch { return null; } });+  const gIdx = genesisIndex(parsed);+  // No genesis yet: the chain is not initialised, which is not the same as tampered.+  if (gIdx < 0) return { ok: true, chained: false };+  const g = parsed[gIdx];+  const digest = sha256hex(lines.slice(0, gIdx).join('\n'));+  if (digest !== g.digest) {+    return { ok: false, brokenAtRev: g.rev, reason: 'pre-genesis history was changed (baseline digest mismatch)' };+  }+  if (g.h !== sha256hex(g.digest)) {+    return { ok: false, brokenAtRev: g.rev, reason: 'genesis hash does not match its digest' };+  }+  let prevH = g.h;+  for (let i = gIdx + 1; i < parsed.length; i++) {+    const e = parsed[i];+    if (!e || typeof e !== 'object') {+      return { ok: false, brokenAtRev: (e && e.rev) || null, reason: 'unparseable event after genesis' };+    }+    if (typeof e.h !== 'string') {+      return { ok: false, brokenAtRev: e.rev, reason: 'event has no chain hash (appended outside the server)' };+    }+    if (e.h !== chainHashEvent(prevH, e)) {+      return { ok: false, brokenAtRev: e.rev, reason: 'event hash mismatch (altered, inserted or reordered outside the server)' };+    }+    prevH = e.h;+  }+  return { ok: true, chained: true };+}++// Verify is O(log) sha256; the handoff loop hits /state often, so memoise on the+// file's size+mtime and only recompute when the log actually changes. The loud log+// line fires on each recompute that finds a break (once per change, not per request).+let _integrityCache = { key: null, result: null };+function integrityStatus() {+  try {+    const st = fs.existsSync(LOG_FILE) ? fs.statSync(LOG_FILE) : null;+    const key = st ? st.size + ':' + st.mtimeMs : 'none';+    if (_integrityCache.key === key && _integrityCache.result) return _integrityCache.result;+    const result = verifyChain();+    _integrityCache = { key, result };+    if (!result.ok) {+      console.error(`adom-gantt: INTEGRITY FAILURE — changelog hash chain broken at rev ${result.brokenAtRev}: ${result.reason}. A line was written outside the server's append path.`);+    }+    return result;+  } catch (e) {+    return { ok: false, brokenAtRev: null, reason: 'integrity check error: ' + e.message };+  }+}+ function appendLog(events, actor) {-  let rev = currentRev();+  // The chokepoint. Every mutation (POST /log, compact, import, uid, sidecar+  // upgrade) lands here, so genesis is guaranteed present and every appended event+  // is chained before it touches disk.+  ensureGenesis();+  const parsed = readLog();+  let rev = parsed.length ? parsed[parsed.length - 1].rev : 0;+  let prevH = chainHeadH(parsed);   const ts = new Date().toISOString();   // The server's own fields go on LAST. Spreading the caller's event last let a   // posted `rev` overwrite the counter, which breaks ?since= monotonicity for   // every reader after it.-  const rows = events.map(e => ({-    ...e,-    rev: ++rev,-    ts: e.ts || ts,-    actor: actor || e.actor || 'user',-  }));+  const rows = events.map(e => {+    const row = {+      ...e,+      rev: ++rev,+      ts: e.ts || ts,+      actor: actor || e.actor || 'user',+    };+    row.h = chainHashEvent(prevH, row);   // h last, over everything else+    prevH = row.h;+    return row;+  });   fs.appendFileSync(LOG_FILE, rows.map(r => JSON.stringify(r)).join('\n') + '\n', 'utf8');   return { rev, added: rows.length, rows }; }@@ -1827,6 +1962,24 @@ const server = http.createServer(async (req, res) => {       return;     } +    // ── Health / integrity (public, no-leak) ──+    //+    // Reachable without a session so a health check works from anywhere, and it+    // leaks nothing: counts and booleans only, never event contents. The integrity+    // object is the loud channel for a broken changelog hash chain — a line written+    // outside the server's append path shows up here as ok:false with the first bad+    // rev. On a clean log it is {ok:true}.+    if (urlPath === '/health') {+      const log = readLog();+      jsonResp(res, 200, {+        ok: true,+        rev: log.length ? log[log.length - 1].rev : 0,+        events: log.length,+        integrity: integrityStatus(),+      });+      return;+    }+     // ── The door ──     //     // Everything below this line is either the plan or something that changes it.@@ -1968,15 +2121,21 @@ const server = http.createServer(async (req, res) => {     // belt and braces for a sidecar dropped in underneath a running server: the     // client is v4-only on purpose, and the one translator lives on this side.     if (urlPath === '/state') {+      const integrity = integrityStatus();       if (fs.existsSync(STATE_FILE)) {         let out;         try { out = upgradeState(loadState(), loadData()); }         catch (e) { out = null; }-        if (out) { jsonResp(res, 200, out); return; }-        res.writeHead(200, { 'Content-Type': 'application/json' });-        res.end(fs.readFileSync(STATE_FILE, 'utf8'));+        if (out) { out.integrity = integrity; jsonResp(res, 200, out); return; }+        // Fallback: the raw sidecar, with the integrity object merged in so a+        // reader gets the tamper signal whichever branch served the state.+        let raw;+        try { raw = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); }+        catch (e) { raw = {}; }+        raw.integrity = integrity;+        jsonResp(res, 200, raw);       } else {-        jsonResp(res, 200, { empty: true });+        jsonResp(res, 200, { empty: true, integrity });       }       return;     }@@ -2185,6 +2344,14 @@ process.on('uncaughtException', err => {  if (SESSION_FILE) loadSessions(); +// Tamper-evidence, before any startup append: fingerprint the existing history as+// the genesis baseline FIRST, so a real prior log becomes the pre-genesis digest+// and any startup-time append (a uid mint, a sidecar upgrade) is a properly chained+// post-genesis event rather than part of the baseline. Idempotent: a restart finds+// the genesis marker and appends nothing. An empty log gets its genesis lazily on+// the first real append.+ensureGenesisIfHistory();+ // Before the door opens, not after: the first request served must already see a // plan where every row has a durable id, or the copy button hands out slugs for // as long as it takes the first person to load the page.@@ -2197,7 +2364,11 @@ server.listen(PORT, '127.0.0.1', () => {   console.log(`adom-gantt serving ${path.basename(DATA_FILE)} on http://127.0.0.1:${PORT}`);   console.log(`State file: ${STATE_FILE}`);   console.log(`Data dir: ${DATA_DIR}`);-  console.log(`Endpoints: /data /state /save /uid /uid/backfill /compact /import/{json,csv,msproject-xml,jira-csv} /export/{json,csv,svg,html}`);+  console.log(`Endpoints: /health /data /state /save /uid /uid/backfill /compact /import/{json,csv,msproject-xml,jira-csv} /export/{json,csv,svg,html}`);+  const integ = integrityStatus();+  console.log(integ.ok+    ? `Changelog integrity: OK (hash chain verified${integ.chained ? '' : ', not yet chained'})`+    : `Changelog integrity: TAMPER — chain broken at rev ${integ.brokenAtRev}: ${integ.reason}`);   console.log(AUTH_OFF     ? 'Auth: OFF (--no-auth). Anyone who can reach this port can read and change the plan.'     : 'Auth: Sign in with Adom required. Direct-loopback GETs of /data /state /changelog /export/* stay open for the AI handoff loop.');