approval-md 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. package/README.md +63 -24
  2. package/SPEC.md +57 -11
  3. package/dist/src/channels/contract.d.ts +34 -1
  4. package/dist/src/channels/contract.js +200 -7
  5. package/dist/src/channels/contract.js.map +1 -1
  6. package/dist/src/channels/telegram.d.ts +123 -11
  7. package/dist/src/channels/telegram.js +218 -23
  8. package/dist/src/channels/telegram.js.map +1 -1
  9. package/dist/src/channels/web.d.ts +9 -0
  10. package/dist/src/channels/web.js +17 -0
  11. package/dist/src/channels/web.js.map +1 -1
  12. package/dist/src/cli/amend.js +214 -30
  13. package/dist/src/cli/amend.js.map +1 -1
  14. package/dist/src/cli/attest.d.ts +9 -0
  15. package/dist/src/cli/attest.js +134 -7
  16. package/dist/src/cli/attest.js.map +1 -1
  17. package/dist/src/cli/channel-telegram.d.ts +99 -26
  18. package/dist/src/cli/channel-telegram.js +311 -13
  19. package/dist/src/cli/channel-telegram.js.map +1 -1
  20. package/dist/src/cli/channel.d.ts +9 -0
  21. package/dist/src/cli/channel.js +9 -0
  22. package/dist/src/cli/channel.js.map +1 -1
  23. package/dist/src/cli/codex-bridge.d.ts +819 -0
  24. package/dist/src/cli/codex-bridge.js +1607 -0
  25. package/dist/src/cli/codex-bridge.js.map +1 -0
  26. package/dist/src/cli/codex.d.ts +1 -1
  27. package/dist/src/cli/codex.js +304 -7
  28. package/dist/src/cli/codex.js.map +1 -1
  29. package/dist/src/cli/daemon.js +4 -1
  30. package/dist/src/cli/daemon.js.map +1 -1
  31. package/dist/src/cli/doctor.js +467 -12
  32. package/dist/src/cli/doctor.js.map +1 -1
  33. package/dist/src/cli/execute.js +25 -2
  34. package/dist/src/cli/execute.js.map +1 -1
  35. package/dist/src/cli/help.d.ts +6 -2
  36. package/dist/src/cli/help.js +165 -60
  37. package/dist/src/cli/help.js.map +1 -1
  38. package/dist/src/cli/hook-codex.d.ts +49 -1
  39. package/dist/src/cli/hook-codex.js +60 -1
  40. package/dist/src/cli/hook-codex.js.map +1 -1
  41. package/dist/src/cli/hook.d.ts +459 -3
  42. package/dist/src/cli/hook.js +1062 -114
  43. package/dist/src/cli/hook.js.map +1 -1
  44. package/dist/src/cli/import.js +1 -1
  45. package/dist/src/cli/import.js.map +1 -1
  46. package/dist/src/cli/main.js +5 -3
  47. package/dist/src/cli/main.js.map +1 -1
  48. package/dist/src/cli/policy-apply.d.ts +195 -0
  49. package/dist/src/cli/policy-apply.js +573 -0
  50. package/dist/src/cli/policy-apply.js.map +1 -0
  51. package/dist/src/cli/policy.js +14 -1
  52. package/dist/src/cli/policy.js.map +1 -1
  53. package/dist/src/cli/preflight.d.ts +151 -13
  54. package/dist/src/cli/preflight.js +398 -41
  55. package/dist/src/cli/preflight.js.map +1 -1
  56. package/dist/src/cli/sandbox.js +17 -1
  57. package/dist/src/cli/sandbox.js.map +1 -1
  58. package/dist/src/cli/scaffold.d.ts +1 -1
  59. package/dist/src/cli/scaffold.js +1 -1
  60. package/dist/src/cli/setup-channel.d.ts +9 -0
  61. package/dist/src/cli/setup-channel.js +28 -1
  62. package/dist/src/cli/setup-channel.js.map +1 -1
  63. package/dist/src/cli/setup-common.d.ts +3 -1
  64. package/dist/src/cli/setup-common.js +3 -2
  65. package/dist/src/cli/setup-common.js.map +1 -1
  66. package/dist/src/cli/setup.d.ts +2 -0
  67. package/dist/src/cli/setup.js +94 -2
  68. package/dist/src/cli/setup.js.map +1 -1
  69. package/dist/src/cli/up.js +115 -51
  70. package/dist/src/cli/up.js.map +1 -1
  71. package/dist/src/cli/values.js +3 -4
  72. package/dist/src/cli/values.js.map +1 -1
  73. package/dist/src/cli/verb-registry.js +174 -9
  74. package/dist/src/cli/verb-registry.js.map +1 -1
  75. package/dist/src/cli/wordmark.d.ts +2 -2
  76. package/dist/src/cli/wordmark.js +2 -2
  77. package/dist/src/codex/broker.d.ts +229 -0
  78. package/dist/src/codex/broker.js +548 -0
  79. package/dist/src/codex/broker.js.map +1 -0
  80. package/dist/src/codex/runner.d.ts +178 -0
  81. package/dist/src/codex/runner.js +231 -0
  82. package/dist/src/codex/runner.js.map +1 -0
  83. package/dist/src/codex/serve.d.ts +56 -0
  84. package/dist/src/codex/serve.js +98 -0
  85. package/dist/src/codex/serve.js.map +1 -0
  86. package/dist/src/codex/workspace-commit.d.ts +219 -0
  87. package/dist/src/codex/workspace-commit.js +549 -0
  88. package/dist/src/codex/workspace-commit.js.map +1 -0
  89. package/dist/src/core/advance-cycle.d.ts +51 -0
  90. package/dist/src/core/advance-cycle.js +66 -2
  91. package/dist/src/core/advance-cycle.js.map +1 -1
  92. package/dist/src/core/agents-md.d.ts +20 -18
  93. package/dist/src/core/agents-md.js +33 -31
  94. package/dist/src/core/agents-md.js.map +1 -1
  95. package/dist/src/core/attest.d.ts +215 -0
  96. package/dist/src/core/attest.js +317 -7
  97. package/dist/src/core/attest.js.map +1 -1
  98. package/dist/src/core/audit.d.ts +18 -0
  99. package/dist/src/core/audit.js +13 -0
  100. package/dist/src/core/audit.js.map +1 -1
  101. package/dist/src/core/channel-owner.d.ts +213 -0
  102. package/dist/src/core/channel-owner.js +358 -0
  103. package/dist/src/core/channel-owner.js.map +1 -0
  104. package/dist/src/core/command-class.d.ts +154 -0
  105. package/dist/src/core/command-class.js +673 -20
  106. package/dist/src/core/command-class.js.map +1 -1
  107. package/dist/src/core/commit-guard.d.ts +272 -0
  108. package/dist/src/core/commit-guard.js +424 -0
  109. package/dist/src/core/commit-guard.js.map +1 -0
  110. package/dist/src/core/daemon-actor.d.ts +45 -0
  111. package/dist/src/core/daemon-actor.js +54 -0
  112. package/dist/src/core/daemon-actor.js.map +1 -0
  113. package/dist/src/core/dark-session.d.ts +109 -8
  114. package/dist/src/core/dark-session.js +266 -82
  115. package/dist/src/core/dark-session.js.map +1 -1
  116. package/dist/src/core/decision-refusal.d.ts +23 -2
  117. package/dist/src/core/decision-refusal.js +24 -2
  118. package/dist/src/core/decision-refusal.js.map +1 -1
  119. package/dist/src/core/env-file.d.ts +5 -0
  120. package/dist/src/core/env-file.js +60 -1
  121. package/dist/src/core/env-file.js.map +1 -1
  122. package/dist/src/core/execute.d.ts +15 -2
  123. package/dist/src/core/execute.js +15 -2
  124. package/dist/src/core/execute.js.map +1 -1
  125. package/dist/src/core/gate.d.ts +86 -1
  126. package/dist/src/core/gate.js +81 -1
  127. package/dist/src/core/gate.js.map +1 -1
  128. package/dist/src/core/gesture-refusal.d.ts +166 -0
  129. package/dist/src/core/gesture-refusal.js +188 -0
  130. package/dist/src/core/gesture-refusal.js.map +1 -0
  131. package/dist/src/core/harness-version.d.ts +1 -1
  132. package/dist/src/core/harness-version.js +3 -1
  133. package/dist/src/core/harness-version.js.map +1 -1
  134. package/dist/src/core/instance.d.ts +59 -2
  135. package/dist/src/core/instance.js +113 -0
  136. package/dist/src/core/instance.js.map +1 -1
  137. package/dist/src/core/log.d.ts +39 -1
  138. package/dist/src/core/log.js.map +1 -1
  139. package/dist/src/core/policy-explain.d.ts +10 -0
  140. package/dist/src/core/policy-explain.js +32 -0
  141. package/dist/src/core/policy-explain.js.map +1 -1
  142. package/dist/src/core/policy-load.d.ts +41 -1
  143. package/dist/src/core/policy-load.js +21 -3
  144. package/dist/src/core/policy-load.js.map +1 -1
  145. package/dist/src/core/policy-match.d.ts +43 -0
  146. package/dist/src/core/policy-match.js +52 -0
  147. package/dist/src/core/policy-match.js.map +1 -1
  148. package/dist/src/core/policy-proposal.d.ts +52 -0
  149. package/dist/src/core/policy-proposal.js +102 -2
  150. package/dist/src/core/policy-proposal.js.map +1 -1
  151. package/dist/src/core/protected-path-guard.d.ts +117 -4
  152. package/dist/src/core/protected-path-guard.js +362 -48
  153. package/dist/src/core/protected-path-guard.js.map +1 -1
  154. package/dist/src/core/question-preempted.d.ts +141 -0
  155. package/dist/src/core/question-preempted.js +152 -0
  156. package/dist/src/core/question-preempted.js.map +1 -0
  157. package/dist/src/core/read-scope.d.ts +172 -0
  158. package/dist/src/core/read-scope.js +252 -0
  159. package/dist/src/core/read-scope.js.map +1 -0
  160. package/dist/src/core/sandbox.d.ts +81 -0
  161. package/dist/src/core/sandbox.js +190 -1
  162. package/dist/src/core/sandbox.js.map +1 -1
  163. package/dist/src/core/sender-identity.d.ts +476 -0
  164. package/dist/src/core/sender-identity.js +572 -0
  165. package/dist/src/core/sender-identity.js.map +1 -0
  166. package/dist/src/core/shlex.d.ts +102 -0
  167. package/dist/src/core/shlex.js +159 -0
  168. package/dist/src/core/shlex.js.map +1 -0
  169. package/dist/src/core/values.d.ts +18 -8
  170. package/dist/src/core/values.js +36 -1
  171. package/dist/src/core/values.js.map +1 -1
  172. package/dist/src/daemon/advance.d.ts +10 -0
  173. package/dist/src/daemon/advance.js +25 -4
  174. package/dist/src/daemon/advance.js.map +1 -1
  175. package/dist/src/daemon/daemon.js +9 -0
  176. package/dist/src/daemon/daemon.js.map +1 -1
  177. package/dist/src/daemon/git-evidence.d.ts +2 -2
  178. package/dist/src/daemon/git-evidence.js +1 -1
  179. package/dist/src/mcp/server.js +8 -0
  180. package/dist/src/mcp/server.js.map +1 -1
  181. package/docs/cli-reference.md +932 -32
  182. package/docs/codex-enforced-session.md +75 -2
  183. package/docs/codex-workspace-broker.md +118 -0
  184. package/package.json +3 -1
  185. package/schema/event.schema.json +538 -9
  186. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-false.json +20 -0
  187. package/schema/fixtures/event/invalid/approval-granted-sender-hashed-raw-id.json +20 -0
  188. package/schema/fixtures/event/invalid/audit-gesture-refused-human-actor.json +16 -0
  189. package/schema/fixtures/event/invalid/audit-gesture-refused-no-actor-no-sender.json +15 -0
  190. package/schema/fixtures/event/invalid/audit-gesture-refused-unknown-gesture.json +16 -0
  191. package/schema/fixtures/event/invalid/audit-question-preempted-agent-actor.json +16 -0
  192. package/schema/fixtures/event/invalid/audit-question-preempted-no-question-id.json +16 -0
  193. package/schema/fixtures/event/invalid/audit-question-preempted-unknown-source.json +15 -0
  194. package/schema/fixtures/event/invalid/gate-path-signed-off-absolute-path.json +14 -0
  195. package/schema/fixtures/event/invalid/gate-path-signed-off-agent-actor.json +14 -0
  196. package/schema/fixtures/event/invalid/gate-path-signed-off-missing-path.json +13 -0
  197. package/schema/fixtures/event/valid/approval-granted-sender-hashed.json +20 -0
  198. package/schema/fixtures/event/valid/audit-gesture-refused-review-note.json +21 -0
  199. package/schema/fixtures/event/valid/audit-gesture-refused-sender-key-unavailable.json +19 -0
  200. package/schema/fixtures/event/valid/audit-gesture-refused.json +19 -0
  201. package/schema/fixtures/event/valid/audit-question-preempted-no-verdict.json +16 -0
  202. package/schema/fixtures/event/valid/audit-question-preempted.json +20 -0
  203. package/schema/fixtures/event/valid/gate-path-signed-off.json +14 -0
  204. package/schema/fixtures/event/valid/harness-kind-claude-code.json +23 -0
  205. package/schema/fixtures/event/valid/harness-kind-codex.json +23 -0
  206. package/schema/fixtures/event/valid/harness-kind-cursor.json +23 -0
  207. package/schema/fixtures/event/valid/harness-kind-grok.json +23 -0
  208. package/schema/fixtures/event/valid/harness-kind-muse.json +23 -0
  209. package/schema/fixtures/policy/invalid/senders-half-keyed.json +20 -0
  210. package/schema/fixtures/policy/valid/canonical.json +1 -1
  211. package/schema/fixtures/policy/valid/senders-keyed.json +24 -0
  212. package/schema/fixtures/policy-md/valid/canonical.md +1 -1
  213. package/schema/fixtures/policy-md/valid/with-values.md +5 -7
  214. package/schema/fixtures/values/invalid/class-shaped.json +1 -1
  215. package/schema/fixtures/values/invalid/duplicate-entry.json +1 -1
  216. package/schema/fixtures/values/invalid/non-string-item.json +1 -1
  217. package/schema/fixtures/values/invalid/over-cap.json +1 -1
  218. package/schema/fixtures/values/invalid/unknown-key.json +1 -1
  219. package/schema/fixtures/values/invalid/version-float.json +1 -0
  220. package/schema/fixtures/values/invalid/version-integer.json +1 -0
  221. package/schema/fixtures/values/invalid/version-wrong-string.json +1 -0
  222. package/schema/fixtures/values/valid/empty-lists.json +2 -3
  223. package/schema/fixtures/values/valid/full.json +5 -7
  224. package/schema/fixtures/values/valid/minimal.json +1 -1
  225. package/schema/fixtures/values-md/invalid/schema-invalid.md +5 -3
  226. package/schema/fixtures/values-md/invalid/two-blocks.md +3 -3
  227. package/schema/fixtures/values-md/invalid/unterminated.md +2 -2
  228. package/schema/fixtures/values-md/invalid/version-1.md +69 -0
  229. package/schema/fixtures/values-md/invalid/version-unquoted.md +64 -0
  230. package/schema/fixtures/values-md/invalid/yaml-error.md +2 -2
  231. package/schema/fixtures/values-md/valid/absent.md +1 -1
  232. package/schema/fixtures/values-md/valid/with-values.md +5 -7
  233. package/schema/policy.schema.json +54 -2
  234. package/schema/values.schema.json +7 -11
  235. package/schema/fixtures/values/invalid/version-string.json +0 -1
