toolroll 0.9.71 → 0.9.73

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 (131) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/dist/agent-fence.d.ts +10 -2
  3. package/dist/agent-fence.js +47 -9
  4. package/dist/approval-authority.d.ts +20 -0
  5. package/dist/approval-authority.js +67 -0
  6. package/dist/assignment.js +26 -4
  7. package/dist/browser/workspace.css +1 -1
  8. package/dist/browser/workspace.js +28 -28
  9. package/dist/builder.js +48 -6
  10. package/dist/chat-actions.js +13 -8
  11. package/dist/chat-decide.d.ts +47 -1
  12. package/dist/chat-decide.js +186 -5
  13. package/dist/check-access.js +2 -2
  14. package/dist/check-report.d.ts +109 -0
  15. package/dist/check-report.js +205 -0
  16. package/dist/claim.js +1 -1
  17. package/dist/coding-provider.js +4 -2
  18. package/dist/commit-extract.d.ts +111 -0
  19. package/dist/commit-extract.js +514 -0
  20. package/dist/contracts/console-api.d.ts +4 -4
  21. package/dist/contracts/console-api.js +5 -4
  22. package/dist/contracts/flow.d.ts +16 -0
  23. package/dist/contracts/flow.js +3 -0
  24. package/dist/contracts/release-check.d.ts +335 -0
  25. package/dist/contracts/release-check.js +159 -0
  26. package/dist/contracts/release-terms.d.ts +66 -0
  27. package/dist/contracts/release-terms.js +74 -0
  28. package/dist/contracts/verification-receipt.d.ts +42 -8
  29. package/dist/contracts/verification-receipt.js +24 -7
  30. package/dist/contracts/verification-view.d.ts +1 -0
  31. package/dist/deploy-gate.d.ts +17 -0
  32. package/dist/deploy-gate.js +68 -0
  33. package/dist/dispatch.js +3 -10
  34. package/dist/dispose.js +4 -1
  35. package/dist/evidence-pack.d.ts +1 -1
  36. package/dist/evidence-pack.js +20 -2
  37. package/dist/evidence.d.ts +1 -0
  38. package/dist/evidence.js +2 -1
  39. package/dist/exec.d.ts +1 -1
  40. package/dist/exec.js +7 -3
  41. package/dist/flow-engine.js +5 -1
  42. package/dist/flow-gallery-ui.js +4 -2
  43. package/dist/flow-gallery.d.ts +3 -0
  44. package/dist/flow-gallery.js +6 -2
  45. package/dist/flow-pull-request.d.ts +5 -2
  46. package/dist/flow-pull-request.js +14 -5
  47. package/dist/flow-schedule.js +25 -6
  48. package/dist/flow-triggers.d.ts +8 -0
  49. package/dist/flow-triggers.js +15 -0
  50. package/dist/flows-cli.js +13 -3
  51. package/dist/flows-ui.js +7 -1
  52. package/dist/guides.js +10 -1
  53. package/dist/invoke.d.ts +17 -6
  54. package/dist/invoke.js +48 -18
  55. package/dist/kits-ui.js +4 -2
  56. package/dist/kits.d.ts +6 -4
  57. package/dist/kits.js +9 -11
  58. package/dist/lead-status.js +5 -2
  59. package/dist/live-terminal.d.ts +10 -6
  60. package/dist/live-terminal.js +55 -14
  61. package/dist/mcp-connect.d.ts +10 -0
  62. package/dist/mcp-connect.js +2 -0
  63. package/dist/nightly-check.d.ts +104 -0
  64. package/dist/nightly-check.js +362 -0
  65. package/dist/operate.js +39 -2
  66. package/dist/person-proof.d.ts +2 -0
  67. package/dist/person-proof.js +14 -0
  68. package/dist/plan-auto.js +4 -4
  69. package/dist/planner-source.d.ts +1 -1
  70. package/dist/project-delete-ui.js +1 -1
  71. package/dist/project-tools.js +3 -1
  72. package/dist/proof.js +2 -0
  73. package/dist/protected-storage.d.ts +67 -0
  74. package/dist/protected-storage.js +398 -0
  75. package/dist/publish.js +156 -144
  76. package/dist/pull-request-flow.d.ts +2 -0
  77. package/dist/pull-request-flow.js +24 -2
  78. package/dist/release-adapters.d.ts +16 -1
  79. package/dist/release-adapters.js +292 -29
  80. package/dist/release-authority.d.ts +118 -0
  81. package/dist/release-authority.js +135 -0
  82. package/dist/release-card.d.ts +76 -0
  83. package/dist/release-card.js +252 -0
  84. package/dist/release-carry.d.ts +236 -0
  85. package/dist/release-carry.js +664 -0
  86. package/dist/release-cli.d.ts +3 -0
  87. package/dist/release-cli.js +158 -6
  88. package/dist/release-coverage.js +3 -0
  89. package/dist/release-git.d.ts +43 -0
  90. package/dist/release-git.js +106 -0
  91. package/dist/release-recheck.d.ts +146 -0
  92. package/dist/release-recheck.js +657 -0
  93. package/dist/release-view.d.ts +34 -0
  94. package/dist/release-view.js +27 -0
  95. package/dist/release.d.ts +139 -3
  96. package/dist/release.js +641 -250
  97. package/dist/remote-main.d.ts +2 -0
  98. package/dist/remote-main.js +1 -1
  99. package/dist/result-completion.d.ts +6 -0
  100. package/dist/result-completion.js +15 -1
  101. package/dist/result-follow-ups.js +29 -1
  102. package/dist/sandbox-runtime.d.ts +16 -5
  103. package/dist/sandbox-runtime.js +19 -6
  104. package/dist/scope.d.ts +122 -5
  105. package/dist/scope.js +236 -7
  106. package/dist/server/flows.js +40 -10
  107. package/dist/server/render-settings.js +1 -1
  108. package/dist/server/render-tasks.d.ts +8 -0
  109. package/dist/server/render-tasks.js +50 -9
  110. package/dist/server/route-table.js +1 -0
  111. package/dist/server/settings.js +26 -5
  112. package/dist/server/task-views.js +4 -0
  113. package/dist/server/tasks.js +12 -0
  114. package/dist/setup-guide.d.ts +2 -2
  115. package/dist/store.d.ts +230 -5
  116. package/dist/store.js +733 -37
  117. package/dist/subagent-memory.d.ts +1 -0
  118. package/dist/subagent-memory.js +1 -1
  119. package/dist/subagent-tools.d.ts +22 -6
  120. package/dist/subagent-tools.js +44 -8
  121. package/dist/subagent-work.d.ts +2 -0
  122. package/dist/subagent-work.js +5 -2
  123. package/dist/subagents-ui.js +6 -4
  124. package/dist/task-outcome-cli.js +7 -3
  125. package/dist/telegram.d.ts +6 -1
  126. package/dist/telegram.js +58 -6
  127. package/dist/toolroll-update.js +2 -2
  128. package/dist/verification-evidence.d.ts +25 -1
  129. package/dist/verification-evidence.js +57 -2
  130. package/dist/work-index.js +3 -6
  131. package/package.json +1 -1
package/dist/store.js CHANGED
@@ -57,9 +57,10 @@ import { hashSecret, mintLeadToken, parseCredential, secretMatches, tokenPurpose
57
57
  * are deferred until after M4, and a column that exists to serve a feature
58
58
  * nobody has designed yet is a column that will be wrong when they do.
59
59
  */
60
- import { createHash, randomBytes, randomUUID } from "node:crypto";
60
+ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
61
61
  import { existsSync, mkdirSync, statSync, unlinkSync } from "node:fs";
62
62
  import { createRequire } from "node:module";
63
+ import { execFileSync } from "node:child_process";
63
64
  import { dirname } from "node:path";
64
65
  import { hostname } from "node:os";
65
66
  import { ownedProcessCount, runOwnerTag } from "./exec.js";
@@ -70,7 +71,8 @@ import { containerEmptiness } from "./container-state.js";
70
71
  import { hasDisguisedText, hasForbiddenControls, validateNote } from "./decision.js";
71
72
  import { parseReviewContext, reviewContextCustodyProblem } from "./review-context.js";
72
73
  import { foldReview, manualReviewOnly } from "./proof.js";
73
- import { approvalOf, digestOf, canonicalProfileJson, chainFromJson, profileDigestOf, profileFromJson, scopeAuthorityOf, routeParityProblem, parseAcceptanceCriteria, exactAcceptance, exactStringList, exactSafeIntegerOrNull } from "./scope.js";
74
+ import { liveReleaseOf } from "./scope.js";
75
+ import { APPROVAL_BASES, approvalOf, machineActing, personProof, proofDetailOf, quarantinedAuthor, sealOriginOf, digestOf, canonicalProfileJson, chainFromJson, profileDigestOf, profileFromJson, scopeAuthorityOf, routeParityProblem, parseAcceptanceCriteria, exactAcceptance, exactStringList, exactSafeIntegerOrNull } from "./scope.js";
74
76
  import { resolveScopeProfile, resolveRouteCandidates, exactPinOf, routeOfTask, agentChoicesFor } from "./agentconfig.js";
75
77
  import { canonicalOverridesJson, canonicalRouteJson, isRiskLevel, isTaskSize, headroomFrom, makesNoPlan, legOf, overridesFromJson, phaseOfRole, recommendRoute, routeDigestOf, routeFromJson, routeProblems, routeStampProblem, ROUTE_ERA, PROVIDER_ID_LIST, } from "./phase-routing.js";
76
78
  import { readAuthModeStrict } from "./keys.js";
@@ -80,6 +82,8 @@ import {} from "./provider.js";
80
82
  import { authenticate as runnerAuthenticate } from "./runner.js";
81
83
  import { stampNewProjectLevel } from "./check-levels.js";
82
84
  import { parseCheckProgressSnapshot } from "./check-progress.js";
85
+ import { authorityStandsSql } from "./approval-authority.js";
86
+ import { releasePolicyDigest, storedReleaseTerms, readReleaseTerms, readReleasePolicy } from "./contracts/release-terms.js";
83
87
  import { accountReachesSql, normalizeProjectAccess, projectAccessAllows, readProjectAccess } from "./project-access.js";
84
88
  import { LEDGER_COLUMNS, LEDGER_SCHEMA, LEDGER_TABLE, LEDGER_V99_TABLE, installLedgerTriggers } from "./action-ledger.js";
85
89
  import { PLAN_AUTO_SCHEMA } from "./plan-auto.js";
@@ -725,7 +729,15 @@ const REGISTERED_AT_FILING = "again, as it was filed";
725
729
  // v133 adds, and changes no saved row: a project's sandbox settings (project_sandbox; project-sandbox.ts,
726
730
  // docs/plans/sandbox-parity.md C9) — what its builds may not read, the macOS services and protected files they may — and
727
731
  // the fence policy a flow was made under (flow.fence_policy; saved flows read NULL: v1, the home configuration denied).
728
- export const SCHEMA_VERSION = 133;
732
+ // v134 adds, and changes no saved row: who gave each yes (docs/plans/approval-policy.md) — a protected-work vote's proof
733
+ // (scope_approval_vote.proof) and a flow decision's authority (flow_event.authority), each NULL on saved rows: no proof
734
+ // on record. Saved approvals a machine credential gave are read for what they are (approval-authority.ts), not rewritten.
735
+ // v135 adds, and changes no saved row: one approval per release (docs/plans/release-once.md) — a release's authority
736
+ // record, its carried commits and its deploy and merge operations (release_authority, release_carry,
737
+ // release_operation), each release check attempt's intended mode, sealed before it runs (release_check_attempt), the
738
+ // nightly full check on main and its open failures (nightly_check, main_check_failure), and a scope's release terms
739
+ // (task_scope.release_terms_json; saved rows read NULL: no release terms).
740
+ export const SCHEMA_VERSION = 135;
729
741
  /** v118: the tables merged into `credential`, in the order they move (a person's API tokens keep their ids first). */
730
742
  export const V118_MERGED_TABLES = Object.freeze({ api_token: "person", coordinator_credential: "coordinator", lead_credential: "lead" });
731
743
  /** The foreign keys that named a merged table, exactly as their DDL wrote them; each names `credential(id)` instead. */
@@ -858,7 +870,7 @@ const V115_DROPPED_REFERENCES = /\s+REFERENCES (?:contestant|contest|tournament_
858
870
  * unchanged), and adds the nullable column in V125_ADDED_COLUMNS, checked the same way. v126 only adds a new table
859
871
  * (task_source) and the columns in V126_ADDED_COLUMNS (null or 0), checked the same way.
860
872
  */
861
- export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133]);
873
+ export const UPDATE_SAFE_MIGRATIONS = Object.freeze([108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135]);
862
874
  /** v119: the columns it adds, by table, with the value every row saved before it reads (toolroll-update's HISTORY_RULES). */
