harnery 0.45.2 → 0.46.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 (216) hide show
  1. package/dist/commander.d.ts +13 -0
  2. package/dist/commander.d.ts.map +1 -1
  3. package/dist/commands/admission.d.ts.map +1 -1
  4. package/dist/commands/admission.js +214 -27
  5. package/dist/commands/agents.d.ts +4 -0
  6. package/dist/commands/agents.d.ts.map +1 -1
  7. package/dist/commands/agents.js +102 -25
  8. package/dist/commands/browse.d.ts +3 -0
  9. package/dist/commands/browse.d.ts.map +1 -1
  10. package/dist/commands/browse.js +172 -8
  11. package/dist/commands/fetch.js +1 -0
  12. package/dist/commands/ledger-v3.d.ts.map +1 -1
  13. package/dist/commands/ledger-v3.js +8 -2
  14. package/dist/commands/presence.d.ts.map +1 -1
  15. package/dist/commands/presence.js +40 -0
  16. package/dist/commands/qa-run.d.ts.map +1 -1
  17. package/dist/commands/qa-run.js +4 -3
  18. package/dist/commands/review-pack.d.ts.map +1 -1
  19. package/dist/commands/review-pack.js +33 -6
  20. package/dist/core/agents/index.d.ts +1 -0
  21. package/dist/core/agents/index.d.ts.map +1 -1
  22. package/dist/core/agents/index.js +1 -0
  23. package/dist/core/agents/live-authority-v3.d.ts.map +1 -1
  24. package/dist/core/agents/live-authority-v3.js +11 -4
  25. package/dist/core/agents/render/session-context.d.ts +0 -6
  26. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  27. package/dist/core/agents/render/session-context.js +1 -6
  28. package/dist/core/agents/session-events.d.ts.map +1 -1
  29. package/dist/core/agents/session-events.js +12 -4
  30. package/dist/core/agents/state/heartbeat-reader.d.ts +3 -0
  31. package/dist/core/agents/state/heartbeat-reader.d.ts.map +1 -1
  32. package/dist/core/agents/state/live-coordination-view.d.ts +3 -1
  33. package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
  34. package/dist/core/agents/state/live-coordination-view.js +6 -2
  35. package/dist/core/agents/state/parent-reference-contract.d.ts +16 -0
  36. package/dist/core/agents/state/parent-reference-contract.d.ts.map +1 -0
  37. package/dist/core/agents/state/parent-reference-contract.js +1 -0
  38. package/dist/core/agents/state/parent-reference.d.ts +18 -0
  39. package/dist/core/agents/state/parent-reference.d.ts.map +1 -0
  40. package/dist/core/agents/state/parent-reference.js +119 -0
  41. package/dist/core/config.d.ts +39 -0
  42. package/dist/core/config.d.ts.map +1 -1
  43. package/dist/core/config.js +63 -0
  44. package/dist/core/diagnostics/path-identity.d.ts +1 -1
  45. package/dist/core/diagnostics/path-identity.d.ts.map +1 -1
  46. package/dist/core/diagnostics/path-identity.js +51 -6
  47. package/dist/core/events/v3/canonical.js +43 -14
  48. package/dist/core/events/v3/index.d.ts +1 -1
  49. package/dist/core/events/v3/index.d.ts.map +1 -1
  50. package/dist/core/events/v3/index.js +1 -1
  51. package/dist/core/events/v3/producers/recorder.d.ts +34 -0
  52. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  53. package/dist/core/events/v3/producers/recorder.js +117 -13
  54. package/dist/core/events/v3/reader.d.ts.map +1 -1
  55. package/dist/core/events/v3/reader.js +53 -13
  56. package/dist/core/events/v3/validate.d.ts.map +1 -1
  57. package/dist/core/events/v3/validate.js +36 -1
  58. package/dist/core/hooks/adapter/native-parent.d.ts +23 -0
  59. package/dist/core/hooks/adapter/native-parent.d.ts.map +1 -0
  60. package/dist/core/hooks/adapter/native-parent.js +91 -0
  61. package/dist/core/hooks/cli.js +79 -4
  62. package/dist/core/hooks/resolve/owner.d.ts +0 -11
  63. package/dist/core/hooks/resolve/owner.d.ts.map +1 -1
  64. package/dist/core/hooks/resolve/owner.js +0 -23
  65. package/dist/core/hooks/shell-admission.d.ts +84 -0
  66. package/dist/core/hooks/shell-admission.d.ts.map +1 -0
  67. package/dist/core/hooks/shell-admission.js +622 -0
  68. package/dist/core/hooks/shell-commit-tree.d.ts +2 -0
  69. package/dist/core/hooks/shell-commit-tree.d.ts.map +1 -0
  70. package/dist/core/hooks/shell-commit-tree.js +104 -0
  71. package/dist/core/inbox/contract.d.ts +12 -0
  72. package/dist/core/inbox/contract.d.ts.map +1 -1
  73. package/dist/core/inbox/contract.js +16 -0
  74. package/dist/core/inbox/peer-messages.d.ts +41 -16
  75. package/dist/core/inbox/peer-messages.d.ts.map +1 -1
  76. package/dist/core/inbox/peer-messages.js +372 -15
  77. package/dist/core/inbox/service.d.ts +8 -1
  78. package/dist/core/inbox/service.d.ts.map +1 -1
  79. package/dist/core/inbox/service.js +83 -3
  80. package/dist/core/inbox/targets.d.ts +17 -3
  81. package/dist/core/inbox/targets.d.ts.map +1 -1
  82. package/dist/core/inbox/targets.js +77 -2
  83. package/dist/core/presence/git.d.ts +18 -12
  84. package/dist/core/presence/git.d.ts.map +1 -1
  85. package/dist/core/presence/git.js +32 -38
  86. package/dist/core/presence/index.d.ts +2 -2
  87. package/dist/core/presence/index.d.ts.map +1 -1
  88. package/dist/core/presence/index.js +8 -6
  89. package/dist/core/presence/mail.d.ts +133 -0
  90. package/dist/core/presence/mail.d.ts.map +1 -0
  91. package/dist/core/presence/mail.js +464 -0
  92. package/dist/core/semantic/contract.d.ts +5 -5
  93. package/dist/core/storage/modes.d.ts +4 -0
  94. package/dist/core/storage/modes.d.ts.map +1 -1
  95. package/dist/core/storage/modes.js +4 -0
  96. package/dist/core/storage/segments.d.ts.map +1 -1
  97. package/dist/core/storage/segments.js +5 -4
  98. package/dist/lib/admission-history.d.ts +96 -0
  99. package/dist/lib/admission-history.d.ts.map +1 -0
  100. package/dist/lib/admission-history.js +196 -0
  101. package/dist/lib/admission.d.ts +56 -7
  102. package/dist/lib/admission.d.ts.map +1 -1
  103. package/dist/lib/admission.js +161 -19
  104. package/dist/lib/browser/client.d.ts +18 -0
  105. package/dist/lib/browser/client.d.ts.map +1 -1
  106. package/dist/lib/browser/client.js +100 -12
  107. package/dist/lib/browser/content-checks.d.ts +3 -1
  108. package/dist/lib/browser/content-checks.d.ts.map +1 -1
  109. package/dist/lib/browser/content-checks.js +10 -2
  110. package/dist/lib/browser/critique.d.ts +32 -0
  111. package/dist/lib/browser/critique.d.ts.map +1 -1
  112. package/dist/lib/browser/critique.js +72 -3
  113. package/dist/lib/browser/geometry.d.ts +2 -0
  114. package/dist/lib/browser/geometry.d.ts.map +1 -1
  115. package/dist/lib/browser/geometry.js +61 -15
  116. package/dist/lib/browser/index.d.ts +1 -1
  117. package/dist/lib/browser/index.d.ts.map +1 -1
  118. package/dist/lib/browser/index.js +1 -1
  119. package/dist/lib/browser/page-review-contracts.d.ts +6 -1
  120. package/dist/lib/browser/page-review-contracts.d.ts.map +1 -1
  121. package/dist/lib/browser/page-review-contracts.js +6 -1
  122. package/dist/lib/browser/page-review-judge.d.ts +3 -1
  123. package/dist/lib/browser/page-review-judge.d.ts.map +1 -1
  124. package/dist/lib/browser/page-review-judge.js +41 -11
  125. package/dist/lib/browser/page-review-pack.d.ts +3 -0
  126. package/dist/lib/browser/page-review-pack.d.ts.map +1 -1
  127. package/dist/lib/browser/page-review-pack.js +5 -2
  128. package/dist/lib/browser/qa-run-contracts.d.ts +11 -0
  129. package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
  130. package/dist/lib/browser/qa-run-contracts.js +11 -4
  131. package/dist/lib/browser/qa-run.d.ts.map +1 -1
  132. package/dist/lib/browser/qa-run.js +73 -16
  133. package/dist/lib/browser/runts.d.ts +4 -2
  134. package/dist/lib/browser/runts.d.ts.map +1 -1
  135. package/dist/lib/browser/runts.js +84 -33
  136. package/dist/lib/browser/windows-chrome.d.ts +14 -0
  137. package/dist/lib/browser/windows-chrome.d.ts.map +1 -1
  138. package/dist/lib/browser/windows-chrome.js +42 -0
  139. package/dist/lib/browser/windows-login-sync.d.ts +110 -0
  140. package/dist/lib/browser/windows-login-sync.d.ts.map +1 -0
  141. package/dist/lib/browser/windows-login-sync.js +171 -0
  142. package/dist/lib/cookies/client.d.ts +30 -5
  143. package/dist/lib/cookies/client.d.ts.map +1 -1
  144. package/dist/lib/cookies/client.js +55 -10
  145. package/dist/lib/cookies/index.d.ts +1 -1
  146. package/dist/lib/cookies/index.d.ts.map +1 -1
  147. package/dist/lib/cookies/index.js +1 -1
  148. package/dist/lib/docs-metadata-sync.d.ts.map +1 -1
  149. package/dist/lib/docs-metadata-sync.js +41 -2
  150. package/dist/lib/durable-job.d.ts +8 -0
  151. package/dist/lib/durable-job.d.ts.map +1 -1
  152. package/dist/lib/durable-job.js +10 -0
  153. package/dist/lib/http/client.d.ts +9 -8
  154. package/dist/lib/http/client.d.ts.map +1 -1
  155. package/dist/lib/http/client.js +44 -2
  156. package/dist/lib/instructions/templates.js +4 -4
  157. package/package.json +2 -2
  158. package/src/commander.ts +13 -0
  159. package/src/commands/admission.ts +272 -29
  160. package/src/commands/agents.ts +132 -27
  161. package/src/commands/browse.ts +208 -6
  162. package/src/commands/fetch.ts +1 -0
  163. package/src/commands/ledger-v3.ts +8 -2
  164. package/src/commands/presence.ts +49 -0
  165. package/src/commands/qa-run.ts +4 -3
  166. package/src/commands/review-pack.ts +41 -5
  167. package/src/core/agents/index.ts +6 -0
  168. package/src/core/agents/live-authority-v3.ts +11 -4
  169. package/src/core/agents/render/session-context.ts +1 -7
  170. package/src/core/agents/session-events.ts +18 -3
  171. package/src/core/agents/state/heartbeat-reader.ts +3 -0
  172. package/src/core/agents/state/live-coordination-view.ts +13 -6
  173. package/src/core/agents/state/parent-reference-contract.ts +14 -0
  174. package/src/core/agents/state/parent-reference.ts +179 -0
  175. package/src/core/config.ts +85 -0
  176. package/src/core/diagnostics/path-identity.ts +52 -6
  177. package/src/core/events/v3/canonical.ts +46 -13
  178. package/src/core/events/v3/index.ts +3 -0
  179. package/src/core/events/v3/producers/recorder.ts +151 -13
  180. package/src/core/events/v3/reader.ts +55 -11
  181. package/src/core/events/v3/validate.ts +42 -1
  182. package/src/core/hooks/adapter/native-parent.ts +109 -0
  183. package/src/core/hooks/cli.ts +83 -4
  184. package/src/core/hooks/resolve/owner.ts +0 -23
  185. package/src/core/hooks/shell-admission.ts +634 -0
  186. package/src/core/hooks/shell-commit-tree.ts +102 -0
  187. package/src/core/inbox/contract.ts +18 -0
  188. package/src/core/inbox/peer-messages.ts +501 -15
  189. package/src/core/inbox/service.ts +108 -3
  190. package/src/core/inbox/targets.ts +94 -3
  191. package/src/core/presence/git.ts +45 -37
  192. package/src/core/presence/index.ts +16 -8
  193. package/src/core/presence/mail.ts +620 -0
  194. package/src/core/storage/modes.ts +6 -0
  195. package/src/core/storage/segments.ts +5 -4
  196. package/src/lib/admission-history.ts +267 -0
  197. package/src/lib/admission.ts +217 -19
  198. package/src/lib/browser/client.ts +147 -35
  199. package/src/lib/browser/content-checks.ts +14 -3
  200. package/src/lib/browser/critique.ts +113 -3
  201. package/src/lib/browser/geometry.ts +63 -14
  202. package/src/lib/browser/index.ts +5 -0
  203. package/src/lib/browser/page-review-contracts.ts +6 -1
  204. package/src/lib/browser/page-review-judge.ts +58 -13
  205. package/src/lib/browser/page-review-pack.ts +13 -2
  206. package/src/lib/browser/qa-run-contracts.ts +30 -4
  207. package/src/lib/browser/qa-run.ts +78 -17
  208. package/src/lib/browser/runts.ts +85 -37
  209. package/src/lib/browser/windows-chrome.ts +50 -0
  210. package/src/lib/browser/windows-login-sync.ts +275 -0
  211. package/src/lib/cookies/client.ts +62 -10
  212. package/src/lib/cookies/index.ts +3 -0
  213. package/src/lib/docs-metadata-sync.ts +44 -2
  214. package/src/lib/durable-job.ts +15 -0
  215. package/src/lib/http/client.ts +63 -1
  216. package/src/lib/instructions/templates.ts +4 -4