@@ -59,12 +59,14 @@ import { createHash } from "node:crypto";
59
59
  import { accessSync, constants, existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync, } from "node:fs";
60
60
  import { tmpdir } from "node:os";
61
61
  import { basename, dirname, isAbsolute, join, relative, resolve as resolvePathSegments, sep } from "node:path";
62
- import { HUMAN_ACTOR_ENV, appendAttestation, checkAttestation, policyFileHash, resolveHumanActor, } from "../core/attest.js";
62
+ import { HUMAN_ACTOR_ENV, appendAttestation, attestedPolicyPayloadHash, checkAttestation, policyBytesHash, resolveHumanActor, } from "../core/attest.js";
63
63
  import { compareChains } from "../core/log-reconcile.js";
64
64
  import { diffPolicies, renderDiff, SPEC_NAMESPACES } from "../core/policy-diff.js";
65
65
  import { checkPolicyExpectations, describeFailure, describePinChange, diffPinSources, expectationsFor, DOGFOOD_SUITE_BUILT, DOGFOOD_SUITE_SOURCE, EXPECTATIONS_MODULE, } from "../core/policy-expectations.js";
66
66
  import { loadPolicy, parseDuration, POLICY_FILENAMES, } from "../core/policy-load.js";
67
- import { proposalState, proposeAttestation, } from "../core/policy-proposal.js";
67
+ import { payloadHash } from "../core/payload.js";
68
+ import { payloadPath, payloadStoreDirFor } from "../core/payload-store.js";
69
+ import { proposalPayloadValue, proposalState, proposeAttestation, } from "../core/policy-proposal.js";
68
70
  import { readVerifiedRecords } from "../core/state.js";
