@akagilnc/pi-workflow-roles 0.1.4062 → 0.1.4196

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 (72) hide show
  1. package/dist/acp-host/production-host.js +2075 -1088
  2. package/dist/archivist-record-entry.js +204 -20
  3. package/dist/archivist-record-topology.js +36 -16
  4. package/dist/auditor-dossier-tool.js +2 -1
  5. package/dist/diarist-contracts.js +96 -0
  6. package/dist/doctor-evidence.js +3 -2
  7. package/dist/headless-host/production-host.js +2215 -1228
  8. package/dist/migrate-book-topology.js +278917 -0
  9. package/dist/navigator-public-session.js +22 -25
  10. package/dist/notary-source-run.js +7 -4
  11. package/dist/pi/durable-principal.js +9 -6
  12. package/dist/public-cli/auto-resume.js +2 -5
  13. package/dist/public-cli/diarist-run.js +27 -25
  14. package/dist/public-cli/instruction-seat-run.js +8 -11
  15. package/dist/public-cli/invocation.js +127 -63
  16. package/dist/public-cli/main.js +1507 -645
  17. package/dist/public-cli/option-definitions.js +2 -2
  18. package/dist/public-cli/post-admission.js +47 -12
  19. package/dist/public-cli/run-lifecycle.js +206 -58
  20. package/dist/public-cli/seat-ticket-binding.js +3 -6
  21. package/dist/public-cli/settlement.js +5 -3
  22. package/dist/role-run-placement.js +84 -0
  23. package/dist/role-run-relocation.js +358 -0
  24. package/dist/run-terminal-artifacts.js +2 -1
  25. package/dist/run-ticket-number.js +51 -32
  26. package/dist/sitian-appender.js +11 -26
  27. package/dist/submission-ledger.js +78 -19
  28. package/dist/ticket-provenance.js +3 -1
  29. package/package.json +3 -2
  30. package/scripts/build-package.mjs +34 -2
  31. package/souls/countersign.md +2 -2
  32. package/souls/fixer.md +3 -2
  33. package/src/analyst-ledger.ts +14 -15
  34. package/src/archivist-record-entry.ts +285 -38
  35. package/src/archivist-record-topology.ts +43 -18
  36. package/src/auditor-dossier-tool.ts +2 -1
  37. package/src/book-topology-migration-placement.ts +493 -0
  38. package/src/book-topology-migration.ts +220 -0
  39. package/src/book-topology-migrators-t11.ts +856 -0
  40. package/src/book-topology-mixed-volume-migrators.ts +470 -0
  41. package/src/book-topology-partition-migrators.ts +25 -0
  42. package/src/book-topology-record-class-migrators.ts +779 -0
  43. package/src/book-topology-runs-migrator.ts +244 -0
  44. package/src/diarist-contracts.ts +116 -0
  45. package/src/diarist-role.ts +6 -0
  46. package/src/diarist.ts +2 -0
  47. package/src/doctor-evidence.ts +4 -1
  48. package/src/doctor-role.ts +1 -1
  49. package/src/navigator-public-session.ts +33 -30
  50. package/src/notary-source-run.ts +9 -4
  51. package/src/pi/durable-principal.ts +9 -13
  52. package/src/public-cli/auto-resume.ts +2 -6
  53. package/src/public-cli/countersign-run.ts +182 -32
  54. package/src/public-cli/diarist-run.ts +34 -26
  55. package/src/public-cli/instruction-seat-run.ts +7 -11
  56. package/src/public-cli/invocation.ts +192 -58
  57. package/src/public-cli/option-definitions.ts +2 -2
  58. package/src/public-cli/post-admission.ts +73 -13
  59. package/src/public-cli/run-lifecycle.ts +255 -65
  60. package/src/public-cli/seat-ticket-binding.ts +3 -6
  61. package/src/public-cli/settlement.ts +6 -4
  62. package/src/role-run-placement.ts +128 -0
  63. package/src/role-run-relocation.ts +491 -0
  64. package/src/role-runtime.ts +11 -5
  65. package/src/run-terminal-artifacts.ts +3 -1
  66. package/src/run-ticket-number.ts +80 -20
  67. package/src/sitian-appender.ts +25 -28
  68. package/src/submission-ledger.ts +102 -19
  69. package/src/ticket-provenance.ts +4 -1
  70. package/src/ticket-trajectory.ts +22 -35
  71. package/src/worker-role.ts +4 -4
  72. package/src/worker-submission-gates.ts +6 -6
