@akagilnc/pi-workflow-roles 0.1.5504 → 0.1.5575

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 (89) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dist/acp-host/production-host.js +5965 -5994
  4. package/dist/analyst-gate-cycles-read.js +23 -55
  5. package/dist/analyst-ledger.js +21 -35
  6. package/dist/analyst-metric-families/acceptance-success-rework.js +25 -1
  7. package/dist/analyst-metric-families/round-timeline.js +2 -5
  8. package/dist/archivist-record-pointer.js +38 -30
  9. package/dist/auditor-dossier-tool.js +4 -3
  10. package/dist/auditor-soul.js +3 -11
  11. package/dist/doctor-evidence.js +3 -3
  12. package/dist/engine-detour-usage.js +6 -14
  13. package/dist/headless-host/production-host.js +5316 -5324
  14. package/dist/host-descriptions.js +0 -4
  15. package/dist/host-session-record.js +266 -267
  16. package/dist/migrate-book-topology.js +1314 -696
  17. package/dist/notary-source-run.js +1 -1
  18. package/dist/packaged-role-registry.js +5 -44
  19. package/dist/pi/role-turn-host.js +53 -3
  20. package/dist/public-cli/auto-resume.js +8 -47
  21. package/dist/public-cli/instruction-seat-run.js +5 -3
  22. package/dist/public-cli/invocation.js +51 -56
  23. package/dist/public-cli/main.js +1916 -1748
  24. package/dist/public-cli/post-admission.js +96 -55
  25. package/dist/public-cli/run-lifecycle.js +27 -53
  26. package/dist/public-cli/settlement.js +157 -302
  27. package/dist/role-run-placement.js +1 -11
  28. package/dist/role-run-relocation.js +72 -50
  29. package/dist/run-dossier-files.js +30 -0
  30. package/dist/run-dossier.js +322 -0
  31. package/dist/run-terminal-artifacts.js +68 -197
  32. package/dist/run-ticket-number.js +16 -3
  33. package/dist/session-identity.js +49 -18
  34. package/dist/sitian-appender.js +13 -16
  35. package/dist/sitian-facade.js +11 -0
  36. package/dist/sitian-reader.js +44 -29
  37. package/dist/submission-gate.js +1 -1
  38. package/dist/ticket-provenance.js +22 -7
  39. package/package.json +1 -1
  40. package/scripts/build-package.mjs +2 -0
  41. package/src/acp-host/production-host.ts +1 -1
  42. package/src/acp-host/role-turn-host.ts +9 -2
  43. package/src/analyst-gate-cycles-read.ts +26 -60
  44. package/src/analyst-ledger.ts +27 -42
  45. package/src/analyst-metric-families/acceptance-success-rework.ts +24 -1
  46. package/src/analyst-metric-families/gate-cycles.ts +1 -1
  47. package/src/analyst-metric-families/round-timeline.ts +2 -5
  48. package/src/analyst-page.ts +3 -3
  49. package/src/archivist-record-entry.ts +2 -2
  50. package/src/archivist-record-pointer.ts +50 -36
  51. package/src/auditor-dossier-tool.ts +7 -5
  52. package/src/auditor-soul.ts +3 -9
  53. package/src/book-topology-migration-placement.ts +16 -3
  54. package/src/book-topology-record-class-migrators.ts +4 -2
  55. package/src/book-topology-runs-migrator.ts +6 -3
  56. package/src/doctor-contracts.ts +1 -1
  57. package/src/doctor-evidence.ts +2 -2
  58. package/src/engine-detour-tool.ts +1 -1
  59. package/src/engine-detour-usage.ts +6 -16
  60. package/src/external-host-turn-loop.ts +25 -0
  61. package/src/headless-host/production-host.ts +1 -4
  62. package/src/headless-host/role-turn-host.ts +54 -20
  63. package/src/host-descriptions.ts +0 -6
  64. package/src/host-session-record.ts +224 -140
  65. package/src/notary-source-run.ts +1 -1
  66. package/src/packaged-role-registry.ts +1 -50
  67. package/src/pi/adapter.ts +50 -7
  68. package/src/pi/role-turn-host.ts +51 -3
  69. package/src/public-cli/auto-resume.ts +7 -43
  70. package/src/public-cli/instruction-seat-run.ts +9 -2
  71. package/src/public-cli/invocation.ts +61 -87
  72. package/src/public-cli/post-admission.ts +111 -72
  73. package/src/public-cli/run-lifecycle.ts +27 -72
  74. package/src/public-cli/settlement.ts +193 -418
  75. package/src/public-cli/terminal.ts +1 -1
  76. package/src/role-run-placement.ts +2 -13
  77. package/src/role-run-relocation.ts +71 -68
  78. package/src/role-runtime.ts +6 -10
  79. package/src/run-dossier-files.ts +33 -0
  80. package/src/run-dossier.ts +357 -0
  81. package/src/run-terminal-artifacts.ts +83 -237
  82. package/src/run-ticket-number.ts +41 -11
  83. package/src/session-identity.ts +52 -20
  84. package/src/sitian-appender.ts +17 -18
  85. package/src/sitian-facade.ts +22 -0
  86. package/src/sitian-reader.ts +62 -32
  87. package/src/submission-gate.ts +1 -1
  88. package/src/ticket-provenance.ts +20 -7
  89. package/src/ticket-trajectory.ts +10 -10