69
71
  import { boolFlag, parseFlags, stringFlag } from "./args.js";
70
72
  import { EXIT_INTEGRITY, EXIT_IO, EXIT_OK, EXIT_TORN_TAIL, EXIT_USAGE, } from "./exit-codes.js";
@@ -83,6 +85,9 @@ const FLAGS = {
83
85
  "--require-load": "boolean",
84
86
  "--dry-run": "boolean",
85
87
  "--commit": "boolean",
88
+ // APRV-341: the whole ceremony in one word. Implies `--commit` and forces the
89
+ // branch flow, so the amendment reaches a protected trunk the only way it can.
90
+ "--pr": "boolean",
86
91
  "--no-publish": "boolean",
87
92
  "--branch": "string",
88
93
  "--direct": "boolean",
@@ -946,7 +951,11 @@ export function commandPolicyAmend(argv, streams, cwd) {
946
951
  }
947
952
  const dryRun = boolFlag(parsed.flags, "--dry-run");
948
953
  const requireLoad = boolFlag(parsed.flags, "--require-load");
949
- const wantCommit = boolFlag(parsed.flags, "--commit");
954
+ // APRV-341. `--pr` is `--commit` plus "and finish the job": the branch flow,
955
+ // whatever the protection probe thinks, because the operator has said which
956
+ // ceremony they want. `--commit` alone keeps letting the probe decide.
957
+ const wantPr = boolFlag(parsed.flags, "--pr");
958
+ const wantCommit = boolFlag(parsed.flags, "--commit") || wantPr;
950
959
  // APRV-130: the ceremony publishes by default (push, and on a protected main
951
960
  // branch + push + PR). `--no-publish` is the operator who wants it to stop at
952
961
  // the commit, which is what `--commit` did before the publishing half existed.
@@ -960,6 +969,12 @@ export function commandPolicyAmend(argv, streams, cwd) {
960
969
  if (branchFlag !== null && branchFlag.trim().length === 0) {
961
970
  return usageError(streams, json, "--branch expects a branch name");
962
971
  }
972
+ if (wantPr && forceDirect) {
973
+ return usageError(streams, json, "--pr and --direct ask for opposite ceremonies: --pr publishes through a branch and a pull request, --direct commits on the branch you are standing on. Pass one of them");
974
+ }
975
+ if (wantPr && noPublish) {
976
+ return usageError(streams, json, "--pr and --no-publish ask for opposite ceremonies: --pr opens the pull request, --no-publish stops at the commit. Pass one of them");
977
+ }
963
978
  // Identity first, before a byte is read. Asking a human to read a diff and
964
979
  // only then telling them their sign-off cannot be attributed wastes the one
965
980
  // resource this system spends.
@@ -1006,13 +1021,54 @@ export function commandPolicyAmend(argv, streams, cwd) {
1006
1021
  return refuse(streams, json, "io", policy.message, EXIT_IO);
1007
1022
  const policyPath = policy.path;
1008
1023
  let liveSha256;
1024
+ /**
1025
+ * The live bytes as text, for the payload file this ceremony will commit
1026
+ * (APRV-356).
1027
+ *
1028
+ * One read for both, on the rule `core/attest.ts` states for the same pair:
1029
+ * two reads of a file an operator may still be editing can disagree, and the
1030
+ * digest reported in the report has to be the digest of the bytes whose store
1031
+ * file the `git add` names.
1032
+ */
1033
+ let liveText;
1009
1034
  try {
1010
- liveSha256 = policyFileHash(policyPath);
1035
+ const liveBytes = readFileSync(policyPath);
1036
+ liveSha256 = policyBytesHash(liveBytes);
1037
+ liveText = liveBytes.toString("utf8");
1011
1038
  }
1012
1039
  catch (cause) {
1013
1040
  return refuse(streams, json, "io", `policy ${policyPath} could not be read: ${detail(cause)}`, EXIT_IO);
1014
1041
  }
1015
1042
  const logPath = resolvePath(stringFlag(parsed.flags, "--log"), DEFAULT_LOG_PATH, cwd);
1043
+ /**
1044
+ * The store file holding the attested policy text, absolute (APRV-356).
1045
+ *
1046
+ * The ceremony commit carries it beside the policy and the log, because a
1047
+ * committed log carrying a binding whose payload file was never committed is
1048
+ * a chain whose in-force bytes are unrecoverable to every reader of the
1049
+ * committed copy, which is the state this task exists to end. The CI
1050
+ * protected-path guard resolves payloads out of committed trees, so that copy
1051
+ * is the one it reads.
1052
+ *
1053
+ * Which file it is depends on which door the attestation comes through, and
1054
+ * both are addressable from the bytes on disk before anything is appended:
1055
+ *
1056
+ * - The HUMAN path appends the attestation here, and since APRV-356 that
1057
+ * record binds `{ text }` (`core/attest.ts`).
1058
+ * - The AGENT path proposes and waits for a tap. The phone's attestation
1059
+ * stores nothing, which APRV-356 left unchanged; what carries the text on
1060
+ * that chain is the `policy.proposed` this ceremony appends, binding
1061
+ * `{ policy_path, text }` (`core/policy-proposal.ts`).
1062
+ *
1063
+ * Addressed from `liveText` rather than read back from the appended record,
1064
+ * so it is a ceremony file from the start: `--dry-run` names it in the `git
1065
+ * add` it would run, and `--commit` counts it as its own rather than as a
1066
+ * stray in the index. An edit landing between here and the append moves the
1067
+ * hash, and the append refuses on the digest before this path is used.
1068
+ */
1069
+ const payloadFile = payloadPath(payloadStoreDirFor(logPath), agentActor === null
1070
+ ? attestedPolicyPayloadHash(liveText)
1071
+ : payloadHash(proposalPayloadValue(policyPath, liveText)));
1016
1072
  // (a-pre) The thirty-three seconds of silence, ended (APRV-167).
1017
1073
  //
1018
1074
  // Everything from here to the `Policy` block below is work the operator could
@@ -1108,7 +1164,11 @@ export function commandPolicyAmend(argv, streams, cwd) {
1108
1164
  const onProtectedDefault = probe.protection === "protected" &&
1109
1165
  probe.currentBranch !== null &&
1110
1166
  probe.currentBranch === probe.defaultBranch;
1111
- const useBranch = branchFlag !== null || (!forceDirect && onProtectedDefault);
1167
+ // APRV-341: `--pr` is a third way into the branch flow, and it is the
1168
+ // operator's own instruction rather than an inference from a probe. A probe
1169
+ // that cannot reach GitHub answers `unknown`, which used to mean "commit in
1170
+ // place"; an operator who typed `--pr` has said what they want either way.
1171
+ const useBranch = branchFlag !== null || wantPr || (!forceDirect && onProtectedDefault);
1112
1172
  const branchName = (seq) => branchFlag ?? `policy-amend-${seq}`;
1113
1173
  // The direct flow's push is about to hit a protected branch. Say so before
1114
1174
  // the human types it, rather than after GitHub says it.
@@ -1133,9 +1193,9 @@ export function commandPolicyAmend(argv, streams, cwd) {
1133
1193
  */
1134
1194
  let pinsChange = null;
1135
1195
  if (wantCommit && !dryRun) {
1136
- const plan = planCommit(policyPath, logPath, useBranch ? { branch: branchFlag } : null);
1196
+ const plan = planCommit(policyPath, logPath, payloadFile, useBranch ? { branch: branchFlag } : null);
1137
1197
  if (!plan.ok) {
1138
- return refuse(streams, json, "commit-preconditions", plan.message, EXIT_USAGE);
1198
+ return refuse(streams, json, plan.code, plan.message, EXIT_USAGE);
1139
1199
  }
1140
1200
  commitPlan = plan.plan;
1141
1201
  const prepared = prepareBase({ root: commitPlan.root, policyArg: commitPlan.policyArg, logPath, progress }, probe, attested?.sha256 ?? null);
@@ -1211,7 +1271,7 @@ export function commandPolicyAmend(argv, streams, cwd) {
1211
1271
  pinsChange = pinsChangeIn(amendRoot, policyPath, "HEAD");
1212
1272
  }
1213
1273
  /**
1214
- * The same command, with the two long absolute paths written the way the
1274
+ * The same command, with the long absolute paths written the way the
1215
1275
  * operator would type them.
1216
1276
  *
1217
1277
  * This is a HUMAN transform and nothing else: `--json`'s `git.commands` keeps
@@ -1224,7 +1284,9 @@ export function commandPolicyAmend(argv, streams, cwd) {
1224
1284
  .split(policyPath)
1225
1285
  .join(relPath(policyPath, cwd))
1226
1286
  .split(logPath)
1227
- .join(relPath(logPath, cwd));
1287
+ .join(relPath(logPath, cwd))
1288
+ .split(payloadFile)
1289
+ .join(relPath(payloadFile, cwd));
1228
1290
  return pinsChange === null
1229
1291
  ? shortened
1230
1292
  : shortened.split(pinsChange.path).join(relPath(pinsChange.path, cwd));
@@ -1242,8 +1304,13 @@ export function commandPolicyAmend(argv, streams, cwd) {
1242
1304
  };
1243
1305
  const summary = summarize(policyPath, diff, pinsChange);
1244
1306
  // APRV-274: the pins file is a member of the `git add` exactly when it moved,
1245
- // so a copied command lands the same three (or two) files the verb would.
1246
- const ceremonyFiles = [policyPath, logPath, ...(pinsChange === null ? [] : [pinsChange.path])];
1307
+ // so a copied command lands the same three (or four) files the verb would.
1308
+ const ceremonyFiles = [
1309
+ policyPath,
1310
+ logPath,
1311
+ ...(pinsChange === null ? [] : [pinsChange.path]),
1312
+ payloadFile,
1313
+ ];
1247
1314
  const commitCommands = (seq) => [
1248
1315
  `git add ${ceremonyFiles.join(" ")}`,
1249
1316
  `git commit -m ${JSON.stringify(`Policy: ${summary} (attested seq ${seq})`)}`,
@@ -1251,15 +1318,31 @@ export function commandPolicyAmend(argv, streams, cwd) {
1251
1318
  const gitCommands = (seq) => {
1252
1319
  // APRV-203: `--commit` runs none of these; it assembles the commit on the
1253
1320
  // remote's tip without a checkout. These are the HAND procedure, and they
1254
- // start where `--commit` starts: at the remote, so the branch is not built
1255
- // on a local trunk that has fallen behind.
1321
+ // start where `--commit` starts: with the checkout brought current, so the
1322
+ // branch is not built on a local trunk that has fallen behind.
1323
+ //
1324
+ // APRV-360: and they never switch branches. The form printed before this
1325
+ // opened with `git checkout -b policy-amend-<seq> origin/main`, which is
1326
+ // the shape that forked the log on 2026-09-16 and that refused outright on
1327
+ // 2026-09-18, when the primary's main was fourteen commits behind and the
1328
+ // switch would have overwritten QUEUE.md, the working log and six
1329
+ // payloads. A branch switch in the primary is never the right answer: the
1330
+ // daemon is appending to the working log while the operator reads this.
1331
+ //
1332
+ // What replaces it is what the 2026-09-18 recovery actually did. `approval
1333
+ // log sync` brings the checkout current (and refuses `log-diverged` rather
1334
+ // than fast-forwarding over a fork), the commit is made where the operator
1335
+ // is standing, and the branch is created on the REMOTE by refspec, so it
1336
+ // exists without any local ref ever being checked out.
1256
1337
  if (useBranch) {
1338
+ const branch = branchName(seq);
1339
+ const base = probe.defaultBranch === null ? "" : ` --base ${probe.defaultBranch}`;
1257
1340
  return [
1258
- "git fetch origin",
1259
- `git checkout -b ${branchName(seq)} origin/${probe.defaultBranch ?? "main"}`,
1341
+ "approval log sync",
1260
1342
  ...commitCommands(seq),
1261
- `git push -u origin ${branchName(seq)}`,
1262
- `gh pr create --title ${JSON.stringify(prTitle(summary, seq))} --body ${JSON.stringify(prBody(seq))}`,
1343
+ `git push origin HEAD:refs/heads/${branch}`,
1344
+ `gh pr create --title ${JSON.stringify(prTitle(summary, seq))} --body ${JSON.stringify(prBody(seq))} --head ${branch}${base}`,
1345
+ `gh pr merge ${branch} --auto --merge`,
1263
1346
  ];
1264
1347
  }
1265
1348
  return amendRoot === null
@@ -1506,6 +1589,7 @@ export function commandPolicyAmend(argv, streams, cwd) {
1506
1589
  branch: null,
1507
1590
  pushed: false,
1508
1591
  prUrl: null,
1592
+ prUpdated: false,
1509
1593
  autoMerge: "not-attempted",
1510
1594
  steps: [],
1511
1595
  stoppedAt: null,
@@ -1615,6 +1699,11 @@ export function commandPolicyAmend(argv, streams, cwd) {
1615
1699
  commitPlan.policyArg,
1616
1700
  commitPlan.logArg,
1617
1701
  ...(pinsChange === null ? [] : [pinsChange.arg]),
1702
+ // APRV-356: the attested policy text, so the commit carrying the
1703
+ // binding carries the bytes it binds. Without it the in-force policy is
1704
+ // unrecoverable for every reader of the committed copy, the CI guard
1705
+ // included.
1706
+ commitPlan.payloadArg,
1618
1707
  ],
1619
1708
  message,
1620
1709
  });
@@ -1669,6 +1758,47 @@ export function commandPolicyAmend(argv, streams, cwd) {
1669
1758
  : `${commitSha.slice(0, 12)} on ${baseLabel} (held at ${anchor}; your checkout was not moved)`;
1670
1759
  /** `gh pr create …` as the operator would type it, for both flows. */
1671
1760
  const prCreateCommand = (head) => `gh pr create --title ${JSON.stringify(prTitle(summary, String(seq)))} --body ${JSON.stringify(prBody(String(seq)))} --head ${head}${probe.defaultBranch === null ? "" : ` --base ${probe.defaultBranch}`}`;
1761
+ /**
1762
+ * The open pull request for `head`, or `null` when there is none (APRV-341).
1763
+ *
1764
+ * Asked before `gh pr create`, because a second run of the ceremony onto a
1765
+ * branch that already carries one is the ordinary shape of "the ceremony
1766
+ * stopped half-way and I ran it again": `gh pr create` answers that with a
1767
+ * failure, and a failure there used to end the verb holding an attestation
1768
+ * it had already appended. A `gh` that cannot answer is `null`, which falls
1769
+ * through to `create` — the path that was there before this existed.
1770
+ */
1771
+ const openPrFor = (head) => {
1772
+ const listed = gh(["pr", "list", "--head", head, "--state", "open", "--json", "url"], commitPlan.root);
1773
+ if (!listed.ok)
1774
+ return null;
1775
+ const text = listed.stdout.trim();
1776
+ if (text.length === 0)
1777
+ return null;
1778
+ let rows;
1779
+ try {
1780
+ rows = JSON.parse(text);
1781
+ }
1782
+ catch {
1783
+ return null;
1784
+ }
1785
+ if (!Array.isArray(rows))
1786
+ return null;
1787
+ const first = rows[0];
1788
+ if (typeof first !== "object" || first === null)
1789
+ return null;
1790
+ const url = first.url;
1791
+ return typeof url === "string" && url.length > 0 ? url : null;
1792
+ };
1793
+ const prEditArgs = (head) => [
1794
+ "pr",
1795
+ "edit",
1796
+ head,
1797
+ "--title",
1798
+ prTitle(summary, String(seq)),
1799
+ "--body",
1800
+ prBody(String(seq)),
1801
+ ];
1672
1802
  const prCreateArgs = (head) => [
1673
1803
  "pr",
1674
1804
  "create",
@@ -1714,11 +1844,22 @@ export function commandPolicyAmend(argv, streams, cwd) {
1714
1844
  publishing.pushed = true;
1715
1845
  output = `${output}\n${`${push.stdout}${push.stderr}`.trim()}`.trim();
1716
1846
  if (ghAvailable(commitPlan.root)) {
1717
- const pr = gh(prCreateArgs(branch), commitPlan.root);
1718
- publishing.steps.push({ command: prCreateCommand(branch), ok: pr.ok });
1847
+ // APRV-341: open, or update the one that is already open for this
1848
+ // branch. A ceremony re-run onto a branch a pull request already stands
1849
+ // on is not an error to report, it is the state to bring up to date.
1850
+ const standing = openPrFor(branch);
1851
+ const pr = standing === null
1852
+ ? gh(prCreateArgs(branch), commitPlan.root)
1853
+ : gh(prEditArgs(branch), commitPlan.root);
1854
+ publishing.steps.push({
1855
+ command: standing === null ? prCreateCommand(branch) : `gh pr edit ${branch} --title … --body …`,
1856
+ ok: pr.ok,
1857
+ });
1719
1858
  if (!pr.ok) {
1720
1859
  const ghFailure = pr.stderr.trim() || pr.stdout.trim() || "gh did not run";
1721
- return stalled("pr-failed", "`gh pr create` failed; the branch is already on origin", pr, `the attestation was appended at seq ${seq}, committed on ${branch} and pushed, but \`gh pr create\` failed: ${ghFailure}; open the pull request by hand and merge it with a merge commit`, [
1860
+ return stalled("pr-failed", standing === null
1861
+ ? "`gh pr create` failed; the branch is already on origin"
1862
+ : "`gh pr edit` failed; the branch and its pull request are already on origin", pr, `the attestation was appended at seq ${seq}, committed on ${branch} and pushed, but \`gh pr create\` failed: ${ghFailure}; open the pull request by hand and merge it with a merge commit`, [
1722
1863
  `attestation appended at seq ${seq}: it is in the log, on disk`,
1723
1864
  `committed on ${branch} and PUSHED: origin has the branch`,
1724
1865
  "no pull request: origin's default branch still carries the previous policy",
@@ -1727,8 +1868,12 @@ export function commandPolicyAmend(argv, streams, cwd) {
1727
1868
  { command: `gh pr merge ${branch} --merge`, note: "or merge it in the web UI" },
1728
1869
  ], 0, [MERGE_COMMIT_LINE]);
1729
1870
  }
1730
- prUrl = lastUrl(pr.stdout);
1871
+ // `gh pr edit` prints the pull request's URL too, but the one this
1872
+ // ceremony already knows is the one it asked for; `create` has nothing
1873
+ // but its own output. Either way `null` is an honest answer.
1874
+ prUrl = standing ?? lastUrl(pr.stdout);
1731
1875
  publishing.prUrl = prUrl;
1876
+ publishing.prUpdated = standing !== null;
1732
1877
  publishing.complete = true;
1733
1878
  // APRV-130: the ceremony offers to finish the last step too.
1734
1879
  armAutoMerge(commitPlan.root, branch);
@@ -1865,7 +2010,10 @@ export function commandPolicyAmend(argv, streams, cwd) {
1865
2010
  // APRV-274: the headline names what the commit actually carries. A reader
1866
2011
  // who is told "the policy and the log" and finds a third file in the diff
1867
2012
  // has been told something false about the one commit that must not lie.
1868
- const carried = pinsChange === null ? "the policy and the log" : "the policy, the log and the pins";
2013
+ // APRV-356 added the fourth: the store file holding the attested bytes.
2014
+ const carried = pinsChange === null
2015
+ ? "the policy, the log and the attested policy text"
2016
+ : "the policy, the log, the pins and the attested policy text";
1869
2017
  const done = [
1870
2018
  branch === null
1871
2019
  ? `${st.glyph("ok")} committed ${carried} together:`
@@ -1958,6 +2106,8 @@ export function commandPolicyAmend(argv, streams, cwd) {
1958
2106
  * across two commits and a hand-run cherry-pick.
1959
2107
  */
1960
2108
  function planCommit(policyPath, logPath,
2109
+ /** The store file holding the attested policy text, absolute (APRV-356). */
2110
+ payloadFile,
1961
2111
  /**
1962
2112
  * The branch flow's preconditions, checked here for the same reason: an
1963
2113
  * `origin` that does not exist, or a branch name already taken, would fail
@@ -1971,11 +2121,16 @@ branchFlow) {
1971
2121
  if (root === null) {
1972
2122
  return {
1973
2123
  ok: false,
2124
+ code: "commit-preconditions",
1974
2125
  message: `--commit needs a git repository and ${policyPath} is not inside one; nothing was attested`,
1975
2126
  };
1976
2127
  }
1977
2128
  const policyArg = repoPath(root, policyPath);
1978
2129
  const logArg = repoPath(root, logPath);
2130
+ // APRV-356. The payload store is a sibling of the log directory, so a log
2131
+ // inside the repository puts this inside it too; the check below covers both
2132
+ // with one sentence rather than naming a path no operator chose.
2133
+ const payloadArg = repoPath(root, payloadFile);
1979
2134
  // APRV-274. `null` where these pins do not govern this policy, and where the
1980
2135
  // repository simply has no pins module: in both cases the ceremony's file set
1981
2136
  // is the two it always was.
@@ -1985,32 +2140,59 @@ branchFlow) {
1985
2140
  if (policyArg.startsWith("../") || logArg.startsWith("../")) {
1986
2141
  return {
1987
2142
  ok: false,
2143
+ code: "commit-preconditions",
1988
2144
  message: `--commit needs the policy (${policyPath}) and the log (${logPath}) inside the same repository (${root}); nothing was attested`,
1989
2145
  };
1990
2146
  }
1991
2147
  const status = git(["status", "--porcelain"], root);
1992
2148
  if (!status.ok) {
1993
- return { ok: false, message: `--commit could not read git status: ${status.stderr.trim()}` };
2149
+ return {
2150
+ ok: false,
2151
+ code: "commit-preconditions",
2152
+ message: `--commit could not read git status: ${status.stderr.trim()}`,
2153
+ };
1994
2154
  }
2155
+ const ceremonyFiles = [policyArg, logArg, payloadArg, ...(pinsArg === null ? [] : [pinsArg])];
2156
+ const carried = pinsArg === null
2157
+ ? "the policy, the log and the attested policy text"
2158
+ : `the policy, the log, the attested policy text and ${pinsArg}`;
1995
2159
  const strays = [];
2160
+ /** Ceremony files staged in one state and left in another (APRV-341). */
2161
+ const split = [];
1996
2162
  for (const line of status.stdout.split("\n")) {
1997
2163
  if (line.trim().length === 0)
1998
2164
  continue;
1999
2165
  const index = line[0] ?? " ";
2000
- // Only the INDEX column matters: an unstaged or untracked file elsewhere is
2001
- // not going into this commit, and refusing over it would make the verb
2002
- // unusable in any working repository.
2166
+ const worktree = line[1] ?? " ";
2167
+ const path = line.slice(3).trim();
2168
+ const ceremony = ceremonyFiles.includes(path);
2169
+ // A ceremony file the operator staged and then edited again. The commit is
2170
+ // assembled from the working tree, so the bytes it would carry are not the
2171
+ // bytes `git diff --cached` shows, and an amendment is the one commit that
2172
+ // may not be a surprise to the person who signed it.
2173
+ if (ceremony && index !== " " && index !== "?" && worktree === "M")
2174
+ split.push(path);
2175
+ // Only the INDEX column matters for everything else: an unstaged or
2176
+ // untracked file elsewhere is not going into this commit (the scratch index
2177
+ // lays exactly the ceremony paths over the remote's tree), and refusing
2178
+ // over one would make the verb unusable in any working repository.
2003
2179
  if (index === " " || index === "?")
2004
2180
  continue;
2005
- const path = line.slice(3).trim();
2006
- if (path === policyArg || path === logArg || path === pinsArg)
2181
+ if (ceremony)
2007
2182
  continue;
2008
2183
  strays.push(path);
2009
2184
  }
2185
+ if (split.length > 0) {
2186
+ return {
2187
+ ok: false,
2188
+ code: "dirty-tree",
2189
+ message: `--commit refuses: ${split.join(", ")} ${split.length === 1 ? "is" : "are"} staged in one state and modified again in the working tree. The amendment commit is assembled from the WORKING TREE, so it would carry bytes your \`git diff --cached\` does not show, and the one commit that may not surprise the person who signed it is this one. Stage the file as it stands (\`git add ${split.join(" ")}\`) or unstage it, then run this again. Nothing was attested`,
2190
+ };
2191
+ }
2010
2192
  if (strays.length > 0) {
2011
- const carried = pinsArg === null ? "the policy and the log" : `the policy, the log and ${pinsArg}`;
2012
2193
  return {
2013
2194
  ok: false,
2195
+ code: "staged-unrelated",
2014
2196
  message: `--commit refuses: the index carries ${strays.length} staged change(s) beyond ${carried} (${strays.join(", ")}). The amendment commit carries EXACTLY those files, so that "this commit is the amendment" stays true. Unstage them, or drop --commit and run the printed commands yourself. Nothing was attested`,
2015
2197
  };
2016
2198
  }
@@ -2019,6 +2201,7 @@ branchFlow) {
2019
2201
  if (!remote.ok) {
2020
2202
  return {
2021
2203
  ok: false,
2204
+ code: "commit-preconditions",
2022
2205
  message: `--commit on a branch needs an "origin" remote to push to, and ${root} has none (${remote.stderr.trim()}); pass --direct to commit in place, or add the remote. Nothing was attested`,
2023
2206
  };
2024
2207
  }
@@ -2027,12 +2210,13 @@ branchFlow) {
2027
2210
  if (exists.ok) {
2028
2211
  return {
2029
2212
  ok: false,
2213
+ code: "commit-preconditions",
2030
2214
  message: `--branch ${branchFlow.branch} already exists in ${root}; the amendment branch is created fresh so it carries exactly one commit. Pick another name. Nothing was attested`,
2031
2215
  };
2032
2216
  }
2033
2217
  }
2034
2218
  }
2035
- return { ok: true, plan: { root, policyArg, logArg, pinsArg } };
2219
+ return { ok: true, plan: { root, policyArg, logArg, pinsArg, payloadArg } };
2036
2220
  }
2037
2221
  /**
2038
2222
  * Fetch the remote and establish that this ceremony may be based on it (APRV-203).