@@ -92,6 +92,8 @@ interface HarneryConfig {
92
92
  requireGitFinalization?: boolean;
93
93
  finalizationRoots?: AgentFinalizationRoot[];
94
94
  };
95
+ /** Admission resource defaults and the heavy-command guard. Project config only. */
96
+ admission?: unknown;
95
97
  /** Host-owned agent instruction settings. */
96
98
  instructions?: {
97
99
  hostAddendumFile?: unknown;
@@ -633,6 +635,89 @@ export function ripgrepAutoInstall(coordRoot?: string | null): boolean {
633
635
  return readConfig(root).tools?.ripgrep?.autoInstall === true;
634
636
  }
635
637
 
638
+ /** Wait before queueing while Linux memory pressure is high (PSI `full avg10`). */
639
+ export interface AdmissionPressureConfig {
640
+ psi_full_avg10: number;
641
+ max_wait_seconds: number;
642
+ }
643
+
644
+ /** Per-resource defaults for `admission run`; command-line flags win. */
645
+ export interface AdmissionResourceConfig {
646
+ capacity?: number;
647
+ timeout_minutes?: number;
648
+ defer_on_pressure?: AdmissionPressureConfig;
649
+ }
650
+
651
+ export interface AdmissionProjectConfig {
652
+ resources: Record<string, AdmissionResourceConfig>;
653
+ /** Present when the agent hook should wrap heavy shell commands in a slot. */
654
+ guard?: { resource: string };
655
+ }
656
+
657
+ function positiveNumber(value: unknown, max: number): number | undefined {
658
+ return typeof value === "number" && Number.isFinite(value) && value > 0 && value <= max
659
+ ? value
660
+ : undefined;
661
+ }
662
+
663
+ /**
664
+ * Admission settings from `.harnery/config.jsonc`:
665
+ *
666
+ * ```jsonc
667
+ * "admission": {
668
+ * "resources": {
669
+ * "build": {
670
+ * "capacity": 2,
671
+ * "timeout_minutes": 30,
672
+ * "defer_on_pressure": { "psi_full_avg10": 40, "max_wait_seconds": 300 }
673
+ * }
674
+ * },
675
+ * "guard": { "resource": "build" }
676
+ * }
677
+ * ```
678
+ *
679
+ * Project config only, like other machine-shaping policy. Malformed values are
680
+ * dropped one by one rather than rejecting the whole block. Undefined when the
681
+ * project declares no admission settings.
682
+ */
683
+ export function readAdmissionConfig(coordRoot?: string | null): AdmissionProjectConfig | undefined {
684
+ const root = coordRoot ?? findCoordRoot();
685
+ if (!root) return undefined;
686
+ const raw = readProjectConfig(root).admission;
687
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
688
+ const record = raw as { resources?: unknown; guard?: unknown };
689
+ const resources: Record<string, AdmissionResourceConfig> = {};
690
+ if (
691
+ record.resources &&
692
+ typeof record.resources === "object" &&
693
+ !Array.isArray(record.resources)
694
+ ) {
695
+ for (const [name, value] of Object.entries(record.resources as Record<string, unknown>)) {
696
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
697
+ const entry = value as Record<string, unknown>;
698
+ const capacity = positiveNumber(entry.capacity, 32);
699
+ const timeout = positiveNumber(entry.timeout_minutes, 1440);
700
+ const pressure = entry.defer_on_pressure as Record<string, unknown> | undefined;
701
+ const threshold = positiveNumber(pressure?.psi_full_avg10, 100);
702
+ const maxWait = positiveNumber(pressure?.max_wait_seconds, 86_400);
703
+ resources[name] = {
704
+ ...(capacity !== undefined && Number.isInteger(capacity) ? { capacity } : {}),
705
+ ...(timeout !== undefined && Number.isInteger(timeout) ? { timeout_minutes: timeout } : {}),
706
+ ...(threshold !== undefined && maxWait !== undefined
707
+ ? { defer_on_pressure: { psi_full_avg10: threshold, max_wait_seconds: maxWait } }
708
+ : {}),
709
+ };
710
+ }
711
+ }
712
+ const guard = record.guard as { resource?: unknown } | undefined;
713
+ return {
714
+ resources,
715
+ ...(guard && typeof guard.resource === "string" && guard.resource
716
+ ? { guard: { resource: guard.resource } }
717
+ : {}),
718
+ };
719
+ }
720
+
636
721
  /**
637
722
  * Whether this repo pins workflow runs to subscription billing:
638
723
  * `.harnery/config.jsonc` `{ "workflow": { "subscriptionOnly": true } }`.
@@ -1,15 +1,61 @@
1
- import { createHash } from "node:crypto";
1
+ import { createHmac, randomBytes } from "node:crypto";
2
+ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { PERSONAL_DIR_MODE, PERSONAL_FILE_MODE } from "../storage/modes.ts";
2
6
 
3
7
  const HOME_PATH =
4
8
  /(?:[A-Za-z]:[\\/]Users[\\/](?:[^\\/\r\n\0:"<>|]+(?=[\\/])|[^\\/\s\0:]+)|\/home\/[^/\s\0:]+|\/Users\/[^/\s\0:]+)/g;
5
9
 
6
- /** One stable representation for private home paths in values and identity inputs. */
10
+ const KEY_PATTERN = /^[0-9a-f]{64}$/;
11
+ let cachedKey: Buffer | undefined;
12
+
13
+ /**
14
+ * Per-install secret for home-path pseudonyms. An unkeyed digest would let
15
+ * anyone holding a bundle recover the user name by hashing guesses, so the
16
+ * key stays in local state and never enters a bundle. Sanitized values carry
17
+ * the pseudonym itself, so replay elsewhere needs no key.
18
+ */
19
+ function identityKeyPath(): string {
20
+ const xdg = process.env.XDG_STATE_HOME;
21
+ const base = xdg?.trim() ? xdg : join(homedir(), ".local", "state");
22
+ return join(base, "harnery", "diagnostic-identity.key");
23
+ }
24
+
25
+ function readKey(path: string): Buffer | undefined {
26
+ try {
27
+ const text = readFileSync(path, "utf8").trim();
28
+ return KEY_PATTERN.test(text) ? Buffer.from(text, "hex") : undefined;
29
+ } catch {
30
+ return undefined;
31
+ }
32
+ }
33
+
34
+ function identityKey(): Buffer {
35
+ if (cachedKey) return cachedKey;
36
+ const path = identityKeyPath();
37
+ let key = readKey(path);
38
+ if (!key) {
39
+ const fresh = randomBytes(32);
40
+ try {
41
+ mkdirSync(dirname(path), { recursive: true, mode: PERSONAL_DIR_MODE });
42
+ writeFileSync(path, `${fresh.toString("hex")}\n`, { flag: "wx", mode: PERSONAL_FILE_MODE });
43
+ chmodSync(path, PERSONAL_FILE_MODE);
44
+ key = fresh;
45
+ } catch {
46
+ // A concurrent writer won the race, or state is unwritable. Prefer the
47
+ // stored key; otherwise stay private with a process-local key.
48
+ key = readKey(path) ?? fresh;
49
+ }
50
+ }
51
+ cachedKey = key;
52
+ return key;
53
+ }
54
+
55
+ /** One stable, non-reversible representation for private home paths in values and identity inputs. */
7
56
  export function canonicalizeDiagnosticIdentity(value: string): string {
8
57
  return value.replace(HOME_PATH, (home) => {
9
- const digest = createHash("sha256")
10
- .update("harnery-diagnostic-home-v1\0")
11
- .update(home)
12
- .digest("hex");
58
+ const digest = createHmac("sha256", identityKey()).update(home).digest("hex");
13
59
  return `[REDACTED:home:${digest}]`;
14
60
  });
15
61
  }
@@ -104,10 +104,19 @@ export function normalizeNativeIdV3(
104
104
  return `hid_${digest}`;
105
105
  }
106
106
 
107
+ /**
108
+ * NFC leaves ASCII unchanged, so ASCII strings and key sets skip normalization
109
+ * and the collision map. Every reader re-serializes each ledger frame to prove
110
+ * it is canonical, which makes this the hottest function on a cold read.
111
+ */
112
+ const NON_ASCII = /[\u0080-\uffff]/;
113
+
107
114
  function serialize(value: unknown, ancestors: Set<object>): string {
108
115
  if (value === null) return "null";
109
116
  if (typeof value === "boolean") return value ? "true" : "false";
110
- if (typeof value === "string") return JSON.stringify(value.normalize("NFC"));
117
+ if (typeof value === "string") {
118
+ return JSON.stringify(NON_ASCII.test(value) ? value.normalize("NFC") : value);
119
+ }
111
120
  if (typeof value === "number") {
112
121
  if (!Number.isFinite(value)) throw new Error("canonical JSON rejects non-finite numbers");
113
122
  return JSON.stringify(Object.is(value, -0) ? 0 : value);
@@ -119,29 +128,53 @@ function serialize(value: unknown, ancestors: Set<object>): string {
119
128
  ancestors.add(value);
120
129
  try {
121
130
  if (Array.isArray(value)) {
122
- return `[${value.map((item) => serialize(item, ancestors)).join(",")}]`;
131
+ let out = "[";
132
+ for (let index = 0; index < value.length; index++) {
133
+ if (index > 0) out += ",";
134
+ out += serialize(value[index], ancestors);
135
+ }
136
+ return `${out}]`;
123
137
  }
124
138
  const prototype = Object.getPrototypeOf(value);
125
139
  if (prototype !== Object.prototype && prototype !== null) {
126
140
  throw new Error("canonical JSON accepts only plain objects");
127
141
  }
128
- const normalized = new Map<string, unknown>();
129
- for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
130
- const normalizedKey = key.normalize("NFC");
131
- if (normalized.has(normalizedKey)) {
132
- throw new Error("canonical JSON key collision after Unicode normalization");
133
- }
134
- normalized.set(normalizedKey, item);
142
+ const object = value as Record<string, unknown>;
143
+ const keys = Object.keys(object);
144
+ if (keys.some((key) => NON_ASCII.test(key))) {
145
+ return serializeNormalizedObject(object, ancestors);
135
146
  }
136
- const keys = [...normalized.keys()].sort(compareUtf16);
137
- return `{${keys
138
- .map((key) => `${JSON.stringify(key)}:${serialize(normalized.get(key), ancestors)}`)
139
- .join(",")}}`;
147
+ keys.sort(compareUtf16);
148
+ let out = "{";
149
+ for (let index = 0; index < keys.length; index++) {
150
+ const key = keys[index] as string;
151
+ if (index > 0) out += ",";
152
+ out += `${JSON.stringify(key)}:${serialize(object[key], ancestors)}`;
153
+ }
154
+ return `${out}}`;
140
155
  } finally {
141
156
  ancestors.delete(value);
142
157
  }
143
158
  }
144
159
 
160
+ function serializeNormalizedObject(
161
+ object: Record<string, unknown>,
162
+ ancestors: Set<object>,
163
+ ): string {
164
+ const normalized = new Map<string, unknown>();
165
+ for (const [key, item] of Object.entries(object)) {
166
+ const normalizedKey = key.normalize("NFC");
167
+ if (normalized.has(normalizedKey)) {
168
+ throw new Error("canonical JSON key collision after Unicode normalization");
169
+ }
170
+ normalized.set(normalizedKey, item);
171
+ }
172
+ const keys = [...normalized.keys()].sort(compareUtf16);
173
+ return `{${keys
174
+ .map((key) => `${JSON.stringify(key)}:${serialize(normalized.get(key), ancestors)}`)
175
+ .join(",")}}`;
176
+ }
177
+
145
178
  function compareUtf16(left: string, right: string): number {
146
179
  return left < right ? -1 : left > right ? 1 : 0;
147
180
  }
@@ -247,7 +247,9 @@ export {
247
247
  type DrainHookIntakeSpoolV3Result,
248
248
  drainHookIntakeSpoolV3,
249
249
  type HookProducerStateRecordV3,
250
+ type HookProducerStateScanV3,
250
251
  type HookProducerStateV3,
252
+ type InvalidHookProducerStateFileV3,
251
253
  listHookProducerStateRecordsV3,
252
254
  type RecordApprovedSessionEndV3Input,
253
255
  type RecordApprovedSessionEndV3Result,
@@ -256,6 +258,7 @@ export {
256
258
  readHookProducerStateV3,
257
259
  recordApprovedSessionEndV3,
258
260
  recordHookSignalV3,
261
+ scanHookProducerStateRecordsV3,
259
262
  } from "./producers/recorder.ts";
260
263
  export {
261
264
  type ClaimProjectionV3,
@@ -465,7 +465,18 @@ function sessionHashForSignal(
465
465
  // single live producer authority instead of hashing the instance into a
466
466
  // second state path. Ambiguous or absent matches keep the fail-closed fallback.
467
467
  if (adapterBehavior(input.adapter).sessionIdentityOnStartOnly) {
468
- const matches = listHookProducerStateRecordsV3(input.coordRoot).filter(
468
+ const scan = scanHookProducerStateRecordsV3(input.coordRoot);
469
+ // This instance's own state is unreadable: fail closed rather than hash
470
+ // the instance into a second state path beside it.
471
+ if (
472
+ scan.invalid.some(
473
+ (file) => file.adapter === input.adapter && file.instance_id === input.instance_id,
474
+ )
475
+ ) {
476
+ throw new Error("V3 producer state is invalid");
477
+ }
478
+ for (const file of scan.invalid) recordInvalidProducerStateDiagnostic(input.coordRoot, file);
479
+ const matches = scan.records.filter(
469
480
  ({ state }) => state.adapter === input.adapter && state.instance_id === input.instance_id,
470
481
  );
471
482
  if (matches.length === 1) {
@@ -3476,15 +3487,44 @@ export function salvageOpenSpansV3(input: SalvageOpenSpansV3Input): SalvageOpenS
3476
3487
  }
3477
3488
  }
3478
3489
 
3479
- export function listHookProducerStateRecordsV3(
3490
+ /** One producer-state file the validator refused during a listing scan. */
3491
+ export interface InvalidHookProducerStateFileV3 {
3492
+ path: string;
3493
+ adapter: EventAdapterIdV3;
3494
+ session_hash: `hid_${string}`;
3495
+ reason: string;
3496
+ /**
3497
+ * The file's raw `instance_id` when it parses as JSON and carries a
3498
+ * well-formed one. Readers that target one instance use it to keep failing
3499
+ * closed on that instance's own invalid state.
3500
+ */
3501
+ instance_id?: `inst_${string}`;
3502
+ }
3503
+
3504
+ export interface HookProducerStateScanV3 {
3505
+ records: HookProducerStateRecordV3[];
3506
+ invalid: InvalidHookProducerStateFileV3[];
3507
+ }
3508
+
3509
+ /**
3510
+ * Scan every producer-state file of the active epoch. A file the validator
3511
+ * refuses is reported in `invalid` instead of failing the scan, so one
3512
+ * session's bad state cannot take coordination away from every other session
3513
+ * in the checkout. An unsafe adapter directory still throws.
3514
+ */
3515
+ export function scanHookProducerStateRecordsV3(
3480
3516
  coordRoot: string,
3481
3517
  options: { includeTerminal?: boolean } = {},
3482
- ): HookProducerStateRecordV3[] {
3518
+ ): HookProducerStateScanV3 {
3483
3519
  const control = readEventV3ControlState(coordRoot);
3484
- if (control.state !== "candidate" && control.state !== "active") return [];
3520
+ if (control.state !== "candidate" && control.state !== "active") {
3521
+ return { records: [], invalid: [] };
3522
+ }
3523
+ const genesisId = control.genesis.event.payload.genesis_id;
3485
3524
  const producerRoot = join(resolve(coordRoot), EVENT_V3_LEDGER_RELATIVE_ROOT, "private-producers");
3486
- if (!existsSync(producerRoot)) return [];
3525
+ if (!existsSync(producerRoot)) return { records: [], invalid: [] };
3487
3526
  const records: HookProducerStateRecordV3[] = [];
3527
+ const invalid: InvalidHookProducerStateFileV3[] = [];
3488
3528
  for (const adapter of EVENT_ADAPTER_IDS_V3) {
3489
3529
  const directory = join(producerRoot, adapter);
3490
3530
  if (!existsSync(directory)) continue;
@@ -3496,19 +3536,86 @@ export function listHookProducerStateRecordsV3(
3496
3536
  /^hid_[a-f0-9]{64}\.json$/.test(entry),
3497
3537
  )) {
3498
3538
  const path = join(directory, name);
3499
- const state = readProducerState(path);
3539
+ let state: HookProducerStateV3;
3540
+ try {
3541
+ state = readProducerState(path);
3542
+ } catch (error) {
3543
+ const raw = rawProducerStateIdentity(path);
3544
+ // A refused file from a replaced epoch would have been skipped anyway.
3545
+ if (raw.epoch_genesis_id !== undefined && raw.epoch_genesis_id !== genesisId) continue;
3546
+ invalid.push({
3547
+ path,
3548
+ adapter,
3549
+ session_hash: name.slice(0, -".json".length) as `hid_${string}`,
3550
+ reason: error instanceof Error ? error.message : String(error),
3551
+ ...(raw.instance_id ? { instance_id: raw.instance_id } : {}),
3552
+ });
3553
+ continue;
3554
+ }
3500
3555
  // Skip authorities left behind by a replaced epoch: their sequences and
3501
3556
  // causal links belong to the archived ledger, not this one.
3502
- if (state.epoch_genesis_id !== control.genesis.event.payload.genesis_id) continue;
3557
+ if (state.epoch_genesis_id !== genesisId) continue;
3503
3558
  if (!options.includeTerminal && state.terminal) continue;
3504
3559
  records.push({ path, modified_at_ms: lstatSync(path).mtimeMs, state });
3505
3560
  }
3506
3561
  }
3507
- return records.sort(
3562
+ records.sort(
3508
3563
  (left, right) =>
3509
3564
  left.state.generation_id.localeCompare(right.state.generation_id) ||
3510
3565
  left.path.localeCompare(right.path),
3511
3566
  );
3567
+ invalid.sort((left, right) => left.path.localeCompare(right.path));
3568
+ return { records, invalid };
3569
+ }
3570
+
3571
+ /**
3572
+ * List the valid producer states of the active epoch. An invalid file is
3573
+ * skipped and recorded as a `producer_state_invalid_skipped` diagnostic that
3574
+ * names its adapter and session hash; readers of that one session's state
3575
+ * still fail closed through `readHookProducerStateV3`.
3576
+ */
3577
+ export function listHookProducerStateRecordsV3(
3578
+ coordRoot: string,
3579
+ options: { includeTerminal?: boolean } = {},
3580
+ ): HookProducerStateRecordV3[] {
3581
+ const scan = scanHookProducerStateRecordsV3(coordRoot, options);
3582
+ for (const file of scan.invalid) recordInvalidProducerStateDiagnostic(coordRoot, file);
3583
+ return scan.records;
3584
+ }
3585
+
3586
+ function recordInvalidProducerStateDiagnostic(
3587
+ coordRoot: string,
3588
+ file: InvalidHookProducerStateFileV3,
3589
+ ): void {
3590
+ writeProducerDiagnosticV3(coordRoot, "producer_state_invalid_skipped", {
3591
+ adapter: file.adapter,
3592
+ session_hash: file.session_hash,
3593
+ reason: file.reason,
3594
+ ...(file.instance_id ? { instance_id: file.instance_id } : {}),
3595
+ });
3596
+ }
3597
+
3598
+ function rawProducerStateIdentity(path: string): {
3599
+ instance_id?: `inst_${string}`;
3600
+ epoch_genesis_id?: string;
3601
+ } {
3602
+ try {
3603
+ const metadata = lstatSync(path);
3604
+ if (!metadata.isFile() || metadata.isSymbolicLink()) return {};
3605
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
3606
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
3607
+ return {
3608
+ ...(typeof parsed.instance_id === "string" &&
3609
+ /^inst_[a-zA-Z0-9._-]{1,128}$/.test(parsed.instance_id)
3610
+ ? { instance_id: parsed.instance_id as `inst_${string}` }
3611
+ : {}),
3612
+ ...(typeof parsed.epoch_genesis_id === "string"
3613
+ ? { epoch_genesis_id: parsed.epoch_genesis_id }
3614
+ : {}),
3615
+ };
3616
+ } catch {
3617
+ return {};
3618
+ }
3512
3619
  }
3513
3620
 
3514
3621
  export interface ReanchorArchivedHookProducersV3Input {
@@ -4277,6 +4384,15 @@ function acquireStateLease(coordRoot: string, statePath: string) {
4277
4384
  };
4278
4385
  }
4279
4386
 
4387
+ /** Test seam: publish a producer state through the real write-side validator. */
4388
+ export function publishHookProducerStateForTestV3(
4389
+ coordRoot: string,
4390
+ path: string,
4391
+ state: HookProducerStateV3,
4392
+ ): void {
4393
+ publishProducerState(coordRoot, path, state);
4394
+ }
4395
+
4280
4396
  function publishProducerState(coordRoot: string, path: string, state: HookProducerStateV3): void {
4281
4397
  const control = readEventV3ControlState(coordRoot);
4282
4398
  if (
@@ -4285,11 +4401,19 @@ function publishProducerState(coordRoot: string, path: string, state: HookProduc
4285
4401
  ) {
4286
4402
  return;
4287
4403
  }
4404
+ const serialized = JSON.stringify(state);
4405
+ // Validate the exact bytes about to land with the reader's own validator.
4406
+ // The previous file stays in place when this refuses.
4407
+ try {
4408
+ validateProducerStateDocument(JSON.parse(serialized));
4409
+ } catch {
4410
+ throw new Error("V3 producer state is invalid; refusing to publish");
4411
+ }
4288
4412
  const temporary = `${path}.tmp-${process.pid}-${randomUUID()}`;
4289
4413
  let fd: number | undefined;
4290
4414
  try {
4291
4415
  fd = openSync(temporary, "wx", stateFileMode());
4292
- writeFileSync(fd, `${JSON.stringify(state)}\n`, "utf8");
4416
+ writeFileSync(fd, `${serialized}\n`, "utf8");
4293
4417
  fsyncSync(fd);
4294
4418
  closeSync(fd);
4295
4419
  fd = undefined;
@@ -4314,6 +4438,16 @@ function readProducerState(path: string): HookProducerStateV3 {
4314
4438
  } catch {
4315
4439
  throw new Error("V3 producer state is unreadable");
4316
4440
  }
4441
+ return validateProducerStateDocument(parsed);
4442
+ }
4443
+
4444
+ /**
4445
+ * The single producer-state validator. Readers run it on every file they
4446
+ * load, and `publishProducerState` runs it on the serialized document before
4447
+ * the rename, so a state no reader would accept fails in the producing hook
4448
+ * instead of in every later reader of the checkout.
4449
+ */
4450
+ function validateProducerStateDocument(parsed: unknown): HookProducerStateV3 {
4317
4451
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
4318
4452
  throw new Error("V3 producer state is invalid");
4319
4453
  }
@@ -4589,8 +4723,10 @@ function validPendingRuntimeContext(value: unknown): value is PendingRuntimeCont
4589
4723
  Number.isFinite(Date.parse(pending.terminal_observed_at)) &&
4590
4724
  typeof pending.turn_id === "string" &&
4591
4725
  /^tid_[a-f0-9]{64}$/.test(pending.turn_id) &&
4592
- (pending.run_id === undefined || /^run_[0-9a-f-]{36}$/.test(pending.run_id)) &&
4593
- (pending.workflow_id === undefined || /^wf_[0-9a-f-]{36}$/.test(pending.workflow_id)) &&
4726
+ // Same shapes the event contract accepts: workflow children carry
4727
+ // stableScopeId hashes (run_<64 hex>), not UUIDs.
4728
+ (pending.run_id === undefined || /^run_[a-zA-Z0-9._-]{1,128}$/.test(pending.run_id)) &&
4729
+ (pending.workflow_id === undefined || /^wf_[a-zA-Z0-9._-]{1,128}$/.test(pending.workflow_id)) &&
4594
4730
  (pending.workflow_agent_id === undefined ||
4595
4731
  /^[a-zA-Z0-9][a-zA-Z0-9._:/+-]{0,127}$/.test(pending.workflow_agent_id)) &&
4596
4732
  typeof pending.native_session_id === "string" &&
@@ -4606,9 +4742,11 @@ function validPendingRuntimeContext(value: unknown): value is PendingRuntimeCont
4606
4742
  (pending.runtime_version === undefined ||
4607
4743
  (typeof pending.runtime_version === "string" &&
4608
4744
  /^[a-zA-Z0-9][a-zA-Z0-9._:/+-]{0,127}$/.test(pending.runtime_version))) &&
4745
+ // Externally driven reconciles (`deferRetryLimit`) keep counting past
4746
+ // RUNTIME_CONTEXT_RETRY_LIMIT until their own final attempt, and publish
4747
+ // those counts, so the reader must accept them.
4609
4748
  Number.isSafeInteger(pending.attempts) &&
4610
- pending.attempts >= 0 &&
4611
- pending.attempts < RUNTIME_CONTEXT_RETRY_LIMIT
4749
+ pending.attempts >= 0
4612
4750
  );
4613
4751
  }
4614
4752
 
@@ -19,6 +19,7 @@ import {
19
19
  readEventV3SegmentManifest,
20
20
  } from "./catalog.ts";
21
21
  import type { EventV3 } from "./contract.ts";
22
+ import { readAdvanceableControlWitnessV3 } from "./control-witness.ts";
22
23
  import { EVENT_V3_SCHEMA_DIGEST } from "./generated.ts";
23
24
  import {
24
25
  type EventV3RecoveryFailure,
@@ -442,7 +443,9 @@ export function readLedgerV3(
442
443
  }
443
444
 
444
445
  const discovered = discoverLedgerFramesV3(coordRoot);
445
- const validated = validateLedgerFramesV3(discovered.frames, options);
446
+ const validated =
447
+ validateWitnessedFramesV3(coordRoot, storage, discovered, options) ??
448
+ validateLedgerFramesV3(discovered.frames, options);
446
449
  const recovery = discoverRecoveryReceiptsV3(coordRoot);
447
450
  const diagnostics = [
448
451
  ...discovered.diagnostics,
@@ -514,9 +517,40 @@ export function readLedgerFramesV3(
514
517
  return validateLedgerFramesV3(frames, options).result;
515
518
  }
516
519
 
520
+ /**
521
+ * Cold-process fast path. Writers advance an authenticated validation
522
+ * checkpoint under the append lease after every drain, so when the witness
523
+ * names exactly the storage this read observed, each frame already passed the
524
+ * contract and canonical-byte checks that dominate a full read. Those two
525
+ * checks are skipped; every ledger-level rule still runs and rebuilds the
526
+ * validator state. The result is used only if that state reproduces the
527
+ * witnessed checkpoint byte for byte with no diagnostics and storage did not
528
+ * move during the read; anything else returns undefined and the caller runs
529
+ * the canonical full validation.
530
+ */
531
+ function validateWitnessedFramesV3(
532
+ coordRoot: string,
533
+ storage: LedgerStorageVersionV3,
534
+ discovered: DiscoveredFramesV3,
535
+ options: ReadLedgerV3Options,
536
+ ): { result: ReadLedgerV3Result; state: LedgerValidationStateV3 } | undefined {
537
+ if (!discovered.active || discovered.diagnostics.length > 0) return undefined;
538
+ const witness = readAdvanceableControlWitnessV3(coordRoot);
539
+ if (!witness || witness.payload.storage.fingerprint !== sha256V3(storage.fingerprint)) {
540
+ return undefined;
541
+ }
542
+ const trusted = validateLedgerFramesV3(discovered.frames, options, true);
543
+ if (trusted.state.diagnostics.length > 0) return undefined;
544
+ const reproduced = checkpointFromValidationStateV3(trusted.state, discovered.active);
545
+ if (canonicalJsonV3(reproduced) !== canonicalJsonV3(witness.checkpoint)) return undefined;
546
+ if (ledgerStorageVersionV3(coordRoot).fingerprint !== storage.fingerprint) return undefined;
547
+ return trusted;
548
+ }
549
+
517
550
  function validateLedgerFramesV3(
518
551
  frames: readonly LedgerFrameV3[],
519
552
  options: ReadLedgerV3Options,
553
+ witnessed = false,
520
554
  ): { result: ReadLedgerV3Result; state: LedgerValidationStateV3 } {
521
555
  const state: LedgerValidationStateV3 = {
522
556
  diagnostics: [],
@@ -530,13 +564,18 @@ function validateLedgerFramesV3(
530
564
  clocks: new Map(),
531
565
  event_count: 0,
532
566
  };
533
- validateLedgerFramesIntoStateV3(frames, state);
567
+ validateLedgerFramesIntoStateV3(frames, state, witnessed);
534
568
  return { result: finishLedgerValidationV3(state, options, ledgerFramesBytesV3(frames)), state };
535
569
  }
536
570
 
571
+ /**
572
+ * `witnessed` skips only the per-frame contract and canonical-byte checks, and
573
+ * only validateWitnessedFramesV3 may pass it, after authenticating the frames.
574
+ */
537
575
  function validateLedgerFramesIntoStateV3(
538
576
  frames: readonly LedgerFrameV3[],
539
577
  state: LedgerValidationStateV3,
578
+ witnessed = false,
540
579
  ): void {
541
580
  state.first_position ??= frames[0]?.position;
542
581
  const {
@@ -576,17 +615,22 @@ function validateLedgerFramesIntoStateV3(
576
615
  diagnostics.push(diagnostic("unsupported_major", frame));
577
616
  continue;
578
617
  }
579
- const validation = validateEventV3(parsed);
580
- if (!validation.ok || !validation.event) {
581
- diagnostics.push(diagnostic("invalid_contract", frame));
582
- continue;
618
+ let event: EventV3;
619
+ if (witnessed) {
620
+ event = parsed as EventV3;
621
+ } else {
622
+ const validation = validateEventV3(parsed);
623
+ if (!validation.ok || !validation.event) {
624
+ diagnostics.push(diagnostic("invalid_contract", frame));
625
+ continue;
626
+ }
627
+ event = validation.event;
628
+ if (canonicalJsonV3(event) !== frame.raw) {
629
+ diagnostics.push(diagnostic("noncanonical_frame", frame, event.event_id));
630
+ continue;
631
+ }
583
632
  }
584
- const event = validation.event;
585
633
  const shape = event as unknown as EventShape;
586
- if (canonicalJsonV3(event) !== frame.raw) {
587
- diagnostics.push(diagnostic("noncanonical_frame", frame, shape.event_id));
588
- continue;
589
- }
590
634
  const priorFrame = seenIds.get(shape.event_id);
591
635
  if (priorFrame !== undefined) {
592
636
  if (priorFrame !== frame.raw) {
@@ -45,8 +45,49 @@ interface RecoveryShape {
45
45
  elapsed_upper_bound_ms?: ObservationShape;
46
46
  }
47
47
 
48
+ type EventVariantSchema = (typeof EventV3Schema.anyOf)[number];
49
+
50
+ /**
51
+ * Every union member requires its own literal `event_type`, so only the member
52
+ * named by a value's `event_type` can accept it. Checking that one member gives
53
+ * the union's verdict without trying the other variants, which more than halves
54
+ * a full-ledger read. Returns null, falling back to the whole union, if a member
55
+ * ever stops carrying a required unique literal.
56
+ */
57
+ let eventVariantsByType: Map<string, EventVariantSchema> | null | undefined;
58
+
59
+ function eventVariantsByTypeV3(): Map<string, EventVariantSchema> | null {
60
+ if (eventVariantsByType !== undefined) return eventVariantsByType;
61
+ const variants = new Map<string, EventVariantSchema>();
62
+ for (const variant of EventV3Schema.anyOf) {
63
+ const literal = (variant.properties as Record<string, { const?: unknown }>).event_type?.const;
64
+ if (
65
+ typeof literal !== "string" ||
66
+ !(variant.required as readonly string[] | undefined)?.includes("event_type") ||
67
+ variants.has(literal)
68
+ ) {
69
+ eventVariantsByType = null;
70
+ return null;
71
+ }
72
+ variants.set(literal, variant);
73
+ }
74
+ eventVariantsByType = variants;
75
+ return variants;
76
+ }
77
+
78
+ function checkEventSchemaV3(value: unknown): boolean {
79
+ const variants = eventVariantsByTypeV3();
80
+ if (!variants) return Value.Check(EventV3Schema, value);
81
+ const eventType =
82
+ typeof value === "object" && value !== null
83
+ ? (value as { event_type?: unknown }).event_type
84
+ : undefined;
85
+ const variant = typeof eventType === "string" ? variants.get(eventType) : undefined;
86
+ return variant !== undefined && Value.Check(variant, value);
87
+ }
88
+
48
89
  export function validateEventV3(value: unknown): EventV3ValidationResult {
49
- if (!Value.Check(EventV3Schema, value)) {
90
+ if (!checkEventSchemaV3(value)) {
50
91
  return {
51
92
  ok: false,
52
93
  issues: [...Value.Errors(EventV3Schema, value)]