@junghanacs/entwurf 0.21.0 → 0.23.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 (122) hide show
  1. package/AGENTS.md +3 -2
  2. package/BASELINE.md +3 -1
  3. package/CHANGELOG.md +658 -0
  4. package/DELIVERY.md +167 -29
  5. package/README.md +104 -14
  6. package/VERIFY.md +73 -11
  7. package/docs/external-mcp-host.md +16 -6
  8. package/docs/setup-clean-host.md +73 -22
  9. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +54 -31
  10. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-caller-seat.js +174 -0
  11. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-declaration.js +609 -0
  12. package/mcp/entwurf-bridge/dist/pi-extensions/lib/codex-fresh-preflight.js +289 -57
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +40 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-fact-provider.js +6 -2
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-facts.js +7 -1
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peer-observe.js +67 -2
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +9 -1
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-self-address.js +24 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -2
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +8 -4
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +423 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +98 -0
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +1018 -0
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-placement.js +213 -0
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +171 -263
  26. package/mcp/entwurf-bridge/dist/scripts/codex-socket-path.js +30 -0
  27. package/mcp/entwurf-bridge/dist/scripts/peer-facts.js +109 -0
  28. package/mcp/entwurf-bridge/src/index.ts +65 -31
  29. package/mcp/entwurf-bridge/tsconfig.build.json +10 -0
  30. package/package.json +4 -4
  31. package/pi-extensions/entwurf-control.ts +91 -52
  32. package/pi-extensions/lib/codex-caller-seat.ts +204 -0
  33. package/pi-extensions/lib/codex-declaration.js +612 -0
  34. package/pi-extensions/lib/codex-fresh-preflight.ts +325 -52
  35. package/pi-extensions/lib/entwurf-control-rpc.ts +46 -0
  36. package/pi-extensions/lib/entwurf-fact-provider.ts +13 -2
  37. package/pi-extensions/lib/entwurf-facts.ts +14 -1
  38. package/pi-extensions/lib/entwurf-peer-observe.ts +72 -2
  39. package/pi-extensions/lib/entwurf-peers-render.ts +9 -1
  40. package/pi-extensions/lib/entwurf-self-address.ts +33 -0
  41. package/pi-extensions/lib/entwurf-v2-runner.ts +3 -2
  42. package/pi-extensions/lib/entwurf-v2-send.ts +16 -11
  43. package/pi-extensions/lib/fresh-call-composition.ts +493 -0
  44. package/pi-extensions/lib/fresh-call-dispatch.ts +142 -0
  45. package/pi-extensions/lib/herdr-fresh-call.ts +1378 -0
  46. package/pi-extensions/lib/herdr-placement.ts +276 -0
  47. package/pi-extensions/lib/mux-fresh-call.ts +257 -304
  48. package/run.sh +522 -10
  49. package/scripts/check-codex-app-server-launch.ts +445 -0
  50. package/scripts/check-codex-birth-hook.ts +187 -0
  51. package/scripts/check-entwurf-control-rpc.ts +138 -0
  52. package/scripts/check-entwurf-fact-provider.ts +4 -2
  53. package/scripts/check-entwurf-facts.ts +12 -6
  54. package/scripts/check-entwurf-peers-surface.ts +3 -1
  55. package/scripts/check-entwurf-self-address.ts +68 -0
  56. package/scripts/check-entwurf-v2-production.ts +42 -1
  57. package/scripts/check-entwurf-v2-send.ts +26 -7
  58. package/scripts/check-fresh-call-dispatch.ts +301 -0
  59. package/scripts/check-gate-qualification.ts +19 -5
  60. package/scripts/check-herdr-activation.ts +1293 -0
  61. package/scripts/check-herdr-fresh-call.ts +1250 -0
  62. package/scripts/check-herdr-placement.ts +383 -0
  63. package/scripts/check-herdr-plugin-build.ts +842 -0
  64. package/scripts/check-herdr-plugin-profile.ts +371 -0
  65. package/scripts/check-herdr-plugin.ts +523 -0
  66. package/scripts/check-herdr-runtime-bootstrap.ts +1264 -0
  67. package/scripts/check-herdr-sandbox.ts +456 -0
  68. package/scripts/check-herdr-supply.ts +184 -0
  69. package/scripts/check-mux-launch-tmux.ts +331 -35
  70. package/scripts/check-mux-launcher-fence.ts +138 -0
  71. package/scripts/check-peer-facts.ts +331 -0
  72. package/scripts/check-release-gate-outcomes.ts +12 -0
  73. package/scripts/check-typing-call-fence.ts +296 -0
  74. package/scripts/codex-app-server-launch.sh +275 -0
  75. package/scripts/codex-birth-doctor.sh +159 -16
  76. package/scripts/codex-birth-install.sh +178 -72
  77. package/scripts/codex-birth-uninstall.sh +162 -7
  78. package/scripts/codex-socket-path.ts +33 -0
  79. package/scripts/codex-terminal-title-config.py +500 -0
  80. package/scripts/codex_toml_io.py +121 -0
  81. package/scripts/fixtures/herdr-supply.json +29 -0
  82. package/scripts/herdr-activation.mjs +536 -0
  83. package/scripts/herdr-plugin-activate.mjs +270 -0
  84. package/scripts/herdr-plugin-deactivate.mjs +193 -0
  85. package/scripts/herdr-runtime.mjs +1203 -0
  86. package/scripts/install-herdr-ci.sh +96 -0
  87. package/scripts/lib/codex-fresh-live-protocol.ts +13 -5
  88. package/scripts/lib/codex-fresh-source-receipts.ts +29 -2
  89. package/scripts/lib/tree-digest.ts +97 -0
  90. package/scripts/meta-bridge-install.sh +19 -2
  91. package/scripts/meta-bridge-state.py +167 -11
  92. package/scripts/mutants/codex-app-server-launch.json +157 -0
  93. package/scripts/mutants/codex-caller-seat.json +334 -0
  94. package/scripts/mutants/codex-native.json +195 -29
  95. package/scripts/mutants/control-socket-disconnect.json +57 -0
  96. package/scripts/mutants/fresh-call-dispatch.json +154 -0
  97. package/scripts/mutants/herdr-activation.json +305 -0
  98. package/scripts/mutants/herdr-fresh-call.json +448 -0
  99. package/scripts/mutants/herdr-placement.json +161 -0
  100. package/scripts/mutants/herdr-plugin-build.json +160 -0
  101. package/scripts/mutants/herdr-plugin-profile.json +202 -0
  102. package/scripts/mutants/herdr-plugin.json +133 -0
  103. package/scripts/mutants/herdr-runtime-bootstrap.json +411 -0
  104. package/scripts/mutants/herdr-supply.json +133 -0
  105. package/scripts/mutants/meta-hook-session-switch.json +2 -2
  106. package/scripts/mutants/mux-fresh-call.json +184 -25
  107. package/scripts/mutants/mux-launcher-fence.json +13 -0
  108. package/scripts/mutants/omp-fresh.json +4 -4
  109. package/scripts/mutants/peer-facts.json +98 -0
  110. package/scripts/mutants/self-address.json +33 -0
  111. package/scripts/mutants/typing-call-fence.json +17 -0
  112. package/scripts/mutants/v2-surface.json +22 -0
  113. package/scripts/peer-facts.ts +120 -0
  114. package/scripts/register-pi-package.py +8 -0
  115. package/scripts/register-pi-provider.py +88 -12
  116. package/scripts/smoke-codex-birth.sh +281 -12
  117. package/scripts/smoke-codex-config-state.sh +192 -3
  118. package/scripts/smoke-codex-fresh-live.ts +277 -37
  119. package/scripts/smoke-entwurf-chain-live.ts +50 -0
  120. package/scripts/smoke-herdr-fresh-call-live.ts +1027 -0
  121. package/scripts/smoke-herdr-plugin-build-live.ts +543 -0
  122. package/scripts/smoke-setup-verdict.sh +13 -11
