toolroll 0.9.57 → 0.9.59

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 (73) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/dist/action-ledger.js +10 -5
  3. package/dist/actor.d.ts +0 -10
  4. package/dist/actor.js +1 -18
  5. package/dist/api-tokens.d.ts +33 -2
  6. package/dist/api-tokens.js +39 -11
  7. package/dist/assignment-delivery.js +1 -1
  8. package/dist/assignment-status.js +1 -1
  9. package/dist/assignment.js +3 -2
  10. package/dist/browser/workspace.js +1 -1
  11. package/dist/builder.js +4 -2
  12. package/dist/chat-channel.d.ts +1 -0
  13. package/dist/chat-channel.js +3 -0
  14. package/dist/chat-delivery.js +2 -0
  15. package/dist/cli-http.d.ts +7 -0
  16. package/dist/cli-http.js +30 -4
  17. package/dist/cli.js +2 -1
  18. package/dist/contract-changes.d.ts +52 -0
  19. package/dist/contract-changes.js +61 -0
  20. package/dist/coordinator.d.ts +2 -1
  21. package/dist/coordinator.js +22 -31
  22. package/dist/decision.d.ts +4 -0
  23. package/dist/decision.js +8 -0
  24. package/dist/deploy-lock.d.ts +8 -0
  25. package/dist/deploy-lock.js +45 -0
  26. package/dist/desktop-update.d.ts +1 -1
  27. package/dist/dispatch.js +1 -1
  28. package/dist/export.js +2 -2
  29. package/dist/lead-cli.js +3 -0
  30. package/dist/lead-doors.js +3 -0
  31. package/dist/lead-tools.js +2 -1
  32. package/dist/mcp-http.js +5 -5
  33. package/dist/needs-you.js +3 -1
  34. package/dist/operate.js +23 -11
  35. package/dist/password-bearer.d.ts +27 -0
  36. package/dist/password-bearer.js +59 -0
  37. package/dist/planner-source.d.ts +23 -53
  38. package/dist/planner-source.js +43 -68
  39. package/dist/release-gate.d.ts +2 -3
  40. package/dist/release-gate.js +44 -16
  41. package/dist/remote-command.js +0 -2
  42. package/dist/remote-exec.d.ts +19 -0
  43. package/dist/remote-exec.js +9 -8
  44. package/dist/request-budget.d.ts +1 -1
  45. package/dist/scope.js +16 -1
  46. package/dist/serve.js +15 -0
  47. package/dist/server/edge-refusal.d.ts +2 -0
  48. package/dist/server/edge-refusal.js +8 -6
  49. package/dist/server/guards.js +5 -4
  50. package/dist/server/http.d.ts +2 -0
  51. package/dist/server/remote.js +6 -13
  52. package/dist/server/render-chat.js +10 -0
  53. package/dist/server/render-tasks.js +19 -9
  54. package/dist/server/route-table.js +0 -3
  55. package/dist/server/tasks.js +3 -1
  56. package/dist/session-cli.d.ts +38 -9
  57. package/dist/session-cli.js +141 -154
  58. package/dist/session-contract.js +2 -2
  59. package/dist/session-remote.d.ts +19 -0
  60. package/dist/session-remote.js +86 -0
  61. package/dist/store.d.ts +72 -12
  62. package/dist/store.js +367 -105
  63. package/dist/task-status.js +3 -1
  64. package/dist/team-cli.d.ts +3 -0
  65. package/dist/team-cli.js +23 -3
  66. package/dist/telegram-lead.js +6 -0
  67. package/dist/telegram.js +9 -7
  68. package/dist/toolroll-update.js +10 -1
  69. package/package.json +1 -1
  70. package/dist/session-http.d.ts +0 -24
  71. package/dist/session-http.js +0 -170
  72. package/dist/session-server.d.ts +0 -17
  73. package/dist/session-server.js +0 -39
package/dist/store.js CHANGED
@@ -19,10 +19,10 @@ import { validateTaskText } from "./task-text.js";
19
19
  import { flowDefinitionForStore, flowDefinitionFromStore } from "./flows.js";
20
20
  import { cardOutputsForStore, cardOutputsFromStore } from "./contracts/stage-output.js";
21
21
  import { chatControlHref, chatResultHref } from "./chat-controls.js";
22
- import { actorLabel, currentActor, leadSecretMatches, mintLeadToken, parseLeadToken } from "./actor.js";
22
+ import { actorLabel, currentActor } from "./actor.js";
23
23
  import { scanForSecrets } from "./evidence.js";
24
24
  import { LIVE_ACTIVITY_KINDS } from "./live.js";
25
- import { tokenPurpose, tokenRotationProblem } from "./api-tokens.js";
25
+ import { hashSecret, mintLeadToken, parseCredential, secretMatches, tokenPurpose, tokenRotationProblem } from "./api-tokens.js";
26
26
  /**
27
27
  * The database: a small task store, and the operational overlay beside it.
28
28
  *
@@ -221,21 +221,11 @@ CREATE TABLE IF NOT EXISTS lead_config (
221
221
  about_json TEXT NOT NULL DEFAULT '[]'
222
222
  );
223
223
  `;
224
- /** Pings follow responsibility (no version bump: additive only). `lead_credential` is a lead token: an agent acting
225
- * for `owner`, only its hash kept. `task_act` is who filed, approved, cancelled, completed or handed on a task
224
+ /** Pings follow responsibility (no version bump: additive only). A lead token (v118: a `credential` row of kind
225
+ * 'lead') is an agent acting for its owner, only its hash kept. `task_act` is who filed, approved, cancelled, completed or handed on a task
226
226
  * (`lead` 1: the owner's lead; `person`/`why`: who the lead asked, and why). `notification_actor` is whose act made
227
227
  * a fact. `task_replacement` is the task that replaced a cancelled one. `project_mute` is a person's muted projects. */
