← Commit history

0.26.0: permanent uid on every item, server-allocated and never reused

Drew Owens ·b9a79ced00 · ·parent be6c951
7 files changed +543−28
README.md+30
@@ -121,6 +121,34 @@ Create a JSON file with three sections:  Omit `color` on a phase and it follows the Adom brand ramp, which adapts between dark and print. See [SKILL.md](SKILL.md) for the full field reference and the ramp values. +## Item ids that do not move++Every row carries a `uid`: a zero-padded number, assigned once, that nothing can+change. It is written into the file for you, so you never type one.++```json+{ "type": "task", "uid": "00042", "id": "t1", "name": "Task name", "...": "..." }+```++It exists because every other name a row has is conditional. A task `id` is a slug+of the name the row had when it was made, so it reads as a lie after a rename. A+group or a milestone has no id at all and is tracked by its position in the file,+which renumbers whenever the file is restructured. A uid is a number, it means+nothing, and it survives a rename, a reorder, a move between lanes, a compact and+a round trip through CSV.++- **Assigned once**, in the order the plan reads, the first time a server on this+  version starts on your data file. It backs the file up first (`.pre-uid.bak`).+- **Handed out by the server**, from `project.nextUid`, one at a time. A browser+  never invents one, so two people adding a row in the same second cannot collide.+- **Retired on delete.** The number goes with the row and is never issued again,+  so a stale reference resolves to nothing rather than to somebody else's row.+- **Carried by every export**, and read back by every import.++Select rows and press the Copy IDs button (or Ctrl+C) to get `uid`, a tab, and the+name, one row per line: paste that into an AI chat, ask for a batch of edits, and+every edit lands on the row you meant.+ ## Sharing a plan with your team  Every page load and every change requires a signed-in Adom account. An unauthenticated@@ -207,6 +235,8 @@ table, the item editor or the batch editor are taken exactly as typed. | `/data` | GET | Current project data | | `/state` | GET | Saved edits (sidecar) | | `/save` | POST | Persist edits |+| `/uid` | POST | Allocate the next permanent item id. `{"count":n}` for a batch, up to 500 |+| `/uid/backfill` | POST | Give a uid to any row that has none. Runs at startup too, so this is only for a data file edited under a running server | | `/changelog` | GET | Edit history. `?since=<rev>` for the tail, `?format=md` for the readable version | | `/log` | POST | Append events to the changelog (used by the app, and by an AI recording its own edits) | | `/changelog/reset` | POST | Delete the changelog |
SKILL.md+54
@@ -49,11 +49,13 @@ Create a JSON file with this structure:   "items": [     {       "type": "group",+      "uid": "00001",       "label": "Phase 0 — Setup (Jun–Aug 2026)",       "phase": 0     },     {       "type": "task",+      "uid": "00002",       "id": "unique-id",       "name": "Task Name",       "start": "2026-06-01",@@ -66,6 +68,7 @@ Create a JSON file with this structure:     },     {       "type": "milestone",+      "uid": "00003",       "name": "M1: First milestone",       "date": "2026-08-31",       "phase": 0,@@ -84,6 +87,7 @@ Create a JSON file with this structure: | title | string | yes | Project name shown in header | | subtitle | string | no | Short tagline (default: "Gantt chart") | | appName | string | no | Shown in brand zone (default: "adom-gantt") |+| nextUid | number | no | The uid counter. Written and owned by the server, never by you. See uid below | | view | object | no | The author's intended default view, see below |  **project.view** (optional) sets how the chart opens. It applies to the HTML export, to@@ -126,6 +130,7 @@ Each item has a `type` field: `"group"`, `"task"`, or `"milestone"`.  | Field | Type | Required | Description | |-------|------|----------|-------------|+| uid | string | assigned | Permanent id. The server writes it; never invent one. See uid below | | label | string | yes | Group header text | | phase | number | yes | Index into phases[] (controls filter visibility) | @@ -133,6 +138,7 @@ Each item has a `type` field: `"group"`, `"task"`, or `"milestone"`.  | Field | Type | Required | Description | |-------|------|----------|-------------|+| uid | string | assigned | Permanent id. The server writes it; never invent one. See uid below | | id | string | yes | Unique identifier (used for deps and state) | | name | string | yes | Task label | | start | YYYY-MM-DD | yes | Start date. Required: the chart is a function of dates |@@ -150,6 +156,7 @@ Each item has a `type` field: `"group"`, `"task"`, or `"milestone"`.  | Field | Type | Required | Description | |-------|------|----------|-------------|+| uid | string | assigned | Permanent id. The server writes it; never invent one. See uid below | | name | string | yes | Milestone label | | date | YYYY-MM-DD | yes | Milestone date, and the Deadline column in the table | | phase | number | yes | Index into phases[] |@@ -278,6 +285,48 @@ versions carry `ms: true` and a `key` instead of an `id`, since milestones have `project.view.mode` pins which lens a plan opens in, the same way `grain` pins the cell size. Use it when the plan is an assignment list more than a timeline. +## uid: the one handle worth quoting++Every item carries a `uid`, a zero-padded number assigned once and never changed.+**Prefer it over every other way of naming a row**, in what you write and in what+you ask the user for.++```json+{ "type": "task", "uid": "00042", "id": "hw-etch-run", "name": "Etch the cards" }+```++Why the alternatives are worse:++| Handle | Breaks when |+|--------|-------------|+| `name` | anybody renames the row, and two rows can share one |+| `id` (tasks only) | never breaks, but it is a slug of the ORIGINAL name, so it goes stale and misleads: a row whose id says `hw-etch-run` may now be called something else entirely |+| position, `#row<n>` | a row is inserted or removed above it, or the file is restructured. Groups and milestones have no id and used to live on this |+| `uid` | nothing. It is a number, it means nothing, and it is never reissued |++The rules, all of them:++- **The server assigns it, never you.** A uid comes from `POST /uid`, which returns+  `{"ok":true,"uid":"00145","nextUid":146}` and persists the counter before it+  answers. `{"count":n}` gets a batch, up to 500. If you are adding rows to a data+  file by hand, either allocate from `/uid` first or add them without a uid and+  `POST /uid/backfill` (the server also does this at startup).+- **A deleted row keeps its number to the grave.** Creating a row after deleting+  one gets a NEW number. So a uid you were given last week either still names the+  same row, or names nothing at all. It can never name something else.+- **`project.nextUid` is the counter** and it only ever goes up, including through+  an import. Do not edit it, and do not "tidy" uids into a tighter range: gaps are+  the record of things that were deleted.+- **Every export carries it** (`/export/json`, `/export/csv` as the first column,+  the HTML snapshot). `/import/json` and `/import/csv` read it back, so a round+  trip lands on the same handles. A duplicate or malformed uid in an imported file+  is re-issued (the first occurrence keeps it) and the response says how many.+  MS Project and Jira imports number from scratch: their ids mean nothing here.++The user's batch-edit workflow depends on this. They select rows, press **Copy+IDs**, and paste you lines of `uid`, a tab, and the current name. Quote the uid+back in whatever you write; the name is there so the paste is readable to them.+ ## Read the changelog before you edit a plan  Every edit a human makes in the browser is appended to `<datafile>-changelog.jsonl`.@@ -367,6 +416,11 @@ nothing to say and the data file's value stands; an empty one means the user cle it. `GET /state` returns this file verbatim, so it is the fastest way for an AI to read the current owner and deadline of every item. +Rows the user created in the app live only in this file, under `added`, and they+carry their `uid` there the same way a data-file row carries it. `deleted` holds the+row keys of rows the user removed; their uids are retired and are not reissued, which+is why `project.nextUid` can be higher than any uid you can see.+ ## Example  ```bash
docs/BUILD-NOTES.md+81
@@ -3,6 +3,87 @@ Decisions taken while building, and the places where the plan and the code disagreed. Newest first. +## Build notes, 2026-08-12 (0.26.0, ids that do not move)++0.25.0 gave the user a Copy IDs button and the ids it copied were the best ones+the app had, which was not good enough. A task id is a slug of the name the row+had when it was made, so it survives a rename and then MISREPRESENTS the row for+the rest of its life. A group or a milestone has no id at all and rides `#row<n>`,+its original index in the data file, which renumbers the moment anything is+inserted above it. That has cost real edits twice, and the standing workaround+was a rule people had to remember: never insert, only append.++**`uid` is a number, it means nothing, and that is the entire feature.** Five+digits, zero-padded, assigned once, on every row including groups and milestones.+Padding is presentation and not identity: past 99999 the strings get+longer, and every comparison in the code is string equality, never width and+never numeric order.++**The server is the only thing that can make one.** `POST /uid` reads the+counter, increments it, writes the data file, and only then answers. The read,+the increment and the write happen in one synchronous run with no await in the+middle, so node's own event loop serialises two creates arriving together. The+client has NO fallback path: if the call cannot be made the create is refused,+the panel stays open with the reason in it, and nothing is added. That is a+deliberate choice against the friendlier option, because a browser that mints its+own number is a browser that mints the same number as the other tab, and a+duplicate durable id is a worse thing to own than a row you have to add again.+Measured both ways: a normal create calls `/uid` exactly once and gets 00145; the+same create with `/uid` rejecting shows the message, leaves the row count+unchanged, keeps the panel open, and works again as soon as the endpoint is back.++**The one place a uid IS minted locally is a snapshot**, which has no server+behind it and nothing else writing to it, and which carries `project.nextUid` in+its inlined data to carry on from.++**A deletion retires the number.** Nothing frees a uid, ever. Delete the row+holding 00145 and the next row created gets 00146, so a uid you were handed last+week either still names the same row or names nothing at all, and can never name+something else. Verified end to end through the UI, not just in the allocator.+The three rows already deleted in the live plan were given uids too, at the end,+so an undo of an old deletion still comes back with a handle.++**Assignment runs at startup, before the door opens.** It walks the MERGED+display order rather than the data file's order, so the numbers climb down the+plan the way the plan reads rather than the way the file happens to be stored,+and it needs the sidecar's `added`, `deleted` and `order` to do that. It backs+both files up once (`.pre-uid.bak`), bumps `stateRev` when it touches the sidecar+so an open tab 409s and reloads instead of writing uid-less rows back over the+new ones, and writes one loud changelog line. It does nothing at all on the+second run, which was measured: byte-identical files, no new log entry. It is+also wrapped so it can never take the server down. A migration that kills the+process is a worse bug than the one it fixes.++**The key test was compact.** `/compact` bakes the merged plan into the data+file, and the merged plan is built by copying rows, which is exactly the shape of+operation that silently drops a field nobody remembered. It does not: 141 uids,+same values and same order before and after, and the whole merged plan compared+equal. The second version of that trap was `getState()`, whose `added` entries go+through an explicit field whitelist. `uid` had to be added to it by hand or every+row the user ever created would have lost its uid on the next save.++**The counter never rewinds, including through an import.** The first CSV round+trip put it back from 145 to 142, because a CSV carries the 141 live rows and has+nowhere to put the counter, which would have handed a retired number to a new+row. Every importer now floors the counter at what this instance has already+issued. Numbers the incoming file CARRIES are still honoured, because an export+coming home has to land on the handles it went out with.++| Import | uids in the file | Result |+|---|---|---|+| JSON, CSV | present | kept, in place |+| JSON, CSV | missing | minted, above the instance's counter |+| JSON, CSV | duplicated or malformed | re-issued, first occurrence keeps it, count in the response |+| MS Project XML, Jira CSV | any | numbered from scratch, above the counter: a Jira key is Jira's handle, not this plan's |++**Regression.** 52-key fingerprint: 50 identical, and the two that moved are the+two that had to (the CSV header gained a `uid` column, `/state` gained uids on+its 40 created rows, and the rest of the sidecar compared equal field by field).+55-check keyboard sweep, 35-step column battery and the 40-key dependency suite:+zero differences. The init-shape invariant still passes at 380 top-level+statements. The HTML snapshot still renders from `file://` with its three+exact-text anchors intact, and Copy IDs inside a snapshot returns a uid.+ ## Build notes, 2026-08-12 (0.25.0, copy the ids)  Select rows, copy their ids, paste them into an AI chat and ask for a batch edit. The
gantt.html+78−19
@@ -666,7 +666,7 @@ table.data-table { font-size: calc(12px * var(--fs)); min-width: calc(900px * va       <button class="tt-btn" id="tblClear" title="Clear every filter">Clear</button>       <span class="sort-lock-note" id="sortLockNote" style="display:none"></span>       <span class="table-count" id="tblCount"></span>-      <button class="tt-btn" id="btnCopyIds" style="display:none" title="Copy the selected items' IDs, one per line with the name, to paste into an AI chat for batch edits">Copy IDs</button>+      <button class="tt-btn" id="btnCopyIds" style="display:none" title="Copy the selected items' permanent IDs, one per line with the name, to paste into an AI chat for batch edits">Copy IDs</button>     </div>     <div class="table-scroll">       <div class="row-drop" id="rowDrop"></div>@@ -774,6 +774,37 @@ const addedItems = []; // id anyway; this matters for created milestones, which have none. let newKeyN = 0; function freshKey(){ return '#new-'+Date.now().toString(36)+'-'+(newKeyN++); }+// ── uid, the durable handle ──+//+// A task id is a slug of the name the row had when it was made, so it reads as a+// lie the moment the row is renamed. A group or a milestone has no id at all and+// rides `#row<n>`, its original index in the data file, which renumbers whenever+// the file is restructured. `uid` is a number, it means nothing, and nothing that+// happens to the row can change it, which is exactly what a batch edit needs to+// quote back.+//+// It is NEVER minted here. The server owns the counter and hands numbers out one+// at a time from POST /uid, so two tabs creating a row in the same second cannot+// invent the same one. If the call fails the create is refused and says so: a+// row you have to add again is a smaller problem than two rows sharing a handle.+const UID_WIDTH=5;+function fmtUid(n){ return String(n).padStart(UID_WIDTH,'0'); }+// A snapshot is a single file with no server behind it and nothing else writing+// to it, so it is the one place a number can be made locally without a race. It+// carries on from the counter the plan was exported with.+let snapUidN=null;+function snapshotUid(){+  if(snapUidN===null){ const n=Math.floor(Number(project&&project.nextUid)); snapUidN=(Number.isFinite(n)&&n>0)?n:1; }+  return fmtUid(snapUidN++);+}+async function allocUid(){+  if(SNAPSHOT)return snapshotUid();+  const r=await fetch('uid',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'});+  if(!r.ok)throw new Error('the server answered '+r.status);+  const j=await r.json();+  if(!j||!j.ok||!j.uid)throw new Error((j&&j.error)||'no ID came back');+  return j.uid;+} function slugify(t){ return String(t||'').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,40)||'item'; } function freshId(name){   const base=slugify(name); let id=base, n=2;@@ -1760,7 +1791,11 @@ function getState(){     order: items.map(rowOrderKey),     deleted: [...deletedKeys],     added: addedItems.map(r=>{const o={};-      ['type','id','name','start','end','date','phase','owner','status','description','deps','note','striped','provenance'].forEach(k=>{if(r[k]!==undefined)o[k]=r[k];});+      // `uid` is in this list because it is not optional. A created row lives only+      // in the sidecar, so a field missing from this whitelist is a field that+      // does not survive the next save: leaving uid out would have quietly+      // stripped the durable id off every row the user ever created.+      ['type','uid','id','name','start','end','date','phase','owner','status','description','deps','note','striped','provenance'].forEach(k=>{if(r[k]!==undefined)o[k]=r[k];});       o._key=r._key||rowOrderKey(r); return o;}),   }; }@@ -2446,18 +2481,19 @@ function doSelect(id, e){   }   refreshSelVisuals();updateSelCount(); }-// What gets copied: the stable id, a tab, then the name. The id is the thing every-// other surface keys on, /state and /export/json and the changelog all speak it, so-// it is what makes an AI batch edit land on the right row; the name rides along on-// the same line so the paste is readable by the human who made it. One row per line.+// What gets copied: the uid, a tab, then the name. The uid is the durable handle,+// the one string about a row that nothing can change, so it is what makes an AI+// batch edit land on the right row a week and three renames later; the name rides+// along on the same line so the paste is readable by the human who made it, and a+// tab means it drops into a spreadsheet as two columns. One row per line. //-// The `rowOrderKey` fallback is for a row with no id. Nothing can currently reach it,-// because a row with no id cannot enter the selection in either lens, but the-// resolver is written correctly so it stays right if milestones ever become-// selectable.+// The fallback chain exists for a plan that has not been through the uid+// assignment yet: a server started on this build stamps every row before it+// serves a request, but a snapshot taken from an older build has no uids in it at+// all, and handing out nothing at all would be worse than handing out the old id. function selectedIdText(){   return items.filter(r=>r.id&&selected.has(r.id))-    .map(r=>(r.id||rowOrderKey(r))+'\t'+(r.name||''))+    .map(r=>(r.uid||r.id||rowOrderKey(r))+'\t'+(r.name||''))     .join('\n'); } function showIdFallback(txt){@@ -2505,7 +2541,7 @@ function updateSelCount(){   syncCopyBtn();   if(selected.size){     selCountEl.innerHTML=`<span class="n">${selected.size}</span> selected &middot; Copy IDs`;-    selCountEl.title="Copy the selected items' IDs, one per line with the name, to paste into an AI chat for batch edits";+    selCountEl.title="Copy the selected items' permanent IDs, one per line with the name, to paste into an AI chat for batch edits";     selCountEl.classList.add('show');   } else selCountEl.classList.remove('show'); }@@ -2755,7 +2791,7 @@ function openBatchEditor(){   const latest=tasks.reduce((a,r)=>r.end>a?r.end:a,tasks[0].end);    editorEl.innerHTML=`<div class="drag-bar"></div>-    <div class="batch-title">${ids.length} tasks selected <button class="de-btn secondary" id="beCopyIds" style="float:right;padding:2px 9px;font-size:10px" title="Copy the selected items' IDs, one per line with the name, to paste into an AI chat for batch edits">Copy IDs</button></div>+    <div class="batch-title">${ids.length} tasks selected <button class="de-btn secondary" id="beCopyIds" style="float:right;padding:2px 9px;font-size:10px" title="Copy the selected items' permanent IDs, one per line with the name, to paste into an AI chat for batch edits">Copy IDs</button></div>     <div class="batch-hint">All changes are relative, each task shifts from its own dates</div>     <div style="margin-bottom:10px">       <div class="de-row">@@ -3504,21 +3540,44 @@ function openCreateEditor(){   editorEl.addEventListener('keydown',e=>{e.stopPropagation();     if(e.key==='Enter'&&e.target.tagName!=='BUTTON'){e.preventDefault();$('ncCreate').click();}     else if(e.key==='Escape'){e.preventDefault();closeEditor();}});-  $('ncCreate').addEventListener('click',()=>{+  // Async, because a new row cannot exist until the server has given it a uid.+  // Every field is read BEFORE the await: the panel can be dismissed while the+  // request is in flight, and reading an input out of a torn-down panel afterwards+  // would throw and lose the row the user had just described.+  let creating=false;+  $('ncCreate').addEventListener('click',async()=>{+    if(creating)return;                       // Enter and a click both land here     const name=$('ncName').value.trim();     if(!name){const er=$('ncErr');er.textContent='A name is required.';er.style.display='';$('ncName').focus();return;}     const isMs=$('ncType').value==='milestone';     const phase=parseInt($('ncPhase').value,10)||0;     const end=$('ncEnd').value||endVal;     const start=$('ncStart').value||startVal;-    const r={type:isMs?'milestone':'task',name,phase};+    const owner=$('ncOwner').value.trim();+    const st=$('ncStatus').value;+    const desc=$('ncDesc').value.trim();+    const prov=$('ncProv').value.trim();+    creating=true;+    const btn=$('ncCreate'); if(btn)btn.disabled=true;+    let uid=null;+    try{ uid=await allocUid(); }+    catch(err){+      creating=false;+      const b=$('ncCreate'); if(b)b.disabled=false;+      const er=$('ncErr');+      if(er){ er.textContent='No item ID could be allocated, so nothing was created ('+err.message+'). Check the connection and try again.'; er.style.display=''; }+      else flash(saveInd,'could not allocate an item ID, nothing was created');+      return;+    }+    creating=false;+    const r={type:isMs?'milestone':'task',uid,name,phase};     if(isMs){ r.date=end; }     else { r.id=freshId(name); r.start=start<=end?start:end; r.end=end; }     if(!isMs)r._key=undefined;-    const owner=$('ncOwner').value.trim(); if(owner)r.owner=owner;-    const st=$('ncStatus').value; if(st)r.status=st;-    const desc=$('ncDesc').value.trim(); if(desc)r.description=desc;-    const prov=$('ncProv').value.trim(); if(prov)r.provenance=prov;+    if(owner)r.owner=owner;+    if(st)r.status=st;+    if(desc)r.description=desc;+    if(prov)r.provenance=prov;     // Only now, past validation and with a row actually being made. A milestone     // hides the start field but the input keeps its value, so `start` is whatever     // was carried in and the remembered start comes through a milestone unchanged,
package.json+1−1
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.25.0",+  "version": "0.26.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+2−2
@@ -2,7 +2,7 @@   "slug": "adom-gantt",   "title": "Adom Gantt",   "type": "app",-  "version": "0.25.0",+  "version": "0.26.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": {@@ -63,4 +63,4 @@     "name": "Drew Owens",     "email": "[email protected]"   }-}\ No newline at end of file+}
server.js+297−6
@@ -266,6 +266,22 @@ function loadData() {   return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); } +// A half-written plan file is worse than an unwritten one, and this server now+// rewrites the data file for reasons that are not "the user imported something"+// (a uid allocation is a one-field change to a 50KB file). Write beside it and+// rename, so a reader either sees the old file or the new one and never a+// truncated middle.+function writeAtomic(file, text) {+  const tmp = file + '.tmp' + process.pid;+  fs.writeFileSync(tmp, text, 'utf8');+  fs.renameSync(tmp, file);+}+function writeDataFile(data) { writeAtomic(DATA_FILE, JSON.stringify(data, null, 2) + '\n'); }+function backupOnce(file, suffix) {+  const dest = file + suffix;+  if (fs.existsSync(file) && !fs.existsSync(dest)) fs.copyFileSync(file, dest);+}+ function loadState() {   if (fs.existsSync(STATE_FILE)) {     return JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));@@ -478,6 +494,10 @@ function describeEvent(e) {   if (action === 'import') {     return `${who} REPLACED the whole plan by importing ${e.items || 0} item(s) in ${e.phases || 0} phase(s) from ${e.format || 'a file'}. Everything before this point describes a different plan${via}`;   }+  if (action === 'uid.assign') {+    const re = e.reminted ? `, ${e.reminted} re-issued after a clash` : '';+    return `${who} gave ${e.count || 0} row(s) a permanent uid${re}: ${e.live || 0} in the plan, ${e.retired || 0} already deleted and retired with theirs. From here a uid is the handle to quote at this app, and it never moves or gets reused. Next is ${e.nextUid || '?'}${via}`;+  }   return `${who} ${action} ${name}${via}`; } @@ -601,6 +621,214 @@ function orderKeyMap(items, keys) {   return m; } +// ── uid: the durable handle on a row ──+//+// Every other name a row has is conditional. A task id is a slug of the name it+// had when it was made, so it goes stale the moment the row is renamed and it+// reads as a lie. A group or a milestone has no id at all and rides `#row<n>`,+// its ORIGINAL index in the data file, which renumbers the instant the file is+// restructured or a row is inserted mid-plan. Both of those have cost real+// edits: the point of a uid is that it is a number, it means nothing, and+// nothing that happens to the row can change it.+//+//   - assigned once, in the order the plan reads, then never again+//   - a deletion RETIRES the number; it is never handed out a second time, so a+//     stale reference resolves to nothing instead of to somebody else's row+//   - handed out by the server only (POST /uid), never minted in a browser, so+//     two tabs creating a row in the same second cannot invent the same one+//   - `project.nextUid` in the data file is the counter, and it is the only+//     thing that decides what comes next+//+// Zero-padded to five digits for readability, which is presentation and not+// identity: past 99999 the strings simply get longer, and comparison is by+// string equality everywhere, never by width or by numeric order.+const UID_WIDTH = 5;+function fmtUid(n) { return String(n).padStart(UID_WIDTH, '0'); }+function isUid(v) { return typeof v === 'string' && /^[0-9]{1,12}$/.test(v); }+function uidNum(v) { return isUid(v) ? parseInt(v, 10) : 0; }++// Every item the app SHOWS, in the order it shows them, as references into the+// two files that own them: the data file's items and the sidecar's `added`+// entries. mergedData() answers a similar question with copies, which is right+// for an export and useless for writing a field back into the file it came+// from. Deleted rows come back separately: they still exist, they still hold+// their number, and they are simply not on screen.+function mergedRefs(data, state) {+  const st = (state && !Array.isArray(state)) ? state : null;+  const keys = data.items.map((r, i) => r.id || ('#row' + i));+  const rows = data.items.map((r, i) => ({ obj: r, key: keys[i], from: 'data' }));+  const seen = new Set(keys);+  ((st && st.added) || []).forEach(a => {+    if (!a || typeof a !== 'object') return;+    const k = a._key || a.id;+    if (!k || seen.has(k)) return;+    seen.add(k);+    rows.push({ obj: a, key: k, from: 'added' });+  });+  const gone = new Set((st && st.deleted) || []);+  const live = rows.filter(r => !gone.has(r.key));+  const dead = rows.filter(r => gone.has(r.key));+  const order = st && st.order;+  if (Array.isArray(order) && order.length === live.length) {+    const byKey = new Map();+    live.forEach(r => byKey.set(r.key, r));+    live.forEach(r => { const k = r.obj.label || r.obj.name; if (k && !byKey.has(k)) byKey.set(k, r); });+    const rr = order.map(k => byKey.get(k)).filter(Boolean);+    // All or nothing, exactly as mergedData's order guard is: one key that no+    // longer resolves must never be allowed to shorten the plan.+    if (rr.length === live.length && new Set(rr).size === live.length) return { live: rr, dead };+  }+  return { live, dead };+}++// The highest number anything in this instance has ever been given, so a counter+// that has gone missing (a hand-edited file, a plan restored from an export+// without its project block) can be rebuilt without ever reissuing a number.+function highWaterUid(data, state) {+  let hi = 0;+  const bump = r => { if (r && uidNum(r.uid) > hi) hi = uidNum(r.uid); };+  (data.items || []).forEach(bump);+  if (state && !Array.isArray(state)) (state.added || []).forEach(bump);+  return hi;+}++// Hand out the next n numbers and persist the counter before saying so. The+// read, the increment and the write happen in one synchronous run with no await+// between them, and node runs one request at a time, so two creates arriving+// together are serialised by the event loop rather than by hope.+function allocUids(count) {+  const data = loadData();+  if (!data.project || typeof data.project !== 'object') data.project = {};+  let next = Math.floor(Number(data.project.nextUid));+  if (!Number.isFinite(next) || next < 1) next = highWaterUid(data, loadState()) + 1;+  const uids = [];+  for (let i = 0; i < count; i++) uids.push(fmtUid(next++));+  data.project.nextUid = next;+  writeDataFile(data);+  return { uids, nextUid: next };+}++// Give every row in a plan a uid, in the order the plan reads.+//+// Keeps a uid the file already carried, because an export re-imported has to+// land on the same handles whoever read it was given. Mints a missing one.+// RE-mints a duplicate or a malformed one, first occurrence keeping it, and+// reports the count, so a repair is never silent. `fresh` throws all of that+// away and numbers from one, which is what an import from a foreign tool wants:+// an MS Project id means nothing here.+function stampUids(data, opts) {+  const fresh = !!(opts && opts.fresh);+  const items = data.items || [];+  let next = 1, kept = 0, minted = 0, reminted = 0;+  if (!fresh) {+    const claimed = new Set();+    items.forEach(r => {+      if (!r || !isUid(r.uid) || claimed.has(r.uid)) return;+      claimed.add(r.uid);+      if (uidNum(r.uid) + 1 > next) next = uidNum(r.uid) + 1;+    });+    const declared = Math.floor(Number(data.project && data.project.nextUid));+    if (Number.isFinite(declared) && declared > next) next = declared;+  }+  // The counter belongs to this INSTANCE, not to the file arriving. An import+  // replaces the plan wholesale, so without this the incoming file's numbering+  // starts again from wherever it likes and hands a number this server has+  // already retired to a brand new row, which is the one thing a uid promises+  // never happens. A number burned here stays burned, whatever the file says.+  // Numbers the file CARRIES are still honoured: a plan exported from here and+  // imported back has to land on the same handles it went out with.+  let floor = 0;+  try {+    const cur = loadData();+    const declared = Math.floor(Number(cur.project && cur.project.nextUid));+    floor = Math.max(Number.isFinite(declared) ? declared : 0, highWaterUid(cur, loadState()) + 1);+  } catch (e) {}+  if (floor > next) next = floor;+  const seen = new Set();+  items.forEach(r => {+    if (!r || typeof r !== 'object') return;+    if (!fresh && isUid(r.uid) && !seen.has(r.uid)) { seen.add(r.uid); kept++; return; }+    if (r.uid !== undefined && r.uid !== null && r.uid !== '') reminted++; else minted++;+    r.uid = fmtUid(next++);+    seen.add(r.uid);+  });+  if (!data.project || typeof data.project !== 'object') data.project = {};+  data.project.nextUid = next;+  return { kept, minted, reminted, nextUid: next };+}++// Bring an existing instance up to having uids, and keep it there.+//+// Runs at boot and on demand, and does nothing at all once every row has one, so+// it is safe to call as often as you like. Numbering follows the MERGED display+// order rather than the data file's order, because the numbers are going to be+// read by people and by an AI and they should climb down the plan the way the+// plan reads. Rows that are already deleted are numbered last: they are retired+// the moment they are born, which costs three numbers and means an undo of an+// old deletion still comes back with a handle.+//+// Never fatal. A plan that could not be stamped still serves; the copy button+// falls back to the old ids and the log says so. A migration that can take the+// server down with it is a worse bug than the one it fixes.+function ensureUids(why) {+  let data, state;+  try { data = loadData(); state = loadState(); } catch (e) {+    console.error('adom-gantt: could not read the plan to assign uids:', e.message);+    return { ok: false, error: e.message };+  }+  const { live, dead } = mergedRefs(data, state);+  const all = live.concat(dead);+  let next = 1;+  const claimed = new Set();+  all.forEach(r => {+    const u = r.obj.uid;+    if (!isUid(u) || claimed.has(u)) return;+    claimed.add(u);+    if (uidNum(u) + 1 > next) next = uidNum(u) + 1;+  });+  const declared = Math.floor(Number(data.project && data.project.nextUid));+  if (Number.isFinite(declared) && declared > next) next = declared;+  const seen = new Set();+  let dataTouched = false, addedTouched = false, minted = 0, reminted = 0;+  all.forEach(r => {+    const u = r.obj.uid;+    if (isUid(u) && !seen.has(u)) { seen.add(u); return; }+    if (u !== undefined && u !== null && u !== '') reminted++; else minted++;+    r.obj.uid = fmtUid(next++);+    seen.add(r.obj.uid);+    if (r.from === 'added') addedTouched = true; else dataTouched = true;+  });+  const counterWas = data.project && data.project.nextUid;+  if (!data.project || typeof data.project !== 'object') data.project = {};+  data.project.nextUid = next;+  if (!dataTouched && !addedTouched && counterWas === next) return { ok: true, minted: 0, reminted: 0, nextUid: next };+  try {+    backupOnce(DATA_FILE, '.pre-uid.bak');+    writeDataFile(data);+    if (addedTouched) {+      backupOnce(STATE_FILE, '.pre-uid.bak');+      // Every open tab is holding rows with no uid on them, and its next save+      // would write them straight back over these. Bumping the revision turns+      // that save into a 409 and a reload, which is the whole point of having one.+      state.stateRev = (state.stateRev || 0) + 1;+      writeAtomic(STATE_FILE, JSON.stringify(state));+    }+  } catch (e) {+    console.error('adom-gantt: could not write the uid assignment:', e.message);+    return { ok: false, error: e.message };+  }+  const info = { ok: true, minted, reminted, nextUid: next, live: live.length, retired: dead.length,+                 stateRev: addedTouched ? state.stateRev : undefined };+  try {+    appendLog([{ action: 'uid.assign', count: minted + reminted, minted, reminted,+                 live: live.length, retired: dead.length, nextUid: next, via: why || 'startup' }], 'adom-gantt');+  } catch (e) {+    console.error('adom-gantt: could not record the uid assignment in the changelog:', e.message);+  }+  console.log(`adom-gantt: assigned ${minted + reminted} uid(s) (${live.length} shown, ${dead.length} retired); next is ${fmtUid(next)}`);+  return info;+}+ function mergedData() {   const data = loadData();   const state = loadState();@@ -758,7 +986,9 @@ function csvUnguard(s) { return (typeof s === 'string' && s.length > 1 && s[0] = // write the numeric index, which /import/csv reads as a phase name, so an export // re-imported produced phases literally called "0".."5" with every row in phase 0. function itemsToCSV(items, phases) {-  const headers = ['id', 'name', 'start', 'end', 'phase', 'owner', 'status', 'description', 'provenance', 'provenance_url', 'deps', 'type', 'label', 'date', 'striped'];+  // uid leads, because it is the column that survives everything else in the row+  // being edited in the spreadsheet, and /import/csv reads it back.+  const headers = ['uid', 'id', 'name', 'start', 'end', 'phase', 'owner', 'status', 'description', 'provenance', 'provenance_url', 'deps', 'type', 'label', 'date', 'striped'];   const escape = v => {     if (v == null) return '';     const s = csvGuard(String(v));@@ -800,10 +1030,14 @@ function importJSON(body) {     throw new Error('phases and items must be arrays');   }   assertDates(data.items);+  // A JSON import is usually this app's own export coming back, so uids the file+  // carries are KEPT: an AI handed a list of numbers must still be able to use+  // them after the round trip. Absent, duplicated or malformed ones are minted.+  const uids = stampUids(data);   fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8');   // Clear state file since data structure changed   if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);-  return { ok: true, items: data.items.length, phases: data.phases.length };+  return { ok: true, items: data.items.length, phases: data.phases.length, uids }; }  // ── Import: CSV ──@@ -857,6 +1091,9 @@ function importCSV(body, queryString) {       name: row.name || row.label || '',       phase: pi,     };+    // A uid column the file carried is kept as-is here and validated in one place+    // afterwards, so the dedupe rule cannot disagree with itself per importer.+    if (row.uid) { item.uid = String(row.uid).trim(); }      if (row.start) { item.start = row.start; }     if (row.end) { item.end = row.end; }@@ -903,9 +1140,12 @@ function importCSV(body, queryString) {   };    assertDates(data.items);+  // Same rule as the JSON import: a CSV that came out of here goes back in on+  // the same numbers, and a CSV that never had a uid column gets fresh ones.+  const uids = stampUids(data);   fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8');   if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);-  return { ok: true, items: items.length, phases: phases.length };+  return { ok: true, items: items.length, phases: phases.length, uids }; }  // ── Import: MS Project XML ──@@ -990,9 +1230,12 @@ function importMSProjectXML(body) {   };    assertDates(data.items);+  // A foreign tool's ids mean nothing in this plan, so this one numbers from the+  // top rather than pretending an MS Project UID is a handle anybody here holds.+  const uids = stampUids(data, { fresh: true });   fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8');   if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);-  return { ok: true, items: tasks.length };+  return { ok: true, items: tasks.length, uids }; }  // ── Import: JIRA CSV ──@@ -1086,9 +1329,12 @@ function importJiraCSV(body) {   };    assertDates(data.items);+  // Fresh numbers, for the same reason as the MS Project importer: a Jira key is+  // Jira's handle on the row, not this plan's.+  const uids = stampUids(data, { fresh: true });   fs.writeFileSync(DATA_FILE, JSON.stringify(data, null, 2), 'utf8');   if (fs.existsSync(STATE_FILE)) fs.unlinkSync(STATE_FILE);-  return { ok: true, items: validItems.length };+  return { ok: true, items: validItems.length, uids }; }  // ── Export: SVG ──@@ -1527,6 +1773,46 @@ const server = http.createServer(async (req, res) => {       return;     } +    // ── uid allocation ──+    //+    // The only place a uid is ever born. It is a POST and it is below the door,+    // so it needs a signed-in person: a number handed out is a number burned,+    // and an open endpoint that burns numbers is a way to make the plan's ids+    // ugly from the outside. `count` batches, for a script adding rows in bulk.+    //+    // The client has no fallback path on purpose. If this call cannot be made,+    // the create is refused and says so, rather than the browser inventing a+    // number that a second tab is inventing at the same moment.+    if (req.method === 'POST' && urlPath === '/uid') {+      const body = await readBody(req);+      let count = 1;+      if (body && body.trim()) {+        let p;+        try { p = JSON.parse(body); }+        catch (e) { jsonResp(res, 400, { ok: false, error: 'body must be JSON, or empty for one id' }); return; }+        if (p && p.count !== undefined) count = p.count;+      }+      try {+        count = Math.floor(Number(count));+        if (!Number.isFinite(count) || count < 1) count = 1;+        if (count > 500) count = 500;+        const out = allocUids(count);+        jsonResp(res, 200, { ok: true, uid: out.uids[0], uids: out.uids, nextUid: out.nextUid });+      } catch (e) {+        jsonResp(res, 500, { ok: false, error: e.message });+      }+      return;+    }++    // Self-heal, for the case the boot-time pass cannot cover: a script that+    // appends rows straight into the data file under a running server. Does+    // nothing when every row already has a uid, so it is safe to spam.+    if (req.method === 'POST' && urlPath === '/uid/backfill') {+      const out = ensureUids('backfill');+      jsonResp(res, out.ok ? 200 : 500, out);+      return;+    }+     // ── Import endpoints ──      if (req.method === 'POST' && urlPath === '/compact') {@@ -1670,11 +1956,16 @@ process.on('uncaughtException', err => {  if (SESSION_FILE) loadSessions(); +// 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.+ensureUids('startup');+ 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 /compact /import/{json,csv,msproject-xml,jira-csv} /export/{json,csv,svg,html}`);+  console.log(`Endpoints: /data /state /save /uid /uid/backfill /compact /import/{json,csv,msproject-xml,jira-csv} /export/{json,csv,svg,html}`);   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.');