@@ -3,6 +3,15 @@ import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import { parse } from "smol-toml";
5
5
 
6
+ import {
7
+ CODEX_BIRTH_EVENT,
8
+ classifyOwnedPath,
9
+ entwurfDeclarationCommand,
10
+ selectEntwurfDeclaration,
11
+ statOwnedPath,
12
+ trustReceiptKey,
13
+ } from "./codex-declaration.js";
14
+
6
15
  import {
7
16
  type CodexProtocolOpener,
8
17
  type CodexRpcProtocol,
@@ -29,9 +38,52 @@ export const CODEX_PREFLIGHT_HINT: Record<CodexPreflightRejectReason, string> =
29
38
  "codex-visible-identity-missing":
30
39
  "Codex tui.status_line does not include thread-title; run `entwurf install-codex-statusline`, then `entwurf doctor-codex-statusline`",
31
40
  "codex-app-server-unavailable":
32
- "the operator-owned Codex app-server default socket is absent or unsafe; start `codex app-server --listen unix://$CODEX_HOME/app-server-control/app-server-control.sock` and retry",
41
+ "the operator-owned Codex app-server default socket is absent or unsafe; start it with `entwurf codex-app-server` from a detached tmux session (one that is NOT where you work), then retry",
42
+ };
43
+
44
+ /**
45
+ * THE CALLER AXIS, AND IT IS NOT THE ONE ABOVE. Everything above asks "can a Codex sibling be
46
+ * OPENED on this host" — birth unit, vendor trust, MCP hand, visible identity, app-server. This
47
+ * asks the opposite question: "can the Codex citizen DOING the opening be located", which is a
48
+ * fact about the CALLER's own config and is required no matter which backend it opens (#95
49
+ * lane B). Keeping them apart is the point: a Pi caller opening a Codex sibling needs the five
50
+ * above and none of this, and a Codex caller opening a Pi sibling needs this and none of those.
51
+ * Folding either into the other would refuse one operator for the other's missing repair.
52
+ */
53
+ export type CodexCallerPreflightRejectReason = "codex-caller-title-missing";
54
+
55
+ /**
56
+ * THE LAUNCH-DIRECTORY AXIS, AND IT IS A THIRD QUESTION AGAIN. The target axis asks "can a Codex
57
+ * sibling be opened on this host", the caller axis asks "can the Codex citizen doing the opening
58
+ * be located", and this asks "will the vendor START A TURN in the directory this launch names" —
59
+ * a fact about ONE directory rather than about the host or the caller, so it is the only axis
60
+ * that needs the resolved launch cwd and the only one that can pass for one call and refuse the
61
+ * next on an unchanged host.
62
+ */
63
+ export type CodexLaunchCwdPreflightRejectReason = "codex-launch-cwd-undecided" | "codex-launch-cwd-untrusted-ancestor";
64
+
65
+ export const CODEX_LAUNCH_CWD_PREFLIGHT_HINT: Record<CodexLaunchCwdPreflightRejectReason, string> = {
66
+ "codex-launch-cwd-undecided":
67
+ "Codex has recorded NO decision for the directory this sibling would start in, so the window would open on the vendor's folder-consent screen and WAIT for a human instead of running its first turn — no rollout, no callback, and nothing to address; either launch in a directory this Codex has already been answered for, or open a plain `codex -C <dir>` there once and answer it, then retry",
68
+ "codex-launch-cwd-untrusted-ancestor":
69
+ "this directory sits inside a project the operator marked `untrusted`, and on this rail the vendor does not offer a consent screen for that — it refuses the startup outright with `pass the repository root explicitly with --cd`, so answering a prompt at this directory would only reproduce that error; launch at that untrusted project's own root instead, or change that project's recorded decision",
33
70
  };
34
71
 
