approval-md 0.4.0 → 0.4.2

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 (190) hide show
  1. package/SPEC.md +11 -0
  2. package/cli.js +72 -4
  3. package/dist/src/channels/contract.d.ts +53 -0
  4. package/dist/src/channels/contract.js +56 -0
  5. package/dist/src/channels/contract.js.map +1 -1
  6. package/dist/src/channels/tagging.d.ts +25 -0
  7. package/dist/src/channels/tagging.js +57 -5
  8. package/dist/src/channels/tagging.js.map +1 -1
  9. package/dist/src/channels/telegram-minimal.d.ts +251 -0
  10. package/dist/src/channels/telegram-minimal.js +618 -0
  11. package/dist/src/channels/telegram-minimal.js.map +1 -0
  12. package/dist/src/channels/telegram.d.ts +454 -19
  13. package/dist/src/channels/telegram.js +931 -80
  14. package/dist/src/channels/telegram.js.map +1 -1
  15. package/dist/src/cli/amend.js +8 -1
  16. package/dist/src/cli/amend.js.map +1 -1
  17. package/dist/src/cli/attest.js +2 -1
  18. package/dist/src/cli/attest.js.map +1 -1
  19. package/dist/src/cli/audit-card.d.ts +1 -1
  20. package/dist/src/cli/audit-card.js +40 -1
  21. package/dist/src/cli/audit-card.js.map +1 -1
  22. package/dist/src/cli/audit.d.ts +7 -5
  23. package/dist/src/cli/audit.js +25 -12
  24. package/dist/src/cli/audit.js.map +1 -1
  25. package/dist/src/cli/channel-telegram-webhook.d.ts +2 -0
  26. package/dist/src/cli/channel-telegram-webhook.js +7 -0
  27. package/dist/src/cli/channel-telegram-webhook.js.map +1 -1
  28. package/dist/src/cli/channel-telegram.d.ts +75 -2
  29. package/dist/src/cli/channel-telegram.js +172 -16
  30. package/dist/src/cli/channel-telegram.js.map +1 -1
  31. package/dist/src/cli/channel-web.js +2 -1
  32. package/dist/src/cli/channel-web.js.map +1 -1
  33. package/dist/src/cli/channel.js +2 -1
  34. package/dist/src/cli/channel.js.map +1 -1
  35. package/dist/src/cli/doctor.js +1 -1
  36. package/dist/src/cli/doctor.js.map +1 -1
  37. package/dist/src/cli/execute.d.ts +12 -0
  38. package/dist/src/cli/execute.js +50 -4
  39. package/dist/src/cli/execute.js.map +1 -1
  40. package/dist/src/cli/gate.js +2 -1
  41. package/dist/src/cli/gate.js.map +1 -1
  42. package/dist/src/cli/help.d.ts +1 -0
  43. package/dist/src/cli/help.js +39 -18
  44. package/dist/src/cli/help.js.map +1 -1
  45. package/dist/src/cli/hook.d.ts +121 -3
  46. package/dist/src/cli/hook.js +719 -79
  47. package/dist/src/cli/hook.js.map +1 -1
  48. package/dist/src/cli/log-checkpoint.js +2 -1
  49. package/dist/src/cli/log-checkpoint.js.map +1 -1
  50. package/dist/src/cli/log-unlock.d.ts +23 -0
  51. package/dist/src/cli/log-unlock.js +110 -0
  52. package/dist/src/cli/log-unlock.js.map +1 -0
  53. package/dist/src/cli/main.js +23 -6
  54. package/dist/src/cli/main.js.map +1 -1
  55. package/dist/src/cli/setup-common.js +2 -1
  56. package/dist/src/cli/setup-common.js.map +1 -1
  57. package/dist/src/cli/up.js +5 -0
  58. package/dist/src/cli/up.js.map +1 -1
  59. package/dist/src/cli/vault.js +2 -1
  60. package/dist/src/cli/vault.js.map +1 -1
  61. package/dist/src/cli/verb-registry.js +1 -0
  62. package/dist/src/cli/verb-registry.js.map +1 -1
  63. package/dist/src/cli/wordmark.d.ts +2 -2
  64. package/dist/src/cli/wordmark.js +2 -2
  65. package/dist/src/core/attest-bootstrap.js +2 -1
  66. package/dist/src/core/attest-bootstrap.js.map +1 -1
  67. package/dist/src/core/attest.js +5 -4
  68. package/dist/src/core/attest.js.map +1 -1
  69. package/dist/src/core/audit.d.ts +113 -9
  70. package/dist/src/core/audit.js +382 -18
  71. package/dist/src/core/audit.js.map +1 -1
  72. package/dist/src/core/budgets.d.ts +12 -0
  73. package/dist/src/core/budgets.js +54 -1
  74. package/dist/src/core/budgets.js.map +1 -1
  75. package/dist/src/core/checkpoint.js +3 -2
  76. package/dist/src/core/checkpoint.js.map +1 -1
  77. package/dist/src/core/command-class.js +6 -0
  78. package/dist/src/core/command-class.js.map +1 -1
  79. package/dist/src/core/decision-refusal.js +2 -1
  80. package/dist/src/core/decision-refusal.js.map +1 -1
  81. package/dist/src/core/delegation.d.ts +109 -0
  82. package/dist/src/core/delegation.js +244 -0
  83. package/dist/src/core/delegation.js.map +1 -0
  84. package/dist/src/core/execute.js +12 -3
  85. package/dist/src/core/execute.js.map +1 -1
  86. package/dist/src/core/gate-window.js +4 -3
  87. package/dist/src/core/gate-window.js.map +1 -1
  88. package/dist/src/core/gate.d.ts +84 -43
  89. package/dist/src/core/gate.js +114 -6
  90. package/dist/src/core/gate.js.map +1 -1
  91. package/dist/src/core/gesture-refusal.js +2 -1
  92. package/dist/src/core/gesture-refusal.js.map +1 -1
  93. package/dist/src/core/identity.d.ts +105 -0
  94. package/dist/src/core/identity.js +122 -0
  95. package/dist/src/core/identity.js.map +1 -0
  96. package/dist/src/core/log-lock.d.ts +435 -0
  97. package/dist/src/core/log-lock.js +1093 -0
  98. package/dist/src/core/log-lock.js.map +1 -0
  99. package/dist/src/core/log.d.ts +82 -3
  100. package/dist/src/core/log.js +285 -40
  101. package/dist/src/core/log.js.map +1 -1
  102. package/dist/src/core/loop.d.ts +11 -0
  103. package/dist/src/core/loop.js +63 -4
  104. package/dist/src/core/loop.js.map +1 -1
  105. package/dist/src/core/policy-diff.js +20 -1
  106. package/dist/src/core/policy-diff.js.map +1 -1
  107. package/dist/src/core/policy-explain.js +18 -2
  108. package/dist/src/core/policy-explain.js.map +1 -1
  109. package/dist/src/core/policy-load.d.ts +38 -1
  110. package/dist/src/core/policy-load.js +34 -2
  111. package/dist/src/core/policy-load.js.map +1 -1
  112. package/dist/src/core/policy-match.js +30 -5
  113. package/dist/src/core/policy-match.js.map +1 -1
  114. package/dist/src/core/policy-proposal.d.ts +17 -0
  115. package/dist/src/core/policy-proposal.js +32 -16
  116. package/dist/src/core/policy-proposal.js.map +1 -1
  117. package/dist/src/core/prompt-layout.d.ts +186 -1
  118. package/dist/src/core/prompt-layout.js +345 -3
  119. package/dist/src/core/prompt-layout.js.map +1 -1
  120. package/dist/src/core/sender-identity.d.ts +24 -1
  121. package/dist/src/core/sender-identity.js +31 -0
  122. package/dist/src/core/sender-identity.js.map +1 -1
  123. package/dist/src/core/token.js +11 -0
  124. package/dist/src/core/token.js.map +1 -1
  125. package/dist/src/core/tool-map.d.ts +125 -0
  126. package/dist/src/core/tool-map.js +179 -0
  127. package/dist/src/core/tool-map.js.map +1 -0
  128. package/dist/src/core/validate.d.ts +20 -6
  129. package/dist/src/core/validate.js +0 -0
  130. package/dist/src/core/validate.js.map +1 -1
  131. package/dist/src/daemon/git-evidence.d.ts +2 -2
  132. package/dist/src/daemon/git-evidence.js +1 -1
  133. package/dist/src/serve/archive.d.ts +8 -0
  134. package/dist/src/serve/archive.js +11 -2
  135. package/dist/src/serve/archive.js.map +1 -1
  136. package/docs/README-extended.md +15 -0
  137. package/docs/cli-reference.md +571 -28
  138. package/package.json +2 -2
  139. package/schema/event.schema.json +387 -29
  140. package/schema/fixtures/event/invalid/audit-lock-reclaimed-age-without-created.json +19 -0
  141. package/schema/fixtures/event/invalid/audit-lock-reclaimed-agent-actor.json +14 -0
  142. package/schema/fixtures/event/invalid/audit-lock-reclaimed-lockfile-path.json +14 -0
  143. package/schema/fixtures/event/invalid/audit-lock-reclaimed-operator-cleared-system-actor.json +14 -0
  144. package/schema/fixtures/event/invalid/audit-lock-reclaimed-unknown-reason.json +14 -0
  145. package/schema/fixtures/event/invalid/audit-lock-reclaimed-without-age.json +13 -0
  146. package/schema/fixtures/event/invalid/audit-reviewed-agent-actor.json +6 -1
  147. package/schema/fixtures/event/invalid/audit-reviewed-bad-payload-hash.json +21 -0
  148. package/schema/fixtures/event/invalid/audit-reviewed-loved-no-note.json +5 -0
  149. package/schema/fixtures/event/invalid/audit-reviewed-model-actor.json +20 -0
  150. package/schema/fixtures/event/invalid/audit-reviewed-no-subject-hash.json +19 -0
  151. package/schema/fixtures/event/invalid/audit-reviewed-no-subject-seq.json +19 -0
  152. package/schema/fixtures/event/invalid/audit-reviewed-no-verdict-source.json +19 -0
  153. package/schema/fixtures/event/invalid/audit-reviewed-no-verdict.json +19 -0
  154. package/schema/fixtures/event/invalid/audit-reviewed-system-actor.json +6 -1
  155. package/schema/fixtures/event/invalid/audit-reviewed-verdict-source-model.json +20 -0
  156. package/schema/fixtures/event/invalid/audit-sampled-no-policy-hash.json +14 -0
  157. package/schema/fixtures/event/invalid/execution-started-harness-tool-space.json +18 -0
  158. package/schema/fixtures/event/valid/audit-lock-reclaimed-implausible-created.json +18 -0
  159. package/schema/fixtures/event/valid/audit-lock-reclaimed-legacy.json +14 -0
  160. package/schema/fixtures/event/valid/audit-lock-reclaimed-unlock.json +19 -0
  161. package/schema/fixtures/event/valid/audit-lock-reclaimed.json +19 -0
  162. package/schema/fixtures/event/valid/audit-reviewed-reaction.json +5 -0
  163. package/schema/fixtures/event/valid/audit-reviewed.json +6 -1
  164. package/schema/fixtures/event/valid/audit-sampled.json +2 -1
  165. package/schema/fixtures/event/valid/daemon-identity.json +2 -1
  166. package/schema/fixtures/event/valid/execution-started-harness-tool.json +19 -0
  167. package/schema/fixtures/policy/invalid/delegation-advice-string.json +15 -0
  168. package/schema/fixtures/policy/invalid/delegation-class-duplicate.json +18 -0
  169. package/schema/fixtures/policy/invalid/delegation-class-wildcard.json +17 -0
  170. package/schema/fixtures/policy/invalid/delegation-daily-cap-fractional.json +15 -0
  171. package/schema/fixtures/policy/invalid/delegation-daily-cap-negative.json +15 -0
  172. package/schema/fixtures/policy/invalid/delegation-daily-cap-too-high.json +15 -0
  173. package/schema/fixtures/policy/invalid/delegation-escalate-duplicate.json +18 -0
  174. package/schema/fixtures/policy/invalid/delegation-escalate-unknown.json +17 -0
  175. package/schema/fixtures/policy/invalid/delegation-max-autonomy-alias.json +15 -0
  176. package/schema/fixtures/policy/invalid/delegation-max-autonomy-autonomous.json +15 -0
  177. package/schema/fixtures/policy/invalid/delegation-model-no-version.json +15 -0
  178. package/schema/fixtures/policy/invalid/delegation-model-provider-form.json +15 -0
  179. package/schema/fixtures/policy/invalid/delegation-null.json +7 -0
  180. package/schema/fixtures/policy/invalid/delegation-reviewer-agent.json +17 -0
  181. package/schema/fixtures/policy/invalid/delegation-reviewer-bare-id.json +17 -0
  182. package/schema/fixtures/policy/invalid/delegation-unknown-key.json +16 -0
  183. package/schema/fixtures/policy/invalid/tools-class-wildcard.json +9 -0
  184. package/schema/fixtures/policy/invalid/tools-match-double-star.json +9 -0
  185. package/schema/fixtures/policy/invalid/unmapped-tool-unknown.json +7 -0
  186. package/schema/fixtures/policy/valid/delegation-empty.json +7 -0
  187. package/schema/fixtures/policy/valid/delegation-grammar-full.json +41 -0
  188. package/schema/fixtures/policy/valid/delegation-off.json +15 -0
  189. package/schema/fixtures/policy/valid/tools-mapping.json +17 -0
  190. package/schema/policy.schema.json +178 -0
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Identity strings by role (APRV-500, amended SPEC.md §5.2 proposed).
3
+ *
4
+ * The runtime has always written three actor kinds, `human:`, `agent:` and
5
+ * `system:`, and every human-only verb (grant, reject, revoke, attest, review,
6
+ * checkpoint, gate window, channel decisions) checked its own `^human:.+`; they
7
+ * now all read {@link isHumanActor} below.
8
+ * APRV-500 RESERVES a fourth kind for the judge, `model:<name>@<version>`, and
9
+ * this module is where that reservation is stated: the one parser that names
10
+ * the kind, and the one place that says which role may carry it.
11
+ *
12
+ * In 0.4.2 that is the `reviewer` role of `delegation.reviewers` and nothing
13
+ * else, and even there it only PARSES: a policy whose block lists any reviewer
14
+ * fails to load with `delegation-not-supported` (`core/delegation.ts`). Every
15
+ * human-only verb checks {@link isHumanActor} (`^human:.+`, and the id does not
16
+ * begin with a reserved kind), so a `model:` actor, and a `human:model:…` one,
17
+ * is refused `actor-not-human` by every verb; the event schema's actor patterns
18
+ * are unchanged, so no record can carry a `model:` actor. A later core that
19
+ * admits the judge widens a role here and a verb there, each as its own change.
20
+ *
21
+ * Pure: no I/O, no clock.
22
+ */
23
+ /**
24
+ * `model:<name>@<major>.<minor>.<patch>`, the judge's identity.
25
+ *
26
+ * `<name>` is the judge service, lowercase alphanumerics and `-`, at most 64
27
+ * characters. The version pins one judge release (prompt, rubric and underlying
28
+ * model snapshot), so a provider change is a new version. The same pattern is
29
+ * `schema/policy.schema.json`'s `modelIdentity`; `tests/policy-delegation.test.ts`
30
+ * holds the two equal.
31
+ */
32
+ export const MODEL_IDENTITY_PATTERN = /^model:[a-z0-9][a-z0-9-]{0,63}@[0-9]+\.[0-9]+\.[0-9]+$/u;
33
+ /** `human:<id>`, the shape every human-only verb already requires. */
34
+ const HUMAN_IDENTITY = /^human:(.+)$/u;
35
+ /**
36
+ * Identity kinds that are reserved and are not a person's name (APRV-500
37
+ * refutation S3). THE one list: every human-only verb reads it through
38
+ * {@link isHumanActor}, so reserving a further kind is one entry here.
39
+ *
40
+ * Why it exists. `^human:.+` admits any id, and a policy that names no approver
41
+ * roster restricts nothing further, so `human:model:judge@0.3.0` used to pass
42
+ * every human-only verb and a channel decision under it was recorded as
43
+ * `approval.granted`: the judge written down as a person, which is the one
44
+ * thing the `model:` reservation exists to prevent. A human id that begins with
45
+ * a reserved kind prefix is therefore not a human identity anywhere. The
46
+ * comparison ignores case and leading whitespace, so `human:Model:judge` and
47
+ * `human: model:judge` are refused too.
48
+ */
49
+ export const RESERVED_IDENTITY_KINDS = ["model"];
50
+ /** Does this `human:` id begin with a reserved kind prefix (`model:`)? */
51
+ export function wearsReservedKind(id) {
52
+ const folded = id.trimStart().toLowerCase();
53
+ return RESERVED_IDENTITY_KINDS.some((kind) => folded.startsWith(`${kind}:`));
54
+ }
55
+ /**
56
+ * Is `actor` a human identity a human-only verb may record (grant, reject,
57
+ * revoke, attest, review, checkpoint, gate window, channel decisions, and the
58
+ * CLI's `--as` / `APPROVAL_HUMAN` resolution)?
59
+ *
60
+ * `^human:.+`, as every verb always required, and the id does not begin with a
61
+ * reserved kind (see {@link RESERVED_IDENTITY_KINDS}). A refusal keeps each
62
+ * verb's existing code, `actor-not-human`: the identity names a judge, not a
63
+ * person, and no refusal code is added.
64
+ */
65
+ export function isHumanActor(actor) {
66
+ if (typeof actor !== "string")
67
+ return false;
68
+ const human = HUMAN_IDENTITY.exec(actor);
69
+ return human !== null && !wearsReservedKind(human[1] ?? "");
70
+ }
71
+ /**
72
+ * The clause a refusal message appends when `actor` is a `human:` id wearing a
73
+ * reserved kind, so the person reading it learns why a `human:` actor was
74
+ * refused as not human. Empty for every other actor.
75
+ */
76
+ export function reservedKindNote(actor) {
77
+ if (typeof actor !== "string")
78
+ return "";
79
+ const human = HUMAN_IDENTITY.exec(actor);
80
+ if (human === null || !wearsReservedKind(human[1] ?? ""))
81
+ return "";
82
+ return ` (a human: id may not begin with a reserved identity kind, ${RESERVED_IDENTITY_KINDS.map((kind) => `${kind}:`).join(", ")}: that names a judge, not a person)`;
83
+ }
84
+ /** The roles a `model:` identity may hold. One, and it is reserved. */
85
+ const MODEL_ROLES = new Set(["reviewer"]);
86
+ /**
87
+ * Parse `text` as an identity for `role`, or `null` when it is not one that
88
+ * role admits.
89
+ *
90
+ * `human:<id>` is admitted for every role, unless the id begins with a reserved
91
+ * kind (`human:model:…`, see {@link RESERVED_IDENTITY_KINDS}), which is `null`
92
+ * for every role. `model:<name>@<version>` is admitted for `reviewer` alone;
93
+ * for any other role it is `null`, exactly as an `agent:` or `system:` actor
94
+ * is. Anything else, a bare id or a malformed model string included, is
95
+ * `null`.
96
+ */
97
+ export function parseIdentity(text, role) {
98
+ if (typeof text !== "string")
99
+ return null;
100
+ if (MODEL_IDENTITY_PATTERN.test(text)) {
101
+ if (!MODEL_ROLES.has(role))
102
+ return null;
103
+ // The pattern admits exactly one `@`, after the name.
104
+ const at = text.indexOf("@");
105
+ return { kind: "model", name: text.slice("model:".length, at), version: text.slice(at + 1) };
106
+ }
107
+ const human = HUMAN_IDENTITY.exec(text);
108
+ // APRV-500 refutation S3: `human:model:judge@0.3.0` is the judge wearing a
109
+ // person's prefix, and it is no identity for any role.
110
+ if (human !== null && !wearsReservedKind(human[1] ?? ""))
111
+ return { kind: "human", id: human[1] ?? "" };
112
+ return null;
113
+ }
114
+ /**
115
+ * {@link isHumanActor} in the shape of the `/^human:.+/u` constants it replaced
116
+ * (APRV-500 refutation S3), so every human-only verb's existing
117
+ * `HUMAN_ACTOR.test(actor)` reads this one rule rather than its own copy.
118
+ */
119
+ export const HUMAN_ACTOR_RULE = {
120
+ test: isHumanActor,
121
+ };
122
+ //# sourceMappingURL=identity.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"identity.js","sourceRoot":"","sources":["../../../src/core/identity.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;GAqBG;AAEH;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,sBAAsB,GAAG,yDAAyD,CAAC;AAEhG,sEAAsE;AACtE,MAAM,cAAc,GAAG,eAAe,CAAC;AAEvC;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG,CAAC,OAAO,CAAU,CAAC;AAE1D,0EAA0E;AAC1E,MAAM,UAAU,iBAAiB,CAAC,EAAU;IAC1C,MAAM,MAAM,GAAG,EAAE,CAAC,SAAS,EAAE,CAAC,WAAW,EAAE,CAAC;IAC5C,OAAO,uBAAuB,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,MAAM,CAAC,UAAU,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,CAAC;AAC/E,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,YAAY,CAAC,KAAc;IACzC,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,KAAK,CAAC;IAC5C,MAAM,KAAK,GAAG,cAAc,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACzC,OAAO,KAAK,KAAK,IAAI,IAAI,CAAC,iBAAiB,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;AAC9D,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,gBAAgB,CAAC,KAAc;IAC7C,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,EAAE,CAAC;IACzC,MAAM,KAAK,GAAG,cAAc,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACzC,IAAI,KAAK,KAAK,IAAI,IAAI,CAAC,iBAAiB,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAAE,OAAO,EAAE,CAAC;IACpE,OAAO,8DAA8D,uBAAuB,CAAC,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,qCAAqC,CAAC;AACzK,CAAC;AAkBD,uEAAuE;AACvE,MAAM,WAAW,GAA8B,IAAI,GAAG,CAAe,CAAC,UAAU,CAAC,CAAC,CAAC;AAEnF;;;;;;;;;;GAUG;AACH,MAAM,UAAU,aAAa,CAAC,IAAY,EAAE,IAAkB;IAC5D,IAAI,OAAO,IAAI,KAAK,QAAQ;QAAE,OAAO,IAAI,CAAC;IAC1C,IAAI,sBAAsB,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QACtC,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,IAAI,CAAC;YAAE,OAAO,IAAI,CAAC;QACxC,sDAAsD;QACtD,MAAM,EAAE,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QAC7B,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,MAAM,EAAE,EAAE,CAAC,EAAE,OAAO,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAC,EAAE,CAAC;IAC/F,CAAC;IACD,MAAM,KAAK,GAAG,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACxC,2EAA2E;IAC3E,uDAAuD;IACvD,IAAI,KAAK,KAAK,IAAI,IAAI,CAAC,iBAAiB,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;QAAE,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,EAAE,EAAE,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC;IACvG,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAmD;IAC9E,IAAI,EAAE,YAAY;CACnB,CAAC"}
@@ -0,0 +1,435 @@
1
+ /**
2
+ * The holder of `<log>.lock`, and the one way a lock left by a writer that died
3
+ * holding it is taken back (APRV-479).
4
+ *
5
+ * ## The defect this exists for
6
+ *
7
+ * `core/log.ts` serializes every append with a lockfile created `wx` and never
8
+ * stolen, because silently breaking somebody else's lock is how two writers
9
+ * come to share a `seq`. A writer that dies while it holds the lock never
10
+ * removes it: a harness hook killed by SIGTERM with no listener registered (the
11
+ * default disposition), any verb killed by SIGKILL, a Hermes gateway restart in
12
+ * the middle of an append. Every later writer, the daemon included, then waits
13
+ * its two seconds and refuses `lock-timeout`, and the tenant's gate stays
14
+ * wedged until a human removes the file.
15
+ *
16
+ * ## The rule every decision here follows
17
+ *
18
+ * A wrong reclaim (two writers under one lock, a forked chain) is far worse than
19
+ * a missed one (a wedge a human clears with one command). So a holder is LIVE
20
+ * unless this process can prove it gone, and a lock is taken back only by a
21
+ * writer that has already waited out its whole lock timeout.
22
+ *
23
+ * ## What this module does
24
+ *
25
+ * 1. **The lockfile names its holder.** {@link createLockFile} writes one JSON
26
+ * line into the file it creates: pid, host, boot, on Linux the pid and time
27
+ * namespaces and the process start time, when the lock was taken, which kind
28
+ * of holder (`append` | `hold`), and a nonce. An older writer's lockfile is
29
+ * empty.
30
+ * 2. **Only a regular file that parses strictly is judged.** Every read opens
31
+ * without blocking and without following links, and reads only a regular
32
+ * file of at most 4 KiB. Anything else at the lock's path (a FIFO, a link, a
33
+ * directory, an oversized or malformed file, a record of another version) is
34
+ * not this writer's to judge: it is LIVE.
35
+ * 3. **The judgement ({@link judgeHolder}).** Gone only when the holder is in
36
+ * this process's own pid namespace and boot, and provably not running:
37
+ * - Linux: `/proc` is this namespace's (`/proc/self` names this pid), the
38
+ * record's boot id and pid-namespace inode equal this process's, and then
39
+ * `kill(pid, 0)` answers ESRCH with `/proc/<pid>` absent or a zombie, or
40
+ * `/proc/<pid>/stat` names a process whose start time (read in the same
41
+ * time namespace) is not the holder's;
42
+ * - elsewhere (macOS): the same hostname, boot readings within
43
+ * {@link BOOT_SLACK_S} seconds, and `kill(pid, 0)` answering ESRCH.
44
+ *
45
+ * Everything else is LIVE: EPERM, another namespace or boot or host, a
46
+ * `/proc` entry that cannot be read.
47
+ * 4. **A lockfile with no holder record** (empty: an older version's, or a
48
+ * writer killed between the create and the write) is gone only once its
49
+ * mtime is {@link LEGACY_LOCK_RECLAIM_AGE_MS} old AND this writer watched
50
+ * that very file (inode, mtime, size) unchanged through its own wait.
51
+ * 5. **The reclaim ({@link reclaimStaleLock}) is two atomic steps, and nothing
52
+ * else is written.** The taker's own complete lockfile is written first,
53
+ * under `<lock>.take.<pid>.<nonce>`, so whoever finds a claim beside the
54
+ * lock also finds its taker and can judge it (`approval log unlock` refuses
55
+ * while one is seen running). Then the claim: `link(2)` of the lock's path to a name
56
+ * derived from the judged record, `<lock>.stale.<pid>.<created ms>`, which
57
+ * fails EEXIST for every other reclaimer of the same lockfile, and which is
58
+ * then read back to prove it names the very file that was judged (inode,
59
+ * mtime, bytes). Then the take: this writer's own lockfile is `rename(2)`d
60
+ * over the lock's path, once the claim and the lock's path are read again
61
+ * and are still the judged file. The lock's
62
+ * path is never empty, so no ordinary `wx` create slips in, and between the
63
+ * claim and the take nothing in the protocol can change it: the holder is
64
+ * dead, every other writer's create fails EEXIST, and every other
65
+ * reclaimer's claim fails EEXIST. (A bare `rename` of the lockfile aside
66
+ * cannot be made safe: a reclaimer that judged the dead lock and stalled
67
+ * would later move whatever live lock had replaced it.)
68
+ * 6. **The reclaim is in the log first.** The reclaimer appends
69
+ * `audit.lock_reclaimed` as the first record under the lock it took
70
+ * (`core/log.ts`), carrying the strictly parsed pid, op and `created` and
71
+ * nothing else from the file, and only then unlinks the stale name.
72
+ * 7. **A termination signal does not leave a lock behind.**
73
+ * {@link guardTerminationWhileLocked} is installed before a lockfile is
74
+ * created, and before a reclaim's claim, and held until the lock is removed: for SIGTERM, SIGINT and SIGHUP with
75
+ * no listener, a listener that re-raises the signal with its default
76
+ * disposition once the lock is released, so the process still dies of it.
77
+ * SIGKILL cannot be caught, which is what 1 to 6 are for.
78
+ *
79
+ * Never reclaimed automatically, whatever the holder: a lock beside a
80
+ * `log sync` snapshot (a sync killed part way may have left the working log at
81
+ * its committed bytes), and a lock beside a log that does not exist. A lock from
82
+ * another container or boot is never reclaimed automatically either; the
83
+ * refusal names the pid and the human verb, `approval log unlock --pid <n>`
84
+ * ({@link takeLockForUnlock}).
85
+ */
86
+ /** The holder record's format version. A reader treats any other as not its to judge. */
87
+ export declare const LOCK_HOLDER_VERSION = 1;
88
+ /**
89
+ * How old an empty lockfile (no holder record) must be before it is reclaimed.
90
+ * An append holds the lock for milliseconds, and the longest holder in this
91
+ * runtime (`approval log sync`) for seconds.
92
+ */
93
+ export declare const LEGACY_LOCK_RECLAIM_AGE_MS: number;
94
+ /**
95
+ * How long a writer must have watched an empty lockfile unchanged (the same
96
+ * inode, mtime and size since the first refusal of its wait) before it may take
97
+ * it as `legacy-aged`. A writer of this version fills its record within
98
+ * microseconds of its create, so an empty file that stays empty across a retry
99
+ * interval is not one; an mtime alone can read old through a stepped clock or
100
+ * a file server's lagging one (APRV-479 R3-8).
101
+ */
102
+ export declare const LEGACY_LOCK_WATCH_MS = 20;
103
+ /**
104
+ * Off Linux, how far apart two boot readings (`now - uptime`, in seconds) may
105
+ * be and still count as one boot. A wall-clock step moves the reading, so a
106
+ * larger gap is "another boot, or a stepped clock": live, never gone.
107
+ */
108
+ export declare const BOOT_SLACK_S = 60;
109
+ /**
110
+ * `created` exactly as `Date#toISOString` writes it AND a real instant: the
111
+ * string must survive a round trip, which `2026-02-30T00:00:00.000Z` (a shape
112
+ * match that V8 rolls over to March 2nd) does not.
113
+ */
114
+ export declare function isCanonicalTimestamp(value: string): boolean;
115
+ /** `append`: one record. `hold`: a whole operation under `withAppendLock`. */
116
+ export type LockOp = "append" | "hold";
117
+ /** Who a process is, as far as a liveness judgement can use it. */
118
+ export interface HolderIdentity {
119
+ pid: number;
120
+ host: string;
121
+ /** Linux: `/proc/sys/kernel/random/boot_id`. Elsewhere: `~<boot epoch seconds>`. */
122
+ boot: string;
123
+ /** Linux only: `/proc/self/ns/pid`. */
124
+ pidns?: string;
125
+ /** Linux only: field 22 of `/proc/self/stat` (clock ticks after boot, in this time namespace). */
126
+ start?: string;
127
+ /** Linux only, where the kernel has time namespaces: `/proc/self/ns/time`. */
128
+ timens?: string;
129
+ }
130
+ /** What a lockfile written by this version holds, one JSON line. */
131
+ export interface LockHolder extends HolderIdentity {
132
+ v: typeof LOCK_HOLDER_VERSION;
133
+ /** When the lock was taken, as `Date#toISOString` writes it. */
134
+ created: string;
135
+ op: LockOp;
136
+ /** Random, so two lockfiles are never byte-identical. */
137
+ nonce: string;
138
+ }
139
+ /**
140
+ * Why a lock was taken over: the `audit.lock_reclaimed` payload's closed
141
+ * `reason` set. `holder-dead` and `legacy-aged` are a writer's proof;
142
+ * `operator-cleared` is a person's assertion (`approval log unlock`), never a
143
+ * proof, and only a `human:` actor carries it.
144
+ */
145
+ export type LockReclaimReason = "holder-dead" | "legacy-aged" | "operator-cleared";
146
+ /**
147
+ * The judgement on one holder record. `running` (on a live verdict): this
148
+ * process saw the holder itself running (Linux: its start time; elsewhere: its
149
+ * pid, on this host, in this boot). A live verdict without it is a holder this
150
+ * process cannot check, which a human may clear with `approval log unlock`.
151
+ */
152
+ export type HolderVerdict = {
153
+ state: "gone";
154
+ why: string;
155
+ } | {
156
+ state: "live";
157
+ why: string;
158
+ running: boolean;
159
+ };
160
+ /** This process, as a holder record describes it. */
161
+ export interface SelfIdentity {
162
+ pid: number;
163
+ host: string;
164
+ boot: string | undefined;
165
+ pidns: string | undefined;
166
+ start: string | undefined;
167
+ timens?: string | undefined;
168
+ linux: boolean;
169
+ /**
170
+ * Linux: whether `/proc` is this process's own pid namespace's procfs
171
+ * (`/proc/self` names `process.pid`). `false` means no pid read from it can be
172
+ * trusted, and every holder is live. Absent is taken as `true` (tests).
173
+ */
174
+ procIsOwn?: boolean;
175
+ }
176
+ /** What `/proc/<pid>/stat` said. */
177
+ export type ProcStatRead = {
178
+ kind: "stat";
179
+ state: string;
180
+ start: string;
181
+ }
182
+ /** ENOENT or ESRCH: no entry this process can see. */
183
+ | {
184
+ kind: "absent";
185
+ }
186
+ /** Anything else (EACCES, EPERM, an unparseable line): nothing can be concluded. */
187
+ | {
188
+ kind: "unreadable";
189
+ why: string;
190
+ };
191
+ /** `kill(pid, 0)`: `ok` (exists), `ESRCH` (no such process), `EPERM` (exists, another user's), `other`. */
192
+ export type SignalZero = "ok" | "ESRCH" | "EPERM" | "other";
193
+ /** What a liveness judgement may ask of the operating system. Injected by tests. */
194
+ export interface LivenessProbe {
195
+ /** Linux: `/proc/<pid>/stat`, as read by this process. */
196
+ linuxStat(pid: number): ProcStatRead;
197
+ signalZero(pid: number): SignalZero;
198
+ }
199
+ /**
200
+ * Parse `/proc/<pid>/stat`: the command name is parenthesized and may contain
201
+ * spaces and parentheses, so the fields are counted from the LAST `)`. After it,
202
+ * index 0 is field 3 (state) and index 19 is field 22 (starttime).
203
+ */
204
+ export declare function parseProcStat(text: string): {
205
+ state: string;
206
+ start: string;
207
+ } | null;
208
+ /** The real probe: `/proc` and `kill(pid, 0)`. */
209
+ export declare const NODE_LIVENESS_PROBE: LivenessProbe;
210
+ /**
211
+ * Is `/proc` this process's own pid namespace's procfs? `/proc/self` resolves
212
+ * to the reader's pid as THAT procfs numbers it, so it names `process.pid` only
213
+ * when the procfs belongs to the namespace `process.pid` was issued in, or when
214
+ * the pid happens to be the same number in both (APRV-479 R3-9). So the
215
+ * `NSpid:` line of `/proc/self/status` must also hold exactly one field, this
216
+ * pid: it lists the pid in every namespace from the procfs's own down to the
217
+ * reader's, and one field means they are the same namespace. A kernel without
218
+ * the line (before 4.1) proves nothing, and every holder is then live.
219
+ */
220
+ export declare function procIsOwnNamespace(read?: (path: string) => string, pid?: number, status?: (path: string) => string): boolean;
221
+ /** This process's identity, read once: none of it changes while the process lives. */
222
+ export declare function selfIdentity(): SelfIdentity;
223
+ /** The record this process writes into a lockfile it creates now. */
224
+ export declare function holderRecord(op: LockOp, now?: Date): LockHolder;
225
+ /**
226
+ * What a lockfile's bytes say: a strictly parsed v1 record, `legacy` (empty or
227
+ * whitespace only: no holder record at all), or `unknown` (anything else: a
228
+ * malformed record, another version, text that is not JSON), which is never
229
+ * judged.
230
+ */
231
+ export declare function parseHolder(bytes: Buffer | string): {
232
+ kind: "v1";
233
+ holder: LockHolder;
234
+ } | {
235
+ kind: "legacy";
236
+ } | {
237
+ kind: "unknown";
238
+ };
239
+ /**
240
+ * Is the process that wrote `holder` provably gone? Pure: the identity of the
241
+ * reader and the operating system's answers are parameters. "Live" means "not
242
+ * provably gone". The holder's boot and pid namespace are established before
243
+ * any pid is looked up, because a pid means nothing outside the namespace it
244
+ * was issued in.
245
+ */
246
+ export declare function judgeHolder(holder: HolderIdentity, self: SelfIdentity, probe: LivenessProbe): HolderVerdict;
247
+ /**
248
+ * Replace the liveness judgement (tests only: the pin that a live holder's
249
+ * lock survives only because of the check). `null` restores the real one.
250
+ */
251
+ export declare function setLockLivenessForTests(judge: ((holder: HolderIdentity) => HolderVerdict) | null): void;
252
+ /** Where a test may act inside the lock's protocol. Nothing in `src/` sets one. */
253
+ export type LockStep = "after-open" | "judged" | "claimed" | "before-take";
254
+ /** Run `seam` at each named step (tests only: a stall, a signal, a planted file). `null` removes it. */
255
+ export declare function setLockSeamForTests(seam: ((step: LockStep) => void) | null): void;
256
+ /** The lock this process created or took: enough to release only that one. */
257
+ export interface OwnLock {
258
+ path: string;
259
+ ino: bigint;
260
+ bytes: Buffer;
261
+ }
262
+ /**
263
+ * Create `path` with `wx` and write this process's holder record into it.
264
+ * Throws what `openSync` throws, so `EEXIST` means another writer holds it. A
265
+ * record that cannot be written leaves an empty lockfile, which is still a lock.
266
+ * The caller installs the signal guard BEFORE calling this.
267
+ */
268
+ export declare function createLockFile(path: string, op: LockOp): OwnLock;
269
+ /**
270
+ * Remove the lock this process holds, and only it: anything else at the path
271
+ * (another inode, other bytes) is left where it is.
272
+ */
273
+ export declare function releaseLockFile(own: OwnLock): void;
274
+ /** What an `audit.lock_reclaimed` record says, and the stale name to unlink once it is in the log. */
275
+ export interface ReclaimNote {
276
+ /** The lockfile's base name. */
277
+ lockfile: string;
278
+ reason: LockReclaimReason;
279
+ /**
280
+ * From a v1 record only: the strictly parsed pid, op and `created`. `created`
281
+ * is `null` when it is not plausible ({@link plausibleCreated}).
282
+ */
283
+ holder?: {
284
+ pid: number;
285
+ op: LockOp;
286
+ created: string | null;
287
+ };
288
+ /**
289
+ * How long the lock had been held, from `created` (or an empty lockfile's
290
+ * mtime), by this process's clock. Absent exactly when `created` is `null`.
291
+ */
292
+ age_ms?: number;
293
+ /** The claim name, removed after the record lands. Never recorded. */
294
+ stale: string;
295
+ /** One line for a person. Never recorded. */
296
+ why: string;
297
+ }
298
+ /** How far before the log's last record a holder's `created` may lie and still be recorded. */
299
+ export declare const CREATED_BEFORE_LAST_RECORD_MS: number;
300
+ /** How far past this process's clock a holder's `created` may lie and still be recorded. */
301
+ export declare const CREATED_AFTER_NOW_MS: number;
302
+ /**
303
+ * The record of a reclaim carries a holder's `created` (and the age derived
304
+ * from it) only when it is plausible: no earlier than the log's last record's
305
+ * `ts` minus {@link CREATED_BEFORE_LAST_RECORD_MS}, and no later than now plus
306
+ * {@link CREATED_AFTER_NOW_MS}. Anything else is a value the file chose (any
307
+ * canonical instant parses, year 0000 and 9999 included), so the record says
308
+ * `created: null` and carries no age. With no last record to measure from, no
309
+ * `created` is plausible. (APRV-479 R3-7, the stricter rule.)
310
+ */
311
+ export declare function plausibleCreated(note: ReclaimNote, lastTs: number | null, now: number): ReclaimNote;
312
+ /** What the writer knows about the log before it claims: whether it could record a reclaim at all, and its last record's time. */
313
+ export type RecordContext = {
314
+ ok: true;
315
+ lastTs: number | null;
316
+ } | {
317
+ ok: false;
318
+ why: string;
319
+ };
320
+ /** What {@link reclaimStaleLock} found and did. */
321
+ export type ReclaimOutcome =
322
+ /** This process now holds the lock and must record `note` first. */
323
+ {
324
+ kind: "taken";
325
+ own: OwnLock;
326
+ releaseGuard: () => void;
327
+ note: ReclaimNote;
328
+ }
329
+ /** The lock moved, or another writer is reclaiming it: wait again. */
330
+ | {
331
+ kind: "retry";
332
+ why: string;
333
+ }
334
+ /** The lock stays where it is; `why` names its holder and the human command. */
335
+ | {
336
+ kind: "kept";
337
+ why: string;
338
+ };
339
+ /** The human verb, as a refusal names it. */
340
+ export declare function unlockCommand(pid: number | null): string;
341
+ /** A lockfile as a writer saw it at the first refusal of its wait (R3-8). */
342
+ export interface LockObservation {
343
+ ino: bigint;
344
+ mtimeNs: bigint;
345
+ size: bigint;
346
+ /** When it was seen, by this process's clock. */
347
+ at: number;
348
+ }
349
+ /** What is at `<logPath>.lock` now, without opening it: a regular file's identity, or `null`. */
350
+ export declare function observeLock(logPath: string, now?: number): LockObservation | null;
351
+ /** Options for {@link reclaimStaleLock}. */
352
+ export interface ReclaimOptions {
353
+ now?: number;
354
+ /** The writer's own write-boundary check of the record it would append: a reclaim it would refuse is not made. */
355
+ recordValid?: (note: ReclaimNote) => boolean;
356
+ /**
357
+ * The lockfile as this writer saw it at the first refusal of this wait
358
+ * ({@link observeLock}). An empty lockfile is taken only when it is that
359
+ * same file, watched for at least {@link LEGACY_LOCK_WATCH_MS}.
360
+ */
361
+ watched?: LockObservation | null;
362
+ /**
363
+ * Can this writer append at all right now (its daemon stamp, the log's
364
+ * tail), and when was the log's last record? Asked before the claim, so a
365
+ * reclaim whose record would be refused is never made; the last record's
366
+ * time bounds the `created` the record may carry ({@link plausibleCreated}).
367
+ * Absent: appendable, with no last record to measure from.
368
+ */
369
+ recordContext?: () => RecordContext;
370
+ }
371
+ /**
372
+ * Judge `<logPath>.lock` once and, when its holder is provably gone, take it:
373
+ * claim, prove the claim, take. Called by a writer only after its whole lock
374
+ * timeout has passed. Nothing is written but the stale name (the claim) and
375
+ * this writer's own lockfile.
376
+ */
377
+ export declare function reclaimStaleLock(logPath: string, op: LockOp, options?: ReclaimOptions): ReclaimOutcome;
378
+ /** The stale name of a reclaim whose record is now in the log. */
379
+ export declare function finishReclaim(note: ReclaimNote): void;
380
+ /** What {@link takeLockForUnlock} found and did. */
381
+ export type UnlockOutcome = {
382
+ kind: "taken";
383
+ own: OwnLock;
384
+ releaseGuard: () => void;
385
+ note: ReclaimNote;
386
+ } | {
387
+ kind: "none";
388
+ } | {
389
+ kind: "refused";
390
+ why: string;
391
+ };
392
+ /**
393
+ * `approval log unlock --pid <n|none>`: a human's take of a lock no writer
394
+ * reclaims (another container, another boot, a pid that cannot be checked, a
395
+ * claim whose reclaimer died, a planted stale name). The human asserts that no
396
+ * writer is running; this refuses only what it can see is wrong: a `--pid`
397
+ * that is not the record's, a holder it sees running, a lock beside a sync
398
+ * snapshot, and anything at the lock's path that is not a lockfile (which `rm`
399
+ * removes). It takes the lock with the same claim and take as a writer's
400
+ * reclaim, so the record of it (reason `operator-cleared`) is the first under
401
+ * the lock it takes.
402
+ */
403
+ export declare function takeLockForUnlock(logPath: string, expected: number | null, lastTs?: number | null, now?: number): UnlockOutcome;
404
+ /**
405
+ * Hold SIGTERM, SIGINT and SIGHUP from just before a lockfile is created until
406
+ * just after it is removed, for each signal nobody listens for (the default
407
+ * disposition would kill the process with the lockfile in place). Returns the
408
+ * release.
409
+ *
410
+ * Node shows a caught signal to JavaScript only when the event loop turns, and
411
+ * removing the last listener before that turn drops it. So after the release:
412
+ * a verb that returns or awaits dies of the signal at its next turn;
413
+ * `approval run` awaits {@link yieldToTerminationSignals} before it spawns, so
414
+ * a stop request during its append ends it first; and a synchronous wait calls
415
+ * {@link settleTerminationGuards} before it blocks, so the wait runs under the
416
+ * default disposition, at the price of losing a signal that landed in the
417
+ * append's own milliseconds. A create that fails EEXIST releases and settles at
418
+ * once, so a lock wait runs unguarded (a signal there kills at once, holding
419
+ * nothing).
420
+ *
421
+ * Windows has no such signals to hold; there this is a no-op.
422
+ */
423
+ export declare function guardTerminationWhileLocked(): () => void;
424
+ /**
425
+ * Synchronous code about to block (an `Atomics.wait` poll, a `spawnSync`) calls
426
+ * this first: when no lock is held, the guard is removed now, so the block is
427
+ * not run with termination signals held. No-op while a lock is held.
428
+ */
429
+ export declare function settleTerminationGuards(): void;
430
+ /**
431
+ * Turn the event loop past a poll phase, so a termination signal a released
432
+ * lock's guard caught is dispatched now and the process dies of it here,
433
+ * before the caller starts whatever it was about to start.
434
+ */
435
+ export declare function yieldToTerminationSignals(): Promise<void>;