863
875
  export const V119_ADDED_COLUMNS = Object.freeze({
864
876
  task_ref: { planning_generation: { definition: "INTEGER NOT NULL DEFAULT 0", saved: "0" } },
@@ -969,6 +981,138 @@ export const V129_ADDED_COLUMNS = Object.freeze({
969
981
  cache_write_tokens: { definition: "INTEGER CHECK (cache_write_tokens IS NULL OR cache_write_tokens >= 0)", saved: "NULL" },
970
982
  },
971
983
  });
984
+ /** v134 (approval policy): how each protected-work vote was proved, and who decided a flow card at a decision zone.
985
+ * Saved rows read NULL: no proof or authority on record, which never counts as a person. */
986
+ export const V134_ADDED_COLUMNS = Object.freeze({
987
+ scope_approval_vote: { proof: { definition: "TEXT", saved: "NULL" } },
988
+ flow_event: { authority: { definition: "TEXT", saved: "NULL" } },
989
+ });
990
+ /** v135 (docs/plans/release-once.md): a scope's release terms, part of its digest when present. Saved rows read NULL. */
991
+ export const V135_ADDED_COLUMNS = Object.freeze({
992
+ task_scope: { release_terms_json: { definition: "TEXT", saved: "NULL" } },
993
+ });
994
+ /**
995
+ * v135: one approval per release (docs/plans/release-once.md). `release_authority` is the single source of truth for a
996
+ * release a person approved: one row per generation, changed only by compare-and-swap on `rev`, with a lease whose
997
+ * epoch goes up on every takeover. `release_carry` records each later commit carried under it, `release_operation` each
998
+ * deploy or merge that can't be undone. `release_check_attempt` seals a check's intended mode, inventory and context
999
+ * before it runs. `main_check_failure` keeps one row per item the nightly full check on main found failing, until a
1000
+ * qualifying full pass clears that exact item; `nightly_check` records each nightly firing.
1001
+ */
1002
+ export const RELEASE_ONCE_SCHEMA = `
1003
+ CREATE TABLE IF NOT EXISTS release_authority (
1004
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1005
+ repo TEXT NOT NULL,
1006
+ generation INTEGER NOT NULL,
1007
+ previous INTEGER REFERENCES release_authority(id),
1008
+ version TEXT NOT NULL,
1009
+ pr INTEGER NOT NULL,
1010
+ branch TEXT NOT NULL,
1011
+ base TEXT NOT NULL,
1012
+ anchor_task TEXT NOT NULL,
1013
+ anchor_digest TEXT NOT NULL,
1014
+ approved_sha TEXT NOT NULL,
1015
+ approved_tree TEXT NOT NULL,
1016
+ artifact_digest TEXT,
1017
+ tip_sha TEXT NOT NULL,
1018
+ tip_task TEXT NOT NULL,
1019
+ policy_json TEXT NOT NULL,
1020
+ policy_digest TEXT NOT NULL,
1021
+ verify_command INTEGER NOT NULL,
1022
+ verify_digest TEXT NOT NULL,
1023
+ access_digest TEXT NOT NULL,
1024
+ state TEXT NOT NULL CHECK (state IN ('open','deploying','merge-reserved','merged','revoked','expired','superseded')),
1025
+ rev INTEGER NOT NULL DEFAULT 0,
1026
+ lease_owner TEXT,
1027
+ lease_epoch INTEGER NOT NULL DEFAULT 0,
1028
+ lease_expires TEXT,
1029
+ expires_at TEXT NOT NULL,
1030
+ created_at TEXT NOT NULL,
1031
+ ended_at TEXT,
1032
+ ended_by TEXT,
1033
+ UNIQUE (repo, version, pr, generation)
1034
+ );
1035
+ CREATE UNIQUE INDEX IF NOT EXISTS release_authority_live ON release_authority (repo, version, pr)
1036
+ WHERE state IN ('open','deploying','merge-reserved');
1037
+ CREATE INDEX IF NOT EXISTS release_authority_anchor ON release_authority (anchor_task);
1038
+ CREATE TABLE IF NOT EXISTS release_carry (
1039
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1040
+ authority INTEGER NOT NULL REFERENCES release_authority(id),
1041
+ task_id TEXT NOT NULL UNIQUE,
1042
+ scope_digest TEXT NOT NULL,
1043
+ request_id TEXT NOT NULL,
1044
+ from_sha TEXT NOT NULL,
1045
+ to_sha TEXT NOT NULL,
1046
+ to_tree TEXT NOT NULL,
1047
+ artifact_digest TEXT NOT NULL,
1048
+ commits_json TEXT NOT NULL,
1049
+ changed_lines INTEGER NOT NULL,
1050
+ files INTEGER NOT NULL,
1051
+ churn INTEGER NOT NULL,
1052
+ runtime TEXT NOT NULL,
1053
+ proved_at TEXT NOT NULL,
1054
+ UNIQUE (authority, to_sha)
1055
+ );
1056
+ CREATE TABLE IF NOT EXISTS release_operation (
1057
+ id TEXT PRIMARY KEY,
1058
+ authority INTEGER NOT NULL REFERENCES release_authority(id),
1059
+ kind TEXT NOT NULL CHECK (kind IN ('deploy','merge')),
1060
+ task_id TEXT NOT NULL,
1061
+ candidate TEXT NOT NULL,
1062
+ epoch INTEGER NOT NULL,
1063
+ state TEXT NOT NULL CHECK (state IN ('reserved','running','succeeded','failed','unknown')),
1064
+ external_ref TEXT,
1065
+ -- The sha256 of a secret only the reserving coordinator holds: a deploy run adopting this operation proves it.
1066
+ secret_hash TEXT,
1067
+ -- 1: reserved by a person's own deploy run with no coordinator holding the release (its epoch is the one it found).
1068
+ direct INTEGER NOT NULL DEFAULT 0,
1069
+ -- The process that reserved, then issued, the call: its pid, its start time and when it was recorded (real time).
1070
+ -- Recovery settles a started operation only once that process is proven gone (review round 2).
1071
+ issuer_pid INTEGER,
1072
+ issuer_started TEXT,
1073
+ issued_at TEXT,
1074
+ -- A person's direct deploy: the staging folder its deployment journals in.
1075
+ stage TEXT,
1076
+ reserved_at TEXT NOT NULL,
1077
+ finished_at TEXT,
1078
+ detail TEXT
1079
+ );
1080
+ CREATE INDEX IF NOT EXISTS release_operation_open ON release_operation (authority, state);
1081
+ CREATE TABLE IF NOT EXISTS release_check_attempt (
1082
+ run INTEGER PRIMARY KEY REFERENCES run(id),
1083
+ authority INTEGER REFERENCES release_authority(id),
1084
+ mode TEXT NOT NULL CHECK (mode IN ('full','targeted')),
1085
+ base_run INTEGER REFERENCES run(id),
1086
+ plan_digest TEXT,
1087
+ snapshot_digest TEXT,
1088
+ inventory_digest TEXT NOT NULL,
1089
+ context_digest TEXT NOT NULL,
1090
+ nonce_hash TEXT NOT NULL,
1091
+ created_at TEXT NOT NULL,
1092
+ settled_at TEXT
1093
+ );
1094
+ CREATE TABLE IF NOT EXISTS main_check_failure (
1095
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1096
+ repo TEXT NOT NULL,
1097
+ sha TEXT NOT NULL,
1098
+ run INTEGER NOT NULL,
1099
+ item TEXT NOT NULL,
1100
+ opened_at TEXT NOT NULL,
1101
+ cleared_by_run INTEGER,
1102
+ cleared_at TEXT,
1103
+ UNIQUE (repo, run, item)
1104
+ );
1105
+ CREATE INDEX IF NOT EXISTS main_check_failure_open ON main_check_failure (repo) WHERE cleared_at IS NULL;
1106
+ CREATE TABLE IF NOT EXISTS nightly_check (
1107
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1108
+ repo TEXT NOT NULL,
1109
+ sha TEXT NOT NULL,
1110
+ task TEXT,
1111
+ run INTEGER,
1112
+ outcome TEXT,
1113
+ at TEXT NOT NULL
1114
+ );
1115
+ `;
972
1116
  /** v130: a project's network (an approver's setting, egress.ts ProjectNetwork as JSON) and the installation's choice
973
1117
  * whether an agent may run where no OS confinement exists (one row; 0 until an instance operator turns it on). */
974
1118
  export const CONFINEMENT_SCHEMA = `
@@ -4064,6 +4208,17 @@ CREATE INDEX IF NOT EXISTS run_checkpoint_by_task ON run_checkpoint (task_ref, i
4064
4208
  convertChatTables(db);
4065
4209
  // v118: every machine credential into the one credential table, after every older step gave the old tables their last shape.
4066
4210
  mergeCredentialsForV118(db);
4211
+ // v134: a vote's proof and a flow decision's authority (V134_ADDED_COLUMNS: saved rows read NULL, no proof on record).
4212
+ for (const [table, columns] of Object.entries(V134_ADDED_COLUMNS)) {
4213
+ for (const [column, { definition }] of Object.entries(columns))
4214
+ addColumn(db, table, column, definition);
4215
+ }
4216
+ // v135: one approval per release (new tables only) and a scope's release terms (V135_ADDED_COLUMNS: saved rows NULL).
4217
+ db.exec(RELEASE_ONCE_SCHEMA);
4218
+ for (const [table, columns] of Object.entries(V135_ADDED_COLUMNS)) {
4219
+ for (const [column, { definition }] of Object.entries(columns))
4220
+ addColumn(db, table, column, definition);
4221
+ }
4067
4222
  // Keep the epoch until reclamation succeeds, including a retry after compaction already committed. A new file has
4068
4223
  // nothing to reclaim.
4069
4224
  if (!fresh)
@@ -6492,6 +6647,8 @@ export class Store {
6492
6647
  // ---- scope --------------------------------------------------------------
6493
6648
  saveScope(scope, mutation = {}, options = {}) {
6494
6649
  this.once(mutation, "saveScope", () => this.transact(() => {
6650
+ // The text's current author mark, read before this write (approval policy §1.4).
6651
+ const prior = this.db.prepare("SELECT proposed_via, release_terms_json FROM task_scope WHERE task_id = ?").get(scope.taskId);
6495
6652
  // THE filing invariant (foundations findings 5/13/19): every scope
6496
6653
  // row leaves this method either RESOLVED (working profile stamped,
6497
6654
  // digest recomputed to bind it, version 2) or UNRESOLVED with its
@@ -6652,6 +6809,9 @@ export class Store {
6652
6809
  const lowered = (one) => { const verdict = underCeiling(orgPolicy, one); return verdict.ok ? verdict.profile : one; };
6653
6810
  if (profile !== null)
6654
6811
  profile = lowered(profile);
6812
+ // v135: release terms the caller names, else the ones this scope already carries (a refile keeps them bound).
6813
+ const priorTerms = storedReleaseTerms(prior?.["release_terms_json"]);
6814
+ const releaseTerms = options.releaseTerms !== undefined ? options.releaseTerms : priorTerms.ok ? priorTerms.terms : null;
6655
6815
  const digestInput = {
6656
6816
  goal: scope.goal,
6657
6817
  outOfScope: scope.outOfScope,
@@ -6660,6 +6820,7 @@ export class Store {
6660
6820
  acceptance: scope.acceptance,
6661
6821
  qualityMode,
6662
6822
  candidate: scope.candidate ?? null,
6823
+ releaseTerms,
6663
6824
  };
6664
6825
  // EVERY route filed since v47 binds into the digest — routine-shaped
6665
6826
  // ones included — so approval freezes exactly which agent runs each
@@ -6689,8 +6850,8 @@ export class Store {
6689
6850
  .prepare(`INSERT INTO task_scope
6690
6851
  (task_id, goal, out_of_scope, touches, budget_microusd, proposed_at, digest, approved_at, approved_by, approved_digest,
6691
6852
  profile_json, profile_state, unresolved_reason, digest_version, profile_provenance, proposed_chain_json, acceptance_json, quality_mode,
6692
- risk_level, proposed_route_json, route_era, candidate)
6693
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
6853
+ risk_level, proposed_route_json, route_era, candidate, release_terms_json)
6854
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
6694
6855
  ON CONFLICT (task_id) DO UPDATE SET