228
228
  const LEAD_QUIET_SCHEMA = `
229
- CREATE TABLE IF NOT EXISTS lead_credential (
230
- id TEXT PRIMARY KEY,
231
- owner TEXT NOT NULL,
232
- secret_hash TEXT NOT NULL,
233
- created_at TEXT NOT NULL,
234
- created_by TEXT NOT NULL,
235
- revoked_at TEXT,
236
- revoked_by TEXT
237
- );
238
- CREATE INDEX IF NOT EXISTS lead_credential_owner ON lead_credential (owner);
239
229
  CREATE TABLE IF NOT EXISTS task_act (
240
230
  id INTEGER PRIMARY KEY AUTOINCREMENT,
241
231
  task_ref INTEGER NOT NULL,
@@ -566,7 +556,62 @@ const REGISTERED_AT_FILING = "again, as it was filed";
566
556
  // the lead's subagents (teammate* → subagent*, their rows' teammate column → subagent), with every row, id and link kept.
567
557
  // mate_turn (and chat_turn.mate_turn) keep their names this release: the update gate the previous build's updater lifts
568
558
  // names them byte for byte (desktop-update-gate.ts), so they move once every updater reads either name.
569
- export const SCHEMA_VERSION = 117;
559
+ // v118 (D7) keeps every machine credential in one table, `credential`: a person's API tokens (so_…), the lead's tokens
560
+ // (lt_…) and coordinators, each with its kind, read/act access, project limit, expiry, revocation and generation. Every
561
+ // row moves with its id, hash and dates; the references to coordinators and API tokens point at it.
562
+ // v119 keeps planning generations: a rewritten scope starts a clean planning context (task_ref, run and plan_revision
563
+ // carry planning_generation, 0 for all earlier history), and a question closed without an answer says why
564
+ // (decision.superseded_reason: its task's scope changed, or the task was cancelled).
565
+ export const SCHEMA_VERSION = 119;
566
+ /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
567
+ export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
568
+ /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
569
+ const V118_REFERENCES = /REFERENCES (?:coordinator_credential\(cid\)|api_token\(id\))/g;
570
+ /** The columns that hold a coordinator's id: rewritten only when the merge had to give one a new canonical id. */
571
+ const V118_COORDINATOR_COLUMNS = [["task_ref", "coordinator_cid"], ["coordinator_event", "cid"], ["mcp_idempotency", "cid"], ["coordinator_proposal", "cid"]];
572
+ /**
573
+ * v118 (D7): one credential table for every machine credential. `id` is the canonical identity every reference holds;
574
+ * `public_id` is the id the secret presents (so_<id>_…, lt_<id>_…) or a coordinator's cid, unique per kind. They are
575
+ * equal unless the merge found two kinds sharing an id. `account` is the person a token acts as (a lead's owner; for
576
+ * a coordinator, who minted it); `projects_json` the project limit (a coordinator's projects); `generation` which
577
+ * issue it is (1, then one more per rotation, per lead token for the same owner, per coordinator of the same name).
578
+ * Lead tokens never expire; a coordinator made before v101 has no expiry.
579
+ */
580
+ export const CREDENTIAL_TABLE = `
581
+ CREATE TABLE IF NOT EXISTS credential (
582
+ id TEXT PRIMARY KEY,
583
+ kind TEXT NOT NULL CHECK (kind IN ('person','lead','coordinator')),
584
+ public_id TEXT NOT NULL,
585
+ account TEXT NOT NULL,
586
+ name TEXT NOT NULL,
587
+ secret_hash TEXT NOT NULL,
588
+ access TEXT NOT NULL CHECK (access IN ('read','act')),
589
+ projects_json TEXT,
590
+ per_hour INTEGER,
591
+ purpose TEXT NOT NULL DEFAULT 'api' CHECK (purpose IN ('api','mcp')),
592
+ generation INTEGER NOT NULL DEFAULT 1 CHECK (generation >= 1),
593
+ created_at TEXT NOT NULL,
594
+ created_by TEXT NOT NULL,
595
+ expires_at TEXT,
596
+ last_used_at TEXT,
597
+ revoked_at TEXT,
598
+ revoked_by TEXT,
599
+ replaces TEXT,
600
+ replaced_by TEXT,
601
+ overlap_until TEXT,
602
+ UNIQUE (kind, public_id)
603
+ );
604
+ `;
605
+ /** Created once the merged tables (and their index of the same name) are gone. */
606
+ const CREDENTIAL_INDEXES = `
607
+ CREATE INDEX IF NOT EXISTS credential_account ON credential (account, kind);
608
+ CREATE INDEX IF NOT EXISTS credential_coordinator_secret ON credential (secret_hash) WHERE kind = 'coordinator';
609
+ CREATE UNIQUE INDEX IF NOT EXISTS coordinator_live_name ON credential (name) WHERE kind = 'coordinator' AND revoked_at IS NULL;
610
+ CREATE TRIGGER IF NOT EXISTS ledger_coordinator_minted AFTER INSERT ON credential WHEN NEW.kind = 'coordinator' BEGIN
611
+ INSERT INTO action_ledger(at,actor,repo,task_id,run_id,action,outcome,source,detail) VALUES (NEW.created_at, NEW.created_by, NULL, NULL, NULL,
612
+ 'coordinator minted: ' || NEW.name, 'minted', 'access', 'projects: ' || NEW.projects_json);
613
+ END;
614
+ `;
570
615
  /** v117: each table renamed, by its old name. Rows, ids, foreign keys and indexes go with it (renameForV117). */
571
616
  export const V117_RENAMED_TABLES = Object.freeze({
572
617
  mate_session: "lead_session", mate_thread: "lead_thread", mate_message: "lead_message", mate_proposal: "lead_proposal",
@@ -587,10 +632,10 @@ function presentTable(db, name) {
587
632
  const old = V117_OLD_NAME[name];
588
633
  return old !== undefined && !tableExists(db, name) && tableExists(db, old) ? old : name;
589
634
  }
590
- /** A row `PRAGMA foreign_key_check` reports, by today's table names: a row already pointing nowhere before v117 is the
591
- * same row after it, though RENAME now reports it (and what it points at) under the new names. */
635
+ /** A row `PRAGMA foreign_key_check` reports, by today's table names: a row already pointing nowhere before v117 (or
636
+ * v118) is the same row after it, though RENAME (or the credential merge) now reports what it points at by the new names. */
592
637
  export function orphanKey(row) {
593
- const named = (value) => typeof value === "string" ? V117_RENAMED_TABLES[value] ?? value : value;
638
+ const named = (value) => typeof value === "string" ? V117_RENAMED_TABLES[value] ?? (Object.hasOwn(V118_MERGED_TABLES, value) ? "credential" : value) : value;
594
639
  return JSON.stringify({ table: named(row["table"]), rowid: row["rowid"], parent: named(row["parent"]), fkid: row["fkid"] });
595
640
  }
596
641
  /** Shapes compare by today's names: a pre-v117 table, index or reference names the same shape by its old one. */
@@ -636,9 +681,20 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
636
681
  * transaction that checks each count before the old tables are dropped; the
637
682
  * rehearsal checks each moved table's rows arrived (HISTORY_RULES). v117 renames the mate's tables to the lead's and the
638
683
  * teammates' to its subagents' (SQLite's RENAME, every row, id and link carried), renames their teammate column, and rewrites four internal kinds from 'teammate' to 'subagent' in place; the rehearsal checks
639
- * each renamed table's rows arrived under its new name.
684
+ * each renamed table's rows arrived under its new name. v118 (D7) is checked the same way: every row of api_token,
685
+ * coordinator_credential and lead_credential moves into credential under its kind in one transaction that counts them
686
+ * before the old tables drop, and the references that named them name credential in place, their rows untouched. v119 only adds four columns
687
+ * (planning_generation on task_ref, run and plan_revision, defaulting to 0, and decision.superseded_reason, null);
688
+ * the rehearsal checks every saved row reads the declared default (HISTORY_RULES' added columns).
640
689
  */
641
- export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117]);
690
+ export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119]);
691
+ /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
692
+ export const V119_ADDED_COLUMNS = Object.freeze({
693
+ task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
694
+ run: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
695
+ plan_revision: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
696
+ decision: { superseded_reason: { definition: "TEXT CHECK (superseded_reason IN ('scope-changed','cancelled'))", saved: "NULL" } },
697
+ });
642
698
  /** Whether a database settled at `version` reaches this build's schema through update-safe migrations alone. */
643
699
  export function updateSafeSchema(version) {
644
700
  if (version === null || !Number.isSafeInteger(version) || version <= 0 || version > SCHEMA_VERSION)
@@ -673,22 +729,9 @@ CREATE TABLE IF NOT EXISTS scope_author (
673
729
  PRIMARY KEY (task_id, digest, author)
674
730
  );
675
731
  `;
676
- /** v101: API tokens and the browser sessions that survive a restart. Only hashes of their secrets are kept. */
732
+ /** v101: the browser sessions that survive a restart (API tokens are `credential` rows since v118). Only hashes of
733
+ * their secrets are kept. */
677
734
  const CREDENTIALS_SCHEMA = `
678
- CREATE TABLE IF NOT EXISTS api_token (
679
- id TEXT PRIMARY KEY,
680
- account TEXT NOT NULL REFERENCES approver(name),
681
- name TEXT NOT NULL,
682
- secret_hash TEXT NOT NULL,
683
- access TEXT NOT NULL CHECK (access IN ('read','act')),
684
- created_at TEXT NOT NULL,
685
- created_by TEXT NOT NULL,
686
- expires_at TEXT NOT NULL,
687
- last_used_at TEXT,
688
- revoked_at TEXT,
689
- revoked_by TEXT
690
- );
691
- CREATE INDEX IF NOT EXISTS api_token_account ON api_token (account);
692
735
  CREATE TABLE IF NOT EXISTS web_session (
693
736
  id_hash TEXT PRIMARY KEY,
694
737
  account TEXT NOT NULL,
@@ -707,7 +750,7 @@ CREATE INDEX IF NOT EXISTS web_session_account ON web_session (account);
707
750
  `;
708
751
  /**
709
752
  * v113: MCP sign-in (OAuth 2.1, mcp-oauth.ts). A registered client, a one-time code (its hash, a minute at most), and
710
- * each grant: the ordinary API token it signs in with (revoked like any other), the account generation and exact
753
+ * each grant: the ordinary API token it signs in with (a person's `credential` row, revoked like any other), the account generation and exact
711
754
  * projects the person chose, and every issued refresh-secret hash (any replay ends the whole family).
712
755
  */
