@akagilnc/pi-workflow-roles 0.1.5068 → 0.1.5079

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.
@@ -1,13 +1,24 @@
1
- /** One shared terminating receipt contract for review officers (#1028). */
1
+ /** One shared terminating receipt contract for review officers (#1028 / #1055). */
2
2
  import { Type } from "typebox";
3
- import { openToolObject } from "./open-tool-schema.js";
4
3
  import { withTerminatingOutputDeclarations } from "./package-contracts/terminating-infrastructure.js";
5
4
  export const REVIEW_SUBMISSION_OUTPUT_TOOL_NAME = "ak_submission_output";
6
- /** Open receipt: the queue reads only status; all other submitted fields ride unchanged. */
7
- export const reviewSubmissionSchema = withTerminatingOutputDeclarations(openToolObject(Type.Object({
8
- status: Type.Optional(Type.Unknown({
9
- description: "converged | continue | escalate — 非三态时请重读后重交,勿改标。",
10
- })),
5
+ /** Single schema copy of the review queue words. Runtime membership reads this set. */
6
+ export const REVIEW_QUEUE_WORDS = ["converged", "continue", "escalate"];
7
+ export const REVIEW_QUEUE_STATUSES = new Set(REVIEW_QUEUE_WORDS);
8
+ /**
9
+ * One object root. Codex structured output rejects a root anyOf, and status
10
+ * stays a required three-state enum with no omitted-status failure branch.
11
+ * A review seat that can still submit records a real external-dependency
12
+ * failure as escalate plus the existing infrastructureFailure field.
13
+ */
14
+ export const reviewSubmissionSchema = withTerminatingOutputDeclarations(Type.Object({
15
+ status: Type.Union([
16
+ Type.Literal(REVIEW_QUEUE_WORDS[0]),
17
+ Type.Literal(REVIEW_QUEUE_WORDS[1]),
18
+ Type.Literal(REVIEW_QUEUE_WORDS[2]),
19
+ ], {
20
+ description: "审核队列三态枚举,必填。审核席仍能交卷而完成审核所需的外部依赖发生真实基础设施失败时,status 取 escalate,细节写入 infrastructureFailure。不得省略 status,不得用另两个枚举值充当放行或封驳。包侧不因格式拒收。",
21
+ }),
11
22
  findings: Type.Optional(Type.Unknown({ description: "审核发现,原样留存" })),
12
23
  reason: Type.Optional(Type.Unknown({ description: "上呈理由" })),
13
24
  violations: Type.Optional(Type.Unknown({ description: "违规条目" })),
@@ -17,4 +28,4 @@ export const reviewSubmissionSchema = withTerminatingOutputDeclarations(openTool
17
28
  note: Type.Optional(Type.Unknown({ description: "裁决附注" })),
18
29
  evidence: Type.Optional(Type.Unknown({ description: "留存证据" })),
19
30
  decisionGate: Type.Optional(Type.Unknown({ description: "需人权威处置的问题与选项;可说明 question、options,原样留存" })),
20
- })));
31
+ }, { additionalProperties: true }));
@@ -50,6 +50,23 @@ export class WorkerUnfinishedReasonReminderError extends Error {
50
50
  this.name = "WorkerUnfinishedReasonReminderError";
51
51
  }
52
52
  }