6695
6856
  goal = excluded.goal, out_of_scope = excluded.out_of_scope,
6696
6857
  touches = excluded.touches, budget_microusd = excluded.budget_microusd,
@@ -6702,7 +6863,7 @@ export class Store {
6702
6863
  profile_provenance = excluded.profile_provenance, proposed_chain_json = excluded.proposed_chain_json,
6703
6864
  acceptance_json = excluded.acceptance_json, quality_mode = excluded.quality_mode,
6704
6865
  risk_level = excluded.risk_level, proposed_route_json = excluded.proposed_route_json, route_era = excluded.route_era,
6705
- candidate = excluded.candidate`)
6866
+ candidate = excluded.candidate, release_terms_json = excluded.release_terms_json`)
6706
6867
  .run(scope.taskId, scope.goal, scope.outOfScope, JSON.stringify(scope.touches), scope.budgetMicrousd ?? null, scope.proposedAt, boundDigest, scope.approvedAt, scope.approvedBy, scope.approvedDigest, profile === null ? null : canonicalProfileJson(profile), profile === null ? "unresolved" : "resolved", unresolvedReason, profile === null ? 1 : 2, provenance,
6707
6868
  // Fallback chains (v30-v114) are gone: no filing names one.
6708
6869
  null, scope.acceptance.length === 0 ? null : JSON.stringify(scope.acceptance), qualityMode,
@@ -6710,10 +6871,13 @@ export class Store {
6710
6871
  // routine risk, the value its signed terms always carried.
6711
6872
  "routine", route === null ? null : canonicalRouteJson(route),
6712
6873
  // The durable era: every row this method writes is a routed row.
6713
- ROUTE_ERA, scope.candidate ?? null);
6874
+ ROUTE_ERA, scope.candidate ?? null, releaseTerms === null ? null : serializeReleaseTerms(releaseTerms));
6714
6875
  // Who wrote THIS text (mate arc, ruling 2): set per write, so a human
6715
- // rewrite clears the lead's mark and a mate rewrite sets it.
6716
- this.db.prepare("UPDATE task_scope SET proposed_via = ? WHERE task_id = ?").run(options.proposedVia ?? null, scope.taskId);
6876
+ // rewrite clears the lead's mark and a mate rewrite sets it — but only a
6877
+ // person's write clears one (approval policy §1.4): the lead (its token)
6878
+ // or a remote API run keeps any mark already there and never erases a
6879
+ // quarantine by refiling, and the lead's own text is marked `lead`.
6880
+ this.db.prepare("UPDATE task_scope SET proposed_via = ? WHERE task_id = ?").run(provenanceAfter(prior?.["proposed_via"], options.proposedVia ?? null), scope.taskId);
6717
6881
  return null;
6718
6882
  }));
6719
6883
  }