713
756
  const OAUTH_SCHEMA = `
@@ -734,7 +777,7 @@ CREATE TABLE IF NOT EXISTS oauth_code (
734
777
  token TEXT
735
778
  );
736
779
  CREATE TABLE IF NOT EXISTS oauth_grant (
737
- token TEXT PRIMARY KEY REFERENCES api_token(id),
780
+ token TEXT PRIMARY KEY REFERENCES credential(id),
738
781
  client TEXT NOT NULL REFERENCES oauth_client(id),
739
782
  account TEXT NOT NULL,
740
783
  generation INTEGER NOT NULL,
@@ -1085,7 +1128,7 @@ CREATE TABLE IF NOT EXISTS task_ref (
1085
1128
  -- AUTHORITATIVE linkage — written only by the branded coordinator door,
1086
1129
  -- joined by exact cid, never parsed out of filed_via (which stays
1087
1130
  -- display-only). NULL is every other filer.
1088
- coordinator_cid TEXT REFERENCES coordinator_credential(cid),
1131
+ coordinator_cid TEXT REFERENCES credential(id),
1089
1132
  -- The deliverable (v34, scout tasks): 'branch' is every task until now;
1090
1133
  -- 'report' dispatches a scout — a read-only session whose whole output
1091
1134
  -- is one report artifact. Stamped at filing, never rewritten.
@@ -1093,29 +1136,15 @@ CREATE TABLE IF NOT EXISTS task_ref (
1093
1136
  UNIQUE (backend, external_id)
1094
1137
  );
1095
1138
 
1096
- -- v31 (MCP gateway): the coordinator principal (DESIGN.md 9b). Minted by
1097
- -- an operator password ceremony; the token is hashed at rest and shown
1098
- -- once. cid is the IMMUTABLE identity — names are unique only among the
1099
- -- living, so revoke-and-remint never conflates generations in audit or
1100
- -- cap accounting.
1101
- CREATE TABLE IF NOT EXISTS coordinator_credential (
1102
- cid TEXT PRIMARY KEY,
1103
- name TEXT NOT NULL,
1104
- credential_hash TEXT NOT NULL,
1105
- repos TEXT NOT NULL,
1106
- per_hour INTEGER NOT NULL,
1107
- created_by TEXT NOT NULL,
1108
- created_at TEXT NOT NULL,
1109
- revoked_at TEXT
1110
- );
1111
- CREATE UNIQUE INDEX IF NOT EXISTS coordinator_live_name
1112
- ON coordinator_credential (name) WHERE revoked_at IS NULL;
1139
+ -- v31 (MCP gateway): the coordinator principal (DESIGN.md 9b) is a row of
1140
+ -- credential (v118, kind 'coordinator'): its id is the IMMUTABLE identity,
1141
+ -- so revoke-and-remint never conflates generations in audit or cap accounting.
1113
1142
 
1114
1143
  -- The coordinator audit (v31): filing, dismissal, and revocation rows,
1115
1144
  -- inserted ATOMICALLY with their state change — never a separate write.
1116
1145
  CREATE TABLE IF NOT EXISTS coordinator_event (
1117
1146
  id INTEGER PRIMARY KEY AUTOINCREMENT,
1118
- cid TEXT NOT NULL REFERENCES coordinator_credential(cid),
1147
+ cid TEXT NOT NULL REFERENCES credential(id),
1119
1148
  kind TEXT NOT NULL CHECK (kind IN ('filed','dismissed','revoked')),
1120
1149
  task_id TEXT,
1121
1150
  detail TEXT,
@@ -1127,7 +1156,7 @@ CREATE TABLE IF NOT EXISTS coordinator_event (
1127
1156
  -- the original answer without a rate charge; a different digest refuses.
1128
1157
  -- The global Store.replay is digest-blind and is NOT this.
1129
1158
  CREATE TABLE IF NOT EXISTS mcp_idempotency (
1130
- cid TEXT NOT NULL REFERENCES coordinator_credential(cid),
1159
+ cid TEXT NOT NULL REFERENCES credential(id),
1131
1160
  key TEXT NOT NULL,
1132
1161
  request_digest TEXT NOT NULL,
1133
1162
  task_id TEXT NOT NULL,
@@ -1789,7 +1818,7 @@ CREATE INDEX IF NOT EXISTS lead_proposal_thread ON lead_proposal (thread, state)
1789
1818
 
1790
1819
  CREATE TABLE IF NOT EXISTS coordinator_proposal (
1791
1820
  id INTEGER PRIMARY KEY AUTOINCREMENT,
1792
- cid TEXT NOT NULL REFERENCES coordinator_credential(cid),
1821
+ cid TEXT NOT NULL REFERENCES credential(id),
1793
1822
  name TEXT NOT NULL,
1794
1823
  repo TEXT NOT NULL,
1795
1824
  kind TEXT NOT NULL CHECK (kind IN ('next','reserve','hold','unhold','scope','cancel','answer')),
@@ -3371,14 +3400,17 @@ function initializeStore(db, file) {
3371
3400
  db.exec(TEAM_SCHEMA);
3372
3401
  db.exec(SSO_SCHEMA);
3373
3402
  db.exec(CREDENTIALS_SCHEMA);
3374
- // v111: a token's project limit (null: every project its person may use) and its rotation.
3375
- addColumn(db, "api_token", "projects_json", "TEXT");
3376
- addColumn(db, "api_token", "replaces", "TEXT");
3377
- addColumn(db, "api_token", "replaced_by", "TEXT");
3378
- addColumn(db, "api_token", "overlap_until", "TEXT");
3403
+ // v111: a token's project limit (null: every project its person may use) and its rotation. (Until v118 moves it.)
3404
+ if (tableExists(db, "api_token")) {
3405
+ addColumn(db, "api_token", "projects_json", "TEXT");
3406
+ addColumn(db, "api_token", "replaces", "TEXT");
3407
+ addColumn(db, "api_token", "replaced_by", "TEXT");
3408
+ addColumn(db, "api_token", "overlap_until", "TEXT");
3409
+ }
3379
3410
  // v113: MCP sign-in (OAuth), and whether a token is an ordinary one or an MCP sign-in's.
3380
3411
  db.exec(OAUTH_SCHEMA);
3381
- addColumn(db, "api_token", "purpose", "TEXT NOT NULL DEFAULT 'api' CHECK (purpose IN ('api','mcp'))");
3412
+ if (tableExists(db, "api_token"))
3413
+ addColumn(db, "api_token", "purpose", "TEXT NOT NULL DEFAULT 'api' CHECK (purpose IN ('api','mcp'))");
3382
3414
  db.exec(APPROVAL_SCHEMA);
3383
3415
  db.exec(LEDGER_CHAIN_SCHEMA);
3384
3416
  db.exec(MONITORING_SCHEMA);
@@ -3454,6 +3486,11 @@ function initializeStore(db, file) {
3454
3486
  // v115: the task each standing-order firing filed, kept on the firing itself (a card's task changes as it moves).
3455
3487
  addColumn(db, "flow_trigger_event", "task", "TEXT");
3456
3488
  db.exec(PLAN_AUTO_SCHEMA);
3489
+ // v119: planning generations, and why a question closed unanswered (additive only; saved rows read the defaults).
3490
+ for (const [table, columns] of Object.entries(V119_ADDED_COLUMNS)) {
3491
+ for (const [column, { definition }] of Object.entries(columns))
3492
+ addColumn(db, table, column, definition);
3493
+ }
3457
3494
  installLedgerTriggers(db);
3458
3495
  // Attention/history indexes come AFTER migration: on a database whose
3459
3496
  // constrained tables still carry a pre-rebuild shape, creating a partial
@@ -3531,6 +3568,8 @@ CREATE INDEX IF NOT EXISTS run_checkpoint_by_task ON run_checkpoint (task_ref, i
3531
3568
  migrateToV115(db);
3532
3569
  // v116: every chat app onto the shared chat tables, after v115 and every older step gave the old tables their last shape.
3533
3570
  convertChatTables(db);
3571
+ // v118: every machine credential into the one credential table, after every older step gave the old tables their last shape.
3572
+ mergeCredentialsForV118(db);
3534
3573
  // Keep the epoch until reclamation succeeds, including a retry after compaction already committed. A new file has
3535
3574
  // nothing to reclaim.
3536
3575
  if (!fresh)
@@ -4180,6 +4219,128 @@ function renameForV117(db) {
4180
4219
  if (check.length !== 1 || check[0] !== "ok")
4181
4220
  throw new Error(`the v117 renames left the database damaged (${check.slice(0, 3).join("; ")})`);
4182
4221
  }
4222
+ /**
4223
+ * v118 (D7): the three credential tables become one. In one transaction every row moves with its id, hash, dates,
4224
+ * access, project limit, expiry and revocation; the counts are checked before the old tables drop; the foreign keys
4225
+ * that named them name `credential(id)` in place (rows untouched); and the coordinator ledger trigger follows. A
4226
+ * person's API token keeps its id. A coordinator or lead token whose id another kind already holds gets `<kind>:<id>`
4227
+ * as its canonical id (a coordinator's references follow it); its secret presents the same id as before either way.
4228
+ */
4229
+ function mergeCredentialsForV118(db) {
4230
+ db.exec(CREDENTIAL_TABLE);
4231
+ const present = Object.keys(V118_MERGED_TABLES).filter(table => tableExists(db, table));
4232
+ const referring = db.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND sql IS NOT NULL").all()
4233
+ .filter(row => !Object.hasOwn(V118_MERGED_TABLES, row.name) && new RegExp(V118_REFERENCES.source).test(row.sql));
4234
+ if (present.length === 0 && referring.length === 0) {
4235
+ db.exec(CREDENTIAL_INDEXES);
4236
+ return;
4237
+ }
4238
+ const orphans = () => new Set(db.prepare("PRAGMA foreign_key_check").all().map(orphanKey));
4239
+ db.exec("PRAGMA foreign_keys = OFF");
4240
+ try {
4241
+ const before = orphans();
4242
+ db.exec("BEGIN IMMEDIATE");
4243
+ try {
4244
+ const taken = new Set(db.prepare("SELECT id FROM credential").all().map(row => row.id));
4245
+ const already = Object.fromEntries(Object.values(V118_MERGED_TABLES).map(kind => [kind, Number(db.prepare("SELECT count(*) AS n FROM credential WHERE kind = ?").get(kind)["n"])]));
4246
+ const claim = (kind, legacy) => {
4247
+ let id = legacy;
4248
+ for (let n = 1; taken.has(id); n++)
4249
+ id = n === 1 ? `${kind}:${legacy}` : `${kind}:${legacy}:${n}`;
4250
+ taken.add(id);
4251
+ return id;
4252
+ };
4253
+ const insert = db.prepare(`INSERT INTO credential (id, kind, public_id, account, name, secret_hash, access, projects_json, per_hour, purpose, generation,
4254
+ created_at, created_by, expires_at, last_used_at, revoked_at, revoked_by, replaces, replaced_by, overlap_until) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
4255
+ const text = (value) => value == null ? null : String(value);
4256
+ if (tableExists(db, "api_token")) {
4257
+ const rows = db.prepare("SELECT * FROM api_token ORDER BY rowid").all();
4258
+ const byId = new Map(rows.map(row => [String(row["id"]), row]));
4259
+ // A rotation's replacement is one generation on from the token it replaced.
4260
+ const generation = (row, seen = new Set()) => {
4261
+ const from = text(row["replaces"]), earlier = from === null ? undefined : byId.get(from);
4262
+ if (earlier === undefined || seen.has(from))
4263
+ return 1;
4264
+ seen.add(from);
4265
+ return generation(earlier, seen) + 1;
4266
+ };
4267
+ for (const row of rows) {
4268
+ const id = String(row["id"]);
4269
+ // Persons move first, into an empty table: an API token's id is never renamed (its grants and budgets name it).
4270
+ if (claim("person", id) !== id)
4271
+ throw new Error(`API token ${id} collides with another credential`);
4272
+ insert.run(id, "person", id, String(row["account"]), String(row["name"]), String(row["secret_hash"]), row["access"] === "act" ? "act" : "read", text(row["projects_json"]), null, row["purpose"] === "mcp" ? "mcp" : "api", generation(row), String(row["created_at"]), String(row["created_by"]), text(row["expires_at"]), text(row["last_used_at"]), text(row["revoked_at"]), text(row["revoked_by"]), text(row["replaces"]), text(row["replaced_by"]), text(row["overlap_until"]));
4273
+ }
4274
+ }
4275
+ if (tableExists(db, "coordinator_credential")) {
4276
+ const issued = new Map();
4277
+ for (const row of db.prepare("SELECT * FROM coordinator_credential ORDER BY created_at, rowid").all()) {
4278
+ const cid = String(row["cid"]), id = claim("coordinator", cid), name = String(row["name"]);
4279
+ const generation = (issued.get(name) ?? 0) + 1;
4280
+ issued.set(name, generation);
4281
+ insert.run(id, "coordinator", cid, String(row["created_by"]), name, String(row["credential_hash"]), "act", String(row["repos"]), Number(row["per_hour"]), "api", generation, String(row["created_at"]), String(row["created_by"]), text(row["expires_at"]), null, text(row["revoked_at"]), null, null, null, null);
4282
+ if (id !== cid)
4283
+ for (const [table, column] of V118_COORDINATOR_COLUMNS) {
4284
+ if (tableExists(db, table) && hasColumn(db, table, column))
4285
+ db.prepare(`UPDATE "${table}" SET "${column}" = ? WHERE "${column}" = ?`).run(id, cid);
4286
+ }
4287
+ }
4288
+ }
4289
+ if (tableExists(db, "lead_credential")) {
4290
+ const issued = new Map();
4291
+ for (const row of db.prepare("SELECT * FROM lead_credential ORDER BY created_at, rowid").all()) {
4292
+ const legacy = String(row["id"]), owner = String(row["owner"]);
4293
+ const generation = (issued.get(owner) ?? 0) + 1;
4294
+ issued.set(owner, generation);
4295
+ insert.run(claim("lead", legacy), "lead", legacy, owner, "lead token", String(row["secret_hash"]), "act", null, null, "api", generation, String(row["created_at"]), String(row["created_by"]), null, null, text(row["revoked_at"]), text(row["revoked_by"]), null, null, null);
4296
+ }
4297
+ }
4298
+ for (const [table, kind] of Object.entries(V118_MERGED_TABLES)) {
4299
+ if (!tableExists(db, table))
4300
+ continue;
4301
+ const had = Number(db.prepare(`SELECT count(*) AS n FROM "${table}"`).get()["n"]);
4302
+ const moved = Number(db.prepare("SELECT count(*) AS n FROM credential WHERE kind = ?").get(kind)["n"]);
4303
+ if (had !== moved - already[kind])
4304
+ throw new Error(`${table} kept ${had} rows but ${moved} arrived in credential`);
4305
+ }
4306
+ // The references keep their rows and point at the one table (SQLite's documented procedure, as v115 does).
4307
+ // Node 24's SQLite opens in defensive mode, which forbids it; it is lifted for the rewrite only.
4308
+ if (referring.length > 0) {
4309
+ const version = Number(db.prepare("PRAGMA schema_version").get()?.["schema_version"]);
4310
+ const defensive = db.enableDefensive?.bind(db);
4311
+ defensive?.(false);
4312
+ db.exec("PRAGMA writable_schema = ON");
4313
+ try {
4314
+ const rewrite = db.prepare("UPDATE sqlite_master SET sql = ? WHERE type = 'table' AND name = ?");
4315
+ for (const row of referring)
4316
+ rewrite.run(row.sql.replace(V118_REFERENCES, "REFERENCES credential(id)"), row.name);
4317
+ db.exec(`PRAGMA schema_version = ${version + 1}`);
4318
+ }
4319
+ finally {
4320
+ db.exec("PRAGMA writable_schema = OFF");
4321
+ defensive?.(true);
4322
+ }
4323
+ }
4324
+ // Their indexes and the coordinator ledger trigger go with them; the one table gets its own.
4325
+ for (const table of present)
4326
+ db.exec(`DROP TABLE "${table}"`);
4327
+ db.exec(CREDENTIAL_INDEXES);
4328
+ if ([...orphans()].some(row => !before.has(row)))
4329
+ throw new Error("foreign keys did not survive the v118 credential merge");
4330
+ db.exec("COMMIT");
4331
+ }
4332
+ catch (error) {
4333
+ db.exec("ROLLBACK");
4334
+ throw error;
4335
+ }
4336
+ }
4337
+ finally {
4338
+ db.exec("PRAGMA foreign_keys = ON");
4339
+ }
4340
+ const check = db.prepare("PRAGMA integrity_check").all().map(row => String(row["integrity_check"]));
4341
+ if (check.length !== 1 || check[0] !== "ok")
4342
+ throw new Error(`the v118 credential merge left the database damaged (${check.slice(0, 3).join("; ")})`);
4343
+ }
4183
4344
  function migrateToV115(db) {
4184
4345
  const present = V115_DROPPED_TABLES.filter(table => tableExists(db, table));
4185
4346
  const referring = db.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND sql IS NOT NULL").all()
@@ -4652,6 +4813,10 @@ export class Store {
4652
4813
  // just the state verb's.
4653
4814
  if (ref !== undefined)
4654
4815
  this.supersedeSteerNotes(Number(ref["id"]), now);
4816
+ // Its open questions close the same way (v119), typed 'cancelled', with their holds and pages: a question about
4817
+ // work that will never run must leave every decision queue. Answered ones stay exactly as they were.
4818
+ if (ref !== undefined)
4819
+ this.closeQuestionsLocked(Number(ref["id"]), "cancelled", now);
4655
4820
  // The reason is part of the transition, not decoration: a
4656
4821
  // coordinator-filed task's dismissal writes its durable event IN THIS
4657
4822
  // transaction — operator text and typed machine codes alike (MCP spec
@@ -6358,11 +6523,11 @@ export class Store {
6358
6523
  // with a revocation or an access change, not a password change.
6359
6524
  this.db.prepare("DELETE FROM web_session WHERE account = ?").run(approver);
6360
6525
  if (by !== "credential-rotation") {
6361
- for (const row of this.db.prepare("SELECT id FROM api_token WHERE account = ? AND revoked_at IS NULL").all(approver))
6526
+ for (const row of this.db.prepare("SELECT id FROM credential WHERE kind = 'person' AND account = ? AND revoked_at IS NULL").all(approver))
6362
6527
  this.revokeApiToken(String(row["id"]), by, now, "ended with the account's standing");
6363
- for (const row of this.db.prepare("SELECT cid, name FROM coordinator_credential WHERE created_by = ? AND revoked_at IS NULL").all(approver)) {
6528
+ for (const row of this.db.prepare("SELECT id AS cid, name FROM credential WHERE kind = 'coordinator' AND created_by = ? AND revoked_at IS NULL").all(approver)) {
6364
6529
  const cid = String(row["cid"]);
6365
- this.db.prepare("UPDATE coordinator_credential SET revoked_at = ? WHERE cid = ?").run(stamp, cid);
6530
+ this.db.prepare("UPDATE credential SET revoked_at = ?, revoked_by = ? WHERE id = ? AND kind = 'coordinator'").run(stamp, by, cid);
6366
6531
  this.db.prepare("INSERT INTO coordinator_event (cid, kind, detail, created_at) VALUES (?, 'revoked', ?, ?)").run(cid, `with ${approver}'s standing, by ${by}`, stamp);
6367
6532
  this.sweepCoordinatorProposals(now, cid);
6368
6533
  this.recordAction({ at: stamp, actor: by, repo: null, taskId: null, runId: null, action: `coordinator revoked: ${String(row["name"])}`, outcome: "revoked", source: "access", detail: `made by ${approver}` });
@@ -6977,8 +7142,8 @@ export class Store {
6977
7142
  createApiToken(token, now) {
6978
7143
  this.transact(() => {
6979
7144
  const projects = token.projects ?? null;
6980
- this.db.prepare("INSERT INTO api_token (id, account, name, secret_hash, access, created_at, created_by, expires_at, projects_json, purpose) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
6981
- .run(token.id, token.account, token.name, token.secretHash, token.access, now.toISOString(), token.by, token.expiresAt, projects === null ? null : JSON.stringify(projects), token.purpose ?? "api");
7145
+ this.db.prepare("INSERT INTO credential (id, kind, public_id, account, name, secret_hash, access, created_at, created_by, expires_at, projects_json, purpose) VALUES (?, 'person', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
7146
+ .run(token.id, token.id, token.account, token.name, token.secretHash, token.access, now.toISOString(), token.by, token.expiresAt, projects === null ? null : JSON.stringify(projects), token.purpose ?? "api");
6982
7147
  this.recordAction({ at: now.toISOString(), actor: token.by, repo: null, taskId: null, runId: null, action: `API token created: ${token.name}`, outcome: token.access, source: "access",
6983
7148
  detail: `for ${token.account}, until ${token.expiresAt.slice(0, 10)}${projects === null ? "" : ` · ${projects.length === 0 ? "no projects" : projects.join(", ")}`} · id ${token.id}` });
6984
7149
  });
@@ -7000,9 +7165,11 @@ export class Store {
7000
7165
  return { ok: false, reason: "replaced" };
7001
7166
  const stamp = now.toISOString();
7002
7167
  const until = new Date(Math.min(at + Math.max(0, overlapMs), Date.parse(old.expiresAt))).toISOString();
7003
- this.db.prepare("INSERT INTO api_token (id, account, name, secret_hash, access, created_at, created_by, expires_at, projects_json, replaces) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
7004
- .run(replacement.id, old.account, old.name, replacement.secretHash, old.access, stamp, by, old.expiresAt, old.projects === null ? null : JSON.stringify(old.projects), old.id);
7005
- this.db.prepare("UPDATE api_token SET replaced_by = ?, overlap_until = ? WHERE id = ? AND replaced_by IS NULL").run(replacement.id, until, old.id);
7168
+ // The replacement is the token's next generation; it keeps every term, so rotating never widens or extends it.
7169
+ this.db.prepare(`INSERT INTO credential (id, kind, public_id, account, name, secret_hash, access, created_at, created_by, expires_at, projects_json, replaces, generation)
7170
+ VALUES (?, 'person', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, (SELECT generation + 1 FROM credential WHERE id = ?))`)
7171
+ .run(replacement.id, replacement.id, old.account, old.name, replacement.secretHash, old.access, stamp, by, old.expiresAt, old.projects === null ? null : JSON.stringify(old.projects), old.id, old.id);
7172
+ this.db.prepare("UPDATE credential SET replaced_by = ?, overlap_until = ? WHERE id = ? AND kind = 'person' AND replaced_by IS NULL").run(replacement.id, until, old.id);
7006
7173
  this.recordAction({ at: stamp, actor: by, repo: null, taskId: null, runId: null, action: `API token rotated: ${old.name}`, outcome: old.access, source: "access",
7007
7174
  detail: `${old.account}'s · id ${old.id} → ${replacement.id}, the old one stops ${until.slice(0, 16).replace("T", " ")} UTC` });
7008
7175
  return { ok: true, row: this.apiTokenSecret(replacement.id).row };
@@ -7010,30 +7177,46 @@ export class Store {
7010
7177
  }
7011
7178
  /** v111: record the end of each replaced token whose overlap has passed (it already signs no one in). */
7012
7179
  endRotatedApiTokens(now) {
7013
- const due = this.db.prepare("SELECT id, overlap_until FROM api_token WHERE revoked_at IS NULL AND overlap_until IS NOT NULL AND overlap_until <= ?").all(now.toISOString());
7180
+ const due = this.db.prepare("SELECT id, overlap_until FROM credential WHERE kind = 'person' AND revoked_at IS NULL AND overlap_until IS NOT NULL AND overlap_until <= ?").all(now.toISOString());
7014
7181
  for (const row of due)
7015
7182
  this.revokeApiToken(String(row["id"]), "system", new Date(String(row["overlap_until"])), "replaced; its overlap ended");
7016
7183
  return due.length;
7017
7184
  }
7018
- /** A token by id with its kept hash (for the check), or null. */
7185
+ /**
7186
+ * D7: the one check every machine credential takes. The credential a presented secret is (its kind and id), when
7187
+ * its kept hash matches in constant time; null otherwise. Whether it is live, and what its kind may do, stays with
7188
+ * each caller's own policy (a person's API token, a lead token, a coordinator).
7189
+ */
7190
+ presentedCredential(presented) {
7191
+ const parsed = parseCredential(presented);
7192
+ if (parsed === null)
7193
+ return null;
7194
+ const row = parsed.kind === "coordinator"
7195
+ ? this.db.prepare("SELECT id, secret_hash FROM credential WHERE kind = 'coordinator' AND secret_hash = ?").get(hashSecret(parsed.secret))
7196
+ : this.db.prepare("SELECT id, secret_hash FROM credential WHERE kind = ? AND public_id = ?").get(parsed.kind, parsed.id);
7197
+ if (row === undefined || !secretMatches(parsed.secret, String(row["secret_hash"])))
7198
+ return null;
7199
+ return { kind: parsed.kind, id: String(row["id"]) };
7200
+ }
7201
+ /** A person's API token by id with its kept hash (for the check), or null. */
7019
7202
  apiTokenSecret(id) {
7020
- const row = this.db.prepare("SELECT * FROM api_token WHERE id = ?").get(id);
7203
+ const row = this.db.prepare("SELECT * FROM credential WHERE id = ? AND kind = 'person'").get(id);
7021
7204
  return row === undefined ? null : { row: readApiToken(row), secretHash: String(row["secret_hash"]) };
7022
7205
  }
7023
7206
  /** Tokens, newest first: one person's, or everyone's (null). */
7024
7207
  apiTokens(account) {
7025
- return (account === null ? this.db.prepare("SELECT * FROM api_token ORDER BY created_at DESC").all() : this.db.prepare("SELECT * FROM api_token WHERE account = ? ORDER BY created_at DESC").all(account)).map(readApiToken);
7208
+ return (account === null ? this.db.prepare("SELECT * FROM credential WHERE kind = 'person' ORDER BY created_at DESC").all() : this.db.prepare("SELECT * FROM credential WHERE kind = 'person' AND account = ? ORDER BY created_at DESC").all(account)).map(readApiToken);
7026
7209
  }
7027
7210
  /** Kept at most once a minute: a busy script doesn't write on every request. */
7028
7211
  touchApiToken(id, now) {
7029
- this.db.prepare("UPDATE api_token SET last_used_at = ? WHERE id = ? AND (last_used_at IS NULL OR last_used_at < ?)").run(now.toISOString(), id, new Date(now.getTime() - 60_000).toISOString());
7212
+ this.db.prepare("UPDATE credential SET last_used_at = ? WHERE id = ? AND kind = 'person' AND (last_used_at IS NULL OR last_used_at < ?)").run(now.toISOString(), id, new Date(now.getTime() - 60_000).toISOString());
7030
7213
  }
7031
7214
  revokeApiToken(id, by, now, why = "revoked") {
7032
7215
  return this.transact(() => {
7033
- const row = this.db.prepare("SELECT name, account FROM api_token WHERE id = ? AND revoked_at IS NULL").get(id);
7216
+ const row = this.db.prepare("SELECT name, account FROM credential WHERE id = ? AND kind = 'person' AND revoked_at IS NULL").get(id);
7034
7217
  if (row === undefined)
7035
7218
  return false;
7036
- this.db.prepare("UPDATE api_token SET revoked_at = ?, revoked_by = ? WHERE id = ?").run(now.toISOString(), by, id);
7219
+ this.db.prepare("UPDATE credential SET revoked_at = ?, revoked_by = ? WHERE id = ? AND kind = 'person'").run(now.toISOString(), by, id);
7037
7220
  this.recordAction({ at: now.toISOString(), actor: by, repo: null, taskId: null, runId: null, action: `API token revoked: ${String(row["name"])}`, outcome: "revoked", source: "access", detail: `${String(row["account"])}'s · ${why}` });
7038
7221
  return true;
7039
7222
  });
@@ -7146,7 +7329,7 @@ export class Store {
7146
7329
  .run(next.refreshHash, next.accessExpiresAt, token, ask.refreshHash);
7147
7330
  if (Number(moved.changes) !== 1)
7148
7331
  return invalid;
7149
- this.db.prepare("UPDATE api_token SET secret_hash = ? WHERE id = ? AND revoked_at IS NULL").run(next.accessHash, token);
7332
+ this.db.prepare("UPDATE credential SET secret_hash = ? WHERE id = ? AND kind = 'person' AND revoked_at IS NULL").run(next.accessHash, token);
7150
7333
  this.db.prepare("INSERT INTO oauth_refresh (token, hash) VALUES (?, ?)").run(token, next.refreshHash);
7151
7334
  return { ok: true, access: kept.access };
7152
7335
  });
@@ -9780,6 +9963,66 @@ export class Store {
9780
9963
  return { ok: true };
9781
9964
  });
9782
9965
  }
9966
+ /**
9967
+ * A rewritten scope starts planning again (v119), in one transaction: the task's planning generation advances, a
9968
+ * requested or drafted plan becomes requested with no strikes, and what the old goal produced leaves the active
9969
+ * planning context — its open questions close (typed 'scope-changed', never a fake answer, their holds and pages
9970
+ * with them) and a revision still waiting for a person is rejected with its hold. The old runs, plans, revisions,
9971
+ * answers and ledger rows all stay: generation-bound reads simply stop offering them to the next planner. Only a
9972
+ * task already planning restarts; false (nothing changed) for any other.
9973
+ */
9974
+ restartPlanning(taskRef, now) {
9975
+ return this.transact(() => {
9976
+ const moved = this.db
9977
+ .prepare("UPDATE task_ref SET planning_generation = planning_generation + 1, plan = 'requested', plan_strikes = 0 WHERE id = ? AND plan IN ('requested','drafted')")
9978
+ .run(taskRef);
9979
+ if (Number(moved.changes) === 0)
9980
+ return false;
9981
+ const ref = this.db.prepare("SELECT external_id, repo, planning_generation FROM task_ref WHERE id = ?").get(taskRef);
9982
+ const generation = Number(ref["planning_generation"]);
9983
+ this.closeQuestionsLocked(taskRef, "scope-changed", now, generation);
9984
+ const blocked = this.db
9985
+ .prepare("SELECT id FROM plan_revision WHERE task_ref = ? AND status = 'blocked' AND planning_generation < ?")
9986
+ .all(taskRef, generation)
9987
+ .map(row => Number(row["id"]));
9988
+ for (const id of blocked) {
9989
+ this.resolvePlanRevision(id, "rejected", "toolroll", now);
9990
+ this.releaseOwnedHold("revision", String(id));
9991
+ }
9992
+ this.recordAction({
9993
+ at: now.toISOString(), actor: currentActor() === null ? "system" : actorLabel(currentActor()), repo: ref["repo"] === null ? null : String(ref["repo"]),
9994
+ taskId: String(ref["external_id"]), runId: null, action: "planning restarted", outcome: "scope changed", source: "work",
9995
+ });
9996
+ this.bumpWake();
9997
+ return true;
9998
+ });
9999
+ }
10000
+ /**
10001
+ * Close a task's open and expired questions without answering them (v119): typed with why, no choice, their
10002
+ * holds lifted and their pages resolved, so they leave every decision queue. Answered questions are never touched.
10003
+ * `belowGeneration` limits it to questions asked in earlier planning generations. Caller holds the transaction.
10004
+ */
10005
+ closeQuestionsLocked(taskRef, reason, now, belowGeneration) {
10006
+ const ids = this.db
10007
+ .prepare(`SELECT decision.id FROM decision JOIN run ON run.id = decision.run
10008
+ WHERE run.task_ref = ? AND decision.state IN ('open','expired') AND (? IS NULL OR run.planning_generation < ?)
10009
+ ORDER BY decision.id`)
10010
+ .all(taskRef, belowGeneration ?? null, belowGeneration ?? null)
10011
+ .map(row => Number(row["id"]));
10012
+ const close = this.db.prepare("UPDATE decision SET state = 'answered', answered_at = ?, answered_by = 'toolroll', superseded_reason = ? WHERE id = ? AND state IN ('open','expired')");
10013
+ for (const id of ids) {
10014
+ close.run(now.toISOString(), reason, id);
10015
+ this.releaseOwnedHold("decision", String(id));
10016
+ this.resolveEpisode(`decision:${id}`, now);
10017
+ }
10018
+ return ids;
10019
+ }
10020
+ /** The number the next plan revision of a task takes: one past every revision it ever had, in any planning
10021
+ * generation, so a restarted plan's revisions never collide with the history they follow. */
10022
+ nextPlanRevisionNumber(taskRef) {
10023
+ const row = this.db.prepare("SELECT coalesce(max(revision), 0) AS n FROM plan_revision WHERE task_ref = ?").get(taskRef);
10024
+ return Number(row?.["n"] ?? 0) + 1;
10025
+ }
9783
10026
  /** Move the planning state; null clears it. Caller owns the transaction story. */
9784
10027
  setPlanState(taskRef, state) {
9785
10028
  this.db.prepare("UPDATE task_ref SET plan = ? WHERE id = ?").run(state, taskRef);
@@ -10912,10 +11155,10 @@ export class Store {
10912
11155
  * before signing). */
10913
11156
  coordinatorProvenanceOf(taskId) {
10914
11157
  const row = this.db
10915
- .prepare(`SELECT c.name AS name, c.cid AS cid,
10916
- (SELECT MIN(e.created_at) FROM coordinator_event e WHERE e.cid = c.cid AND e.task_id = task_ref.external_id AND e.kind = 'filed') AS filed_at
11158
+ .prepare(`SELECT c.name AS name, c.public_id AS cid,
11159
+ (SELECT MIN(e.created_at) FROM coordinator_event e WHERE e.cid = c.id AND e.task_id = task_ref.external_id AND e.kind = 'filed') AS filed_at
10917
11160
  FROM task_ref
10918
- JOIN coordinator_credential c ON c.cid = task_ref.coordinator_cid
11161
+ JOIN credential c ON c.id = task_ref.coordinator_cid AND c.kind = 'coordinator'
10919
11162
  WHERE task_ref.backend = ? AND task_ref.external_id = ?`)
10920
11163
  .get(BUILT_IN, taskId);
10921
11164
  if (row === undefined)
@@ -11491,8 +11734,8 @@ export class Store {
11491
11734
  })();
11492
11735
  const inserted = this.db
11493
11736
  .prepare(`INSERT INTO run (task_ref, lease_id, runner, branch, worktree, model, role, provider, parent_run, session_id, quality_mode,
11494
- started_at, review_attempt)
11495
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
11737
+ started_at, review_attempt, planning_generation)
11738
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, (SELECT planning_generation FROM task_ref WHERE id = ?1))`)
11496
11739
  .run(run.taskRef, run.leaseId, run.runner, run.branch ?? null, run.worktree ?? null, model, role, provider ?? "claude", run.parentRun ?? null, run.sessionId ?? null, qualityMode, run.now.toISOString(),
11497
11740
  // The root reviewer's ordinal (v50) rides the insert: a root and
11498
11741
  // its attempt number are one fact, and the ordinal index refuses a
@@ -12437,12 +12680,14 @@ export class Store {
12437
12680
  * reads in the order the questions arrived. "unanswered" is open + expired:
12438
12681
  * expiry makes a decision louder, never gone.
12439
12682
  */
12440
- /** The newest planner run's plan document for a task, when one exists. */
12683
+ /** The newest planner run's plan document for a task, when one exists in its current planning generation (v119:
12684
+ * a plan drafted for a scope since rewritten is history, read by its artifact id). */
12441
12685
  latestPlanArtifact(taskRef) {
12442
12686
  const row = this.db
12443
12687
  .prepare(`SELECT artifact.* FROM artifact
12444
12688
  JOIN run ON run.id = artifact.run
12445
12689
  WHERE run.task_ref = ? AND run.role = 'planner' AND artifact.kind = 'plan'
12690
+ AND run.planning_generation = (SELECT planning_generation FROM task_ref WHERE id = run.task_ref)
12446
12691
  ORDER BY artifact.id DESC LIMIT 1`)
12447
12692
  .get(taskRef);
12448
12693
  return row === undefined ? null : readArtifact(row);
@@ -12457,6 +12702,7 @@ export class Store {
12457
12702
  JOIN run ON run.id = artifact.run
12458
12703
  WHERE run.task_ref = ? AND run.role = 'planner' AND artifact.kind = 'plan-contract'
12459
12704
  AND artifact.key LIKE '%/plan-contract.json'
12705
+ AND run.planning_generation = (SELECT planning_generation FROM task_ref WHERE id = run.task_ref)
12460
12706
  ORDER BY artifact.id DESC LIMIT 1`)
12461
12707
  .get(taskRef);
12462
12708
  return row === undefined ? null : readArtifact(row);
@@ -12484,16 +12730,17 @@ export class Store {
12484
12730
  const inserted = this.db
12485
12731
  .prepare(`INSERT INTO plan_revision
12486
12732
  (task_ref, revision, artifact, parent_hash, reason, evidence_link, author, origin_run,
12487
- kind, authority_kind, authority_digest, changed_fields, status, created_at)
12488
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
12733
+ kind, authority_kind, authority_digest, changed_fields, status, created_at, planning_generation)
12734
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, (SELECT planning_generation FROM task_ref WHERE id = ?1))`)
12489
12735
  .run(revision.taskRef, revision.revision, revision.artifact, revision.parentHash, revision.reason, revision.evidenceLink, revision.author, revision.originRun, revision.kind, revision.authorityKind, revision.authorityDigest, JSON.stringify(revision.changedFields), revision.status, now.toISOString());
12490
12736
  return Number(inserted.lastInsertRowid);
12491
12737
  }
12492
- /** Every revision for a task, newest first — the immutable history a
12493
- * reader can show without a second table. */
12738
+ /** Every revision for a task in its current planning generation, newest
12739
+ * first — the immutable history a reader can show without a second table
12740
+ * (v119: an earlier generation's rows stay, read by id). */
12494
12741
  listPlanRevisions(taskRef) {
12495
12742
  return this.db
12496
- .prepare("SELECT * FROM plan_revision WHERE task_ref = ? ORDER BY revision DESC")
12743
+ .prepare("SELECT * FROM plan_revision WHERE task_ref = ? AND planning_generation = (SELECT planning_generation FROM task_ref WHERE id = plan_revision.task_ref) ORDER BY revision DESC")
12497
12744
  .all(taskRef)
12498
12745
  .map(readPlanRevision);
12499
12746
  }
@@ -12504,7 +12751,7 @@ export class Store {
12504
12751
  * `latestPlanArtifact`'s synthetic revision 1 in both cases. */
12505
12752
  currentPlanRevision(taskRef) {
12506
12753
  const row = this.db
12507
- .prepare("SELECT * FROM plan_revision WHERE task_ref = ? AND status = 'applied' ORDER BY revision DESC LIMIT 1")
12754
+ .prepare("SELECT * FROM plan_revision WHERE task_ref = ? AND planning_generation = (SELECT planning_generation FROM task_ref WHERE id = plan_revision.task_ref) AND status = 'applied' ORDER BY revision DESC LIMIT 1")
12508
12755
  .get(taskRef);
12509
12756
  return row === undefined ? null : readPlanRevision(row);
12510
12757
  }
@@ -12512,7 +12759,7 @@ export class Store {
12512
12759
  * 'blocked' awaiting an operator's decision — what a reader shows
12513
12760
  * alongside the current one to render a pending proposal at all. */
12514
12761
  latestPlanRevision(taskRef) {
12515
- const row = this.db.prepare("SELECT * FROM plan_revision WHERE task_ref = ? ORDER BY revision DESC LIMIT 1").get(taskRef);
12762
+ const row = this.db.prepare("SELECT * FROM plan_revision WHERE task_ref = ? AND planning_generation = (SELECT planning_generation FROM task_ref WHERE id = plan_revision.task_ref) ORDER BY revision DESC LIMIT 1").get(taskRef);
12516
12763
  return row === undefined ? null : readPlanRevision(row);
12517
12764
  }
12518
12765
  getPlanRevision(id) {
@@ -12600,7 +12847,8 @@ export class Store {
12600
12847
  .get(taskRef);
12601
12848
  return row === undefined ? null : readArtifact(row);
12602
12849
  }
12603
- /** Answered questions for one task, newest first — the planner's memory. */
12850
+ /** Answered questions for one task, newest first — the planner's memory. Only real answers from its current
12851
+ * planning generation (v119): a question closed unanswered, or asked about a scope since rewritten, is history. */
12604
12852
  answeredDecisionsFor(taskId, limit = 5) {
12605
12853
  return this.db
12606
12854
  .prepare(`SELECT decision.question, decision.choice, decision.note
@@ -12608,7 +12856,8 @@ export class Store {
12608
12856
  JOIN run ON run.id = decision.run
12609
12857
  JOIN task_ref ON task_ref.id = run.task_ref
12610
12858
  WHERE task_ref.backend = ? AND task_ref.external_id = ?
12611
- AND decision.answered_at IS NOT NULL
12859
+ AND decision.answered_at IS NOT NULL AND decision.superseded_reason IS NULL
12860
+ AND run.planning_generation = task_ref.planning_generation
12612
12861
  ORDER BY decision.answered_at DESC, decision.id DESC LIMIT ?`)
12613
12862
  .all(BUILT_IN, taskId, Math.max(1, Math.min(Math.floor(limit), 20)))
12614
12863
  .map(row => ({
@@ -12707,6 +12956,9 @@ export class Store {
12707
12956
  JOIN task_ref ON task_ref.id = run.task_ref
12708
12957
  WHERE run.task_ref = ? AND decision.state = 'answered'
12709
12958
  AND decision.contestant IS NULL
12959
+ -- v119: a question closed unanswered carries no answer, and one asked about a rewritten scope is history.
12960
+ AND decision.superseded_reason IS NULL
12961
+ AND run.planning_generation = task_ref.planning_generation
12710
12962
  AND NOT EXISTS (
12711
12963
  SELECT 1 FROM run_decision
12712
12964
  JOIN run AS delivered ON delivered.id = run_decision.run
@@ -12860,6 +13112,9 @@ export class Store {
12860
13112
  const existing = this.getDecision(answer.id);
12861
13113
  if (existing === null)
12862
13114
  return { ok: false, reason: "unknown-decision" };
13115
+ // A question closed without an answer (v119) stays closed: its task's scope changed or the task was cancelled.
13116
+ if (existing.supersededReason !== null)
13117
+ return { ok: false, reason: "closed" };
12863
13118
  if (!existing.options.some(option => option.id === answer.choice)) {
12864
13119
  return { ok: false, reason: "bad-option" };
12865
13120
  }
@@ -16692,9 +16947,13 @@ export class Store {
16692
16947
  mintLeadCredential(owner, by, now) {
16693
16948
  return this.transact(() => {
16694
16949
  const stamp = now.toISOString();
16695
- this.db.prepare("UPDATE lead_credential SET revoked_at = ?, revoked_by = ? WHERE owner = ? AND revoked_at IS NULL").run(stamp, by, owner);
16696
- const minted = mintLeadToken();
16697
- this.db.prepare("INSERT INTO lead_credential (id, owner, secret_hash, created_at, created_by) VALUES (?, ?, ?, ?, ?)").run(minted.id, owner, minted.hash, stamp, by);
16950
+ this.db.prepare("UPDATE credential SET revoked_at = ?, revoked_by = ? WHERE kind = 'lead' AND account = ? AND revoked_at IS NULL").run(stamp, by, owner);
16951
+ let minted = mintLeadToken();
16952
+ // One id per credential across kinds; the canonical id is the one the token presents.
16953
+ while (this.db.prepare("SELECT 1 FROM credential WHERE id = ? OR (kind = 'lead' AND public_id = ?)").get(minted.id, minted.id) !== undefined)
16954
+ minted = mintLeadToken();
16955
+ this.db.prepare(`INSERT INTO credential (id, kind, public_id, account, name, secret_hash, access, created_at, created_by, generation)
16956
+ VALUES (?, 'lead', ?, ?, 'lead token', ?, 'act', ?, ?, 1 + (SELECT count(*) FROM credential WHERE kind = 'lead' AND account = ?))`).run(minted.id, minted.id, owner, minted.hash, stamp, by, owner);
16698
16957
  this.recordAction({ at: stamp, actor: by, repo: null, taskId: null, runId: null, action: "lead token created", outcome: "recorded", source: "access", detail: `lead for ${owner}` });
16699
16958
  return { id: minted.id, token: minted.token };
16700
16959
  });
@@ -16702,7 +16961,7 @@ export class Store {
16702
16961
  /** End every live lead token for one person. */
16703
16962
  revokeLeadCredentials(owner, by, now) {
16704
16963
  return this.transact(() => {
16705
- const ended = Number(this.db.prepare("UPDATE lead_credential SET revoked_at = ?, revoked_by = ? WHERE owner = ? AND revoked_at IS NULL").run(now.toISOString(), by, owner).changes);
16964
+ const ended = Number(this.db.prepare("UPDATE credential SET revoked_at = ?, revoked_by = ? WHERE kind = 'lead' AND account = ? AND revoked_at IS NULL").run(now.toISOString(), by, owner).changes);
16706
16965
  if (ended > 0)
16707
16966
  this.recordAction({ at: now.toISOString(), actor: by, repo: null, taskId: null, runId: null, action: "lead token revoked", outcome: "recorded", source: "access", detail: `lead for ${owner}` });
16708
16967
  return ended;
@@ -16710,14 +16969,14 @@ export class Store {
16710
16969
  }
16711
16970
  /** Whose lead a presented token is: a live token whose owner is still an approver, or null. */
16712
16971
  leadFor(presented) {
16713
- const parsed = parseLeadToken(presented);
16714
- if (parsed === null)
16972
+ const found = this.presentedCredential(presented);
16973
+ if (found?.kind !== "lead")
16715
16974
  return null;
16716
- const row = this.db.prepare("SELECT owner, secret_hash FROM lead_credential WHERE id = ? AND revoked_at IS NULL").get(parsed.id);
16717
- if (row === undefined || !leadSecretMatches(parsed.secret, String(row["secret_hash"])))
16975
+ const row = this.db.prepare("SELECT account, public_id FROM credential WHERE id = ? AND kind = 'lead' AND revoked_at IS NULL").get(found.id);
16976
+ if (row === undefined)
16718
16977
  return null;
16719
- const owner = String(row["owner"]), account = this.accountOf(owner);
16720
- return account === null || account.revokedAt !== null || account.role !== "approver" ? null : { owner, id: parsed.id };
16978
+ const owner = String(row["account"]), account = this.accountOf(owner);
16979
+ return account === null || account.revokedAt !== null || account.role !== "approver" ? null : { owner, id: String(row["public_id"]) };
16721
16980
  }
16722
16981
  /** Record who did this to a task, when someone is acting. The lead's acts are also in the ledger as "lead for <owner>". */
16723
16982
  noteTaskAct(taskRef, act, now, handed, actor = currentActor()) {
@@ -17277,6 +17536,7 @@ function readRun(row) {
17277
17536
  taskRef: Number(row["task_ref"]),
17278
17537
  leaseId: String(row["lease_id"]),
17279
17538
  runner: String(row["runner"]),
17539
+ planningGeneration: Number(row["planning_generation"] ?? 0),
17280
17540
  watchIncarnation: row["watch_incarnation"] == null ? null : String(row["watch_incarnation"]),
17281
17541
  reviewAttempt: wholeNumber(row["review_attempt"]),
17282
17542
  role: String(row["role"] ?? "builder"),
@@ -17409,6 +17669,7 @@ function readDecision(row) {
17409
17669
  answeredVia: row["answered_via"] === null ? null : String(row["answered_via"]),
17410
17670
  choice: row["choice"] === null ? null : String(row["choice"]),
17411
17671
  note: row["note"] === null ? null : String(row["note"]),
17672
+ supersededReason: row["superseded_reason"] === "scope-changed" || row["superseded_reason"] === "cancelled" ? row["superseded_reason"] : null,
17412
17673
  };
17413
17674
  }
17414
17675
  function readArtifact(row) {
@@ -17596,6 +17857,7 @@ function readTaskRef(row) {
17596
17857
  ? null
17597
17858
  : String(row["plan"]),
17598
17859
  planStrikes: Number(row["plan_strikes"] ?? 0),
17860
+ planningGeneration: Number(row["planning_generation"] ?? 0),
17599
17861
  deliverable: row["deliverable"] === "report" ? "report" : "branch",
17600
17862
  strikes: Number(row["strikes"] ?? 0),
17601
17863
  agentProvider: row["agent_provider"] === null || row["agent_provider"] === undefined