53
+ /**
54
+ * Host constraint missed a routing discriminator (#1055).
55
+ * Names live only in the submission schema. This notice carries the field and the received value.
56
+ */
57
+ export function unreadableDiscriminatorNotice(field, received) {
58
+ if (received === undefined)
59
+ return `读不出 ${field}`;
60
+ return `读不出 ${field}:${readableGateItem(received)}`;
61
+ }
62
+ /** The received discriminator only — never the rest of the receipt. */
63
+ export function receivedDiscriminator(receipt, field) {
64
+ if (receipt === null || typeof receipt !== "object" || Array.isArray(receipt))
65
+ return undefined;
66
+ if (!Object.hasOwn(receipt, field))
67
+ return undefined;
68
+ return receipt[field];
69
+ }
53
70
  /**
54
71
  * Parent seat status unreadable for queueing (#753).
55
72
  * Correctable back to the parent itself — not a gate officer bounce, no officer field.
@@ -6,7 +6,8 @@ import { runIdFromRunDirectory } from "./run-terminal-artifacts.js";
6
6
  import { readSitianRecords, resolveSitianRecordPathInLedger, sitianReport } from "./sitian-facade.js";
7
7
  import { findRunDirectoryById } from "./public-cli/run-lifecycle.js";
8
8
  import { isCorrectableExecuteError } from "./submission-correctable-error.js";
9
- import { failOnInfrastructureFailureDeclaration } from "./package-contracts/terminating-infrastructure.js";
9
+ import { failOnInfrastructureFailureDeclaration, infrastructureFailureDiagnostic } from "./package-contracts/terminating-infrastructure.js";
10
+ import { REVIEW_SUBMISSION_OUTPUT_TOOL_NAME } from "./review-submission.js";
10
11
  /**
11
12
  * Admitted run identity for the ledger subject.
12
13
  * Prefer AK_ROLE_RUN_DIR via sole runDirectory→runId parser (public-CLI correlation);
@@ -480,11 +481,22 @@ export function createSubmissionLedgerHost(host, outputTools, failInfrastructure
480
481
  });
481
482
  let result;
482
483
  try {
483
- failOnInfrastructureFailureDeclaration(params, {
484
- failInfrastructure(error, ctx) {
485
- failInfrastructure(error, ctx);
486
- },
487
- }, context, toolCallId, tool.bounceInfrastructureDeclaration);
484
+ // A review escalate that carries the failure declaration is the
485
+ // seat's own submission. Every other declaration stays on the
486
+ // host-failure seam. Tool identity comes from the registry map.
487
+ const reviewEscalate = tool.name === REVIEW_SUBMISSION_OUTPUT_TOOL_NAME
488
+ && infrastructureFailureDiagnostic(params) !== undefined
489
+ && params !== null
490
+ && typeof params === "object"
491
+ && !Array.isArray(params)
492
+ && params.status === "escalate";
493
+ if (!reviewEscalate) {
494
+ failOnInfrastructureFailureDeclaration(params, {
495
+ failInfrastructure(error, ctx) {
496
+ failInfrastructure(error, ctx);
497
+ },
498
+ }, context, toolCallId, tool.bounceInfrastructureDeclaration);
499
+ }
488
500
  result = await tool.execute(toolCallId, params, signal, update, context);
489
501
  }
490
502
  catch (error) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akagilnc/pi-workflow-roles",
3
- "version": "0.1.5068",
3
+ "version": "0.1.5079",
4
4
  "description": "Soul-bound workflow roles for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -4,7 +4,8 @@ import { auditorRunDirectory } from "./auditor-dossier-tool.ts";
4
4
  import type { HostContext } from "./host-contracts.ts";
5
5
  import type { NoReceiptLifecycleFacts } from "./receipt-delivery-policy.ts";
6
6
  import type { PublicSummonResult } from "./public-role-summons.ts";
7
- import { OFFICER_CONCLUSION_REASK } from "./gatekeeper-role.ts";
7
+ import { officerConclusionReask } from "./gatekeeper-role.ts";
8
+ import { receivedDiscriminator } from "./submission-errors.ts";
8
9
  import { coalesceSubmissionRows } from "./public-cli/terminal.ts";
9
10
  import { readableGateItem } from "./readable-gate-item.ts";
10
11
 
@@ -226,7 +227,7 @@ export async function runComplianceAudit(options: RunComplianceAuditOptions): Pr
226
227
  const summoned = await summon(subject, runDirectory, options.signal, reask, submission);
227
228
  const decision = await projectAuditorTerminal(summoned);
228
229
  if (decision.status === "received") {
229
- reask = OFFICER_CONCLUSION_REASK;
230
+ reask = officerConclusionReask(receivedDiscriminator(decision.reply, "status"));
230
231
  continue;
231
232
  }
232
233
  return decision;
@@ -5,7 +5,7 @@
5
5
  * is defaulted, rewritten, or dropped (ADR 0055).
6
6
  */
7
7
 
8
- import { REVIEW_SUBMISSION_OUTPUT_TOOL_NAME } from "./review-submission.ts";
8
+ import { REVIEW_QUEUE_STATUSES, REVIEW_SUBMISSION_OUTPUT_TOOL_NAME } from "./review-submission.ts";
9
9
 
10
10
  export const COUNTERSIGN_OUTPUT_TOOL_NAME = REVIEW_SUBMISSION_OUTPUT_TOOL_NAME;
11
11
 
@@ -37,7 +37,7 @@ export function validateRecordedCountersignOutput(verdict: unknown): Countersign
37
37
  if (typeof status !== "string") {
38
38
  throw new Error("Countersign verdict has no execution discriminator");
39
39
  }