72
+ export const CODEX_CALLER_PREFLIGHT_HINT: Record<CodexCallerPreflightRejectReason, string> = {
73
+ "codex-caller-title-missing":
74
+ "this Codex caller's tui.terminal_title does not include thread-id, so the multiplexer reports no pane title naming this thread and there is no caller seat to open the sibling beside; run `entwurf install-codex-terminal-title`, then `entwurf doctor-codex-terminal-title` (an explicit placement.tmuxSession skips this check entirely, because it never needs the seat)",
75
+ };
76
+
77
+ /**
78
+ * What `codex-birth-install-state/v2` records ABOUT the declaration — the normalized digest of
79
+ * entwurf's own `SessionStart` group and the command string that selects it. Never a file digest:
80
+ * hooks.json is shared, and its bytes are not this unit's to certify.
81
+ */
82
+ interface RecordedDeclaration {
83
+ command: string;
84
+ sha256: string;
85
+ }
86
+
35
87
  export interface CodexUnitPaths {
36
88
  hooksFile: string;
37
89
  helperDir: string;
@@ -75,28 +127,22 @@ const CODEX_MCP_ENV_VARS = [
75
127
  "TMUX_PANE",
76
128
  ] as const;
77
129
 
130
+ /**
131
+ * ONE POLICY, FOUR SURFACES. The predicate itself lives in `codex-declaration.js` (sol B3,
132
+ * 2026-09-18) because the installer, the inverse and the doctor decide about the SAME shared
133
+ * hooks.json and used to decide differently: they checked symlink-and-regular, this checked owner
134
+ * and mode as well, and the doctor checked neither. That split let an install and a doctor say yes
135
+ * about bytes every fresh call then refused. The executable bit stays HERE — it is this rail's
136
+ * question about the launcher codex is going to exec, not a fact about shared ownership.
137
+ */
78
138
  function isSafeOwnedFile(file: string, expectedUid: number, executable = false): boolean {
79
- try {
80
- const stat = fs.lstatSync(file);
81
- return (
82
- stat.isFile() &&
83
- !stat.isSymbolicLink() &&
84
- stat.uid === expectedUid &&
85
- (stat.mode & 0o022) === 0 &&
86
- (!executable || (stat.mode & 0o111) !== 0)
87
- );
88
- } catch {
89
- return false;
90
- }
139
+ const stat = statOwnedPath(fs, file);
140
+ if (classifyOwnedPath(stat, expectedUid) !== "ok") return false;
141
+ return !executable || (stat.mode & 0o111) !== 0;
91
142
  }
92
143
 
93
144
  function isSafeOwnedDir(dir: string, expectedUid: number): boolean {
94
- try {
95
- const stat = fs.lstatSync(dir);
96
- return stat.isDirectory() && !stat.isSymbolicLink() && stat.uid === expectedUid && (stat.mode & 0o022) === 0;
97
- } catch {
98
- return false;
99
- }
145
+ return classifyOwnedPath(statOwnedPath(fs, dir), expectedUid, { kind: "directory" }) === "ok";
100
146
  }
101
147
 
102
148
  /**
@@ -104,21 +150,37 @@ function isSafeOwnedDir(dir: string, expectedUid: number): boolean {
104
150
  * EXIST prove nothing: the launcher codex is about to exec must be the one this unit
105
151
  * published, so every recorded member is compared to its recorded digest before a sibling
106
152
  * is opened. No digest is computed for the VENDOR here — that is a different axis below.
153
+ *
154
+ * WHAT THIS NO LONGER READS (#117): a WHOLE-FILE `hooksSha256`. `codex-birth-install-state/v2`
155
+ * records the normalized digest of entwurf's own declaration instead, because hooks.json is a
156
+ * file this unit SHARES — a neighbouring integration appending its own `SessionStart` group
157
+ * changes every byte of that file and none of our declaration's meaning. The declaration axis
158
+ * lives in `birthDeclaration` below, which needs the live document anyway to find where our group
159
+ * currently sits.
107
160
  */
108
- function closureDriftedFromState(paths: CodexUnitPaths, expectedUid: number): boolean {
161
+ function certifiedState(paths: CodexUnitPaths, expectedUid: number): RecordedDeclaration | null {
109
162
  // The directory holding the state carries the state's authority: anyone who can write it
110
163
  // can replace the inventory every digest below is compared against.
111
- if (!isSafeOwnedDir(path.dirname(paths.stateFile), expectedUid)) return true;
112
- if (!isSafeOwnedDir(path.dirname(path.dirname(paths.stateFile)), expectedUid)) return true;
113
- if (!isSafeOwnedFile(paths.stateFile, expectedUid)) return true;
164
+ if (!isSafeOwnedDir(path.dirname(paths.stateFile), expectedUid)) return null;
165
+ if (!isSafeOwnedDir(path.dirname(path.dirname(paths.stateFile)), expectedUid)) return null;
166
+ if (!isSafeOwnedFile(paths.stateFile, expectedUid)) return null;
114
167
  let state: Record<string, unknown>;
115
168
  try {
116
169
  state = JSON.parse(fs.readFileSync(paths.stateFile, "utf8")) as Record<string, unknown>;
117
170
  } catch {
118
- return true;
171
+ return null;
119
172
  }
120
- if (state.schema !== "codex-birth-install-state/v1" || state.status !== "installed") return true;
121
- if (state.hooksFile !== paths.hooksFile || state.helperDir !== paths.helperDir) return true;
173
+ // v2 ONLY, and a v1 state is refused rather than read leniently: v1 recorded a whole-file
174
+ // digest, so a reader that tolerated it would be certifying an authority nobody holds any
175
+ // more. `entwurf install-codex-birth` supersedes it — that is the one forward path.
176
+ if (state.schema !== "codex-birth-install-state/v2" || state.status !== "installed") return null;
177
+ if (state.hooksFile !== paths.hooksFile || state.helperDir !== paths.helperDir) return null;
178
+ const declaration = state.declaration;
179
+ if (declaration === null || typeof declaration !== "object" || Array.isArray(declaration)) return null;
180
+ const recorded = declaration as Record<string, unknown>;
181
+ if (recorded.event !== CODEX_BIRTH_EVENT) return null;
182
+ if (typeof recorded.command !== "string" || typeof recorded.sha256 !== "string") return null;
183
+ if (!/^[0-9a-f]{64}$/.test(recorded.sha256)) return null;
122
184
  const digest = (file: string): string | null => {
123
185
  try {
124
186
  return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
@@ -126,9 +188,8 @@ function closureDriftedFromState(paths: CodexUnitPaths, expectedUid: number): bo
126
188
  return null;
127
189
  }
128
190
  };
129
- if (typeof state.hooksSha256 !== "string" || digest(paths.hooksFile) !== state.hooksSha256) return true;
130
191
  const members = state.helperFiles;
131
- if (!Array.isArray(members)) return true;
192
+ if (!Array.isArray(members)) return null;
132
193
  // Exactly the closure the installer publishes: a short inventory would leave a member
133
194
  // nothing compares, which is the same hole as not checking digests at all.
134
195
  const named = members.map((raw) => (raw as Record<string, unknown>)?.path);
@@ -140,21 +201,38 @@ function closureDriftedFromState(paths: CodexUnitPaths, expectedUid: number): bo
140
201
  "lib/session-id.js",
141
202
  "entwurf-capabilities.json",
142
203
  ];
143
- if (named.length !== expected.length) return true;
144
- if (expected.some((name) => !named.includes(name))) return true;
145
- if (named.some((name, index) => named.indexOf(name) !== index)) return true;
204
+ if (named.length !== expected.length) return null;
205
+ if (expected.some((name) => !named.includes(name))) return null;
206
+ if (named.some((name, index) => named.indexOf(name) !== index)) return null;
146
207
  for (const raw of members) {
147
208
  const member = raw as Record<string, unknown>;
148
209
  const rel = member.path;
149
210
  if (typeof rel !== "string" || rel.length === 0 || rel.startsWith("/") || rel.split("/").includes("..")) {
150
- return true;
211
+ return null;
151
212
  }
152
- if (typeof member.sha256 !== "string" || digest(path.join(paths.helperDir, rel)) !== member.sha256) return true;
213
+ if (typeof member.sha256 !== "string" || digest(path.join(paths.helperDir, rel)) !== member.sha256) return null;
153
214
  }
154
- return false;
215
+ return { command: recorded.command, sha256: recorded.sha256 };
155
216
  }
156
217
 
157
- function birthMissing(paths: CodexUnitPaths, expectedUid: number): boolean {
218
+ /**
219
+ * WHERE ENTWURF'S DECLARATION CURRENTLY SITS, or nothing.
220
+ *
221
+ * `null` here means the SAME reject as before — `codex-birth-unit-missing` — but the question
222
+ * it answers is narrower than it used to be (#117). It no longer asks "is hooks.json exactly the
223
+ * file we wrote"; it asks "is entwurf's own `SessionStart` group present exactly once, shaped the
224
+ * way the operator approved, and still the declaration this unit recorded". A Herdr (or any
225
+ * other) integration declaring its own group beside ours changes neither answer.
226
+ *
227
+ * The POSITION is the return value rather than a side note because the vendor keys its trust
228
+ * receipt by index: a declaration that has been renumbered by a neighbour's install needs its
229
+ * receipt read at the new index, and reading the old one would report a NEIGHBOUR's approval as
230
+ * our own.
231
+ */
232
+ function birthDeclaration(
233
+ paths: CodexUnitPaths,
234
+ expectedUid: number,
235
+ ): { groupIndex: number; handlerIndex: number } | null {
158
236
  const launcher = path.join(paths.helperDir, "codex-birth-launch.sh");
159
237
  const directories = [
160
238
  path.dirname(paths.hooksFile),
@@ -162,8 +240,8 @@ function birthMissing(paths: CodexUnitPaths, expectedUid: number): boolean {
162
240
  path.join(paths.helperDir, "lib"),
163
241
  path.join(paths.helperDir, "lib", "native-push"),
164
242
  ];
165
- if (directories.some((dir) => !isSafeOwnedDir(dir, expectedUid))) return true;
166
- if (!isSafeOwnedFile(paths.hooksFile, expectedUid)) return true;
243
+ if (directories.some((dir) => !isSafeOwnedDir(dir, expectedUid))) return null;
244
+ if (!isSafeOwnedFile(paths.hooksFile, expectedUid)) return null;
167
245
  const closure = [
168
246
  [launcher, true],
169
247
  [path.join(paths.helperDir, "meta-bridge-hook-codex.ts"), false],
@@ -172,20 +250,23 @@ function birthMissing(paths: CodexUnitPaths, expectedUid: number): boolean {
172
250
  [path.join(paths.helperDir, "lib", "session-id.js"), false],
173
251
  [path.join(paths.helperDir, "entwurf-capabilities.json"), false],
174
252
  ] as const;
175
- if (closure.some(([file, executable]) => !isSafeOwnedFile(file, expectedUid, executable))) return true;
253
+ if (closure.some(([file, executable]) => !isSafeOwnedFile(file, expectedUid, executable))) return null;
254
+ const recorded = certifiedState(paths, expectedUid);
255
+ if (recorded === null) return null;
256
+ // The state's own account of what it certified must be the declaration these paths produce;
257
+ // a receipt bound to a different launcher is a receipt for a different unit.
258
+ if (recorded.command !== entwurfDeclarationCommand(launcher)) return null;
259
+ let selected: ReturnType<typeof selectEntwurfDeclaration>;
176
260
  try {
177
- const parsed = JSON.parse(fs.readFileSync(paths.hooksFile, "utf8")) as Record<string, unknown>;
178
- const events = (parsed.hooks as Record<string, unknown> | undefined)?.SessionStart;
179
- if (!Array.isArray(events) || events.length !== 1) return true;
180
- const group = events[0] as Record<string, unknown>;
181
- if ("matcher" in group || !Array.isArray(group.hooks) || group.hooks.length !== 1) return true;
182
- const hook = group.hooks[0] as Record<string, unknown>;
183
- if (Object.keys(hook).sort().join(",") !== "command,timeout,type") return true;
184
- if (hook.type !== "command" || hook.command !== `'${launcher}'` || hook.timeout !== 30) return true;
261
+ selected = selectEntwurfDeclaration(JSON.parse(fs.readFileSync(paths.hooksFile, "utf8")), launcher);
185
262
  } catch {
186
- return true;
263
+ return null;
187
264
  }
188
- return closureDriftedFromState(paths, expectedUid);
265
+ if (!selected.ok) return null;
266
+ // The NORMALIZED digest, so a neighbour re-serializing the document (measured: Herdr does)
267
+ // cannot make our intact declaration look edited — and an edit to our own handler still can.
268
+ if (selected.digest !== recorded.sha256) return null;
269
+ return { groupIndex: selected.groupIndex, handlerIndex: selected.handlerIndex };
189
270
  }
190
271
 
191
272
  /**
@@ -194,13 +275,19 @@ function birthMissing(paths: CodexUnitPaths, expectedUid: number): boolean {
194
275
  * `trusted_hash` of the shape `sha256:<64 hex>`. What this asserts is that a receipt EXISTS
195
276
  * for our declaration identity — never that the hash is correct, which only the vendor can
196
277
  * say, and never by launching Codex to find out.
278
+ *
279
+ * THE KEY IS MEASURED, NOT ASSUMED (#117). It used to be spelled `…:session_start:0:0`, which
280
+ * was true only while entwurf was the sole declaration in the file. With a neighbour at index 0
281
+ * that constant reads THEIR receipt and calls it ours — a false green for a hook the vendor has
282
+ * never been asked to run. The caller passes the key built from the position our declaration was
283
+ * just found at.
197
284
  */
198
- function trustReceiptMissing(config: Record<string, unknown>, hooksFile: string): boolean {
285
+ function trustReceiptMissing(config: Record<string, unknown>, key: string): boolean {
199
286
  const hooks = config.hooks;
200
287
  if (hooks == null || typeof hooks !== "object" || Array.isArray(hooks)) return true;
201
288
  const state = (hooks as Record<string, unknown>).state;
202
289
  if (state == null || typeof state !== "object" || Array.isArray(state)) return true;
203
- const entry = (state as Record<string, unknown>)[`${hooksFile}:session_start:0:0`];
290
+ const entry = (state as Record<string, unknown>)[key];
204
291
  if (entry == null || typeof entry !== "object" || Array.isArray(entry)) return true;
205
292
  const digest = (entry as Record<string, unknown>).trusted_hash;
206
293
  return typeof digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(digest);
@@ -250,6 +337,190 @@ function visibleIdentityMissing(config: Record<string, unknown>): boolean {
250
337
  return !Array.isArray(statusLine) || !statusLine.includes("thread-title");
251
338
  }
252
339
 
340
+ /** `[tui].terminal_title` membership, the exact axis `entwurf doctor-codex-terminal-title`
341
+ * judges. Same shape as `visibleIdentityMissing` and a DIFFERENT key: `status_line` is what a
342
+ * human reads inside the TUI, `terminal_title` is what the multiplexer reports back as
343
+ * `#{pane_title}`. Neither substitutes for the other. */
344
+ function callerTitleMissing(config: Record<string, unknown>): boolean {
345
+ const tui = config.tui;
346
+ if (tui == null || typeof tui !== "object" || Array.isArray(tui)) return true;
347
+ const terminalTitle = (tui as Record<string, unknown>).terminal_title;
348
+ return !Array.isArray(terminalTitle) || !terminalTitle.includes("thread-id");
349
+ }
350
+
351
+ /**
352
+ * The CALLER-side capability, pre-mutation and synchronous.
353
+ *
354
+ * Synchronous because it reads one config file and nothing else: there is no app-server axis
355
+ * here and there must not be one — whether the operator's app-server is up says nothing about
356
+ * whether a caller's pane can be found, and asking would make a placement check fail for a
357
+ * delivery reason.
358
+ *
359
+ * Call it only when the anchor will actually be USED — a codex caller that named an explicit
360
+ * `placement` never consults the title, so refusing it for a missing `thread-id` would be a
361
+ * refusal for an unused capability.
362
+ */
363
+ export function codexCallerFreshPreflight(env: NodeJS.ProcessEnv): CodexCallerPreflightRejectReason | null {
364
+ const config = readConfig(env);
365
+ if (config === null || callerTitleMissing(config)) return "codex-caller-title-missing";
366
+ return null;
367
+ }
368
+
369
+ /**
370
+ * The launch directory's own capability, pre-mutation and synchronous — and DELIBERATELY NARROWER
371
+ * than the vendor's own decision, because the leaf that decides here reads one TOML file while the
372
+ * vendor asks its app-server. Read this whole comment as the scope statement it is: what follows
373
+ * says what this answers, and then says what it refuses to claim.
374
+ *
375
+ * WHY THE AXIS EXISTS. `[source rust-v0.153.4]` a fresh call always passes `--remote`, so the TUI
376
+ * resolves `AppServerTarget::Remote` and startup ALWAYS runs `check_directory_trust` on the `-C`
377
+ * value (`tui/src/lib.rs:1699-1725`). Nothing on that path consults the approval or sandbox policy
378
+ * (`tui/src/onboarding/directory_trust.rs:33-130`), which is why the
379
+ * `--dangerously-bypass-approvals-and-sandbox` token this argv already carries does NOT cover it:
380
+ * approvals and folder consent are two different gates. A directory with no recorded decision
381
+ * renders the consent screen and blocks, and a blocked TUI has started no turn — no rollout, no
382
+ * birth, no callback, and the caller waits out its whole timeout on a window that is merely
383
+ * waiting to be answered. `[측정 2026-09-16]` the two release-gate runs that failed launched in
384
+ * `…-2zznHl` and `…-kSsoAn`, neither of which had an entry; the two that passed launched in
385
+ * `…-db65N2` and `…-pBXxOJ`, both of which did. Unattended, the failure is silent.
386
+ *
387
+ * THE THREE VENDOR OUTCOMES THIS LEAF MODELS, in the vendor's own order
388
+ * (`tui/src/config_update.rs:290-371`, `ProjectTrustHost::Remote`):
389
+ *
390
+ * 1. A DIRECT decision on the exact cwd starts a turn, and BOTH recorded answers do. `trusted`
391
+ * returns before any screen (`:346-354`); a saved `untrusted` is explicitly skipped for a
392
+ * remote target — `if target.uses_remote_workspace() && trust_level == Some(Untrusted) {
393
+ * continue; }` (`onboarding/directory_trust.rs:94-96`), and `uses_remote_workspace()` is
394
+ * exactly `matches!(self, Self::Remote { .. })` (`tui/src/lib.rs:307-309`). Refusing a
395
+ * deliberate `untrusted` would be entwurf inventing a policy the vendor does not have.
396
+ * The key is the exact cwd — `cwd_keys = vec![cwd_key]` (`:290-296`), no root marker, no git
397
+ * root, no parent, all three of which exist only on the Local host. `[측정 2026-09-16]`
398
+ * `~/.codex/config.toml` carried a trusted `/tmp` and still recorded two separate
399
+ * `/tmp/entwurf-codex-fresh-live-*` entries, one per run a human answered.
400
+ * 2. With NO direct decision, an enabled PROJECT LAYER consents on the directory's behalf
401
+ * (`trust_level.is_none() && disabled_project.is_none() && project_layers.any(no
402
+ * disabledReason)` → `Ok(None)`, `:346-354`). Those layers come from the app-server's
403
+ * `ConfigRead { include_layers: true }` answer, which this leaf does not have. A layer that
404
+ * is present but DISABLED is a fourth outcome — the vendor preserves an unknown or untrusted
405
+ * project layer as disabled rather than dropping it, and that path falls through to the
406
+ * screen — and this leaf cannot tell the two apart either. So wherever a layer could exist at
407
+ * all it answers `null`, which folds both outcomes into "proceed".
408
+ * 3. With no direct decision and NO layers, a cwd inside an explicitly `untrusted` ancestor is
409
+ * not a consent screen at all: the remote branch returns an ERROR
410
+ * (`"remote project directory is inside an explicitly untrusted project; pass the repository
411
+ * root explicitly with --cd"`, `:357-371`). That is a different failure with a different
412
+ * repair, so it gets its own reason — answering `Trust` at the child would only reproduce the
413
+ * same vendor error. Note the vendor's own precondition there is `project_layers.is_empty()`,
414
+ * which is why this reason is only reachable after step 2 has found no layer anywhere.
415
+ *
416
+ * WHAT THIS LEAF IS FOR, AND WHAT IT IS NOT. It is NOT equivalent to the vendor's judgment and
417
+ * must not be described as asking "the same question": it reads the operator's own `config.toml`,
418
+ * while the vendor reads an EFFECTIVE config — system, managed and cloud layers merged around that
419
+ * user layer (`config/src/loader/mod.rs:258-290`, `:430-460`) — through its app-server.
420
+ *
421
+ * That gap is affordable because NOTHING HERE REFUSES A LAUNCH. `freshCall` prints what this leaf
422
+ * saw and opens the window anyway: the consent screen is self-repairing when a human is there, and
423
+ * one answer teaches the vendor the directory for good. The one caller that treats the answer as a
424
+ * precondition is `smoke-codex-fresh-live`, where nobody is at the keyboard and a named
425
+ * precondition is worth more than a callback timeout — that gate owns one directory, answered
426
+ * once.
427
+ *
428
+ * So read a non-null answer as "the vendor will probably stop here, and this is the repair", never
429
+ * as a verdict. Absence is not evidence either way, and every case this leaf cannot see resolves
430
+ * to `null`.
431
+ */
432
+ export function codexLaunchCwdFreshPreflight(
433
+ env: NodeJS.ProcessEnv,
434
+ launchCwd: string,
435
+ ): CodexLaunchCwdPreflightRejectReason | null {
436
+ // THREE NON-ANSWERS, AND EVERY ONE OF THEM PROCEEDS. Each is a case where this leaf holds no
437
+ // evidence about what the vendor will do, and a refusal without evidence is exactly the false
438
+ // refusal this axis promises not to produce:
439
+ //
440
+ // - A RELATIVE directory. It cannot match a stored key here, but the vendor does not give up
441
+ // on one — it asks its app-server for a cwd and joins (`config_update.rs:203-224`), so the
442
+ // joined path may well be answered. In production this branch is unreachable anyway: the
443
+ // shared cwd leaf already refuses a non-absolute request as `cwd-not-absolute`, which is a
444
+ // better reason than anything this axis could give.
445
+ // - NO READABLE USER CONFIG. A missing `config.toml` is not "no decisions": the vendor loads
446
+ // an empty user table and merges system, managed and cloud layers around it
447
+ // (`config/src/loader/mod.rs:258-290`, `:430-460`, `:520-610`), any of which can carry the
448
+ // decision or the layer that starts the turn.
449
+ // - NO `projects` TABLE. Same reason: it says the USER layer records nothing, not that the
450
+ // effective config does.
451
+ if (!path.isAbsolute(launchCwd)) return null;
452
+ const config = readConfig(env);
453
+ if (config === null) return null;
454
+ const projects = config.projects;
455
+ if (projects == null || typeof projects !== "object" || Array.isArray(projects)) {
456
+ return null;
457
+ }
458
+ const table = projects as Record<string, unknown>;
459
+ const levelOf = (key: string): string | null => {
460
+ const entry = table[key];
461
+ if (entry == null || typeof entry !== "object" || Array.isArray(entry)) return null;
462
+ const level = (entry as Record<string, unknown>).trust_level;
463
+ // An unrecognised value leaves `trust_level` as `None` on the vendor side too, and `None`
464
+ // with no project layer is precisely the case that renders the screen.
465
+ return level === "trusted" || level === "untrusted" ? level : null;
466
+ };
467
+ // Outcome 1: a direct decision, either answer.
468
+ if (levelOf(launchCwd) !== null) return null;
469
+ // Outcome 2: a PROJECT LAYER may consent with no entry at all, and this leaf cannot enumerate
470
+ // layers — they come from the app-server's `ConfigRead { include_layers: true }` answer. What
471
+ // it CAN decide is the negative: a `.codex` anywhere from the directory upward is the only
472
+ // place such a layer comes from, so when none exists the vendor's `project_layers` really is
473
+ // empty and the two remaining outcomes below are computable. Where one could exist, answer
474
+ // `null`.
475
+ //
476
+ // EXISTENCE, not ownership, and the weaker predicate is the load-bearing one. The vendor
477
+ // admits a layer on its own terms — an unknown or untrusted one is preserved as a DISABLED
478
+ // layer, not dropped — and none of that consults owner/mode/symlink safety. Asking for a
479
+ // safely-owned directory here would let a layer the vendor sees go unseen by this leaf, which
480
+ // would then synthesise an `untrusted-ancestor` refusal for a launch the vendor was going to
481
+ // run. That is the false refusal this axis must never produce, so the check is the widest
482
+ // thing that still means "a layer could live here".
483
+ //
484
+ // The operator's own CODEX HOME is excluded, and excluding it is what keeps this axis from
485
+ // being a no-op: `~/.codex` is an ancestor of nearly every directory anyone launches a sibling
486
+ // in, and it is the USER config root rather than a project layer — the vendor names those
487
+ // apart (`layer.name.dotCodexFolder` for a project, the home for the user layer). Counting it
488
+ // would answer `null` for every path under `$HOME` and the check would never fire in real use.
489
+ const codexHome = path.resolve(env.CODEX_HOME?.trim() || path.join(env.HOME ?? "", ".codex"));
490
+ for (let dir = launchCwd; ; ) {
491
+ const candidate = path.join(dir, ".codex");
492
+ if (candidate !== codexHome && fs.existsSync(candidate)) return null;
493
+ const parent = path.dirname(dir);
494
+ if (parent === dir) break;
495
+ dir = parent;
496
+ }
497
+ // Outcome 3: no layer anywhere and an explicitly untrusted ancestor — the vendor's own
498
+ // precondition for that branch is exactly `project_layers.is_empty()`, which the loop above
499
+ // has now established. It is reported as its own failure because its repair is to launch at
500
+ // that root, never to answer a prompt at the child.
501
+ //
502
+ // The MATCH IS NOT THE VENDOR'S and is not claimed to be. The vendor compares path URIs
503
+ // (`LegacyAppPathString` → `PathUri::starts_with`, segment-aware, fail-closed on encoded
504
+ // separators); this compares plain strings on a separator boundary. Where the two could
505
+ // disagree — a key or a cwd that is not a plain POSIX path — this answers `undecided` rather
506
+ // than `untrusted-ancestor`. Be exact about what that buys: BOTH are refusals, so this is NOT
507
+ // the launch-permissive direction. What it weakens is the SPECIFIC-ERROR claim — the ancestor
508
+ // reason names another directory as the repair, and naming the wrong one is worse than saying
509
+ // "no decision here". Neither vendor outcome on this branch starts a turn, so the guarantee
510
+ // above is untouched either way.
511
+ const plainPosixPath = (value: string): boolean =>
512
+ !value.includes("%") && !value.includes("\\") && !value.split("/").some((seg) => seg === "." || seg === "..");
513
+ if (plainPosixPath(launchCwd)) {
514
+ for (const key of Object.keys(table)) {
515
+ if (levelOf(key) !== "untrusted" || !plainPosixPath(key)) continue;
516
+ if (launchCwd === key || launchCwd.startsWith(key.endsWith("/") ? key : `${key}/`)) {
517
+ return "codex-launch-cwd-untrusted-ancestor";
518
+ }
519
+ }
520
+ }
521
+ return "codex-launch-cwd-undecided";
522
+ }
523
+
253
524
  const DEFAULT_APP_SERVER_TIMEOUT_MS = 5_000;
254
525
 
255
526
  function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
@@ -341,14 +612,16 @@ export async function codexFreshPreflight(
341
612
  if (!path.isAbsolute(paths.hooksFile) || !path.isAbsolute(paths.helperDir) || !path.isAbsolute(paths.stateFile)) {
342
613
  return "codex-birth-unit-missing";
343
614
  }
344
- if (birthMissing(paths, deps.operatorUid ?? process.getuid?.() ?? -1)) {
615
+ const declaration = birthDeclaration(paths, deps.operatorUid ?? process.getuid?.() ?? -1);
616
+ if (declaration === null) {
345
617
  return "codex-birth-unit-missing";
346
618
  }
347
619
  const config = readConfig(env);
348
620
  // The vendor receipt is a SEPARATE reject from our bytes: perfect bytes the vendor will
349
621
  // not run and absent bytes are two different repairs, and folding them would send the
350
622
  // operator to the installer for something only they can answer in their own Codex.
351
- if (config === null || trustReceiptMissing(config, paths.hooksFile)) return "codex-birth-trust-missing";
623
+ const key = trustReceiptKey(paths.hooksFile, declaration.groupIndex, declaration.handlerIndex);
624
+ if (config === null || trustReceiptMissing(config, key)) return "codex-birth-trust-missing";
352
625
  if (mcpMissing(config)) return "codex-mcp-hand-missing";
353
626
  if (visibleIdentityMissing(config)) return "codex-visible-identity-missing";
354
627
  const socketPath = resolveCodexDefaultSocketPath(env);
@@ -102,6 +102,52 @@ export interface RpcGetInfoCommand {
102
102
 
103
103
  export type RpcCommand = RpcSendCommand | RpcGetMessageCommand | RpcClearCommand | RpcAbortCommand | RpcGetInfoCommand;
104
104
 
105
+ // ============================================================================
106
+ // Accepted-connection disconnect policy (server half of the same wire)
107
+ // ============================================================================
108
+
109
+ /** The two codes that mean THE PEER WENT AWAY, and nothing else. `EPIPE` is a write to a pipe the
110
+ * far side already closed; `ECONNRESET` is the far side resetting it. Both describe the client, not
111
+ * this process's state, which is why they are absorbed rather than diagnosed. */
112
+ const PEER_DISCONNECT_CODES = new Set(["EPIPE", "ECONNRESET"]);
113
+
114
+ export function isPeerDisconnect(error: NodeJS.ErrnoException): boolean {
115
+ return typeof error.code === "string" && PEER_DISCONNECT_CODES.has(error.code);
116
+ }
117
+
118
+ /**
119
+ * Install the disconnect policy on a connection this process ACCEPTED. Must run before any data
120
+ * handler and before any response can be written back.
121
+ *
122
+ * `[측정 2026-09-14, .agent-reports/116-c4-live-blocker-20260914.md]` a resident pi DIED without
123
+ * this. A sibling's control-socket send timed out while the receiving session was mid-turn, the
124
+ * sender closed its end, and the server then wrote its late response to a socket whose peer was
125
+ * gone. That EPIPE does NOT arrive as a throw — `writeResponse`'s synchronous try/catch cannot see
126
+ * it — it arrives asynchronously as an `error` event, and an `error` event with no listener is an
127
+ * uncaught exception, so Node terminated the whole session. The citizen then simply read as `dead`
128
+ * and the stale socket it left behind was the only trace.
129
+ *
130
+ * A client that went away is a BOUNDED environment condition, not invalid state: Rule 15's "crash,
131
+ * don't warn" governs states we cannot reason about, and losing an entire resident session because
132
+ * someone hung up is itself the silent failure. Same policy the MCP probe already carries for an
133
+ * async EPIPE on a child's stdin (`scripts/probe-bridge-command.ts:88-97`).
134
+ *
135
+ * Anything that is NOT a peer hanging up is diagnosed exactly once with its code and message and
136
+ * then served on. Swallowing every error would hide the class this listener is not here to absorb;
137
+ * rethrowing from an event callback would be the crash this whole function exists to prevent.
138
+ *
139
+ * `diagnose` is injected so a gate can count diagnostics without capturing global stderr.
140
+ */
141
+ export function attachAcceptedSocketDisconnectPolicy(
142
+ socket: net.Socket,
143
+ diagnose: (line: string) => void = (line) => console.error(line),
144
+ ): void {
145
+ socket.on("error", (error: NodeJS.ErrnoException) => {
146
+ if (isPeerDisconnect(error)) return;
147
+ diagnose(`[entwurf-control] control socket error (${error.code ?? "no code"}): ${error.message}`);
148
+ });
149
+ }
150
+
105
151
  export interface RpcClientOptions {
106
152
  timeout?: number;
107
153
  }
@@ -34,8 +34,9 @@ import {
34
34
  resolveFactList,
35
35
  UNOBSERVED_PEER,
36
36
  } from "./entwurf-facts.ts";
37
- import { observePeerFacts } from "./entwurf-peer-observe.ts";
37
+ import { makeObservePeerFacts, readHerdrPlacementIndex } from "./entwurf-peer-observe.ts";
38
38
  import { isLivenessSupported } from "./entwurf-v2-contract.ts";
39
+ import type { HerdrPlacementIndex } from "./herdr-placement.ts";
39
40
  import {
40
41
  type ActiveStoreEntry,
41
42
  FRESH_CUT_PRESCRIPTION,
@@ -105,6 +106,12 @@ export interface EntwurfFactsDeps {
105
106
  * wiring sites (MCP + pi-native) cannot drift by each passing their own observer, and a
106
107
  * gate still drives the assembly with a fake and no filesystem. */
107
108
  observe?: PeerObserver;
109
+ /** Placement axis (#116 S1): the ONE placement-owner read this listing is allowed.
110
+ * Injected so a gate drives the whole assembly with no herdr binary and no child
111
+ * process. `undefined` uses the real read; a reader that returns `null` is the
112
+ * no-herdr host and every row then says `unobserved`. Deliberately a READ, not a
113
+ * per-citizen lookup: a per-citizen hook would invite one child process per record. */
114
+ readPlacementIndex?: () => HerdrPlacementIndex | null;
108
115
  /** Optional presentation budget. Undefined preserves the generic fact provider's full
109
116
  * observation contract; peers surfaces pass their shared render limit. Older machine
110
117
  * payload rows then say `unobserved` rather than fabricating `active` / `exists`. */
@@ -209,7 +216,11 @@ export async function listEntwurfFacts(deps: EntwurfFactsDeps): Promise<EntwurfF
209
216
  // not performed becomes `unobserved`.
210
217
  const cleanIdentities = identities.filter((i) => !conflictGids.has(i.gardenId));
211
218
  const cleanProbes = probes.filter((p) => !conflictGids.has(p.gardenId));
212
- const observe = deps.observe ?? observePeerFacts;
219
+ // The placement owner is read EXACTLY ONCE per listing, before any row is shaped, and
220
+ // never again for this call. That single read is the anti-watcher rule in code: herdr
221
+ // publishes no "the session reference landed" event, and a retry loop around that gap
222
+ // is the discovery watcher `docs/mux-launch-rail.md` §7 refuses by name.
223
+ const observe = deps.observe ?? makeObservePeerFacts((deps.readPlacementIndex ?? readHerdrPlacementIndex)());
213
224
  let selectedObserve = observe;
214
225
  if (deps.observationLimit !== undefined) {
215
226
  if (!Number.isSafeInteger(deps.observationLimit) || deps.observationLimit < 0) {
@@ -31,6 +31,7 @@
31
31
  */
32
32
 
33
33
  import { type FactLiveness, factLivenessOf, isLivenessSupported } from "./entwurf-v2-contract.ts";
34
+ import { type PlacementObservation, UNOBSERVED_PLACEMENT } from "./herdr-placement.ts";
34
35
  import type { MetaCitizenBackend, MetaIdentity } from "./meta-session.ts";
35
36
  import type { SocketLiveness } from "./socket-probe.ts";
36
37
 
@@ -57,6 +58,12 @@ export interface PeerFact {
57
58
  // "the newest record in this cwd" picked the phantom as often as the real one.
58
59
  receiver: ReceiverObservation;
59
60
  transcript: TranscriptObservation;
61
+ // — the third observed axis (#116 S1): WHERE a citizen is visible, when a
62
+ // placement owner reported it on a key we independently own. A pane is an
63
+ // ephemeral view (Hard Rule 16), so this sits beside `receiver`/`transcript`
64
+ // as evidence and never beside `liveness` as a claim about being alive: a
65
+ // pane can outlive its process and a process can outlive its pane.
66
+ placement: PlacementObservation;
60
67
  }
61
68
 
62
69
  /**
@@ -79,11 +86,16 @@ export type TranscriptObservation = "exists" | "absent" | "unobserved";
79
86
  export interface PeerObservations {
80
87
  receiver: ReceiverObservation;
81
88
  transcript: TranscriptObservation;
89
+ placement: PlacementObservation;
82
90
  }
83
91
 
84
92
  /** What a caller that measured nothing must say. Explicit and greppable — a row that
85
93
  * silently defaulted to `none`/`absent` would be a fabricated fact. */
86
- export const UNOBSERVED_PEER: PeerObservations = { receiver: "unobserved", transcript: "unobserved" };
94
+ export const UNOBSERVED_PEER: PeerObservations = {
95
+ receiver: "unobserved",
96
+ transcript: "unobserved",
97
+ placement: UNOBSERVED_PLACEMENT,
98
+ };
87
99
 
88
100
  /** Measure the two observed axes for one citizen. Injected, so this module stays pure. */
89
101
  export type PeerObserver = (identity: MetaIdentity) => PeerObservations;
@@ -116,6 +128,7 @@ export function resolvePeerFact(
116
128
  liveness: factLivenessOf(identity.backend, socket),
117
129
  receiver: observations.receiver,
118
130
  transcript: observations.transcript,
131
+ placement: observations.placement,
119
132
  };
120
133
  }
121
134