@@ -9,14 +9,18 @@ import type {
9
9
  * any existing run with an available Pi session principal may be resumed; caller decides.
10
10
  * Prose is never regex-classified as quota evidence.
11
11
  */
12
- import { chmod, open, readdir, readFile, unlink, writeFile } from "node:fs/promises";
13
- import { isAbsolute, join } from "node:path";
12
+ import { chmod, lstat, open, readdir, readFile, unlink, writeFile } from "node:fs/promises";
13
+ import { basename, isAbsolute, join } from "node:path";
14
14
 
15
15
  import {
16
16
  activationBookDirectory,
17
17
  resolveActivationLedgerHome,
18
18
  } from "../activation-ledger-topology.ts";
19
- import { readRunTicketNumber } from "../run-ticket-number.ts";
19
+ import { listBookRunDirectories } from "../role-run-placement.ts";
20
+ import {
21
+ isSafePositiveTicketNumber,
22
+ readBoardTicketNumber,
23
+ } from "../run-ticket-number.ts";
20
24
  import { CliUsageError } from "./cli-errors.ts";
21
25
  import {
22
26
  readLatestTypedProviderHttpObservation,
@@ -30,6 +34,10 @@ export {
30
34
  import type { FixerPhase } from "../package-contracts/fixer-output.ts";
31
35
  import type { FixerPrerequisite } from "../package-contracts/fixer-packet.ts";
32
36
  import { parseCollectorRepository } from "../collector-config.ts";
37
+ import {
38
+ interpretDurableCourtTicketNumbers,
39
+ sameCourtTicketNumbers,
40
+ } from "../diarist-contracts.ts";
33
41
  import type { DoctorCaseIdentity } from "../doctor-contracts.ts";
34
42
  import type { NotarySourceRunLocator } from "../notary-contracts.ts";
35
43
  import {
@@ -709,6 +717,8 @@ export class RunWriterLeaseHeldError extends Error {
709
717
 
710
718
  export type RunWriterLease = {
711
719
  readonly lockPath: string;
720
+ /** Keep lease cleanup anchored when its run is relocated while held. */
721
+ relocate(runDirectory: string): void;
712
722
  release(): Promise<void>;
713
723
  };
714
724
 
@@ -755,31 +765,34 @@ function isProcessAlive(pid: number): boolean {
755
765
  }
756
766
 
757
767
 
758
- type WriterLockAutopsy =
759
- | { verdict: "absent"; readFailure?: unknown }
768
+ export type WriterLockAutopsy =
769
+ | { verdict: "absent" }
770
+ | { verdict: "unknown"; reason: "unparseable"; content: string }
771
+ | { verdict: "unknown"; reason: "unreadable"; readFailure: unknown }
760
772
  | { verdict: "dead"; pid: number }
761
773
  | { verdict: "alive"; pid: number };
762
774
 
763
775
  /**
764
- * Holder autopsy for an existing writer.lock (#552). "absent" covers no file,
765
- * no parseable pid (a live creator mid-acquisition reads as empty, and so does
766
- * the crash-window leftover), and unreadable files — absent alone never
767
- * authorizes reclaim; only a "dead" verdict does. A non-ENOENT read failure
768
- * still decides "absent" but rides along as readFailure so the true cause can
769
- * land in the cleanup sink instead of being laundered away.
776
+ * The writer lease holder is the shared authority for current run activity.
777
+ * Only ENOENT proves no holder; malformed or unreadable locks remain unknown
778
+ * because they can be observed while a live creator is writing its pid.
770
779
  */
771
- async function autopsyWriterLock(lockPath: string): Promise<WriterLockAutopsy> {
780
+ export async function autopsyWriterLock(lockPath: string): Promise<WriterLockAutopsy> {
772
781
  let content: string;
773
782
  try {
774
783
  content = await readFile(lockPath, "utf8");
775
784
  } catch (error) {
776
785
  if (errorCodeOf(error) === "ENOENT") return { verdict: "absent" };
777
- return { verdict: "absent", readFailure: error };
786
+ return { verdict: "unknown", reason: "unreadable", readFailure: error };
778
787
  }
779
788
  const normalized = content.trim();
780
- if (!/^[1-9]\d*$/.test(normalized)) return { verdict: "absent" };
789
+ if (!/^[1-9]\d*$/.test(normalized)) {
790
+ return { verdict: "unknown", reason: "unparseable", content };
791
+ }
781
792
  const pid = Number.parseInt(normalized, 10);
782
- if (!Number.isSafeInteger(pid) || pid <= 0) return { verdict: "absent" };
793
+ if (!Number.isSafeInteger(pid) || pid <= 0) {
794
+ return { verdict: "unknown", reason: "unparseable", content };
795
+ }
783
796
  return isProcessAlive(pid) ? { verdict: "alive", pid } : { verdict: "dead", pid };
784
797
  }
785
798
 
@@ -790,9 +803,9 @@ function describeAutopsy(autopsy: WriterLockAutopsy): string {
790
803
  case "dead":
791
804
  return `dead pid ${autopsy.pid}`;
792
805
  case "absent":
793
- return autopsy.readFailure !== undefined
794
- ? "unreadable lock"
795
- : "absent or unparseable holder";
806
+ return "absent holder";
807
+ case "unknown":
808
+ return autopsy.reason === "unreadable" ? "unreadable lock" : "unparseable holder";
796
809
  }
797
810
  }
798
811
 
@@ -839,7 +852,7 @@ async function reclaimStaleWriterLock(
839
852
  async function createWriterLease(
840
853
  lockPath: string,
841
854
  runDirectory: string,
842
- reportCleanupFailure: (error: unknown) => void,
855
+ reportCleanupFailure: (error: unknown, lockPath: string) => void,
843
856
  ): Promise<RunWriterLease> {
844
857
  const handle = await open(lockPath, "wx");
845
858
  try {
@@ -850,24 +863,32 @@ async function createWriterLease(
850
863
  throw error;
851
864
  }
852
865
  let released = false;
866
+ let currentRunDirectory = runDirectory;
867
+ let currentLockPath = lockPath;
853
868
  return {
854
- lockPath,
869
+ get lockPath() {
870
+ return currentLockPath;
871
+ },
872
+ relocate(nextRunDirectory: string) {
873
+ currentRunDirectory = nextRunDirectory;
874
+ currentLockPath = join(nextRunDirectory, WRITER_LOCK_FILE);
875
+ },
855
876
  async release() {
856
877
  if (released) return;
857
878
  released = true;
858
879
  await handle.close().catch(() => undefined);
859
880
  try {
860
- await unlink(lockPath);
881
+ await unlink(currentLockPath);
861
882
  } catch (error) {
862
883
  if (errorCodeOf(error) === "EACCES") {
863
884
  try {
864
- await chmod(runDirectory, 0o755);
865
- await unlink(lockPath);
885
+ await chmod(currentRunDirectory, 0o755);
886
+ await unlink(currentLockPath);
866
887
  } catch (retryError) {
867
- reportCleanupFailure(retryError);
888
+ reportCleanupFailure(retryError, currentLockPath);
868
889
  }
869
890
  } else {
870
- reportCleanupFailure(error);
891
+ reportCleanupFailure(error, currentLockPath);
871
892
  }
872
893
  }
873
894
  },
@@ -925,9 +946,9 @@ export async function acquireRunWriterLease(
925
946
  * acquire reads it as live and rejects; nothing here may promise that the
926
947
  * next acquire reclaims it.
927
948
  */
928
- const reportCleanupFailure = (error: unknown): void => {
949
+ const reportCleanupFailure = (error: unknown, lockPath: string): void => {
929
950
  reportDiagnostic(
930
- `writer lease lock cleanup failed (release is best-effort; residual lock left in place) at ${join(runDirectory, WRITER_LOCK_FILE)}: ${describeErrorIdentity(error)}`,
951
+ `writer lease lock cleanup failed (release is best-effort; residual lock left in place) at ${lockPath}: ${describeErrorIdentity(error)}`,
931
952
  );
932
953
  };
933
954
  /** Contested-lock read failure: nothing was cleaned up; the lock stays exactly where it is. */
@@ -946,7 +967,7 @@ export async function acquireRunWriterLease(
946
967
  if (errorCodeOf(error) !== "EEXIST") throw error;
947
968
  }
948
969
  lastAutopsy = await autopsyWriterLock(lockPath);
949
- if (lastAutopsy.verdict === "absent" && lastAutopsy.readFailure !== undefined) {
970
+ if (lastAutopsy.verdict === "unknown" && lastAutopsy.reason === "unreadable") {
950
971
  reportReadFailure(lastAutopsy.readFailure);
951
972
  }
952
973
  if (lastAutopsy.verdict === "alive") {
@@ -954,13 +975,18 @@ export async function acquireRunWriterLease(
954
975
  `role run writer lease is already held by live pid ${lastAutopsy.pid} at ${lockPath}`,
955
976
  );
956
977
  }
957
- if (lastAutopsy.verdict === "absent") {
978
+ if (lastAutopsy.verdict === "unknown") {
958
979
  throw new RunWriterLeaseHeldError(
959
- lastAutopsy.readFailure !== undefined
980
+ lastAutopsy.reason === "unreadable"
960
981
  ? `role run writer lease lock is unreadable at ${lockPath}: ${describeErrorIdentity(lastAutopsy.readFailure)}; holder liveness unverifiable, lock left in place`
961
982
  : `role run writer lease lock at ${lockPath} has no verifiable holder pid (empty or unparseable); holder liveness unverifiable, lock left in place`,
962
983
  );
963
984
  }
985
+ if (lastAutopsy.verdict === "absent") {
986
+ throw new RunWriterLeaseHeldError(
987
+ `role run writer lease disappeared before holder autopsy at ${lockPath}`,
988
+ );
989
+ }
964
990
  if (reclaimsLeft <= 0) break;
965
991
  let reclaimed = false;
966
992
  try {
@@ -991,10 +1017,15 @@ export async function acquireRunWriterLease(
991
1017
  /**
992
1018
  * Locate a Role run directory by run ID under the ledger books home.
993
1019
  * Returns undefined when the ID is unknown.
1020
+ * Walk surface = listBookRunDirectories (flat legacy + subject-tree).
1021
+ * Collects every match under the supplied book/role filters: zero → undefined,
1022
+ * one → path, many → loud ambiguity (never readdir-first).
994
1023
  */
995
1024
  export async function findRunDirectoryById(
996
- home: string,
1025
+ home: string | undefined,
997
1026
  runId: string,
1027
+ onlyBookKey?: string,
1028
+ onlyRole?: string,
998
1029
  ): Promise<string | undefined> {
999
1030
  if (runId.trim() === "") return undefined;
1000
1031
  const ledgerHome = resolveActivationLedgerHome(home);
@@ -1002,24 +1033,37 @@ export async function findRunDirectoryById(
1002
1033
  let bookKeys: string[];
1003
1034
  try {
1004
1035
  bookKeys = await readdir(booksRoot);
1005
- } catch {
1006
- return undefined;
1036
+ } catch (error) {
1037
+ // Only a missing books root is "unknown id"; permission/IO damage propagates.
1038
+ if (errorCodeOf(error) === "ENOENT") return undefined;
1039
+ throw error;
1007
1040
  }
1041
+ const matches: string[] = [];
1008
1042
  for (const bookKey of bookKeys) {
1009
- const runsDir = join(activationBookDirectory(ledgerHome, bookKey), "runs");
1010
- let entries: string[];
1043
+ if (onlyBookKey !== undefined && bookKey !== onlyBookKey) continue;
1044
+ const bookDir = activationBookDirectory(ledgerHome, bookKey);
1045
+ let runDirectories: string[];
1011
1046
  try {
1012
- entries = await readdir(runsDir);
1013
- } catch {
1014
- continue;
1047
+ runDirectories = await listBookRunDirectories(bookDir);
1048
+ } catch (error) {
1049
+ if (errorCodeOf(error) === "ENOENT") continue;
1050
+ throw error;
1015
1051
  }
1016
- for (const entry of entries) {
1017
- if (entry === `${runId}@judge` || entry.startsWith(`${runId}@`)) {
1018
- return join(runsDir, entry);
1052
+ for (const runDirectory of runDirectories) {
1053
+ const entry = basename(runDirectory);
1054
+ if (
1055
+ (onlyRole === undefined && (entry === `${runId}@judge` || entry.startsWith(`${runId}@`))) ||
1056
+ entry === `${runId}@${onlyRole}`
1057
+ ) {
1058
+ matches.push(runDirectory);
1019
1059
  }
1020
1060
  }
1021
1061
  }
1022
- return undefined;
1062
+ if (matches.length === 0) return undefined;
1063
+ if (matches.length === 1) return matches[0];
1064
+ throw new Error(
1065
+ `ambiguous role run id ${runId}: ${matches.join(", ")}`,
1066
+ );
1023
1067
  }
1024
1068
 
1025
1069
  /** Code-owned gate inspector summons prefix (public-role-summons / #747). */
@@ -1064,12 +1108,38 @@ export async function readRunParentPath(
1064
1108
  return undefined;
1065
1109
  }
1066
1110
 
1111
+ /**
1112
+ * True when durable run-state names a session principal that has actually formed
1113
+ * (session file present as a real file). Provisional runs relocated+terminalized
1114
+ * before dispatch never form one — same-ticket lookup must skip them so a newer
1115
+ * abandoned mint cannot eclipse an older resumable principal (#859).
1116
+ * Not a terminal-state gate: ADR/#416 allows terminal resume when principal exists.
1117
+ */
1118
+ async function runHasFormedSessionPrincipal(runDirectory: string): Promise<boolean> {
1119
+ const disk = await readRoleRunStateDisk(runDirectory);
1120
+ if (disk === undefined) return false;
1121
+ const sessionFile =
1122
+ typeof disk.principalWire.sessionFile === "string" &&
1123
+ disk.principalWire.sessionFile.trim() !== ""
1124
+ ? disk.principalWire.sessionFile
1125
+ : join(disk.principalWire.sessionDirectory, "session.jsonl");
1126
+ try {
1127
+ const stat = await lstat(sessionFile);
1128
+ return stat.isFile() && !stat.isSymbolicLink();
1129
+ } catch (error) {
1130
+ // Absent session file → not formed; permission/IO/damage keeps identity.
1131
+ if (errorCodeOf(error) === "ENOENT") return false;
1132
+ throw error;
1133
+ }
1134
+ }
1135
+
1067
1136
  /**
1068
1137
  * Locate the latest retained run for one seat under a book (#637 / #747).
1069
- * Same walk surface as findRunDirectoryById. Match by parent run path (officer
1070
- * seats, #747) or by ticket number (countersign / diarist principal).
1071
- * runId is UUIDv7 — lexicographic max is latest. No parallel index.
1072
- * Only a truly missing runs directory means no history; damage/permission errors propagate.
1138
+ * Same walk surface as findRunDirectoryById (listBookRunDirectories). Match by
1139
+ * parent run path (officer seats, #747) or by ticket number (countersign /
1140
+ * diarist principal). runId is UUIDv7 — lexicographic max is latest among runs
1141
+ * that formed a session principal. No parallel index.
1142
+ * Only a truly missing book directory means no history; damage/permission errors propagate.
1073
1143
  */
1074
1144
  export async function findLatestRunIdForSeatTicket(input: {
1075
1145
  readonly home: string;
@@ -1078,34 +1148,35 @@ export async function findLatestRunIdForSeatTicket(input: {
1078
1148
  readonly ticketNumber?: number;
1079
1149
  readonly parentRunPath?: string;
1080
1150
  }): Promise<string | undefined> {
1151
+ if (input.ticketNumber === undefined && input.parentRunPath === undefined) {
1152
+ return undefined;
1153
+ }
1081
1154
  const ledgerHome = resolveActivationLedgerHome(input.home);
1082
- const runsDir = join(
1083
- activationBookDirectory(ledgerHome, input.bookKey),
1084
- "runs",
1085
- );
1086
- let entries: string[];
1155
+ const bookDir = activationBookDirectory(ledgerHome, input.bookKey);
1156
+ let runDirectories: string[];
1087
1157
  try {
1088
- entries = await readdir(runsDir);
1158
+ runDirectories = await listBookRunDirectories(bookDir);
1089
1159
  } catch (error) {
1090
1160
  if (errorCodeOf(error) === "ENOENT") return undefined;
1091
1161
  throw error;
1092
1162
  }
1093
1163
  const suffix = `@${input.role}`;
1094
1164
  let best: string | undefined;
1095
- for (const entry of entries) {
1165
+ for (const runDirectory of runDirectories) {
1166
+ const entry = basename(runDirectory);
1096
1167
  if (!entry.endsWith(suffix)) continue;
1097
1168
  const runId = entry.slice(0, entry.length - suffix.length);
1098
1169
  if (runId.length === 0) continue;
1099
- const runDirectory = join(runsDir, entry);
1100
1170
  if (input.parentRunPath !== undefined) {
1101
1171
  const parentPath = await readRunParentPath(runDirectory);
1102
1172
  if (parentPath !== input.parentRunPath) continue;
1103
1173
  } else if (input.ticketNumber !== undefined) {
1104
- const ticketNumber = await readRunTicketNumber(runDirectory);
1174
+ // Same-ticket resume is board identity only — never migration-derived.
1175
+ const ticketNumber = await readBoardTicketNumber(runDirectory);
1105
1176
  if (ticketNumber !== input.ticketNumber) continue;
1106
- } else {
1107
- continue;
1108
1177
  }
1178
+ // Durable fact: never resume-select a provisional that never formed principal.
1179
+ if (!(await runHasFormedSessionPrincipal(runDirectory))) continue;
1109
1180
  if (best === undefined || runId > best) best = runId;
1110
1181
  }
1111
1182
  return best;
@@ -1126,6 +1197,13 @@ type LoadedAdmittedRequestFields = {
1126
1197
  readonly derived?: DerivedMergerEnvelope;
1127
1198
  readonly correlationId?: string;
1128
1199
  readonly ticketNumber?: number;
1200
+ /** #871 typed co-review set restored on countersign resume. */
1201
+ readonly courtTicketNumbers?: readonly number[];
1202
+ /**
1203
+ * #871 durable set present-but-damaged diagnostic. Countersign resume settles
1204
+ * controlled failure with this text; not a structural usage rejection.
1205
+ */
1206
+ readonly courtTicketNumbersDamage?: string;
1129
1207
  /** Collector — admitted repository/PR identity restored on resume (#633). */
1130
1208
  readonly prNumber?: number;
1131
1209
  readonly repository?: string;
@@ -1145,7 +1223,7 @@ type LoadedAdmittedRequestFields = {
1145
1223
  readonly model?: InvocationEffectiveModel;
1146
1224
  };
1147
1225
 
1148
- /** Restore optional correlation + typed ticket identity from a durable admitted page. */
1226
+ /** Restore optional correlation + typed main-ticket identity from a durable page. */
1149
1227
  function parsePersistedTicketIdentity(
1150
1228
  record: Record<string, unknown>,
1151
1229
  ): {
@@ -1156,25 +1234,99 @@ function parsePersistedTicketIdentity(
1156
1234
  typeof record.correlationId === "string" && record.correlationId.trim() !== ""
1157
1235
  ? record.correlationId
1158
1236
  : undefined;
1159
- const ticketNumber =
1160
- typeof record.ticketNumber === "number" &&
1161
- Number.isSafeInteger(record.ticketNumber) &&
1162
- record.ticketNumber >= 1
1163
- ? record.ticketNumber
1164
- : undefined;
1237
+ const ticketNumber = isSafePositiveTicketNumber(record.ticketNumber)
1238
+ ? record.ticketNumber
1239
+ : undefined;
1165
1240
  return {
1166
1241
  ...(correlationId === undefined ? {} : { correlationId }),
1167
1242
  ...(ticketNumber === undefined ? {} : { ticketNumber }),
1168
1243
  };
1169
1244
  }
1170
1245
 
1246
+ /**
1247
+ * #871 durable set from one run page.
1248
+ * Absent → absent. Present damage keeps the original reason (no throw here).
1249
+ */
1250
+ function readDurableCourtTicketNumbersPage(
1251
+ record: Record<string, unknown>,
1252
+ principalTicket: number | undefined,
1253
+ pageLabel: string,
1254
+ ):
1255
+ | { readonly kind: "absent" }
1256
+ | { readonly kind: "ok"; readonly tickets: readonly number[] }
1257
+ | { readonly kind: "damage"; readonly reason: string } {
1258
+ const interpreted = interpretDurableCourtTicketNumbers(record, {
1259
+ ...(principalTicket === undefined ? {} : { principalTicket }),
1260
+ });
1261
+ if (interpreted.kind === "damage") {
1262
+ return {
1263
+ kind: "damage",
1264
+ reason: `role run ${pageLabel} courtTicketNumbers is damaged: ${interpreted.reason}`,
1265
+ };
1266
+ }
1267
+ if (interpreted.kind === "ok") {
1268
+ return { kind: "ok", tickets: interpreted.tickets };
1269
+ }
1270
+ return { kind: "absent" };
1271
+ }
1272
+
1273
+ /**
1274
+ * Merge admitted + invocation #871 sets.
1275
+ * Both present must match; one present wins; both absent stays legacy.
1276
+ * Damage / cross-page mismatch return a diagnostic — countersign resume settles
1277
+ * controlled failure; load itself does not structural-reject.
1278
+ */
1279
+ function mergeDurableCourtTicketNumbers(input: {
1280
+ readonly admittedRecord: Record<string, unknown> | undefined;
1281
+ readonly invocationRecord: Record<string, unknown> | undefined;
1282
+ readonly principalTicket: number | undefined;
1283
+ }):
1284
+ | { readonly kind: "ok"; readonly tickets?: readonly number[] }
1285
+ | { readonly kind: "damage"; readonly reason: string } {
1286
+ const fromAdmitted =
1287
+ input.admittedRecord === undefined
1288
+ ? ({ kind: "absent" } as const)
1289
+ : readDurableCourtTicketNumbersPage(
1290
+ input.admittedRecord,
1291
+ input.principalTicket,
1292
+ "admitted-request",
1293
+ );
1294
+ const fromInvocation =
1295
+ input.invocationRecord === undefined
1296
+ ? ({ kind: "absent" } as const)
1297
+ : readDurableCourtTicketNumbersPage(
1298
+ input.invocationRecord,
1299
+ input.principalTicket,
1300
+ "invocation",
1301
+ );
1302
+ if (fromAdmitted.kind === "damage") return fromAdmitted;
1303
+ if (fromInvocation.kind === "damage") return fromInvocation;
1304
+ if (fromAdmitted.kind === "ok" && fromInvocation.kind === "ok") {
1305
+ if (!sameCourtTicketNumbers(fromAdmitted.tickets, fromInvocation.tickets)) {
1306
+ return {
1307
+ kind: "damage",
1308
+ reason:
1309
+ "role run courtTicketNumbers differs between admitted-request and invocation",
1310
+ };
1311
+ }
1312
+ return { kind: "ok", tickets: fromAdmitted.tickets };
1313
+ }
1314
+ if (fromAdmitted.kind === "ok") return { kind: "ok", tickets: fromAdmitted.tickets };
1315
+ if (fromInvocation.kind === "ok") return { kind: "ok", tickets: fromInvocation.tickets };
1316
+ return { kind: "ok" };
1317
+ }
1318
+
1171
1319
  function restoredTicketFields(fields: LoadedAdmittedRequestFields): {
1172
1320
  correlationId?: string;
1173
1321
  ticketNumber?: number;
1322
+ courtTicketNumbers?: readonly number[];
1174
1323
  } {
1175
1324
  return {
1176
1325
  ...(fields.correlationId === undefined ? {} : { correlationId: fields.correlationId }),
1177
1326
  ...(fields.ticketNumber === undefined ? {} : { ticketNumber: fields.ticketNumber }),
1327
+ ...(fields.courtTicketNumbers === undefined
1328
+ ? {}
1329
+ : { courtTicketNumbers: fields.courtTicketNumbers }),
1178
1330
  };
1179
1331
  }
1180
1332
 
@@ -1224,6 +1376,10 @@ async function loadResumableRunRecord(
1224
1376
  let derived: DerivedMergerEnvelope | undefined;
1225
1377
  let correlationId: string | undefined;
1226
1378
  let ticketNumber: number | undefined;
1379
+ let courtTicketNumbers: readonly number[] | undefined;
1380
+ let courtTicketNumbersDamage: string | undefined;
1381
+ let admittedIdentityRecord: Record<string, unknown> | undefined;
1382
+ let invocationIdentityRecord: Record<string, unknown> | undefined;
1227
1383
  let prNumber: number | undefined;
1228
1384
  let repository: string | undefined;
1229
1385
  let repositoryDisplay: string | undefined;
@@ -1382,6 +1538,7 @@ async function loadResumableRunRecord(
1382
1538
  };
1383
1539
  }
1384
1540
  }
1541
+ admittedIdentityRecord = record;
1385
1542
  const fromAdmitted = parsePersistedTicketIdentity(record);
1386
1543
  correlationId = fromAdmitted.correlationId;
1387
1544
  ticketNumber = fromAdmitted.ticketNumber;
@@ -1405,6 +1562,7 @@ async function loadResumableRunRecord(
1405
1562
  !Array.isArray(invocationRaw)
1406
1563
  ) {
1407
1564
  const rec = invocationRaw as Record<string, unknown>;
1565
+ invocationIdentityRecord = rec;
1408
1566
  if (typeof rec.provider === "string" && typeof rec.model === "string") {
1409
1567
  model = {
1410
1568
  provider: rec.provider,
@@ -1421,6 +1579,11 @@ async function loadResumableRunRecord(
1421
1579
  }
1422
1580
  }
1423
1581
  } catch (error) {
1582
+ if (
1583
+ error instanceof CliUsageError
1584
+ ) {
1585
+ throw error;
1586
+ }
1424
1587
  if (
1425
1588
  !(
1426
1589
  error instanceof Error &&
@@ -1434,6 +1597,19 @@ async function loadResumableRunRecord(
1434
1597
  );
1435
1598
  }
1436
1599
  }
1600
+ // #871: resolve set after main ticket so principal membership can be checked.
1601
+ // Present-but-damaged / cross-page mismatch keep diagnostic for countersign
1602
+ // controlled-failure; both-absent stays legacy; single lawful page restores.
1603
+ const mergedSet = mergeDurableCourtTicketNumbers({
1604
+ admittedRecord: admittedIdentityRecord,
1605
+ invocationRecord: invocationIdentityRecord,
1606
+ principalTicket: ticketNumber,
1607
+ });
1608
+ if (mergedSet.kind === "damage") {
1609
+ courtTicketNumbersDamage = mergedSet.reason;
1610
+ } else if (mergedSet.tickets !== undefined) {
1611
+ courtTicketNumbers = mergedSet.tickets;
1612
+ }
1437
1613
  return {
1438
1614
  run,
1439
1615
  principal,
@@ -1453,6 +1629,10 @@ async function loadResumableRunRecord(
1453
1629
  ...(derived === undefined ? {} : { derived }),
1454
1630
  ...(correlationId === undefined ? {} : { correlationId }),
1455
1631
  ...(ticketNumber === undefined ? {} : { ticketNumber }),
1632
+ ...(courtTicketNumbers === undefined ? {} : { courtTicketNumbers }),
1633
+ ...(courtTicketNumbersDamage === undefined
1634
+ ? {}
1635
+ : { courtTicketNumbersDamage }),
1456
1636
  ...(prNumber === undefined ? {} : { prNumber }),
1457
1637
  ...(repository === undefined ? {} : { repository }),
1458
1638
  ...(repositoryDisplay === undefined ? {} : { repositoryDisplay }),
@@ -1721,9 +1901,19 @@ export async function loadResumableCountersignRun(
1721
1901
  `role run ${runId} belongs to ${loaded.run.role}, not countersign`,
1722
1902
  );
1723
1903
  }
1904
+ const base = resumedBaseAdmitted(loaded);
1724
1905
  const admitted: AdmittedCountersignInvocation = {
1725
1906
  role: "countersign",
1726
- ...resumedBaseAdmitted(loaded),
1907
+ ...base,
1908
+ ...(base.courtTicketNumbers === undefined
1909
+ ? {}
1910
+ : { courtTicketNumbers: base.courtTicketNumbers }),
1911
+ ...(loaded.admittedFields.courtTicketNumbersDamage === undefined
1912
+ ? {}
1913
+ : {
1914
+ courtTicketNumbersDamage:
1915
+ loaded.admittedFields.courtTicketNumbersDamage,
1916
+ }),
1727
1917
  };
1728
1918
  return seatLoadedResult(loaded, admitted);
1729
1919
  }
@@ -1,17 +1,14 @@
1
1
  /**
2
2
  * Shared ticket identity seam for public court seats (#635 / #637 / #709 / #747 / #771).
3
3
  *
4
- * ADR 0075 `diarist-resolves-ticket-llm-layer`: the seat's LLM names the court
5
- * target as a typed assertion; the mechanical layer only verifies that assertion
6
- * (complete decimal of N appears in the summons; ticket exists). Code never
7
- * judges which ticket the summons is about — no prose harvest, no first-#N
8
- * position pick, no matching book-known numbers against instruction text
4
+ * ADR 0075 `diarist-resolves-ticket-llm-layer`: the diarist LLM recognizes the
5
+ * court target; recognized ticket → provenance, truly unbound → no provenance,
6
+ * or escalate when it cannot recognize one. Code does not re-judge the ticket
9
7
  * (锚定宪法; owner 2026-09-08: 代码不准做判断). Other seats reuse a typed
10
8
  * identity already handed over (起居郎 assertion / source-run / already-bound
11
9
  * resume) — they do not re-recognize from instruction. No CLI --ticket, no
12
10
  * attachment frontmatter. #747: officer same-parent resume also lives here.
13
11
  *
14
- * Mechanical verify helpers live on diarist.ts / invocation.ts (sole owners).
15
12
  * This module owns same-ticket / same-parent resume only.
16
13
  */
17
14
  import { resolveBookKeyFromGit } from "../activation-ledger-git.ts";
@@ -126,6 +126,7 @@ import type {
126
126
  DurablePrincipalAuthority,
127
127
  DurablePrincipalCoordinates,
128
128
  } from "../host-contracts.ts";
129
+ import { roleRunArtifactsDirectory } from "../role-run-placement.ts";
129
130
  import {
130
131
  ensureRunArtifactsDir,
131
132
  homeFromRunDirectory,
@@ -162,9 +163,10 @@ export type SettlementCourtScope = {
162
163
  function ledgerReadScope(
163
164
  admitted: Pick<AdmittedRoleInvocation, "runDirectory">,
164
165
  scope?: SettlementCourtScope,
165
- ): { home: string; attemptId?: string } {
166
+ ): { home: string; sessionParent: string; attemptId?: string } {
166
167
  return {
167
168
  home: sealedLedgerHome(admitted),
169
+ sessionParent: join(admitted.runDirectory, "session", "session.jsonl"),
168
170
  ...(scope?.courtAttemptId === undefined || scope.courtAttemptId.length === 0
169
171
  ? {}
170
172
  : { attemptId: scope.courtAttemptId }),
@@ -242,7 +244,7 @@ export async function attemptProducedFreshSubmission(
242
244
  admitted.projectRoot,
243
245
  admitted.runId,
244
246
  scope.courtAttemptId,
245
- sealedLedgerHome(admitted),
247
+ ledgerReadScope(admitted, scope),
246
248
  );
247
249
  }
248
250
 
@@ -3844,7 +3846,7 @@ function uniqueFailureFallbackDirs(
3844
3846
  async function resolveFailureArtifactsBase(
3845
3847
  runDirectory: string,
3846
3848
  ): Promise<{ baseDir: string; attempt?: PublicationAttempt }> {
3847
- const artifactsDir = join(runDirectory, "artifacts");
3849
+ const artifactsDir = roleRunArtifactsDirectory(runDirectory);
3848
3850
  try {
3849
3851
  await ensureRunArtifactsDir(runDirectory);
3850
3852
  return { baseDir: artifactsDir };
@@ -3936,7 +3938,7 @@ export async function publishFailureArtifacts(
3936
3938
  // Prefer conventional names; unique fallback dirs keep colliding fixed paths
3937
3939
  // from stranding the original failure outside settlement. Include the ledger
3938
3940
  // runs/ parent so a locked run directory (EACCES) cannot exhaust durability.
3939
- const underArtifacts = baseDir === join(admitted.runDirectory, "artifacts");
3941
+ const underArtifacts = baseDir === roleRunArtifactsDirectory(admitted.runDirectory);
3940
3942
  const uniqueFallbackDirs = uniqueFailureFallbackDirs(
3941
3943
  admitted.runDirectory,
3942
3944
  baseDir,