@@ -1,275 +1,121 @@
1
1
  /**
2
- * Canonical layout and reader for run-directory typed terminal artifacts.
3
- * Settlement publish*Artifacts uses these names (report.json / error.json /
4
- * audit-incomplete.json under artifacts/, plus durable failure fallbacks).
5
- * The reader only checks presence and structural
2
+ * The leg's terminal: the `terminal` section of current.json (#1161), written
3
+ * whole by settlement, last write wins. `report` = an accepted / escalated
4
+ * receipt, `error` = a controlled failure, `no_receipt` = the leg ended with no
5
+ * accepted receipt (#836). The reader only checks presence and structural
6
6
  * readability — it does not re-derive role outcomes or invent a second
7
7
  * candidate algorithm.
8
8
  */
9
- import { randomUUID } from "node:crypto";
10
- import { readdir, readFile } from "node:fs/promises";
11
- import { basename, dirname, join } from "node:path";
9
+ import { basename } from "node:path";
12
10
 
13
- import { parseRunLeaf, roleRunArtifactsDirectory } from "./role-run-placement.ts";
11
+ import { parseRunLeaf } from "./role-run-placement.ts";
12
+ import { readHistoryRowsSync, readSectionSync, writeSectionSync } from "./run-dossier.ts";
14
13
 
15
- import { isRecord, errorText, isMissingPathError } from "./unknown-value.ts";
14
+ import { isRecord } from "./unknown-value.ts";
16
15
 
17
- export const RUN_TERMINAL_REPORT_FILE = "report.json" as const;
18
- export const RUN_TERMINAL_ERROR_FILE = "error.json" as const;
19
- export const RUN_TERMINAL_EVIDENCE_FILE = "evidence.json" as const;
20
- export const RUN_TERMINAL_ERROR_SETTLEMENT_FILE = "error.settlement.json" as const;
21
- export const RUN_TERMINAL_ARTIFACT_FILES = [
22
- RUN_TERMINAL_REPORT_FILE,
23
- RUN_TERMINAL_ERROR_FILE,
24
- "audit-incomplete.json",
25
- ] as const;
16
+ export type RunTerminalFace = "report" | "error" | "no_receipt";
26
17
 
27
- export type RunTerminalArtifactFile = (typeof RUN_TERMINAL_ARTIFACT_FILES)[number];
28
-
29
- /**
30
- * Fixed durable failure paths publishFailureArtifacts may settle when the
31
- * conventional artifacts/error.json name cannot be written. Shared face so the
32
- * reader follows the publisher — not a parallel search algorithm.
33
- * Relative to the run directory.
34
- */
35
- export const RUN_TERMINAL_ERROR_FALLBACK_RELATIVE_PATHS = [
36
- `artifacts/${RUN_TERMINAL_ERROR_SETTLEMENT_FILE}`,
37
- RUN_TERMINAL_ERROR_SETTLEMENT_FILE,
38
- ] as const;
39
-
40
- /** Publisher and reader share the open-ended error face stem. */
41
- export const UNIQUE_ERROR_FALLBACK_STEM = "error";
42
- export function uniqueErrorFallbackName(): string {
43
- return `${UNIQUE_ERROR_FALLBACK_STEM}.${randomUUID()}.json`;
44
- }
45
- const UNIQUE_ERROR_FALLBACK_NAME = new RegExp(
46
- `^${UNIQUE_ERROR_FALLBACK_STEM}\\.[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\\.json$`, "i",
47
- );
48
-
49
- export type RunTerminalArtifactRead =
18
+ export type RunTerminalRead =
50
19
  | { readonly status: "absent" }
51
20
  | {
52
21
  readonly status: "present";
53
- readonly file: RunTerminalArtifactFile;
54
- readonly path: string;
22
+ readonly face: RunTerminalFace;
55
23
  readonly body: Record<string, unknown>;
56
24
  }