40
- if (["converged", "continue", "escalate"].includes(status)) {
40
+ if (REVIEW_QUEUE_STATUSES.has(status)) {
41
41
  return verdict as CountersignVerdict;
42
42
  }
43
43
  throw new Error("Countersign verdict has no execution discriminator");
@@ -11,13 +11,9 @@ export type { CountersignVerdict };
11
11
  // #836 r16 class 1: fix/note/decisionGate are LLM/human-read narrative content —
12
12
  // no code branches on their length or nested presence. `status` alone is the
13
13
  // machine discriminator the queue reads.
14
- // #836 (2026-09-11 御批 / ADR 0003 Amendment): registered-to-provider closed
15
- // value domains reject an unknown status before the submission
16
- // ledger ever records it, defeating 读不出三态→resume 说话者本人. Kept open
17
- // (Type.Unknown) like every other gate-queue status field in this package
18
- // (notary/auditor/gatekeeper/navigator); src/role-runtime.ts:997-1025 still
19
- // classifies the recorded value and reasks the countersign itself when it
20
- // isn't one of the three states — code, not the transport, does that work.
14
+ // #1055: the three-state field is an enum+required host generation constraint
15
+ // on reviewSubmissionSchema. Package execute still does not reject; an unreadable
16
+ // value reasks the countersign itself with the received value.
21
17
  /** 给事中票庭审读五问的交卷形状(ADR 0074);形状指引,非 schema 闸。 */
22
18
  export const countersignVerdictSchema = reviewSubmissionSchema;
23
19
  export type CountersignVerdictParameters = import("./review-submission.ts").ReviewSubmission;
@@ -4,9 +4,9 @@ import type { HostContext } from "./host-contracts.ts";
4
4
  import { auditorRunDirectory } from "./auditor-dossier-tool.ts";
5
5
  import { packagedGateStageLabel } from "./packaged-role-registry.ts";
6
6
  import type { NoReceiptLifecycleFacts } from "./receipt-delivery-policy.ts";
7
- import { GatekeeperDecisionError } from "./submission-errors.ts";
7
+ import { GatekeeperDecisionError, receivedDiscriminator, unreadableDiscriminatorNotice } from "./submission-errors.ts";
8
8
  import { INSPECTOR_OUTPUT_TOOL_NAME } from "./inspector-contracts.ts";
9
- import { REVIEW_SUBMISSION_OUTPUT_TOOL_NAME } from "./review-submission.ts";
9
+ import { REVIEW_QUEUE_STATUSES, REVIEW_SUBMISSION_OUTPUT_TOOL_NAME } from "./review-submission.ts";
10
10
  import {
11
11
  GATEKEEPER_OUTPUT_TOOL_NAME,
12
12
  gatekeeperDecisionSchema,
@@ -66,6 +66,8 @@ export type GatekeeperResult =
66
66
  readonly status: "needs_reask";
67
67
  readonly officer: GateOfficer;
68
68
  readonly receipt: unknown;
69
+ /** Discriminator the queue already read. Not written back onto the receipt. */
70
+ readonly receivedStatus?: unknown;
69
71
  readonly runId?: string;
70
72
  }
71
73
  | { readonly status: "no_receipt"; readonly stage: GateOfficer; readonly reason: string; readonly facts: NoReceiptLifecycleFacts }
@@ -210,12 +212,9 @@ function readRecord(value: unknown): Record<string, unknown> | undefined {
210
212
  * `fallbackStatus` is the terminal outcome.status when the receipt body has no status key
211
213
  * (keeps missing-args sentinel intact as the receipt).
212
214
  */
213
- /** Map the shared review verdict onto the gate queue words (#1028). */
215
+ /** Map the shared review verdict onto the gate queue words (#1028 / #1055). */
214
216
  function reviewQueueStatus(status: string): string | undefined {
215
- if (status === "converged") return "converged";
216
- if (status === "continue") return "continue";
217
- if (status === "escalate") return "escalate";
218
- return undefined;
217
+ return REVIEW_QUEUE_STATUSES.has(status) ? status : undefined;
219
218
  }
220
219
 
221
220
  function projectOfficerDecision(
@@ -235,7 +234,7 @@ function projectOfficerDecision(
235
234
  if (queueStatus === "continue" || queueStatus === "escalate") {
236
235
  return { status: queueStatus, officer, receipt };
237
236
  }
238
- return { status: "needs_reask", officer, receipt };
237
+ return { status: "needs_reask", officer, receipt, receivedStatus: status };
239
238
  }
240
239
 
241
240
  /**
@@ -371,12 +370,14 @@ function projectOfficerTerminal(
371
370
  summoned,
372
371
  );
373
372
  }
373
+ const receipt = thisCourt.length > 0 ? thisCourt[thisCourt.length - 1] : retainedReceipt(outcome);
374
374
  return withOfficerRunId(
375
375
  {
376
376
  status: "needs_reask",
377
377
  officer,
378
378
  // This-court receipt only (#879).
379
- receipt: thisCourt.length > 0 ? thisCourt[thisCourt.length - 1] : retainedReceipt(outcome),
379
+ receipt,
380
+ receivedStatus: receivedDiscriminator(receipt, "status"),
380
381
  },
381
382
  summoned,
382
383
  );
@@ -391,16 +392,11 @@ export type GatekeeperProjection = {
391
392
  };
392
393
 
393
394
  /**
394
- * Plain-language re-ask when the officer conclusion is not converged|continue|escalate.
395
- * Not a packaged engine handbook line (#755 exception for 读不出三态).
396
- * Every review officer uses the same `status` field and three queue words (#1028).
395
+ * Officer conclusion was missing or not a queue word (#1055).
396
+ * `received` is the status value only, not the rest of the receipt.
397
397
  */
398
- export const OFFICER_CONCLUSION_REASK =
399
- "上次交卷的 status 不是 converged、continue、escalate 三态之一。请重新输出,status 写明其一;continue 或 escalate 的话就是给对方看的原文。" as const;
400
-
401
- /** Pick the re-ask line for the officer under review. */
402
- export function officerConclusionReask(officer: GateOfficer): string {
403
- return OFFICER_CONCLUSION_REASK;
398
+ export function officerConclusionReask(received: unknown): string {
399
+ return unreadableDiscriminatorNotice("status", received);
404
400
  }
405
401
 
406
402
  /**
@@ -4,7 +4,7 @@
4
4
  * Dual path: gate-dispatched and independently callable (#568 / ADR 0074).
5
5
  */
6
6
 
7
- import { REVIEW_SUBMISSION_OUTPUT_TOOL_NAME } from "./review-submission.ts";
7
+ import { REVIEW_QUEUE_STATUSES, REVIEW_SUBMISSION_OUTPUT_TOOL_NAME } from "./review-submission.ts";
8
8
 
9
9
  export const INSPECTOR_OUTPUT_TOOL_NAME: string = REVIEW_SUBMISSION_OUTPUT_TOOL_NAME;
10
10
  export const INSPECTOR_SOURCE_RUN_FLAG = {
@@ -30,7 +30,10 @@ export function validateRecordedInspectorOutput(value: unknown): InspectorOutput
30
30
  } catch {
31
31
  throw new Error("Inspector output has no execution discriminator");
32
32
  }
33
- if (status === "converged" || status === "continue" || status === "escalate") {
33
+ if (typeof status !== "string") {
34
+ throw new Error("Inspector output has no execution discriminator");
35
+ }
36
+ if (REVIEW_QUEUE_STATUSES.has(status)) {
34
37
  return value as InspectorOutput;
35
38
  }
36
39
  throw new Error("Inspector output has no execution discriminator");
package/src/judge-role.ts CHANGED
@@ -1,19 +1,15 @@
1
1
  import type { RoleHost, HostContext, HostToolResult, HostGatekeeperActions } from "./host-contracts.ts";
2
2
  import type { Static } from "typebox";
3
- import { reviewSubmissionSchema } from "./review-submission.ts";
4
- import { GatekeeperDecisionError, ParentQueueReaskError } from "./submission-errors.ts";
3
+ import { REVIEW_QUEUE_STATUSES, reviewSubmissionSchema } from "./review-submission.ts";
4
+ import { GatekeeperDecisionError, ParentQueueReaskError, unreadableDiscriminatorNotice } from "./submission-errors.ts";
5
5
  import { projectGatekeeperEscalation } from "./audit-escalation.ts";
6
6
 
7
7
  import { readableGateItem } from "./readable-gate-item.ts";
8
8
  import {
9
9
  JUDGE_OUTPUT_TOOL_NAME,
10
- validateAcceptedJudgeDetails,
11
10
  type JudgeVerdict,
12
11
  } from "./package-contracts/judge-output.ts";
13
12
 
14
- const JUDGE_QUEUE_STATUSES = new Set(["converged", "continue", "escalate"]);
15
- const JUDGE_STATUS_REASK =
16
- "status 不是 converged、continue、escalate 三态之一。请重新交卷,status 写明其一。" as const;
17
13
 
18
14
  export { JUDGE_OUTPUT_TOOL_NAME };
19
15
  export type { JudgeVerdict };
@@ -25,8 +21,6 @@ export type { JudgeVerdict };
25
21
  // The shared open schema preserves role-specific prose without adding another contract.
26
22
  export const judgeVerdictSchema = reviewSubmissionSchema;
27
23
 
28
- type JudgeVerdictParameters = Static<typeof judgeVerdictSchema>;
29
-
30
24
  export type JudgeRoleDependencies = {
31
25
  loadSoul(): Promise<string>;
32
26
  };
@@ -36,10 +30,6 @@ export type JudgeRoleHostActions = HostGatekeeperActions & {
36
30
  bindPriorGatePass(toolCallId: string, receipt: unknown): void;
37
31
  };
38
32
 
39
- export function validateVerdict(verdict: JudgeVerdictParameters): JudgeVerdict {
40
- return validateAcceptedJudgeDetails(verdict);
41
- }
42
-
43
33
 
44
34
  export function createJudgeRoleRuntime(
45
35
  pi: RoleHost,
@@ -70,19 +60,21 @@ export function createJudgeRoleRuntime(
70
60
  && typeof (parameters as Record<string, unknown>).status === "string"
71
61
  ? (parameters as Record<string, unknown>).status as string
72
62
  : undefined;
73
- if (rawStatus === undefined || !JUDGE_QUEUE_STATUSES.has(rawStatus)) {
74
- throw new ParentQueueReaskError(JUDGE_STATUS_REASK);
63
+ if (rawStatus === undefined || !REVIEW_QUEUE_STATUSES.has(rawStatus)) {
64
+ const received = parameters !== null && typeof parameters === "object" && !Array.isArray(parameters)
65
+ ? (parameters as Record<string, unknown>).status
66
+ : undefined;
67
+ throw new ParentQueueReaskError(unreadableDiscriminatorNotice("status", received));
75
68
  }
76
69
  if (rawStatus === "escalate") {
77
70
  // Parent escalate → throw to caller as-is; officers do not attend (#753 / #756).
78
- const verdict = validateVerdict(parameters);
79
71
  return {
80
72
  content: [],
81
- details: verdict,
73
+ details: parameters,
82
74
  terminate: true as const,
83
75
  };
84
76
  }
85
- const verdict = validateVerdict(parameters);
77
+ // Queue membership above is the only three-state check. The receipt is not re-judged.
86
78
  // Candidate verdict is already on the parent session books as this
87
79
  // tool-call leaf (first-record-then-audit; run 019fea05 L61/L62).
88
80
  // #753: 符宝郎内闸 — continue returns the raw receipt as a nonterminal tool result.
@@ -101,7 +93,7 @@ export function createJudgeRoleRuntime(
101
93
  if (draftPass?.status === "continue") {
102
94
  return {
103
95
  content: [{ type: "text" as const, text: readableGateItem(draftPass.receipt) }],
104
- details: verdict,
96
+ details: parameters,
105
97
  terminate: false,
106
98
  };
107
99
  }
@@ -121,18 +113,18 @@ export function createJudgeRoleRuntime(
121
113
  if (compliancePass?.status === "continue") {
122
114
  return {
123
115
  content: [draftPass, compliancePass].filter((pass) => pass !== undefined).map((pass) => ({ type: "text" as const, text: readableGateItem(pass.receipt) })),
124
- details: verdict,
116
+ details: parameters,
125
117
  terminate: false,
126
118
  };
127
119
  }
128
120
  return {
129
121
  content: [draftPass, compliancePass].filter((pass) => pass !== undefined).map((pass) => ({ type: "text" as const, text: readableGateItem(pass.receipt) })),
130
- details: verdict,
122
+ details: parameters,
131
123
  terminate: true as const,
132
124
  };
133
125
  } catch (error) {
134
126
  if (error instanceof GatekeeperDecisionError && error.result.status === "escalate") {
135
- return projectGatekeeperEscalation(error.result, verdict);
127
+ return projectGatekeeperEscalation(error.result, parameters);
136
128
  }
137
129
  throw error;
138
130
  }
@@ -12,7 +12,7 @@ function declarationIdentity(schema: TSchema): string {
12
12
  return JSON.stringify(semantic);
13
13
  }
14
14
 
15
- /** Collapse transport variants into one provider-compatible, zero-required open object. */
15
+ /** Collapse transport variants into one provider-compatible open object. Non-three-state tools stay zero-required. */
16
16
  export function openToolObjectFromUnion(schema: UnionSchema): TSchema {
17
17
  const declarations = new Map<string, TSchema[]>();
18
18
  for (const variant of schema.anyOf) {
@@ -35,10 +35,15 @@ export function openToolObjectFromUnion(schema: UnionSchema): TSchema {
35
35
  return object;
36
36
  }
37
37
 
38
- /** Open a pre-existing object transport schema without changing declarations. */
39
- export function openToolObject(schema: TSchema & { properties: Record<string, TSchema> }): TSchema {
38
+ /** Open a pre-existing object transport schema. Every field stays optional. */
39
+ export function openToolObject(
40
+ schema: TSchema & { properties: Record<string, TSchema> },
41
+ ): TSchema {
40
42
  const object = Type.Object(
41
- Object.fromEntries(Object.entries(schema.properties).map(([name, declaration]) => [name, Type.Optional(described(name, declaration))])),
43
+ Object.fromEntries(Object.entries(schema.properties).map(([name, declaration]) => [
44
+ name,
45
+ Type.Optional(described(name, declaration)),
46
+ ])),
42
47
  { additionalProperties: true },
43
48
  );
44
49
  (object as unknown as { required: string[] }).required = [];
@@ -1,7 +1,7 @@
1
1
  /** Package-owned Judge output leaf — no role registration surface. */
2
2
 
3
3
 
4
- import { REVIEW_SUBMISSION_OUTPUT_TOOL_NAME } from "../review-submission.ts";
4
+ import { REVIEW_QUEUE_STATUSES, REVIEW_SUBMISSION_OUTPUT_TOOL_NAME } from "../review-submission.ts";
5
5
 
6
6
  export const JUDGE_OUTPUT_TOOL_NAME: string = REVIEW_SUBMISSION_OUTPUT_TOOL_NAME;
7
7
 
@@ -39,9 +39,7 @@ export function validateAcceptedJudgeDetails(verdict: unknown): JudgeVerdict {
39
39
  if (typeof status !== "string") {
40
40
  throw new Error("Judge verdict has no execution discriminator");
41
41
  }
42
- if (
43
- ["converged", "continue", "escalate"].includes(status)
44
- ) {
42
+ if (REVIEW_QUEUE_STATUSES.has(status)) {
45
43
  return verdict as JudgeVerdict;
46
44
  }
47
45
  throw new Error("Judge verdict has no execution discriminator");
@@ -50,8 +50,9 @@ const infrastructureFailureDeclarationSchema = Type.Object(
50
50
 
51
51
  /**
52
52
  * Compose declarations shared by terminating output tools: infrastructure failure
53
- * and the optional role-asserted ticket identity. Returns an open object (additionalProperties: true,
54
- * required: []) with the base schema's properties plus the shared declaration.
53
+ * and the optional role-asserted ticket identity. Returns an open object (additionalProperties: true)
54
+ * with the base schema's properties plus the shared declaration. Incoming required
55
+ * keys that still exist are kept; every other key stays optional.
55
56
  * Static typing is preserved on the base (`as S`), so existing
56
57
  * `Static<typeof ...>` derived parameter types are unchanged.
57
58
  */
@@ -78,7 +79,11 @@ export function withTerminatingOutputDeclarations<
78
79
  ],
79
80
  };
80
81
  const object = Type.Object(properties, { additionalProperties: true });
81
- (object as unknown as { required: string[] }).required = [];
82
+ const incoming = (schema as { required?: unknown }).required;
83
+ const preserved = Array.isArray(incoming)
84
+ ? incoming.filter((key): key is string => typeof key === "string" && Object.hasOwn(properties, key))
85
+ : [];
86
+ (object as unknown as { required: string[] }).required = preserved;
82
87
  return object as unknown as S;
83
88
  }
84
89
 
@@ -115,7 +120,7 @@ function isInfrastructureFailureDeclaration(
115
120
  }
116
121
 
117
122
  /** Non-empty trimmed diagnostic from the declaration, else undefined. */
118
- function infrastructureFailureDiagnostic(
123
+ export function infrastructureFailureDiagnostic(
119
124
  parameters: unknown,
120
125
  ): string | undefined {
121
126
  if (!isInfrastructureFailureDeclaration(parameters)) return undefined;
@@ -1,18 +1,33 @@
1
- /** One shared terminating receipt contract for review officers (#1028). */
1
+ /** One shared terminating receipt contract for review officers (#1028 / #1055). */
2
2
  import { Type } from "typebox";
3
3
 
4
- import { openToolObject } from "./open-tool-schema.ts";
5
4
  import { withTerminatingOutputDeclarations } from "./package-contracts/terminating-infrastructure.ts";
6
5
 
7
6
  export const REVIEW_SUBMISSION_OUTPUT_TOOL_NAME = "ak_submission_output" as const;
8
7
 
9
- /** Open receipt: the queue reads only status; all other submitted fields ride unchanged. */
8
+ /** Single schema copy of the review queue words. Runtime membership reads this set. */
9
+ export const REVIEW_QUEUE_WORDS = ["converged", "continue", "escalate"] as const;
10
+ export type ReviewQueueWord = (typeof REVIEW_QUEUE_WORDS)[number];
11
+ export const REVIEW_QUEUE_STATUSES: ReadonlySet<string> = new Set(REVIEW_QUEUE_WORDS);
12
+
13
+ /**
14
+ * One object root. Codex structured output rejects a root anyOf, and status
15
+ * stays a required three-state enum with no omitted-status failure branch.
16
+ * A review seat that can still submit records a real external-dependency
17
+ * failure as escalate plus the existing infrastructureFailure field.
18
+ */
10
19
  export const reviewSubmissionSchema = withTerminatingOutputDeclarations(
11
- openToolObject(
12
- Type.Object({
13
- status: Type.Optional(Type.Unknown({
14
- description: "converged | continue | escalate — 非三态时请重读后重交,勿改标。",
15
- })),
20
+ Type.Object({
21
+ status: Type.Union(
22
+ [
23
+ Type.Literal(REVIEW_QUEUE_WORDS[0]),
24
+ Type.Literal(REVIEW_QUEUE_WORDS[1]),
25
+ Type.Literal(REVIEW_QUEUE_WORDS[2]),
26
+ ],
27
+ {
28
+ description: "审核队列三态枚举,必填。审核席仍能交卷而完成审核所需的外部依赖发生真实基础设施失败时,status 取 escalate,细节写入 infrastructureFailure。不得省略 status,不得用另两个枚举值充当放行或封驳。包侧不因格式拒收。",
29
+ },
30
+ ),
16
31
  findings: Type.Optional(Type.Unknown({ description: "审核发现,原样留存" })),
17
32
  reason: Type.Optional(Type.Unknown({ description: "上呈理由" })),
18
33
  violations: Type.Optional(Type.Unknown({ description: "违规条目" })),
@@ -22,8 +37,7 @@ export const reviewSubmissionSchema = withTerminatingOutputDeclarations(
22
37
  note: Type.Optional(Type.Unknown({ description: "裁决附注" })),
23
38
  evidence: Type.Optional(Type.Unknown({ description: "留存证据" })),
24
39
  decisionGate: Type.Optional(Type.Unknown({ description: "需人权威处置的问题与选项;可说明 question、options,原样留存" })),
25
- }),
26
- ),
40
+ }, { additionalProperties: true }),
27
41
  );
28
42
 
29
43
  export type ReviewSubmission = { readonly status?: unknown; readonly [key: string]: unknown };
@@ -314,7 +314,8 @@ export {
314
314
  gateOfficerForSubject,
315
315
  };
316
316
  export type { GatekeeperResult, GatekeeperSubject, SubmissionGateNonPassResult, GateOfficer, RunGatekeeperOptions } from "./gatekeeper-role.ts";
317
- import { ParentQueueReaskError } from "./submission-errors.ts";
317
+ import { ParentQueueReaskError, unreadableDiscriminatorNotice } from "./submission-errors.ts";
318
+ import { REVIEW_QUEUE_STATUSES } from "./review-submission.ts";
318
319
 
319
320
  export {
320
321
  DOCTOR_EVIDENCE_TOOL_NAME,
@@ -872,8 +873,6 @@ function readDiaristRunCoordinates(ctx: HostContext): {
872
873
  const DIARIST_BOUNDS_REASK =
873
874
  "边界无法使用。请重交 sessions:每卷 path + ranges,每端以原生 id 或本轮行号二选一指名。" as const;
874
875
  const DIARIST_ROUTING_STATUSES = new Set(["completed", "escalate"]);
875
- const DIARIST_STATUS_REASK =
876
- "status 不是 completed、escalate 之一。请重新交卷,status 写明其一。" as const;
877
876
 
878
877
  /**
879
878
  * #708 / #779 / #901: 起居郎 public seat on the shared filed-officer envelope.
@@ -900,7 +899,7 @@ export function createDiaristRoleRuntime(
900
899
  // before interpreting any other submitted field; caller-owned data stays open.
901
900
  const status = submitted?.status;
902
901
  if (typeof status !== "string" || !DIARIST_ROUTING_STATUSES.has(status)) {
903
- throw new ParentQueueReaskError(DIARIST_STATUS_REASK);
902
+ throw new ParentQueueReaskError(unreadableDiscriminatorNotice("status", submitted?.status));
904
903
  }
905
904
  if (status === "escalate") return parameters;
906
905
  const assertion = readDiaristTicketAssertion(submitted);
@@ -943,26 +942,9 @@ export function createDiaristRoleRuntime(
943
942
  );
944
943
  }
945
944
 
946
- /** Shared review-submission status words the queue reads (#1028). */
947
- const COUNTERSIGN_QUEUE_STATUSES = new Set(["converged", "continue", "escalate"]);
948
-
949
- /**
950
- * Plain-language re-ask when status is not a known queue word.
951
- * Back to countersign itself — notary is not summoned (#753).
952
- */
953
- const COUNTERSIGN_STATUS_REASK =
954
- "status 不是 converged、continue、escalate 三态之一。请重新交卷,status 写明其一。" as const;
955
-
956
- /** Secretariat status words the submission gate reads. */
945
+ /** Secretariat status words the submission gate reads. Not the review three-state field. */
957
946
  const SECRETARIAT_QUEUE_STATUSES = new Set(["converged", "escalate"]);
958
947
 
959
- /**
960
- * Plain-language re-ask when secretariatStatus is not a known queue word.
961
- * Back to secretariat itself — 给事中 is not summoned for escalation.
962
- */
963
- const SECRETARIAT_STATUS_REASK =
964
- "secretariatStatus 不是 converged、escalate 之一。请重新交卷,status 写明其一。" as const;
965
-
966
948
  /**
967
949
  * Secretariat output is terminal; converged submissions enter the shared
968
950
  * submission gate on every host that mounts gatekeeper actions.
@@ -989,7 +971,9 @@ export function createSecretariatRoleRuntime(
989
971
  ? record.secretariatStatus
990
972
  : undefined;
991
973
  if (status === undefined || !SECRETARIAT_QUEUE_STATUSES.has(status)) {
992
- throw new ParentQueueReaskError(SECRETARIAT_STATUS_REASK);
974
+ throw new ParentQueueReaskError(
975
+ unreadableDiscriminatorNotice("secretariatStatus", record?.secretariatStatus),
976
+ );
993
977
  }
994
978
  if (status === "escalate") {
995
979
  // Parent escalate → throw to caller as-is; countersign does not attend.
@@ -1132,10 +1116,10 @@ export function createCountersignRoleRuntime(
1132
1116
  record !== undefined && typeof record.status === "string"
1133
1117
  ? record.status
1134
1118
  : undefined;
1135
- if (status === undefined || !COUNTERSIGN_QUEUE_STATUSES.has(status)) {
1119
+ if (status === undefined || !REVIEW_QUEUE_STATUSES.has(status)) {
1136
1120
  // Parent status unreadable → back to countersign itself; do not summon notary,
1137
1121
  // do not forge an officer bounce face (#753).
1138
- throw new ParentQueueReaskError(COUNTERSIGN_STATUS_REASK);
1122
+ throw new ParentQueueReaskError(unreadableDiscriminatorNotice("status", record?.status));
1139
1123
  }
1140
1124
  if (status === "escalate") {
1141
1125
  // Parent escalate → throw to caller as-is; notary does not attend (#753).
@@ -58,6 +58,22 @@ export class WorkerUnfinishedReasonReminderError extends Error {
58
58
  }
59
59
  }
60
60
 
61
+ /**
62
+ * Host constraint missed a routing discriminator (#1055).
63
+ * Names live only in the submission schema. This notice carries the field and the received value.
64
+ */
65
+ export function unreadableDiscriminatorNotice(field: string, received: unknown): string {
66
+ if (received === undefined) return `读不出 ${field}`;
67
+ return `读不出 ${field}:${readableGateItem(received)}`;
68
+ }
69
+
70
+ /** The received discriminator only — never the rest of the receipt. */
71
+ export function receivedDiscriminator(receipt: unknown, field: string): unknown {
72
+ if (receipt === null || typeof receipt !== "object" || Array.isArray(receipt)) return undefined;
73
+ if (!Object.hasOwn(receipt, field)) return undefined;
74
+ return (receipt as Record<string, unknown>)[field];
75
+ }
76
+
61
77
  /**
62
78
  * Parent seat status unreadable for queueing (#753).
63
79
  * Correctable back to the parent itself — not a gate officer bounce, no officer field.
@@ -27,6 +27,7 @@ import {
27
27
  type GateOfficerSummon,
28
28
  } from "./gatekeeper-role.ts";
29
29
  import type { PublicSummonResult } from "./public-role-summons.ts";
30
+
30
31
  import { sessionFileFromPublicSummon } from "./session-assistant-usage.ts";
31
32
 
32
33
  /**
@@ -178,7 +179,7 @@ export async function requireSubmissionGate(options: {
178
179
  };
179
180
  }
180
181
  if (gatekeeper.status === "needs_reask") {
181
- reask = officerConclusionReask(projected.officer);
182
+ reask = officerConclusionReask(gatekeeper.receivedStatus);
182
183
  continue;
183
184
  }
184
185
  if (gatekeeper.status === "transport_failure") {