@@ -6744,6 +6908,14 @@ export class Store {
6744
6908
  * scope vanished mid-ceremony. */
6745
6909
  sealScopeApproval(taskId, by, now, mutation = {}, basis, approverKind) {
6746
6910
  return (this.once(mutation, "sealScopeApproval", () => {
6911
+ // ONLY A PERSON'S SEAL HAS NO BASIS (approval policy, phase 1): with none given this is the person's
6912
+ // ceremony (scope.ts approve), so a machine — the lead's token, a remote API run — never mints one, whatever
6913
+ // road it came by.
6914
+ if (basis === undefined && machineActing())
6915
+ return false;
6916
+ // `carried` is written by the carry transaction alone (carryReleaseApproval), never through a seal.
6917
+ if (basis?.kind === "carried")
6918
+ return false;
6747
6919
  // THE COORDINATOR QUARANTINE, enforced in the primitive (MCP spec
6748
6920
  // v6, round-3 f1): mode coverage never admits a coordinator-filed
6749
6921
  // task — console, CLI `task scope`, and direct callers alike hit
@@ -6812,6 +6984,21 @@ export class Store {
6812
6984
  return false;
6813
6985
  if (routeProblems(authority.route).length > 0)
6814
6986
  return false;
6987
+ // RELEASE TERMS (release-once.md): only a person's own yes (the ceremony or a paired-chat tap) seals them, and
6988
+ // that yes opens the release's authority record in this same transaction — or the seal doesn't stand.
6989
+ const terms = storedReleaseTerms(working["release_terms_json"]);
6990
+ if (!terms.ok)
6991
+ return false;
6992
+ // A DEPLOY GATE FROM CHAT (chat-management.md §0a.5): a signed candidate or release terms seal from chat only
6993
+ // with the release card's evidence (its act and card digest), never through the plain plan card or a generic
6994
+ // one. Read from the raw row, so a terms column that doesn't parse is a deploy gate too.
6995
+ const deployGate = (typeof working["candidate"] === "string" && working["candidate"] !== "") || working["release_terms_json"] != null;
6996
+ if (deployGate && basis?.kind === "chat" && !(basis.card === "release" && typeof basis.cardDigest === "string" && /^[0-9a-f]{64}$/.test(basis.cardDigest)))
6997
+ return false;
6998
+ if (terms.terms !== null && basis !== undefined && basis.kind !== "chat")
6999
+ return false;
7000
+ if (terms.terms !== null && releaseSealProblem(this, terms.terms) !== null)
7001
+ return false;
6815
7002
  const changed = this.db
6816
7003
  .prepare(`UPDATE task_scope
6817
7004
  SET approved_at = ?, approved_by = ?, approved_digest = digest,
@@ -6827,6 +7014,10 @@ export class Store {
6827
7014
  approved_route_json = proposed_route_json
6828
7015
  WHERE task_id = ?`)
6829
7016
  .run(now.toISOString(), by, basis === undefined ? "password" : basis.kind, basis === undefined ? null : basis.modeDigest, taskId);
7017
+ if (changed.changes > 0 && terms.ok && terms.terms !== null) {
7018
+ if (openReleaseAuthority(this, { taskId, digest: String(working["digest"]), terms: terms.terms, by, now }) === null)
7019
+ throw new ReleaseSealRefused();
7020
+ }
6830
7021
  if (changed.changes > 0) {
6831
7022
  // The project's tools this yes saw (v80): a later addition needs a later yes.
6832
7023
  this.sealProjectTools(taskId, String(working["digest"]), now);
@@ -6849,12 +7040,14 @@ export class Store {
6849
7040
  taskRef: Number(ref["id"]), kind: "scope-approved", identity: `t${Number(ref["id"])}`,
6850
7041
  subject: "Scope approved",
6851
7042
  body: basis === undefined
6852
- ? `Approved by ${currentActor()?.lead === true ? "the lead" : lifecycleWords(by, 40)}. A connected worker can take it next.`
7043
+ ? `Approved by ${lifecycleWords(by, 40)}. A connected worker can take it next.`
6853
7044
  : basis.kind === "chat"
6854
7045
  ? `Approved by ${lifecycleWords(by, 40)} in chat. A connected worker can take it next.`
6855
7046
  : basis.kind === "grant"
6856
7047
  ? "Approved automatically: its flow runs on its own. A connected worker can take it next."
6857
- : "Approved automatically under the operating mode. A connected worker can take it next.",
7048
+ : basis.kind === "schedule"
7049
+ ? "Approved by its standing order. A connected worker can take it next."
7050
+ : "Approved automatically under the operating mode. A connected worker can take it next.",
6858
7051
  link: chatControlHref("task", taskId),
6859
7052
  }, now);
6860
7053
  }
@@ -6874,7 +7067,7 @@ export class Store {
6874
7067
  }
6875
7068
  getScope(taskId) {
6876
7069
  const row = this.db.prepare("SELECT * FROM task_scope WHERE task_id = ?").get(taskId);
6877
- return row === undefined ? null : readScope(row);
7070
+ return row === undefined ? null : sealedAs(this, readScope(row));
6878
7071
  }
6879
7072
  /**
6880
7073
  * The mode-approval belt, as ONE question every consumer asks the same
@@ -6885,20 +7078,23 @@ export class Store {
6885
7078
  * scopes answer true: this belt only ever REMOVES mode authority.
6886
7079
  */
6887
7080
  modeApprovalLive(taskRef, now) {
6888
- // A flow's "runs on its own" grant (flow-unattended.ts) is held to the same belt.
7081
+ // Approval policy §1.5: an allowlist over the seal that exists. No seal claims no authority (a pre-approval
7082
+ // planner, a scope-free check — the role gates decide those); a seal stands only on an authority that still does
7083
+ // (approval-authority.ts), so a lead seal, a null basis or a word this code doesn't write is dead here.
6889
7084
  const dead = this.db
6890
7085
  .prepare(`SELECT 1 AS dead FROM task_scope
6891
7086
  JOIN task_ref ON task_ref.id = ? AND task_ref.backend = 'built-in' AND task_scope.task_id = task_ref.external_id
6892
7087
  WHERE task_scope.approved_at IS NOT NULL
6893
- AND ((task_scope.approval_basis = 'mode'
6894
- AND NOT EXISTS (SELECT 1 FROM operating_mode om
6895
- JOIN approver signer ON signer.name = om.signed_by
6896
- WHERE om.repo = task_ref.repo AND om.revoked_at IS NULL
6897
- AND om.absolute_expiry > ? AND om.digest = task_scope.mode_digest
6898
- AND signer.revoked_at IS NULL AND signer.role = 'approver' AND ${accountReachesSql("signer", "task_ref.repo")}))
6899
- OR (task_scope.approval_basis = 'grant' AND NOT ${grantStandsSql("task_scope.mode_digest", "?", "task_ref.repo")}))`)
6900
- .get(taskRef, now.toISOString(), now.toISOString());
6901
- return dead === undefined;
7088
+ AND NOT ${authorityStandsSql("task_scope", "task_ref.repo", "?")}`)
7089
+ .get(taskRef, now.toISOString(), now.toISOString(), now.toISOString());
7090
+ if (dead !== undefined)
7091
+ return false;
7092
+ // A release task (release-once.md): the whole live-release predicate, beyond what the SQL can say.
7093
+ const external = this.db.prepare("SELECT external_id FROM task_ref WHERE id = ? AND backend = 'built-in'").get(taskRef);
7094
+ if (external === undefined)
7095
+ return true;
7096
+ const live = liveReleaseOf(this, String(external["external_id"]), "dispatch", now);
7097
+ return !live.applies || live.ok;
6902
7098
  }
6903
7099
  // ---- approvers ----------------------------------------------------------
6904
7100
  saveApprover(name, credentialHash, now, mutation = {}) {
@@ -7359,7 +7555,8 @@ export class Store {
7359
7555
  // Only people who can still approve here count: a vote from an account since revoked, demoted or moved off the project doesn't.
7360
7556
  const standing = (name) => { const account = this.accountOf(name); return account !== null && account.revokedAt === null && account.role === "approver" && this.accountCanAccess(name, repo); };
7361
7557
  const digest = scope === undefined ? "" : String(scope["digest"]);
7362
- const voters = new Set(this.db.prepare("SELECT approver FROM scope_approval_vote WHERE task_id = ? AND digest = ?").all(taskId, digest).map(row => String(row["approver"])).filter(standing));
7558
+ // Only a person's vote counts (approval policy §1.3): a machine's, or one with no recorded proof, is history only.
7559
+ const voters = new Set(this.db.prepare("SELECT approver, proof FROM scope_approval_vote WHERE task_id = ? AND digest = ?").all(taskId, digest).filter(row => personVote(this.db, taskId, digest, row)).map(row => String(row["approver"])).filter(standing));
7363
7560
  const already = voters.has(by);
7364
7561
  voters.add(by);
7365
7562
  for (const name of requesters)
@@ -7380,10 +7577,10 @@ export class Store {
7380
7577
  const hits = changed === null ? null : protectedChanges(rules, changed);
7381
7578
  if (hits !== null && hits.length === 0)
7382
7579
  return null;
7383
- const scope = this.db.prepare("SELECT approved_by, approved_digest FROM task_scope WHERE task_id = ?").get(taskId);
7580
+ const scope = this.db.prepare("SELECT approved_by, approved_digest, approved_at, approval_basis FROM task_scope WHERE task_id = ?").get(taskId);
7384
7581
  const approvedDigest = scope?.["approved_digest"] == null ? "" : String(scope["approved_digest"]);
7385
7582
  const standing = (name) => { const account = this.accountOf(name); return account !== null && account.revokedAt === null && account.role === "approver" && this.accountCanAccess(name, repo); };
7386
- const voters = new Set(this.db.prepare("SELECT approver FROM scope_approval_vote WHERE task_id = ? AND digest = ?").all(taskId, approvedDigest).map(row => String(row["approver"])).filter(standing));
7583
+ const voters = new Set(this.db.prepare("SELECT approver, proof FROM scope_approval_vote WHERE task_id = ? AND digest = ?").all(taskId, approvedDigest).filter(row => personVote(this.db, taskId, approvedDigest, row)).map(row => String(row["approver"])).filter(standing));
7387
7584
  // Whoever asked for the work never counts as the second pair of eyes on it, whatever the requester rule says.
7388
7585
  const requesters = this.requestersOf(taskId);
7389
7586
  for (const name of requesters)
@@ -7394,13 +7591,35 @@ export class Store {
7394
7591
  const what = hits === null ? "files this project protects (its changed files couldn't be read)" : `protected files (${hits.slice(0, 3).join(", ")}${hits.length > 3 ? ` and ${hits.length - 3} more` : ""})`;
7395
7592
  if (completer === null)
7396
7593
  return `This result changed ${what}, so a person other than its approver marks it complete.`;
7397
- if (completer === approver || requesters.has(completer) || !standing(completer))
7398
- return `This result changed ${what}, and ${approver ?? "one person"} approved its scope alone. Someone else has to mark it complete.`;
7399
- return null;
7594
+ // Two DISTINCT people across the votes, the seal and the completion (approval policy §1.7a): the seal's approver
7595
+ // counts only when a person's proof stands behind that seal (never a lead or other machine credential under their
7596
+ // name, and never a seal with no proof on record: fail closed, as personApprovalOf), a
7597
+ // requester never counts, and someone who already voted is not a second person by also completing.
7598
+ const people = new Set(voters);
7599
+ const basis = scope?.["approval_basis"];
7600
+ // (The approver is the first pair of eyes and may be who asked; the second may not.)
7601
+ if (approver !== null && (basis === "password" || basis === "chat") && scope?.["approved_at"] != null
7602
+ && sealOriginOf(this, taskId, approver, approvedDigest, String(scope["approved_at"])).origin === "person")
7603
+ people.add(approver);
7604
+ const before = people.size;
7605
+ if (!requesters.has(completer) && standing(completer))
7606
+ people.add(completer);
7607
+ // No seal on record at all (nothing to pair with): the completer, if they can still approve here, is the person.
7608
+ if (approver === null && people.size > before && voters.size === 0)
7609
+ return null;
7610
+ // Two distinct people across the votes, the person-made seal and the completer. Each person counts once, so one
7611
+ // person voting and completing is still one; a voter who completes a seal another person made is two.
7612
+ if (people.size >= 2)
7613
+ return null;
7614
+ return `This result changed ${what}, and ${approver ?? "one person"} approved its scope alone. Someone else has to mark it complete.`;
7400
7615
  }
7401
7616
  /** One person's yes to these exact bytes (protected work counts two). */
7402
- recordApprovalVote(taskId, digest, by, now) {
7403
- const fresh = this.db.prepare("INSERT OR IGNORE INTO scope_approval_vote (task_id, digest, approver, at) VALUES (?,?,?,?)").run(taskId, digest, by, now.toISOString());
7617
+ recordApprovalVote(taskId, digest, by, now, proof) {
7618
+ // How this yes was proved rides with it (approval policy §1.3): the counters count only a person's.
7619
+ // A person's yes replaces a row a machine credential left for the same person and bytes (or one with no proof).
7620
+ const fresh = this.db.prepare(`INSERT INTO scope_approval_vote (task_id, digest, approver, at, proof) VALUES (?,?,?,?,?)
7621
+ ON CONFLICT (task_id, digest, approver) DO UPDATE SET at = excluded.at, proof = excluded.proof
7622
+ WHERE COALESCE(scope_approval_vote.proof, '') NOT IN ('password-now','sso-now','fresh-password','fresh-sso','session','cli-login','chat','passkey')`).run(taskId, digest, by, now.toISOString(), proof);
7404
7623
  if (fresh.changes > 0) {
7405
7624
  const repo = this.db.prepare("SELECT repo FROM task_ref WHERE backend = ? AND external_id = ?").get(BUILT_IN, taskId)?.["repo"];
7406
7625
  this.recordAction({ at: now.toISOString(), actor: by, repo: repo == null ? null : String(repo), taskId, runId: null, action: "approval recorded", outcome: "recorded", source: "work", detail: `digest ${digest.slice(0, 12)}` });
@@ -11370,7 +11589,7 @@ export class Store {
11370
11589
  if (route === null) {
11371
11590
  return { ok: false, reason: "unreadable", detail: scope.approvedRouteJson == null ? "the approval sealed no agent route — re-file the scope and approve it again" : "the approval's sealed agent route cannot be read — re-file the scope and approve it again" };
11372
11591
  }
11373
- const fields = { goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd, acceptance: scope.acceptance, candidate: scope.candidate ?? null, qualityMode: scope.qualityMode ?? "default" };
11592
+ const fields = { goal: scope.goal, outOfScope: scope.outOfScope, touches: scope.touches, budgetMicrousd: scope.budgetMicrousd, acceptance: scope.acceptance, candidate: scope.candidate ?? null, qualityMode: scope.qualityMode ?? "default", releaseTerms: scope.releaseTerms ?? null };
11374
11593
  const chain = scope.approvalKind === "chain" ? chainFromJson(scope.approvedChainJson ?? null) : null;
11375
11594
  const target = chain !== null ? { chain } : (scope.approvedProfile ?? null);
11376
11595
  if (target === null)
@@ -14775,6 +14994,12 @@ export class Store {
14775
14994
  * an existing row's posture is the reconciler's job, never creation's. */
14776
14995
  createMergeIntent(intent, now) {
14777
14996
  this.transact(() => {
14997
+ // The nightly check of main is a check only (release-once.md, part 3): nothing merges it.
14998
+ if (nightlyPublication(this, intent.publication))
14999
+ throw new Error(NIGHTLY_CHECK_ONLY);
15000
+ // A release's result merges only through its coordinator (release-once.md; security review): no intent for it.
15001
+ if (releasePublication(this, intent.publication))
15002
+ throw new Error(RELEASE_MERGES_ONLY);
14778
15003
  const mode = this.activeMode(intent.repo, now);
14779
15004
  let state = "pending";
14780
15005
  let basis = "grant";
@@ -14881,8 +15106,18 @@ export class Store {
14881
15106
  fireMergeIntent(id, generation, proof, now) {
14882
15107
  return this.transact(() => {
14883
15108
  const row = this.db
14884
- .prepare("SELECT state, generation, head_sha, grant_terms_hash, authority_basis, mode_digest FROM merge_intent WHERE id = ?")
15109
+ .prepare("SELECT state, generation, head_sha, grant_terms_hash, authority_basis, mode_digest, publication FROM merge_intent WHERE id = ?")
14885
15110
  .get(id);
15111
+ // The shared merge authority (release-once.md, part 3): a nightly check of main never merges, by any road.
15112
+ if (row !== undefined && nightlyPublication(this, Number(row["publication"]))) {
15113
+ this.settleMergeIntent(id, generation, "refused", now, { error: NIGHTLY_CHECK_ONLY, from: "claimed" });
15114
+ return { ok: false, outcome: "superseded", why: NIGHTLY_CHECK_ONLY };
15115
+ }
15116
+ // Nor does a release's result, whatever intent was planted for it: only `toolroll release` merges it.
15117
+ if (row !== undefined && releasePublication(this, Number(row["publication"]))) {
15118
+ this.settleMergeIntent(id, generation, "refused", now, { error: RELEASE_MERGES_ONLY, from: "claimed" });
15119
+ return { ok: false, outcome: "superseded", why: RELEASE_MERGES_ONLY };
15120
+ }
14886
15121
  if (row === undefined || String(row["state"]) !== "claimed" || Number(row["generation"]) !== generation) {
14887
15122
  return { ok: false, outcome: "raced", why: "the claim moved" };
14888
15123
  }
@@ -15002,6 +15237,12 @@ export class Store {
15002
15237
  * reach a PR" are one write or neither.
15003
15238
  */
15004
15239
  createPublicationIntent(intent, now) {
15240
+ // The nightly check of main is a check only: it is never published, so no pull request or merge can follow.
15241
+ if (nightlyTaskRef(this, intent.taskRef))
15242
+ throw new Error(NIGHTLY_CHECK_ONLY);
15243
+ // Nor is a release's task: `toolroll release` merges its own pull request, so no publication (and no merge sweep) has it.
15244
+ if (releaseTaskRef(this, intent.taskRef))
15245
+ throw new Error(RELEASE_MERGES_ONLY);
15005
15246
  const inserted = this.db
15006
15247
  .prepare(`INSERT INTO publication
15007
15248
  (run, task_ref, github_repo, remote, base, head, head_sha, body_hash, draft, state, created_at, updated_at)
@@ -15339,7 +15580,7 @@ export class Store {
15339
15580
  const stamp = now.toISOString();
15340
15581
  this.db.prepare("UPDATE flow_card SET stage = ?, entry = entry + 1, task = ?, waiting = NULL, note = COALESCE(?, note), updated_at = ? WHERE id = ?")
15341
15582
  .run(move.to, move.task ?? null, move.note ?? null, stamp, id);
15342
- this.db.prepare("INSERT INTO flow_event (card, from_stage, to_stage, outcome, actor, note, at) VALUES (?, ?, ?, ?, ?, ?, ?)").run(id, card.stage, move.to, move.outcome, move.actor, move.historyNote ?? move.note ?? null, stamp);
15583
+ this.db.prepare("INSERT INTO flow_event (card, from_stage, to_stage, outcome, actor, note, at, authority) VALUES (?, ?, ?, ?, ?, ?, ?, ?)").run(id, card.stage, move.to, move.outcome, move.actor, move.historyNote ?? move.note ?? null, stamp, move.authority ?? null);
15343
15584
  return true;
15344
15585
  });
15345
15586
  }
@@ -15367,6 +15608,8 @@ export class Store {
15367
15608
  return this.db.prepare("SELECT * FROM flow_event WHERE card = ? ORDER BY id").all(card).map(row => ({
15368
15609
  fromStage: row["from_stage"] === null ? null : String(row["from_stage"]), toStage: String(row["to_stage"]), outcome: String(row["outcome"]),
15369
15610
  actor: String(row["actor"]), note: row["note"] === null ? null : String(row["note"]), at: String(row["at"]),
15611
+ // A word this code doesn't write (or a move from before v134) is no recorded authority.
15612
+ authority: FLOW_DECISION_AUTHORITIES.find(one => one === row["authority"]) ?? null,
15370
15613
  }));
15371
15614
  }
15372
15615
  /** When a card came into the zone it is in: its latest history line (it is written with every move). */
@@ -19861,7 +20104,7 @@ export function scopeTermsProblem(row) {
19861
20104
  // null for a person) is corruption, never a value that slips past the
19862
20105
  // quarantine because it is not literally 'mate'.
19863
20106
  const via = row["proposed_via"];
19864
- if (via !== null && via !== undefined && via !== "mate" && via !== "coordinator" && via !== "scout")
20107
+ if (via !== null && via !== undefined && via !== "mate" && via !== "coordinator" && via !== "scout" && via !== "lead")
19865
20108
  return "the proposed-via marker is not one this code writes";
19866
20109
  const era = row["route_era"];
19867
20110
  if (era !== null && era !== undefined && (typeof era !== "number" || !Number.isSafeInteger(era) || era < 1 || era > ROUTE_ERA))
@@ -19870,6 +20113,12 @@ export function scopeTermsProblem(row) {
19870
20113
  if (typeof row[column] !== "string")
19871
20114
  return `${column.replace("_", " ")} is not stored as text`;
19872
20115
  }
20116
+ // v135: release terms read back exactly, or the row states why (a reader that didn't select them skips this).
20117
+ if ("release_terms_json" in row) {
20118
+ const terms = storedReleaseTerms(row["release_terms_json"]);
20119
+ if (!terms.ok)
20120
+ return terms.problem;
20121
+ }
19873
20122
  for (const column of ["approved_at", "approved_by", "approved_digest", "profile_json", "approved_profile_json", "proposed_chain_json", "approved_chain_json", "proposed_route_json", "approved_route_json", "unresolved_reason"]) {
19874
20123
  if (row[column] !== null && row[column] !== undefined && typeof row[column] !== "string")
19875
20124
  return `${column.replace(/_/g, " ")} is not stored as text`;
@@ -19886,9 +20135,10 @@ function readScope(row) {
19886
20135
  proposedAt: String(row["proposed_at"]),
19887
20136
  digest: String(row["digest"]),
19888
20137
  budgetMicrousd: row["budget_microusd"] === null || row["budget_microusd"] === undefined ? null : Number(row["budget_microusd"]),
20138
+ // Only the authorities this code writes; any other stored word reads as `unknown`, which no reader treats as a yes.
19889
20139
  approvalBasis: row["approval_basis"] === null || row["approval_basis"] === undefined
19890
20140
  ? null
19891
- : String(row["approval_basis"]),
20141
+ : APPROVAL_BASES.find(one => one === row["approval_basis"]) ?? "unknown",
19892
20142
  modeDigest: row["mode_digest"] === null || row["mode_digest"] === undefined ? null : String(row["mode_digest"]),
19893
20143
  approvedAt: row["approved_at"] === null ? null : String(row["approved_at"]),
19894
20144
  approvedBy: row["approved_by"] === null ? null : String(row["approved_by"]),
@@ -19913,7 +20163,8 @@ function readScope(row) {
19913
20163
  approvedRouteJson: row["approved_route_json"] === null || row["approved_route_json"] === undefined ? null : String(row["approved_route_json"]),
19914
20164
  routeEra: row["route_era"] === null || row["route_era"] === undefined ? null : Number(row["route_era"]),
19915
20165
  candidate: typeof row["candidate"] === "string" && row["candidate"] !== "" ? row["candidate"] : null,
19916
- proposedVia: row["proposed_via"] === "mate" || row["proposed_via"] === "coordinator" || row["proposed_via"] === "scout" ? row["proposed_via"] : null,
20166
+ releaseTerms: (() => { const read = storedReleaseTerms(row["release_terms_json"]); return read.ok ? read.terms : null; })(),
20167
+ proposedVia: row["proposed_via"] === "mate" || row["proposed_via"] === "coordinator" || row["proposed_via"] === "scout" || row["proposed_via"] === "lead" ? row["proposed_via"] : null,
19917
20168
  };
19918
20169
  }
19919
20170
  function readRunner(row) {
@@ -20050,3 +20301,448 @@ function readAcpCheck(row) {
20050
20301
  settledAt: text("settled_at"),
20051
20302
  };
20052
20303
  }
20304
+ /**
20305
+ * Who wrote a scope's text after this write (docs/plans/approval-policy.md §1.4), from trusted context only. A person's
20306
+ * write sets what the caller says (null for their own words), as always. A machine's write — the lead's token or a
20307
+ * remote API run — never clears or downgrades a mark: a quarantined author (a chat proposal, a scout, a coordinator)
20308
+ * stays, and otherwise the lead's write is marked `lead` (a remote run's keeps whatever was there).
20309
+ */
20310
+ export function provenanceAfter(prior, requested) {
20311
+ if (!machineActing())
20312
+ return requested;
20313
+ const was = prior === "mate" || prior === "coordinator" || prior === "scout" || prior === "lead" ? prior : null;
20314
+ if (quarantinedAuthor(was))
20315
+ return was;
20316
+ if (quarantinedAuthor(requested))
20317
+ return requested;
20318
+ return currentActor()?.lead === true ? "lead" : (requested ?? was);
20319
+ }
20320
+ export const FLOW_DECISION_AUTHORITIES = ["person", "subagent", "lead", "remote"];
20321
+ /** Whether a protected-work vote is a person's (approval policy §1.3): the proof stored with it, or for a vote from
20322
+ * before votes kept one, the approver's own proof row for those bytes. None on record is no person. */
20323
+ function personVote(db, taskId, digest, row) {
20324
+ if (typeof row["proof"] === "string")
20325
+ return personProof(row["proof"]);
20326
+ // The approver's newest proof row for these exact bytes, parsed (either form); none is no person.
20327
+ const proof = db.prepare("SELECT outcome, detail FROM action_ledger WHERE task_id = ? AND action = 'approval proved' AND actor = ? ORDER BY id DESC LIMIT 50")
20328
+ .all(taskId, String(row["approver"])).find(one => proofDetailOf(one["detail"])?.digest === digest);
20329
+ return personProof(proof === undefined ? null : String(proof["outcome"]));
20330
+ }
20331
+ /**
20332
+ * A scope's approval read for what its seal really was (approval policy §1.5): a seal that says `password` or `chat`
20333
+ * but that a machine credential made reads as `lead` (the lead's token) or `unknown` (any other machine credential),
20334
+ * and a standing order's old firing as `schedule`. Nothing is rewritten; every reader of getScope sees the truth.
20335
+ */
20336
+ function sealedAs(store, scope) {
20337
+ if (scope.approvedAt === null || scope.approvedBy === null || scope.approvedDigest === null)
20338
+ return scope;
20339
+ if (scope.approvalBasis !== "password" && scope.approvalBasis !== "chat")
20340
+ return scope;
20341
+ const origin = sealOriginOf(store, scope.taskId, scope.approvedBy, scope.approvedDigest, scope.approvedAt);
20342
+ if (origin.origin === "machine")
20343
+ return { ...scope, approvalBasis: origin.lead ? "lead" : "unknown" };
20344
+ if (origin.origin === "automation")
20345
+ return { ...scope, approvalBasis: "schedule" };
20346
+ return scope;
20347
+ }
20348
+ function readCheckAttempt(row) {
20349
+ const text = (key) => row[key] == null ? null : String(row[key]);
20350
+ return {
20351
+ run: Number(row["run"]), authority: row["authority"] == null ? null : Number(row["authority"]),
20352
+ mode: row["mode"] === "targeted" ? "targeted" : "full", baseRun: row["base_run"] == null ? null : Number(row["base_run"]),
20353
+ planDigest: text("plan_digest"), snapshotDigest: text("snapshot_digest"), inventoryDigest: String(row["inventory_digest"]),
20354
+ contextDigest: String(row["context_digest"]), nonceHash: String(row["nonce_hash"]), createdAt: String(row["created_at"]), settledAt: text("settled_at"),
20355
+ };
20356
+ }
20357
+ /** Seal a check attempt before its command is spawned. Once written it never changes (bar its settled mark). */
20358
+ export function recordCheckAttempt(store, attempt, now) {
20359
+ store.handle.prepare(`INSERT INTO release_check_attempt (run, authority, mode, base_run, plan_digest, snapshot_digest, inventory_digest, context_digest, nonce_hash, created_at)
20360
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(attempt.run, attempt.authority, attempt.mode, attempt.baseRun, attempt.planDigest, attempt.snapshotDigest, attempt.inventoryDigest, attempt.contextDigest, attempt.nonceHash, now.toISOString());
20361
+ }
20362
+ /** A run's sealed check attempt, or null. */
20363
+ export function checkAttemptOf(store, run) {
20364
+ const row = store.handle.prepare("SELECT * FROM release_check_attempt WHERE run = ?").get(run);
20365
+ return row === undefined ? null : readCheckAttempt(row);
20366
+ }
20367
+ /** Release terms as this code writes them: the contract's own reading of them, serialized. */
20368
+ export function serializeReleaseTerms(terms) {
20369
+ const read = readReleaseTerms(terms);
20370
+ if (!read.ok)
20371
+ throw new Error(`The release terms don't match their contract: ${read.issues[0]?.line ?? "invalid"}`);
20372
+ return JSON.stringify(read.value);
20373
+ }
20374
+ // ---- one approval per release: the authority record (release-once.md, part 1) --------------------------------------
20375
+ export { LIVE_RELEASE_STATES, belongsToRelease, newestReleaseGeneration, outstandingReleaseOperations, releaseAuthority, releaseAuthorityOfTask, releaseCarries, releaseCarryTo, releaseOperation, releaseOperations, releaseSealProblem, } from "./release-authority.js";
20376
+ import { LIVE_RELEASE_STATES, belongsToRelease, newestReleaseGeneration, outstandingReleaseOperations, releaseAuthority, releaseCarryTo, releaseOperation, releaseSealProblem, } from "./release-authority.js";
20377
+ /**
20378
+ * Open a release's authority for a person's seal of its terms (inside the seal's transaction): the next generation,
20379
+ * superseding the live one. False when it can't (releaseSealProblem), which refuses the seal.
20380
+ */
20381
+ export function openReleaseAuthority(store, input) {
20382
+ const { terms, now } = input;
20383
+ if (releaseSealProblem(store, terms) !== null)
20384
+ return null;
20385
+ const live = newestReleaseGeneration(store, terms.repo, terms.packageVersion, terms.pr);
20386
+ if (live !== null && LIVE_RELEASE_STATES.includes(live.state)) {
20387
+ store.handle.prepare("UPDATE release_authority SET state = 'superseded', ended_at = ?, ended_by = ?, rev = rev + 1 WHERE id = ? AND rev = ?")
20388
+ .run(now.toISOString(), input.by, live.id, live.rev);
20389
+ }
20390
+ const policyJson = JSON.stringify(terms.policy);
20391
+ const result = store.handle.prepare(`INSERT INTO release_authority (repo, generation, previous, version, pr, branch, base, anchor_task, anchor_digest, approved_sha, approved_tree,
20392
+ artifact_digest, tip_sha, tip_task, policy_json, policy_digest, verify_command, verify_digest, access_digest, state, rev, lease_epoch, expires_at, created_at)
20393
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, 'open', 0, 0, ?, ?)`).run(terms.repo, (live?.generation ?? 0) + 1, live?.id ?? null, terms.packageVersion, terms.pr, terms.branch, terms.base, input.taskId, input.digest, terms.approvedSha, terms.approvedTree, terms.approvedSha, input.taskId, policyJson, releasePolicyDigest(terms.policy), terms.grant.id, terms.grant.digest, terms.access, terms.expiresAt, now.toISOString());
20394
+ const id = Number(result.lastInsertRowid);
20395
+ store.recordAction({ at: now.toISOString(), actor: input.by, repo: terms.repo, taskId: input.taskId, runId: null, action: "release approved", outcome: "open", source: "work",
20396
+ detail: `release ${id} · generation ${(live?.generation ?? 0) + 1} · ${terms.packageVersion} · pull request #${terms.pr} · ${terms.approvedSha}` });
20397
+ return id;
20398
+ }
20399
+ /** Seal the anchor's artifact digest once (the person's seal authorized building that approved commit). */
20400
+ export function sealAnchorArtifact(store, id, digest, now) {
20401
+ return store.transact(() => {
20402
+ const changed = store.handle.prepare("UPDATE release_authority SET artifact_digest = ?, rev = rev + 1 WHERE id = ? AND artifact_digest IS NULL AND state = 'open'").run(digest, id);
20403
+ if (Number(changed.changes) === 1)
20404
+ store.recordAction({ at: now.toISOString(), actor: "toolroll", repo: releaseAuthority(store, id)?.repo ?? null, taskId: releaseAuthority(store, id)?.anchorTask ?? null, runId: null,
20405
+ action: "release artifact sealed", outcome: "ok", source: "work", detail: `release ${id} · ${digest}` });
20406
+ return Number(changed.changes) === 1;
20407
+ });
20408
+ }
20409
+ /** Take (or keep) a release's lease. A takeover by another owner raises the epoch, which fences every operation the
20410
+ * old owner reserved. */
20411
+ export function takeReleaseLease(store, id, owner, now, ttlMs = 10 * 60_000) {
20412
+ return store.transact(() => {
20413
+ const authority = releaseAuthority(store, id);
20414
+ if (authority === null)
20415
+ return { ok: false, why: `No release ${id}.` };
20416
+ const held = authority.leaseOwner !== null && authority.leaseOwner !== owner && authority.leaseExpires !== null && authority.leaseExpires > now.toISOString();
20417
+ if (held)
20418
+ return { ok: false, why: `Release ${id} is being run by ${authority.leaseOwner} until ${authority.leaseExpires}.` };
20419
+ // A person's own deploy still at work holds the release as a lease would: nothing takes it over (review round 2).
20420
+ const direct = outstandingReleaseOperations(store, id).find(op => op.direct && !releaseIssuerGone(op));
20421
+ if (direct !== undefined)
20422
+ return { ok: false, why: `A deploy of this release (operation ${direct.id}, process ${direct.issuer?.pid ?? "unknown"}) is still running.` };
20423
+ const epoch = authority.leaseOwner === owner && authority.leaseExpires !== null && authority.leaseExpires > now.toISOString() ? authority.leaseEpoch : authority.leaseEpoch + 1;
20424
+ const changed = store.handle.prepare("UPDATE release_authority SET lease_owner = ?, lease_epoch = ?, lease_expires = ?, rev = rev + 1 WHERE id = ? AND rev = ?")
20425
+ .run(owner, epoch, new Date(now.getTime() + ttlMs).toISOString(), id, authority.rev);
20426
+ return Number(changed.changes) === 1 ? { ok: true, epoch } : { ok: false, why: "The release changed while taking its lease; try again." };
20427
+ });
20428
+ }
20429
+ /** Renew a lease this owner still holds at this epoch. */
20430
+ export function renewReleaseLease(store, id, owner, epoch, now, ttlMs = 10 * 60_000) {
20431
+ const changed = store.handle.prepare("UPDATE release_authority SET lease_expires = ? WHERE id = ? AND lease_owner = ? AND lease_epoch = ? AND lease_expires > ?")
20432
+ .run(new Date(now.getTime() + ttlMs).toISOString(), id, owner, epoch, now.toISOString());
20433
+ return Number(changed.changes) === 1;
20434
+ }
20435
+ /**
20436
+ * Reserve a deploy or a merge of the release tip (the freeze happens here, in the store): a deploy moves `open` to
20437
+ * `deploying` (or, already deploying, reserves a new attempt of the same frozen candidate); a merge moves `deploying`
20438
+ * to `merge-reserved`. `epoch` null is a direct deploy by a person: it takes the current epoch, and only while no
20439
+ * coordinator holds a live lease. Refused while any earlier operation is unsettled.
20440
+ */
20441
+ export function reserveRelease(store, input) {
20442
+ return store.transact(() => {
20443
+ const a = releaseAuthority(store, input.authority);
20444
+ if (a === null)
20445
+ return { ok: false, why: `No release ${input.authority}.` };
20446
+ const at = input.now.toISOString();
20447
+ // Expiry stops every live state; an operation already reserved settles only through reconciliation.
20448
+ if (a.expiresAt <= at)
20449
+ return { ok: false, why: "The release approval expired." };
20450
+ if (a.tipTask !== input.taskId || a.tipSha !== input.candidate)
20451
+ return { ok: false, why: `Task ${input.taskId} is not the release's current commit.` };
20452
+ const leased = a.leaseOwner !== null && a.leaseExpires !== null && a.leaseExpires > at;
20453
+ if (input.epoch === null ? leased : input.epoch !== a.leaseEpoch || !leased)
20454
+ return { ok: false, why: input.epoch === null ? `A release coordinator (${a.leaseOwner}) holds this release; let it deploy.` : "This coordinator no longer holds the release's lease." };
20455
+ const outstanding = outstandingReleaseOperations(store, a.id);
20456
+ if (outstanding.length > 0)
20457
+ return { ok: false, why: `An earlier ${outstanding[0].kind} (${outstanding[0].id}) hasn't been settled yet.` };
20458
+ // The whole live-release predicate, inside this transaction, at this moment (review round 1): the approver's
20459
+ // standing and access, the grant and the sandbox, the generation and the tip, and open main-check debt.
20460
+ const live = liveReleaseOf(store, input.taskId, input.kind, input.now);
20461
+ if (!live.applies || !live.ok)
20462
+ return { ok: false, why: `The release can't ${input.kind}: ${live.applies && !live.ok ? live.why : "the task isn't part of it"}.` };
20463
+ const next = input.kind === "deploy"
20464
+ ? a.state === "open" || a.state === "deploying" ? "deploying" : null
20465
+ : a.state === "deploying" || a.state === "merge-reserved" ? "merge-reserved" : null;
20466
+ if (next === null)
20467
+ return { ok: false, why: `The release is ${a.state}, so it can't ${input.kind}.` };
20468
+ const changed = store.handle.prepare("UPDATE release_authority SET state = ?, rev = rev + 1 WHERE id = ? AND rev = ?").run(next, a.id, a.rev);
20469
+ if (Number(changed.changes) !== 1)
20470
+ return { ok: false, why: "The release changed while reserving; try again." };
20471
+ const issuer = input.issuer ?? currentReleaseIssuer();
20472
+ store.handle.prepare("INSERT INTO release_operation (id, authority, kind, task_id, candidate, epoch, state, secret_hash, direct, issuer_pid, issuer_started, issued_at, stage, reserved_at) VALUES (?, ?, ?, ?, ?, ?, 'reserved', ?, ?, ?, ?, ?, ?, ?)")
20473
+ .run(input.operationId, a.id, input.kind, input.taskId, input.candidate, a.leaseEpoch, input.secret === undefined ? null : createHash("sha256").update(input.secret).digest("hex"), input.epoch === null ? 1 : 0, issuer.pid, issuer.started, issuer.at, input.stage ?? null, at);
20474
+ store.recordAction({ at, actor: "toolroll", repo: a.repo, taskId: input.taskId, runId: null, action: `release ${input.kind} reserved`, outcome: next, source: "work", detail: `release ${a.id} · operation ${input.operationId} · epoch ${a.leaseEpoch}` });
20475
+ return { ok: true, operation: releaseOperation(store, input.operationId) };
20476
+ });
20477
+ }
20478
+ /** The external call's own fence, taken immediately before the call is spawned: `reserved` → `running` (issuing) only
20479
+ * at the epoch that reserved it, while that epoch still holds the lease (a direct deploy's epoch is checked as
20480
+ * reserved) and the release stands, recording the process that issues it. False: the call must not start. */
20481
+ export function runReleaseOperation(store, id, epoch, now, issuer = currentReleaseIssuer()) {
20482
+ return store.transact(() => {
20483
+ const op = releaseOperation(store, id);
20484
+ const a = op === null ? null : releaseAuthority(store, op.authority);
20485
+ if (op === null || a === null || op.state !== "reserved" || op.epoch !== epoch || a.leaseEpoch !== epoch)
20486
+ return false;
20487
+ // Past the expiry nothing new starts; the reserved operation is settled by reconciliation instead.
20488
+ if (a.expiresAt <= now.toISOString())
20489
+ return false;
20490
+ // The lease that reserved it still holds (a person's direct deploy: still no coordinator holding the release), and
20491
+ // the live-release predicate still accepts it, at this moment (review round 1).
20492
+ const at = now.toISOString();
20493
+ const leased = a.leaseOwner !== null && a.leaseExpires !== null && a.leaseExpires > at;
20494
+ const direct = Number(store.handle.prepare("SELECT direct FROM release_operation WHERE id = ?").get(id)?.["direct"] ?? 0) === 1;
20495
+ if (direct ? leased : !leased)
20496
+ return false;
20497
+ const live = liveReleaseOf(store, op.taskId, op.kind, now);
20498
+ if (!live.applies || !live.ok)
20499
+ return false;
20500
+ const changed = store.handle.prepare("UPDATE release_operation SET state = 'running', issuer_pid = ?, issuer_started = ?, issued_at = ? WHERE id = ? AND state = 'reserved' AND epoch = ?")
20501
+ .run(issuer.pid, issuer.started, issuer.at, id, epoch);
20502
+ if (Number(changed.changes) === 1)
20503
+ store.recordAction({ at: now.toISOString(), actor: "toolroll", repo: a.repo, taskId: op.taskId, runId: null, action: `release ${op.kind} started`, outcome: "running", source: "work", detail: `operation ${id}` });
20504
+ return Number(changed.changes) === 1;
20505
+ });
20506
+ }
20507
+ /** Settle an operation: `succeeded`, `failed` or `unknown` (reconciled later). A merge that succeeded ends the release
20508
+ * as `merged`; one that failed puts it back to `deploying`. */
20509
+ export function finishReleaseOperation(store, id, outcome, facts, now, epoch) {
20510
+ return store.transact(() => {
20511
+ const op = releaseOperation(store, id);
20512
+ if (op === null || op.state === "succeeded" || op.state === "failed")
20513
+ return false;
20514
+ // Fenced by the epoch that holds the release now (review round 1): a coordinator that lost the lease settles nothing.
20515
+ if (releaseAuthority(store, op.authority)?.leaseEpoch !== epoch)
20516
+ return false;
20517
+ store.handle.prepare("UPDATE release_operation SET state = ?, external_ref = COALESCE(?, external_ref), detail = ?, finished_at = ? WHERE id = ?")
20518
+ .run(outcome, facts.externalRef ?? null, facts.detail ?? null, outcome === "unknown" ? null : now.toISOString(), id);
20519
+ const a = releaseAuthority(store, op.authority);
20520
+ if (op.kind === "merge" && outcome === "succeeded")
20521
+ store.handle.prepare("UPDATE release_authority SET state = 'merged', ended_at = ?, ended_by = 'toolroll', rev = rev + 1 WHERE id = ?").run(now.toISOString(), a.id);
20522
+ if (op.kind === "merge" && outcome === "failed" && a.state === "merge-reserved")
20523
+ store.handle.prepare("UPDATE release_authority SET state = 'deploying', rev = rev + 1 WHERE id = ?").run(a.id);
20524
+ store.recordAction({ at: now.toISOString(), actor: "toolroll", repo: a.repo, taskId: op.taskId, runId: null, action: `release ${op.kind} ${outcome}`, outcome, source: "work", detail: `operation ${id}${facts.detail ? ` · ${facts.detail}` : ""}` });
20525
+ return true;
20526
+ });
20527
+ }
20528
+ /** Stop carrying later commits: any active approver, any time before the merge starts. */
20529
+ export function revokeRelease(store, id, by, now) {
20530
+ return store.transact(() => {
20531
+ const a = releaseAuthority(store, id);
20532
+ if (a === null)
20533
+ return { ok: false, why: `No release ${id}.` };
20534
+ if (a.state === "merge-reserved" || a.state === "merged")
20535
+ return { ok: false, why: "The merge already started, so the release can't be stopped now." };
20536
+ if (!LIVE_RELEASE_STATES.includes(a.state))
20537
+ return { ok: false, why: `The release is already ${a.state}.` };
20538
+ store.handle.prepare("UPDATE release_authority SET state = 'revoked', ended_at = ?, ended_by = ?, rev = rev + 1 WHERE id = ? AND rev = ?").run(now.toISOString(), by, id, a.rev);
20539
+ store.recordAction({ at: now.toISOString(), actor: by, repo: a.repo, taskId: a.anchorTask, runId: null, action: "release revoked", outcome: "revoked", source: "work", detail: `release ${id}` });
20540
+ return { ok: true };
20541
+ });
20542
+ }
20543
+ /**
20544
+ * THE carry transaction (release-carry.ts proveCarry step 4), one BEGIN IMMEDIATE: every mutable fact is proved again
20545
+ * — the authority's `rev` and tip, its state and expiry and the whole live-release predicate (liveReleaseOf), the
20546
+ * anchor's sealed artifact digest equal to the candidate's, and whatever `recheck` re-reads (the policy, grant and
20547
+ * check access as they are now) — then it writes the carried task and its scope (the anchor's approved terms, profile,
20548
+ * route and acceptance verbatim, with candidate `to`), its seal (basis `carried`, reference `<authority>:<carry>`), the
20549
+ * carry row, the new tip with `rev + 1`, and the ledger row. A carry already written for (authority, `to`) is returned
20550
+ * as it is.
20551
+ */
20552
+ export function carryReleaseApproval(store, w, now, recheck) {
20553
+ return store.transact(() => {
20554
+ const a = releaseAuthority(store, w.authority);
20555
+ if (a === null)
20556
+ return { ok: false, why: `No release ${w.authority}.` };
20557
+ const already = releaseCarryTo(store, a.id, w.to);
20558
+ if (already !== null)
20559
+ return { ok: true, taskId: already.taskId, carry: already };
20560
+ if (a.rev !== w.expectRev)
20561
+ return { ok: false, why: "The release changed since this carry was checked; check it again." };
20562
+ if (a.tipSha !== w.expectTip)
20563
+ return { ok: false, why: "The release's current commit moved since this carry was checked." };
20564
+ const live = liveReleaseOf(store, a.tipTask, "dispatch", now);
20565
+ if (!live.applies || !live.ok)
20566
+ return { ok: false, why: live.applies && !live.ok ? `The release approval doesn't stand: ${live.why}.` : "The release's current commit isn't part of it." };
20567
+ if (a.artifactDigest === null || a.artifactDigest !== w.artifactDigest)
20568
+ return { ok: false, why: "The production build differs from the approved one." };
20569
+ const problem = recheck();
20570
+ if (problem !== null)
20571
+ return { ok: false, why: problem };
20572
+ if (store.getTask(w.taskId) !== null)
20573
+ return { ok: false, why: `A task ${w.taskId} already exists.` };
20574
+ const anchorRow = store.handle.prepare("SELECT * FROM task_scope WHERE task_id = ?").get(a.anchorTask);
20575
+ if (anchorRow === undefined)
20576
+ return { ok: false, why: "The approved release task has no scope." };
20577
+ const anchor = readScope(anchorRow);
20578
+ const route = routeFromJson(anchor.approvedRouteJson ?? null);
20579
+ if (anchor.approvedProfile == null || route === null || anchor.releaseTerms == null)
20580
+ return { ok: false, why: "The approved release scope can't be read exactly." };
20581
+ const fields = { goal: anchor.goal, outOfScope: anchor.outOfScope, touches: anchor.touches, budgetMicrousd: anchor.budgetMicrousd, acceptance: anchor.acceptance,
20582
+ qualityMode: anchor.qualityMode ?? "default", candidate: w.to, releaseTerms: anchor.releaseTerms };
20583
+ const digest = digestOf(fields, anchor.approvedProfile, route);
20584
+ store.createTask({ id: w.taskId, title: w.title }, now);
20585
+ const ref = store.refFor(BUILT_IN, w.taskId, "ours");
20586
+ if (store.placeTask(ref.id, a.repo, {}, now) !== true)
20587
+ return { ok: false, why: "The carried task couldn't be placed in the project." };
20588
+ // Its checks, as the anchor's: the Full release check.
20589
+ store.recordAction({ at: now.toISOString(), actor: "toolroll", repo: a.repo, taskId: w.taskId, runId: null, action: "task checks chosen", outcome: "full", source: "request", detail: "Checks for this task: Full" });
20590
+ const carryId = Number(store.handle.prepare(`INSERT INTO release_carry (authority, task_id, scope_digest, request_id, from_sha, to_sha, to_tree, artifact_digest, commits_json, changed_lines, files, churn, runtime, proved_at)
20591
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(a.id, w.taskId, digest, w.requestId, a.approvedSha, w.to, w.toTree, w.artifactDigest, JSON.stringify(w.commits), w.changedLines, w.files, w.churn, w.runtime, now.toISOString()).lastInsertRowid);
20592
+ // The anchor's approved row, copied; only the candidate, the digest and the seal differ. Nobody but this
20593
+ // transaction writes basis `carried` (sealScopeApproval refuses it from every caller).
20594
+ store.handle.prepare(`INSERT INTO task_scope (task_id, goal, out_of_scope, touches, budget_microusd, proposed_at, digest, approved_at, approved_by, approved_digest,
20595
+ profile_json, profile_state, unresolved_reason, approved_profile_json, digest_version, profile_provenance, proposed_chain_json, approved_chain_json, acceptance_json,
20596
+ quality_mode, risk_level, approval_basis, mode_digest, approval_kind, proposed_route_json, approved_route_json, route_era, candidate, proposed_via, release_terms_json)
20597
+ SELECT ?, goal, out_of_scope, touches, budget_microusd, ?, ?, ?, approved_by, ?,
20598
+ approved_profile_json, 'resolved', NULL, approved_profile_json, digest_version, profile_provenance, NULL, NULL, acceptance_json,
20599
+ quality_mode, risk_level, 'carried', ?, 'profile', approved_route_json, approved_route_json, route_era, ?, proposed_via, release_terms_json
20600
+ FROM task_scope WHERE task_id = ?`).run(w.taskId, now.toISOString(), digest, now.toISOString(), digest, `${a.id}:${carryId}`, w.to, a.anchorTask);
20601
+ const moved = store.handle.prepare("UPDATE release_authority SET tip_sha = ?, tip_task = ?, rev = rev + 1 WHERE id = ? AND rev = ?").run(w.to, w.taskId, a.id, a.rev);
20602
+ if (Number(moved.changes) !== 1)
20603
+ throw new Error("The release changed under its own transaction.");
20604
+ store.recordAction({ at: now.toISOString(), actor: "toolroll", repo: a.repo, taskId: w.taskId, runId: null, action: "release carried", outcome: "carried", source: "work",
20605
+ detail: `release ${a.id} · carry ${carryId} · ${a.tipSha} → ${w.to} · ${w.files} files · ${w.changedLines} lines · request ${w.requestId}` });
20606
+ // One quiet task notification, no push.
20607
+ store.enqueueNotification({ dedupeKey: `release-carried:${a.id}:${carryId}`, kind: "release-carried", subject: `Carried to ${w.to.slice(0, 7)}`,
20608
+ body: `Release ${a.version} now ships ${w.to.slice(0, 7)} (${w.files} file${w.files === 1 ? "" : "s"}, ${w.changedLines} line${w.changedLines === 1 ? "" : "s"}) under ${anchor.approvedBy ?? "the"} release approval.`,
20609
+ link: chatControlHref("task", w.taskId), source: { taskRef: ref.id } }, now);
20610
+ return { ok: true, taskId: w.taskId, carry: releaseCarryTo(store, a.id, w.to) };
20611
+ });
20612
+ }
20613
+ /** Thrown inside a seal whose release authority can't open: the whole seal rolls back. */
20614
+ export class ReleaseSealRefused extends Error {
20615
+ }
20616
+ /**
20617
+ * Whether a deploy run may adopt operation `id` (deploy-browser.mjs --operation): it is this release's running deploy of
20618
+ * this candidate, reserved at the epoch that still holds a live lease, and the run proves the secret only the
20619
+ * reserving coordinator holds (handed over on stdin, never in argv).
20620
+ */
20621
+ export function adoptReleaseOperation(store, input) {
20622
+ const op = releaseOperation(store, input.id);
20623
+ const a = releaseAuthority(store, input.authority);
20624
+ if (op === null || a === null || op.authority !== a.id || op.kind !== "deploy" || op.candidate !== input.candidate || op.state !== "running")
20625
+ return { ok: false, why: `Operation ${input.id} isn't this release's running deploy.` };
20626
+ if (op.epoch !== a.leaseEpoch || a.leaseOwner === null || a.leaseExpires === null || a.leaseExpires <= input.now.toISOString())
20627
+ return { ok: false, why: "The coordinator that reserved this deploy no longer holds the release." };
20628
+ const row = store.handle.prepare("SELECT secret_hash FROM release_operation WHERE id = ?").get(input.id);
20629
+ const expected = row?.["secret_hash"];
20630
+ const given = createHash("sha256").update(input.secret).digest("hex");
20631
+ if (typeof expected !== "string" || expected.length !== given.length || !timingSafeEqual(Buffer.from(expected), Buffer.from(given)))
20632
+ return { ok: false, why: "This run can't prove it is the coordinator's deploy." };
20633
+ return { ok: true };
20634
+ }
20635
+ /** The nightly check of main is a check only (release-once.md, part 3). */
20636
+ export const NIGHTLY_CHECK_ONLY = "The nightly check of main is only a check; it is never published or merged.";
20637
+ /** Whether a task reference is a nightly check of main. */
20638
+ export function nightlyTaskRef(store, taskRef) {
20639
+ return store.handle.prepare("SELECT 1 AS hit FROM nightly_check n JOIN task_ref r ON r.external_id = n.task AND r.backend = 'built-in' WHERE r.id = ?").get(taskRef) !== undefined;
20640
+ }
20641
+ /** Whether a publication belongs to a nightly check of main. */
20642
+ export function nightlyPublication(store, publication) {
20643
+ const row = store.handle.prepare("SELECT task_ref FROM publication WHERE id = ?").get(publication);
20644
+ return row !== undefined && nightlyTaskRef(store, Number(row["task_ref"]));
20645
+ }
20646
+ /** A release's result merges only through its coordinator: every other merge road refuses it. */
20647
+ export const RELEASE_MERGES_ONLY = "This result belongs to a release; it merges only through `toolroll release`.";
20648
+ /** Whether a task reference is a release's anchor or a carried release task. */
20649
+ export function releaseTaskRef(store, taskRef) {
20650
+ const row = store.handle.prepare("SELECT external_id FROM task_ref WHERE id = ? AND backend = 'built-in'").get(taskRef);
20651
+ return row !== undefined && belongsToRelease(store, String(row["external_id"]));
20652
+ }
20653
+ /** Whether a publication belongs to a release's task. */
20654
+ export function releasePublication(store, publication) {
20655
+ const row = store.handle.prepare("SELECT task_ref FROM publication WHERE id = ?").get(publication);
20656
+ return row !== undefined && releaseTaskRef(store, Number(row["task_ref"]));
20657
+ }
20658
+ /** The ledger act that records a trusted read of a project's recorded remote main (remote-main.ts). */
20659
+ export const TRUSTED_MAIN_ACTION = "remote main read";
20660
+ /** The identity of a project's recorded remote: its URL and the ledger record that last set it (0: set on admission
20661
+ * with no record). A read of main stands only for the remote identity it was read from (review round 2). */
20662
+ export function trustedRemoteIdentity(store, repo) {
20663
+ const url = store.projectRemote(repo);
20664
+ if (url === null)
20665
+ return null;
20666
+ const row = store.handle.prepare("SELECT id FROM action_ledger WHERE repo = ? AND action = 'project remote recorded' ORDER BY id DESC LIMIT 1").get(repo);
20667
+ return { url, record: row === undefined ? 0 : Number(row["id"]) };
20668
+ }
20669
+ /** Record the commit the project's recorded remote main was at, as Toolroll's own process fetched it just now from
20670
+ * `url`, bound to the remote's identity at this moment. Refused (nothing recorded) when `url` isn't the recorded one. */
20671
+ export function recordTrustedMain(store, repo, sha, url, now) {
20672
+ const identity = trustedRemoteIdentity(store, repo);
20673
+ if (identity === null || identity.url !== url || !/^[0-9a-f]{40}$/.test(sha))
20674
+ return false;
20675
+ store.recordAction({ at: now.toISOString(), actor: "toolroll", repo, taskId: null, runId: null, action: TRUSTED_MAIN_ACTION, outcome: sha, source: "work", detail: JSON.stringify({ v: 2, sha, url, record: identity.record }) });
20676
+ return true;
20677
+ }
20678
+ /** A failed read of the recorded remote: the last read no longer stands. */
20679
+ export function forgetTrustedMain(store, repo, why, now) {
20680
+ store.recordAction({ at: now.toISOString(), actor: "toolroll", repo, taskId: null, runId: null, action: TRUSTED_MAIN_ACTION, outcome: "unread", source: "work", detail: JSON.stringify({ v: 2, why: why.slice(0, 300) }) });
20681
+ }
20682
+ /**
20683
+ * A pull request check failed after the deploy reservation (Alex, 2026-10-25): the candidate froze at deploy, so the
20684
+ * release stops and asks a person again — even for the same commit, a new person-approved generation continues it.
20685
+ */
20686
+ export function requireFreshReleaseApproval(store, id, reason, epoch, now) {
20687
+ return store.transact(() => {
20688
+ const a = releaseAuthority(store, id);
20689
+ if (a === null)
20690
+ return false;
20691
+ // Already ended (revoked, expired, superseded, merged): no approval of it stands to carry the commit.
20692
+ if (!LIVE_RELEASE_STATES.includes(a.state))
20693
+ return a.state !== "merged";
20694
+ if (a.leaseEpoch !== epoch || a.state === "merge-reserved")
20695
+ return false;
20696
+ const changed = store.handle.prepare("UPDATE release_authority SET state = 'revoked', ended_at = ?, ended_by = ?, rev = rev + 1 WHERE id = ? AND rev = ?").run(now.toISOString(), `toolroll: ${reason}; a person approves it again`, id, a.rev);
20697
+ if (Number(changed.changes) !== 1)
20698
+ return false;
20699
+ store.recordAction({ at: now.toISOString(), actor: "toolroll", repo: a.repo, taskId: a.tipTask, runId: null, action: "release revoked", outcome: "needs-approval", source: "work", detail: `release ${id}: ${reason}` });
20700
+ return true;
20701
+ });
20702
+ }
20703
+ /** This process, as a release operation's issuer. Real time, whatever clock a caller passes for the rest. */
20704
+ export function currentReleaseIssuer() {
20705
+ const now = Date.now();
20706
+ return { pid: process.pid, started: new Date(now - Math.round(process.uptime() * 1000)).toISOString(), at: new Date(now).toISOString() };
20707
+ }
20708
+ /**
20709
+ * Whether an operation's issuer is proven gone (review round 2): no process has its pid, or the process that has it
20710
+ * started after the issuer was recorded (so it is another one). Anything unknown — no issuer recorded, a start time
20711
+ * that can't be read — is not proof, and the operation stays for a person.
20712
+ */
20713
+ export function releaseIssuerGone(op) {
20714
+ const issuer = op.issuer;
20715
+ if (issuer === null || !Number.isSafeInteger(issuer.pid) || issuer.pid <= 0)
20716
+ return false;
20717
+ try {
20718
+ process.kill(issuer.pid, 0);
20719
+ }
20720
+ catch (error) {
20721
+ const code = error.code;
20722
+ if (code === "ESRCH")
20723
+ return true;
20724
+ if (code !== "EPERM")
20725
+ return false;
20726
+ }
20727
+ const started = processStartedAt(issuer.pid);
20728
+ const recorded = Date.parse(issuer.at);
20729
+ // ps shows whole seconds (a lower bound): a start a full second after the issuer was recorded is another process.
20730
+ return started !== null && Number.isFinite(recorded) && started > recorded + 1000;
20731
+ }
20732
+ /** A live process's start time from ps (whole seconds, UTC), or null when it can't be read. */
20733
+ function processStartedAt(pid) {
20734
+ if (process.platform === "win32")
20735
+ return null;
20736
+ try {
20737
+ const raw = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8", env: { ...process.env, LC_ALL: "C", TZ: "UTC" }, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 1024, timeout: 1_000, killSignal: "SIGKILL" })
20738
+ .trim().replace(/ +/g, " ");
20739
+ const match = /^(?:Sun|Mon|Tue|Wed|Thu|Fri|Sat) (Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (\d{1,2}) (\d{2}:\d{2}:\d{2}) (\d{4})$/.exec(raw);
20740
+ if (match === null)
20741
+ return null;
20742
+ const at = Date.parse(`${match[2].padStart(2, "0")} ${match[1]} ${match[4]} ${match[3]} GMT`);
20743
+ return Number.isFinite(at) ? at : null;
20744
+ }
20745
+ catch {
20746
+ return null;
20747
+ }
20748
+ }