57
- | {
58
- readonly status: "unreadable";
59
- readonly file: RunTerminalArtifactFile;
60
- readonly path: string;
61
- readonly reason: string;
62
- };
25
+ | { readonly status: "unreadable"; readonly reason: string };
26
+
27
+ /**
28
+ * Terminal body field naming the `attempt-history` row this settlement appended.
29
+ * Readers resolve submitted payloads by this identity (#1161 甲); they must not
30
+ * pick the last history row of the same role.
31
+ */
32
+ export const ATTEMPT_HISTORY_IDENTITY_FIELD = "attemptHistoryIdentity" as const;
33
+
34
+ /**
35
+ * Terminal body field naming the court that wrote this terminal (#1161 R2).
36
+ * Historical `state.jsonl` terminal rows keep it so a later re-projection of the
37
+ * same court can recover that court's `attemptHistoryIdentity` without reading
38
+ * the leg's latest terminal or guessing by payload.
39
+ */
40
+ export const TERMINAL_COURT_ATTEMPT_FIELD = "courtAttemptId" as const;
41
+
42
+ /** Replace the leg's terminal. */
43
+ export function writeRunTerminal(
44
+ runDirectory: string,
45
+ face: RunTerminalFace,
46
+ body: Record<string, unknown>,
47
+ ): void {
48
+ writeSectionSync(runDirectory, "terminal", { face, at: new Date().toISOString(), body });
49
+ }
63
50
 
64
51
  /**
65
- * Minimum producer-owned face shared by settlement terminal artifacts
66
- * (report / error / audit-incomplete). Consumer-driven: enough to identify a
67
- * usable typed terminal artifact; null, arrays, primitives, and role-less
68
- * objects are unreadable (ADR 0043).
52
+ * Read the leg's terminal. Minimum producer-owned face: a typed object body
53
+ * with a nonblank `role` (ADR 0043); anything else is unreadable. Absence is a
54
+ * valid no-receipt state. A damaged current.json throws.
69
55
  */
70
- function readUsableTerminalArtifactBody(
71
- body: unknown,
72
- ): { readonly ok: true; readonly body: Record<string, unknown> } | { readonly ok: false; readonly reason: string } {
73
- if (body === null) {
74
- return { ok: false, reason: "terminal artifact JSON value is null" };
56
+ export function readRunTerminal(runDirectory: string): RunTerminalRead {
57
+ const terminal = readSectionSync(runDirectory, "terminal");
58
+ if (terminal === undefined) return { status: "absent" };
59
+ const { face, body } = terminal;
60
+ if (face !== "report" && face !== "error" && face !== "no_receipt") {
61
+ return { status: "unreadable", reason: "terminal section has no known face" };
75
62
  }
76
63
  if (!isRecord(body)) {
77
- return {
78
- ok: false,
79
- reason: `terminal artifact JSON value is not a typed object (${Array.isArray(body) ? "array" : typeof body})`,
80
- };
64
+ return { status: "unreadable", reason: "terminal body is not a typed object" };
81
65
  }
82
66
  if (typeof body.role !== "string" || body.role.trim() === "") {
83
- return {
84
- ok: false,
85
- reason: "terminal artifact missing nonblank producer-owned role field",
86
- };
67
+ return { status: "unreadable", reason: "terminal body missing nonblank producer-owned role field" };
87
68
  }
88
- return { ok: true, body };
69
+ if (face !== "report") return { status: "present", face, body };
70
+ const resolved = withSubmittedPayloads(runDirectory, body);
71
+ if (resolved.status === "unreadable") return resolved;
72
+ return { status: "present", face, body: resolved.body };
89
73
  }
90
74
 
91
- async function readTerminalArtifactAtPath(
92
- path: string,
93
- file: RunTerminalArtifactFile,
94
- ): Promise<RunTerminalArtifactRead | undefined> {
95
- let raw: string;
96
- try {
97
- raw = await readFile(path, "utf8");
98
- } catch (error) {
99
- if (isMissingPathError(error)) return undefined;
100
- return {
101
- status: "unreadable",
102
- file,
103
- path,
104
- reason: errorText(error),
105
- };
106
- }
107
- let parsed: unknown;
108
- try {
109
- parsed = JSON.parse(raw);
110
- } catch (error) {
111
- return {
112
- status: "unreadable",
113
- file,
114
- path,
115
- reason:
116
- error instanceof Error
117
- ? error.message
118
- : `terminal artifact JSON parse failed: ${String(error)}`,
119
- };
75
+ /**
76
+ * The report's outcome carries the verdict facts only; the role's submitted payloads are not
77
+ * stored a second time. A reader that wants them takes them from the attempt-history row this
78
+ * settlement named on the terminal body (`attemptHistoryIdentity`). When payloads are absent
79
+ * and that identity is missing or cannot supply them, the gap is unreadable — never a silent
80
+ * present body (#1161 R2-gap).
81
+ */
82
+ function withSubmittedPayloads(
83
+ runDirectory: string,
84
+ body: Record<string, unknown>,
85
+ ):
86
+ | { readonly status: "present"; readonly body: Record<string, unknown> }
87
+ | { readonly status: "unreadable"; readonly reason: string } {
88
+ const outcome = body.outcome;
89
+ if (!isRecord(outcome) || outcome.payloads !== undefined) {
90
+ return { status: "present", body };
120
91
  }
121
- const usable = readUsableTerminalArtifactBody(parsed);
122
- if (!usable.ok) {
92
+ const identity = body[ATTEMPT_HISTORY_IDENTITY_FIELD];
93
+ if (typeof identity !== "string" || identity.trim() === "") {
123
94
  return {
124
95
  status: "unreadable",
125
- file,
126
- path,
127
- reason: usable.reason,
96
+ reason: "terminal report has no payloads and no attemptHistoryIdentity",
128
97
  };
129
98
  }
130
- return { status: "present", file, path, body: usable.body };
131
- }
132
-
133
- async function listUniqueErrorFallbackPaths(
134
- directories: readonly string[],
135
- ): Promise<string[]> {
136
- const found: string[] = [];
137
- for (const dir of directories) {
138
- let names: string[];
139
- try {
140
- names = await readdir(dir);
141
- } catch (error) {
142
- if (isMissingPathError(error)) continue;
143
- throw error;
144
- }
145
- for (const name of names.sort((a, b) => a.localeCompare(b))) {
146
- if (!UNIQUE_ERROR_FALLBACK_NAME.test(name)) continue;
147
- found.push(join(dir, name));
99
+ for (const row of readHistoryRowsSync(runDirectory)) {
100
+ if (row.kind !== "attempt-history" || row.identity !== identity || !isRecord(row.payload)) continue;
101
+ const attempt = row.payload.outcome;
102
+ if (isRecord(attempt) && Array.isArray(attempt.payloads)) {
103
+ return {
104
+ status: "present",
105
+ body: { ...body, outcome: { ...outcome, payloads: attempt.payloads } },
106
+ };
148
107
  }
149
108
  }
150
- return found;
109
+ return {
110
+ status: "unreadable",
111
+ reason: `terminal attemptHistoryIdentity ${identity} has no readable attempt-history payloads`,
112
+ };
151
113
  }
152
114
 
153
115
  /**
154
- * Publisher run-directory face is `<runId>@<role>`. Parent-directory unique
155
- * fallbacks are shared across sibling runs, so binding uses this runId only.
116
+ * Run-directory face is `<runId>@<role>`.
156
117
  * Sole authority for runDirectory → runId (last `@` split).
157
118
  */
158
119
  export function runIdFromRunDirectory(runDirectory: string): string | undefined {
159
120
  return parseRunLeaf(basename(runDirectory))?.runId;
160
121
  }
161
-
162
- /**
163
- * Shared parent-directory unique fallback may be adopted only when the
164
- * publisher-owned body.runId equals this run directory's runId. Same-run
165
- * artifactsDir / runDirectory candidates keep path ownership and skip this.
166
- * expectedRunId undefined (unparseable run dir) → never bound.
167
- */
168
- function presentUniqueFallbackBoundToRun(
169
- body: Record<string, unknown>,
170
- expectedRunId: string | undefined,
171
- ): boolean {
172
- if (expectedRunId === undefined) return false;
173
- return typeof body.runId === "string" && body.runId === expectedRunId;
174
- }
175
-
176
- /**
177
- * Sole authority for seam-owned unique error.<uuid>.json candidates.
178
- * Used by both clearOpposite (settlement) and readRunTerminalArtifact — do not
179
- * re-enumerate the same-run / parent unique set elsewhere.
180
- * Ownership:
181
- * - same-run dirs (artifacts/, runDir): path ownership — all unique names
182
- * - parent runs/: only body.runId-bound faces; unparseable runId or unreadable body → none
183
- */
184
- export async function listSeamOwnedUniqueErrorFacePaths(
185
- runDirectory: string,
186
- ): Promise<readonly string[]> {
187
- const artifactsDir = roleRunArtifactsDirectory(runDirectory);
188
- const owned: string[] = await listUniqueErrorFallbackPaths([
189
- artifactsDir,
190
- runDirectory,
191
- ]);
192
- const expectedRunId = runIdFromRunDirectory(runDirectory);
193
- for (const path of await listUniqueErrorFallbackPaths([dirname(runDirectory)])) {
194
- const read = await readTerminalArtifactAtPath(path, "error.json");
195
- if (read === undefined || read.status !== "present") continue;
196
- if (!presentUniqueFallbackBoundToRun(read.body, expectedRunId)) continue;
197
- owned.push(path);
198
- }
199
- return owned;
200
- }
201
-
202
- type PresentOrUnreadable = Exclude<RunTerminalArtifactRead, { status: "absent" }>;
203
-
204
- /**
205
- * Publish contract (#953): only failure publish continues after clearOpposite
206
- * failure, so a multi-class residue (residual report/audit beside a new error
207
- * face) means the current settlement is failure. Prefer failure-class faces
208
- * over success/audit — never filesystem mtime (copy/restore/utimes can lie).
209
- */
210
- function failureClassRank(file: RunTerminalArtifactFile): number {
211
- return file === "error.json" ? 1 : 0;
212
- }
213
-
214
- /**
215
- * Read the current typed terminal artifact for a run directory.
216
- *
217
- * Candidate set (publisher-owned faces only):
218
- * 1) conventional artifacts/{report,error,audit-incomplete}.json
219
- * 2) publisher fixed failure fallbacks (error.settlement.json faces)
220
- * 3) seam-owned unique error.<uuid>.json via listSeamOwnedUniqueErrorFacePaths
221
- * (same-run path ownership + parent body.runId binding — shared with clear)
222
- *
223
- * Invariant: when more than one present face remains (e.g. clearOpposite failed
224
- * during failure publish and a fallback was settled beside a residual report),
225
- * adopt failure-class over success/audit by the publish contract — not mtime.
226
- * Same-class ties keep candidate enumeration order (conventional before
227
- * fallbacks before unique).
228
- *
229
- * Unreadable faces never outrank a present face. Parent unique unreadable
230
- * files never enter the shared enumerator (cannot prove run identity).
231
- * Absence of every known durable face is a valid no-receipt state.
232
- */
233
- export async function readRunTerminalArtifact(
234
- runDirectory: string,
235
- ): Promise<RunTerminalArtifactRead> {
236
- const artifactsDir = roleRunArtifactsDirectory(runDirectory);
237
- const present: Array<Extract<PresentOrUnreadable, { status: "present" }>> = [];
238
- const unreadable: Array<Extract<PresentOrUnreadable, { status: "unreadable" }>> =
239
- [];
240
-
241
- const consider = (read: RunTerminalArtifactRead | undefined): void => {
242
- if (read === undefined || read.status === "absent") return;
243
- if (read.status === "present") {
244
- present.push(read);
245
- return;
246
- }
247
- unreadable.push(read);
248
- };
249
-
250
- for (const file of RUN_TERMINAL_ARTIFACT_FILES) {
251
- consider(await readTerminalArtifactAtPath(join(artifactsDir, file), file));
252
- }
253
-
254
- for (const relative of RUN_TERMINAL_ERROR_FALLBACK_RELATIVE_PATHS) {
255
- consider(
256
- await readTerminalArtifactAtPath(join(runDirectory, relative), "error.json"),
257
- );
258
- }
259
-
260
- // Unique same-run + parent-bound faces: one enumerator shared with clear.
261
- for (const path of await listSeamOwnedUniqueErrorFacePaths(runDirectory)) {
262
- consider(await readTerminalArtifactAtPath(path, "error.json"));
263
- }
264
-
265
- if (present.length > 0) {
266
- present.sort(
267
- (a, b) => failureClassRank(b.file) - failureClassRank(a.file),
268
- );
269
- return present[0]!;
270
- }
271
- if (unreadable.length > 0) {
272
- return unreadable[0]!;
273
- }
274
- return { status: "absent" };
275
- }
@@ -1,10 +1,12 @@
1
1
  /**
2
2
  * Typed ticketNumber reader for a retained run's durable pages.
3
- * Board pages first (admitted-request, then invocation); migration derivation
4
- * page last so worktree-derived placement stays readable without forging a
5
- * board assertion. Missing page (ENOENT) → try next / undefined; damage and
6
- * non-ENOENT IO failures propagate.
3
+ * Production board reads use state.jsonl rows only (admitted, then invocation).
4
+ * Pre-#1161 page files are readable only via readMigrationBoardTicketNumber.
5
+ * Migration derivation page last so worktree-derived placement stays readable
6
+ * without forging a board assertion. Missing page (ENOENT) → try next / undefined;
7
+ * damage and non-ENOENT IO failures propagate.
7
8
  */
9
+ import { readPageSync } from "./run-dossier.ts";
8
10
  import { readFile } from "node:fs/promises";
9
11
  import { join } from "node:path";
10
12
 
@@ -111,25 +113,53 @@ async function readJsonObject(
111
113
  }
112
114
  }
113
115
 
114
- async function readBoardPageTicketNumber(
116
+ /** Pre-#1161 separate board pages — migration input only, not a runtime fact path. */
117
+ const LEGACY_BOARD_PAGE: Readonly<Record<"admitted" | "invocation", string>> = {
118
+ admitted: "admitted-request.json",
119
+ invocation: "invocation.json",
120
+ };
121
+
122
+ function readBoardPageTicketNumberFromRows(
123
+ runDirectory: string,
124
+ section: "admitted" | "invocation",
125
+ ): number | undefined {
126
+ const fromRows = readPageSync(runDirectory, section);
127
+ return fromRows === undefined ? undefined : ticketFromRecord(fromRows);
128
+ }
129
+
130
+ async function readBoardPageTicketNumberFromLegacy(
115
131
  runDirectory: string,
116
- page: "admitted-request.json" | "invocation.json",
132
+ section: "admitted" | "invocation",
117
133
  ): Promise<number | undefined> {
118
- const record = await readJsonObject(join(runDirectory, page));
134
+ const record = await readJsonObject(join(runDirectory, LEGACY_BOARD_PAGE[section]));
119
135
  if (record === undefined) return undefined;
120
136
  return ticketFromRecord(record);
121
137
  }
122
138
 
123
139
  /**
124
- * Board-only ticketNumber (admitted → invocation). Placement attribution and
125
- * any caller that must ignore derivation pages use this sole projection.
140
+ * Board-only ticketNumber from fact rows (admitted → invocation).
141
+ * Public / production identity paths use this sole projection — never legacy pages.
126
142
  */
127
143
  export async function readBoardTicketNumber(
128
144
  runDirectory: string,
129
145
  ): Promise<number | undefined> {
130
146
  return (
131
- (await readBoardPageTicketNumber(runDirectory, "admitted-request.json")) ??
132
- (await readBoardPageTicketNumber(runDirectory, "invocation.json"))
147
+ readBoardPageTicketNumberFromRows(runDirectory, "admitted") ??
148
+ readBoardPageTicketNumberFromRows(runDirectory, "invocation")
149
+ );
150
+ }
151
+
152
+ /**
153
+ * Migration-boundary board ticket: fact rows first, then pre-#1161 page files.
154
+ * Only #852 migrators and placement may call this; runtime identity must not.
155
+ */
156
+ export async function readMigrationBoardTicketNumber(
157
+ runDirectory: string,
158
+ ): Promise<number | undefined> {
159
+ return (
160
+ (await readBoardTicketNumber(runDirectory)) ??
161
+ (await readBoardPageTicketNumberFromLegacy(runDirectory, "admitted")) ??
162
+ (await readBoardPageTicketNumberFromLegacy(runDirectory, "invocation"))
133
163
  );
134
164
  }
135
165
 
@@ -1,42 +1,77 @@
1
- import { mkdir, readFile } from "node:fs/promises";
2
1
  import { dirname, join } from "node:path";
3
2
 
4
- import { writeFileAtomically } from "./atomic-write.ts";
5
3
  import type { DurablePrincipal, DurablePrincipalAuthority } from "./host-contracts.ts";
6
4
  import {
7
5
  DEFAULT_ROLE_TURN_HOST,
8
6
  lookupHeadlessHostDescription,
9
7
  lookupHostDescription,
10
8
  } from "./host-descriptions.ts";
9
+ import { renderCurrentSync, RUN_STATE_FILE } from "./run-dossier.ts";
10
+ import { readSitianRecords, reportRunRecord } from "./sitian-facade.ts";
11
+ import { errorText, isRecord } from "./unknown-value.ts";
11
12
  import type { SessionIdentityAuthority } from "./prepared-role-turn.ts";
12
13
 
13
- /** Durable session binding stored beside the host-owned session principal. */
14
+ /** Log record kind of one host-session binding (a host's native session id for this run). */
15
+ export const HOST_SESSION_ID_RECORD_KIND = "host-session-id" as const;
16
+
17
+ /**
18
+ * Durable session binding: one state row per bind, scoped by host name — a run
19
+ * that changes hosts on resume must never hand one host's native session id to
20
+ * another. The latest record for the host wins. The public call projects
21
+ * `current.json` at the bind so host.sessions appears before the host CLI
22
+ * starts; a refused projection does not undo the binding fact or become a
23
+ * host/session failure (facts stay in state.jsonl; settlement still renders).
24
+ */
14
25
  export function createSessionIdentityAuthority(
15
26
  authority: DurablePrincipalAuthority,
16
- sessionBindingFile: string,
27
+ hostName: string,
17
28
  ): SessionIdentityAuthority {
18
- const bindingPath = (principal: DurablePrincipal): string =>
19
- join(authority.decode(principal).sessionDirectory, sessionBindingFile);
29
+ const runDirectoryOf = (principal: DurablePrincipal): string =>
30
+ dirname(authority.decode(principal).sessionDirectory);
20
31
  return {
21
32
  resolveSessionFile(principal) {
22
33
  return authority.decode(principal).sessionFile;
23
34
  },
24
35
  async load(principal) {
25
- try {
26
- const value: unknown = JSON.parse(await readFile(bindingPath(principal), "utf8"));
27
- if (typeof value !== "object" || value === null || typeof (value as { sessionId?: unknown }).sessionId !== "string") {
36
+ const runDirectory = runDirectoryOf(principal);
37
+ const { records, diagnostics } = await readSitianRecords(join(runDirectory, RUN_STATE_FILE));
38
+ // Control path fails closed on damaged rows — same class as readPageSync.
39
+ // Rendering may keep reachable facts; dispatch must not reuse a prior binding
40
+ // after syntax damage (#1161 C3).
41
+ if (diagnostics.length > 0) {
42
+ throw new Error(
43
+ `${RUN_STATE_FILE} has ${diagnostics.length} malformed row(s); refusing stale session binding: ${runDirectory}`,
44
+ );
45
+ }
46
+ let bound: string | undefined;
47
+ for (const record of records) {
48
+ if (record.kind !== HOST_SESSION_ID_RECORD_KIND) continue;
49
+ // Non-object / cannot name a host: unattributable damage — refuse
50
+ // (settled C3-payload). Never treat as a skippable "other host" row.
51
+ if (!isRecord(record.payload) || typeof record.payload.host !== "string") {
28
52
  throw new Error("durable session binding is invalid");
29
53
  }
30
- return (value as { sessionId: string }).sessionId;
31
- } catch (error) {
32
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
33
- throw error;
54
+ // Explicit other host only: that host's bad id must not block this one.
55
+ if (record.payload.host !== hostName) continue;
56
+ // This host's null / non-string sessionId: refuse — do not reuse a prior
57
+ // binding for the same host (#1161 C3).
58
+ if (typeof record.payload.sessionId !== "string") {
59
+ throw new Error("durable session binding is invalid");
60
+ }
61
+ bound = record.payload.sessionId;
34
62
  }
63
+ return bound;
35
64
  },
36
65
  async bind(principal, sessionId) {
37
- const target = bindingPath(principal);
38
- await mkdir(dirname(target), { recursive: true });
39
- await writeFileAtomically(target, `${JSON.stringify({ sessionId })}\n`, { mode: 0o600 });
66
+ const runDirectory = runDirectoryOf(principal);
67
+ reportRunRecord(runDirectory, HOST_SESSION_ID_RECORD_KIND, { host: hostName, sessionId }, "session-identity");
68
+ try {
69
+ renderCurrentSync(runDirectory);
70
+ } catch (error) {
71
+ process.stderr.write(
72
+ `[session-identity] current.json render refused after host-session-id; binding fact kept: ${runDirectory}: ${errorText(error)}\n`,
73
+ );
74
+ }
40
75
  },
41
76
  };
42
77
  }
@@ -54,10 +89,7 @@ export async function readStoredHostSessionId(
54
89
  if (host === undefined || host === DEFAULT_ROLE_TURN_HOST) return undefined;
55
90
  const description = lookupHostDescription(host) ?? lookupHeadlessHostDescription(host);
56
91
  if (description === undefined) return undefined;
57
- const sessionId = await createSessionIdentityAuthority(
58
- authority,
59
- description.sessionBindingFile,
60
- ).load(principal);
92
+ const sessionId = await createSessionIdentityAuthority(authority, host).load(principal);
61
93
  if (sessionId === undefined || sessionId === "") return undefined;
62
94
  return sessionId;
63
95
  }
@@ -5,9 +5,10 @@
5
5
  */
6
6
  import { randomUUID } from "node:crypto";
7
7
  import { appendFileSync } from "node:fs";
8
- import { dirname, join } from "node:path";
8
+ import { basename, dirname, join } from "node:path";
9
9
 
10
- import { sessionDirectoryOf } from "./role-run-placement.ts";
10
+ import { parseRunLeaf, sessionDirectoryOf } from "./role-run-placement.ts";
11
+ import { RUN_HISTORY_FILE, RUN_HISTORY_KINDS, RUN_LOG_FILE, RUN_STATE_FILE, RUN_STATE_KINDS } from "./run-dossier-files.ts";
11
12
  import { ticketNumberFromSitianSubject } from "./run-ticket-number.ts";
12
13
 
13
14
  import { resolveBookKeyFromGit } from "./activation-ledger-git.ts";
@@ -42,20 +43,8 @@ export function appendSitianRecordBlock(input: SitianRecordInput, block: string)
42
43
  }
43
44
  }
44
45
 
45
- /** Authorized S4 submission ledger kinds that share a common run submission volume. */
46
- export const S4_SUBMISSION_LEDGER_KINDS = new Set([
47
- "candidate",
48
- "roundContext",
49
- "outcome",
50
- "sealed",
51
- "post-seal-anomaly"
52
- ]);
53
-
54
- /** Compute the volume partition key for directory placement. */
46
+ /** Volume directory name for a record kind: the kind itself. */
55
47
  export function resolveSitianVolumeCategory(kind: string): string {
56
- if (S4_SUBMISSION_LEDGER_KINDS.has(kind)) {
57
- return "submission-ledger";
58
- }
59
48
  return kind;
60
49
  }
61
50
 
@@ -115,8 +104,10 @@ export function resolveSitianRecordPathInLedger(
115
104
  sessionDir = paths.sessionDir;
116
105
  recordFile = paths.recordFile;
117
106
  } else if (category === "ticket-provenance" && input.runDirectory !== undefined) {
107
+ // True-unbound diary staging: the run's history volume (#1161). Never a
108
+ // top-level run/records.jsonl — that leaf is only the ticket-root diary.
118
109
  sessionDir = input.runDirectory;
119
- recordFile = sitianVolumeRecordsFile(sessionDir);
110
+ recordFile = join(sessionDir, RUN_HISTORY_FILE);
120
111
  } else {
121
112
  if (
122
113
  input.sessionParent === undefined
@@ -125,8 +116,16 @@ export function resolveSitianRecordPathInLedger(
125
116
  ) {
126
117
  throw new Error("Sitian record ownership requires a parent session inside the ledger home");
127
118
  }
128
- sessionDir = sitianVolumeDirectory(dirname(input.sessionParent), category);
129
- recordFile = sitianVolumeRecordsFile(sessionDir);
119
+ const sessionParentDir = dirname(input.sessionParent);
120
+ const runDirectory = dirname(sessionParentDir);
121
+ if (basename(sessionParentDir) === "session" && parseRunLeaf(basename(runDirectory)) !== undefined) {
122
+ // A role run's own session: every record kind shares the run's one log (#1161).
123
+ sessionDir = runDirectory;
124
+ recordFile = join(runDirectory, RUN_HISTORY_KINDS.has(input.kind) ? RUN_HISTORY_FILE : RUN_STATE_KINDS.has(input.kind) ? RUN_STATE_FILE : RUN_LOG_FILE);
125
+ } else {
126
+ sessionDir = sitianVolumeDirectory(sessionParentDir, category);
127
+ recordFile = sitianVolumeRecordsFile(sessionDir);
128
+ }
130
129
  }
131
130
  if (!physicallyContainedIn(ledgerHome, sessionDir)) {
132
131
  throw new Error("Sitian record ownership requires a directory inside the ledger home");
@@ -6,6 +6,7 @@
6
6
  * - sitianReport → appender (SitianRecord rows, append + identity claim)
7
7
  * Read paths: readSitianRecords (canonical rows).
8
8
  */
9
+ import { sessionFileOf } from "./role-run-placement.ts";
9
10
  import { appendSitianRecord } from "./sitian-appender.ts";
10
11
  import type { RecordPointer, SitianRecordInput } from "./sitian-contracts.ts";
11
12
 
@@ -20,3 +21,24 @@ export * from "./sitian-reader.ts";
20
21
  export function sitianReport(input: SitianRecordInput): RecordPointer {
21
22
  return appendSitianRecord(input);
22
23
  }
24
+
25
+ /**
26
+ * One record of a role run: history.jsonl for the history kinds and state.jsonl for the
27
+ * state kinds (see the appender), log.jsonl for the run's own diagnostics (host dossier pointers,
28
+ * dispatch / resume / post-admission faults, host stderr). Same single entry
29
+ * as every other record; the run is named, never a file path.
30
+ */
31
+ export function reportRunRecord(
32
+ runDirectory: string,
33
+ kind: string,
34
+ payload: unknown,
35
+ source: string,
36
+ ): RecordPointer {
37
+ return appendSitianRecord({
38
+ level: "event",
39
+ kind,
40
+ sessionParent: sessionFileOf(runDirectory),
41
+ source,
42
+ payload,
43
+ });
44
+ }