create-principles-disciple 1.144.13 → 1.144.15

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 (49) hide show
  1. package/_release/product-identity.json +1 -1
  2. package/console/dist/server/models/ApprovalsGroupedConsoleModel.d.ts +9 -0
  3. package/console/dist/server/models/ApprovalsGroupedConsoleModel.js +12 -0
  4. package/console/dist/server/routes/update-history.d.ts +14 -0
  5. package/console/dist/server/routes/update-history.js +59 -9
  6. package/console/dist/ui/i18n/en.json +1 -0
  7. package/console/dist/ui/i18n/zh-CN.json +1 -0
  8. package/console/dist/ui/pages/focus/FocusPage.js +7 -4
  9. package/console/dist/ui/pages/focus/focus-validation.d.ts +12 -1
  10. package/console/dist/ui/pages/focus/focus-validation.js +29 -0
  11. package/console/dist/ui/utils/validators.d.ts +6 -0
  12. package/console/dist/ui/utils/validators.js +6 -0
  13. package/console/dist/web/assets/app.js +17 -17
  14. package/dist/installer.d.ts +7 -0
  15. package/dist/installer.d.ts.map +1 -1
  16. package/dist/installer.js.map +1 -1
  17. package/dist/update/console-surface.d.ts +1 -1
  18. package/dist/update/console-surface.d.ts.map +1 -1
  19. package/dist/update/console-surface.js +1 -1
  20. package/dist/update/console-surface.js.map +1 -1
  21. package/dist/update/release-manager.d.ts +8 -0
  22. package/dist/update/release-manager.d.ts.map +1 -1
  23. package/dist/update/release-manager.js +22 -1
  24. package/dist/update/release-manager.js.map +1 -1
  25. package/dist/update/transaction-journal.d.ts +8 -0
  26. package/dist/update/transaction-journal.d.ts.map +1 -1
  27. package/dist/update/transaction-journal.js +7 -0
  28. package/dist/update/transaction-journal.js.map +1 -1
  29. package/package.json +2 -2
  30. package/pd-cli/package.json +1 -1
  31. package/plugin/dist/openclaw.plugin.json +1 -1
  32. package/plugin/openclaw.plugin.json +1 -1
  33. package/plugin/package.json +1 -1
  34. package/release-manager/dist/installer.d.ts +7 -0
  35. package/release-manager/dist/installer.d.ts.map +1 -1
  36. package/release-manager/dist/installer.js.map +1 -1
  37. package/release-manager/dist/update/console-surface.d.ts +1 -1
  38. package/release-manager/dist/update/console-surface.d.ts.map +1 -1
  39. package/release-manager/dist/update/console-surface.js +1 -1
  40. package/release-manager/dist/update/console-surface.js.map +1 -1
  41. package/release-manager/dist/update/release-manager.d.ts +8 -0
  42. package/release-manager/dist/update/release-manager.d.ts.map +1 -1
  43. package/release-manager/dist/update/release-manager.js +22 -1
  44. package/release-manager/dist/update/release-manager.js.map +1 -1
  45. package/release-manager/dist/update/transaction-journal.d.ts +8 -0
  46. package/release-manager/dist/update/transaction-journal.d.ts.map +1 -1
  47. package/release-manager/dist/update/transaction-journal.js +7 -0
  48. package/release-manager/dist/update/transaction-journal.js.map +1 -1
  49. package/release-manager/package.json +2 -2
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "productVersion": "2.2.0",
4
- "sourceCommit": "04bd7eceea7c50a1c5e1dd7408ab1c92d0bf8c3e"
4
+ "sourceCommit": "aa4ccf19f76c862789bf3425c33e63c5847e6b01"
5
5
  }
@@ -15,6 +15,15 @@ export interface ApprovalGroup {
15
15
  * (the client has no access to the escaped, serialized size).
16
16
  */
17
17
  fitsPromptBudget?: boolean;
18
+ /**
19
+ * PRI-940: the pinned artifact no longer exists in pi_artifacts (typically
20
+ * superseded by a newer scribe revision). There is no contentJson, so
21
+ * `candidateDescription` is unavailable and `principleTitle` degrades to the
22
+ * synthesized `unlinked:<artifactId>` grouping key — a machine id that must
23
+ * never be rendered as a title. Absent when the artifact is readable. rc-9:
24
+ * the UI shows a visible degradation note instead of going silently blank.
25
+ */
26
+ artifactUnavailable?: boolean;
18
27
  records: {
19
28
  id: string;
20
29
  artifactId: string;
@@ -170,6 +170,10 @@ export class ApprovalsGroupedConsoleModel {
170
170
  // the pre-approval badge never promises rotation for an entry that is
171
171
  // oversized (rotation cannot rescue a per-entry overflow).
172
172
  const artifactFitsBudgetMap = new Map();
173
+ // PRI-940: which approval artifacts are MISSING from the store (superseded
174
+ // or pruned) — those cards have no contentJson, so the UI must degrade
175
+ // visibly instead of rendering the `unlinked:` machine id as a title.
176
+ const artifactUnavailableMap = new Map();
173
177
  const stateDir = path.join(this.workspaceDir, '.state');
174
178
  const resolutionDeps = createArtifactPrincipleResolutionDeps(conn, artifactStore, this.workspaceDir);
175
179
  for (const approval of allApprovals) {
@@ -185,12 +189,14 @@ export class ApprovalsGroupedConsoleModel {
185
189
  artifactDescriptionMap.set(approval.artifactId, null);
186
190
  }
187
191
  artifactFitsBudgetMap.set(approval.artifactId, candidateFitsPromptBudget(artifact));
192
+ artifactUnavailableMap.set(approval.artifactId, artifact === null);
188
193
  }
189
194
  catch (err) {
190
195
  if (isMissingTableError(err)) {
191
196
  artifactPrincipleMap.set(approval.artifactId, null);
192
197
  artifactDescriptionMap.set(approval.artifactId, null);
193
198
  artifactFitsBudgetMap.set(approval.artifactId, false);
199
+ artifactUnavailableMap.set(approval.artifactId, true);
194
200
  }
195
201
  else {
196
202
  throw err;
@@ -258,6 +264,12 @@ export class ApprovalsGroupedConsoleModel {
258
264
  ...(firstArtifactId !== undefined && artifactFitsBudgetMap.has(firstArtifactId)
259
265
  ? { fitsPromptBudget: artifactFitsBudgetMap.get(firstArtifactId) === true }
260
266
  : {}),
267
+ // PRI-940: absent when the pinned artifact is readable — same
268
+ // spread style as fitsPromptBudget so the wire contract stays
269
+ // unchanged for healthy cards.
270
+ ...(firstArtifactId !== undefined && artifactUnavailableMap.get(firstArtifactId) === true
271
+ ? { artifactUnavailable: true }
272
+ : {}),
261
273
  records,
262
274
  });
263
275
  }
@@ -32,6 +32,20 @@ export declare function appendUpdateHistory(workspaceDir: string, entry: Omit<Up
32
32
  * executor, the initiating Console may be dead when an update reaches a
33
33
  * terminal state — the journal is the durable fact, and this derives any
34
34
  * missing history entries from it (idempotent by transactionId; append-only).
35
+ *
36
+ * PRI-926 truthfulness rules:
37
+ * - Only journals in the ReleaseManager update domain
38
+ * (`isReleaseManagerTransactionId`) are Owner-facing update facts —
39
+ * installer/test transactions sharing ~/.pd/transactions/ never synthesize
40
+ * rows, and rows previously projected from outside the domain are dropped
41
+ * (a wrongly-projected row of this read model, not Owner data).
42
+ * - A synthesized row carries the journal's own truth: fromVersion from the
43
+ * planned line's `preUpdateProductVersion` (absence stays 'unknown' — the
44
+ * honest gap for pre-PRI-926 journals, rc-9), and the timestamp of the
45
+ * journal's first line, never the scan moment.
46
+ * - The synthesized id is deterministic (`reconciled-<transactionId>`), so a
47
+ * row evicted by the 50-entry cap cannot resurrect under a fresh id; merged
48
+ * rows are ordered by fact time.
35
49
  */
36
50
  export declare function reconcileUpdateHistoryFromJournals(workspaceDir: string, pdHome: string): void;
37
51
  export declare function handleUpdateHistoryRoute(req: IncomingMessage, res: ServerResponse, workspaceDir: string, _subPath: string): Promise<void>;
@@ -1,5 +1,6 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
+ import { isReleaseManagerTransactionId } from 'create-principles-disciple/update-console';
3
4
  import { sendSuccess, sendMethodNotAllowed } from '../utils/response.js';
4
5
  /** Historical authority values remain readable; only ReleaseManager writes new attempts. */
5
6
  export const UPDATE_HISTORY_AUTHORITIES = ['release-manager', 'legacy-console-updater'];
@@ -62,6 +63,17 @@ function parseHistoryEntry(value) {
62
63
  function getHistoryPath(workspaceDir) {
63
64
  return path.join(workspaceDir, '.pd', 'update-history.json');
64
65
  }
66
+ /**
67
+ * PRI-926: sort key for fact-time ordering. History rows span eras and
68
+ * formats (legacy records, journal `at` values are loosely read), so string
69
+ * comparison would mis-order mixed shapes like '…00Z' vs '…00.000Z' or
70
+ * non-UTC offsets — compare parsed instants instead. Unparseable values take
71
+ * a stable epoch fallback (sort oldest); they are never dropped.
72
+ */
73
+ function factTimeMs(timestamp) {
74
+ const ms = Date.parse(timestamp);
75
+ return Number.isNaN(ms) ? 0 : ms;
76
+ }
65
77
  function loadHistory(historyPath) {
66
78
  if (fs.existsSync(historyPath)) {
67
79
  try {
@@ -106,13 +118,32 @@ export function appendUpdateHistory(workspaceDir, entry) {
106
118
  * executor, the initiating Console may be dead when an update reaches a
107
119
  * terminal state — the journal is the durable fact, and this derives any
108
120
  * missing history entries from it (idempotent by transactionId; append-only).
121
+ *
122
+ * PRI-926 truthfulness rules:
123
+ * - Only journals in the ReleaseManager update domain
124
+ * (`isReleaseManagerTransactionId`) are Owner-facing update facts —
125
+ * installer/test transactions sharing ~/.pd/transactions/ never synthesize
126
+ * rows, and rows previously projected from outside the domain are dropped
127
+ * (a wrongly-projected row of this read model, not Owner data).
128
+ * - A synthesized row carries the journal's own truth: fromVersion from the
129
+ * planned line's `preUpdateProductVersion` (absence stays 'unknown' — the
130
+ * honest gap for pre-PRI-926 journals, rc-9), and the timestamp of the
131
+ * journal's first line, never the scan moment.
132
+ * - The synthesized id is deterministic (`reconciled-<transactionId>`), so a
133
+ * row evicted by the 50-entry cap cannot resurrect under a fresh id; merged
134
+ * rows are ordered by fact time.
109
135
  */
110
136
  export function reconcileUpdateHistoryFromJournals(workspaceDir, pdHome) {
111
137
  const transactionsDir = path.join(pdHome, 'transactions');
112
138
  if (!fs.existsSync(transactionsDir))
113
139
  return;
114
140
  const historyPath = getHistoryPath(workspaceDir);
115
- const history = loadHistory(historyPath);
141
+ const loaded = loadHistory(historyPath);
142
+ // A row WITHOUT a transactionId has no domain evidence either way — realtime
143
+ // and legacy records stay untouched. A transactionId OUTSIDE the update
144
+ // domain proves the row was projected from a non-update journal.
145
+ const history = loaded.filter((entry) => entry.transactionId === undefined || isReleaseManagerTransactionId(entry.transactionId));
146
+ const changedByCleanup = history.length !== loaded.length;
116
147
  const journaledIds = new Set(history
117
148
  .filter((entry) => typeof entry.transactionId === 'string' && entry.transactionId.length > 0)
118
149
  .map((entry) => entry.transactionId));
@@ -122,47 +153,66 @@ export function reconcileUpdateHistoryFromJournals(workspaceDir, pdHome) {
122
153
  failed: { kind: 'failure', success: false, toVersion: 'failed' },
123
154
  refused: { kind: 'refusal', success: false, toVersion: '' },
124
155
  };
125
- let changed = false;
156
+ let added = 0;
126
157
  for (const entry of fs.readdirSync(transactionsDir)) {
127
158
  if (!entry.endsWith('.jsonl'))
128
159
  continue;
129
160
  const transactionId = entry.slice(0, -'.jsonl'.length);
161
+ if (!isReleaseManagerTransactionId(transactionId))
162
+ continue;
130
163
  if (journaledIds.has(transactionId))
131
164
  continue;
132
165
  let lastState = null;
133
166
  let productVersion = null;
167
+ let fromVersion = null;
168
+ let firstAt = null;
134
169
  try {
135
170
  const raw = fs.readFileSync(path.join(transactionsDir, entry), 'utf-8');
136
171
  for (const line of raw.split('\n')) {
137
172
  if (line.trim().length === 0)
138
173
  continue;
139
- const { to: transitionTo, productVersion: transitionProductVersion } = JSON.parse(line);
174
+ const parsed = JSON.parse(line);
175
+ const { to: transitionTo, productVersion: transitionProductVersion } = parsed;
140
176
  if (typeof transitionTo === 'string')
141
177
  lastState = transitionTo;
142
178
  if (typeof transitionProductVersion === 'string')
143
179
  productVersion = transitionProductVersion;
180
+ // First-line truth: the planned transition carries the transaction's
181
+ // moment and the version the update started FROM (PRI-926).
182
+ if (firstAt === null && typeof parsed.at === 'string' && parsed.at.length > 0)
183
+ firstAt = parsed.at;
184
+ if (fromVersion === null && typeof parsed.preUpdateProductVersion === 'string' && parsed.preUpdateProductVersion.length > 0) {
185
+ fromVersion = parsed.preUpdateProductVersion;
186
+ }
144
187
  }
145
188
  }
146
189
  catch {
147
190
  // unreadable journal — recovery diagnostics own that; skip for history
148
191
  continue;
149
192
  }
150
- const mapping = lastState !== null ? terminalKind[lastState] : undefined;
193
+ // External input indexes a plain-object table — guard with Object.hasOwn
194
+ // before the lookup (ERR-013: '__proto__' must not resolve a mapping).
195
+ let mapping;
196
+ if (lastState !== null && Object.hasOwn(terminalKind, lastState)) {
197
+ mapping = terminalKind[lastState];
198
+ }
151
199
  if (mapping === undefined)
152
200
  continue;
153
201
  history.push({
154
- fromVersion: 'unknown',
202
+ fromVersion: fromVersion ?? 'unknown',
155
203
  toVersion: mapping.toVersion === '' ? (productVersion ?? 'unknown') : mapping.toVersion,
156
204
  success: mapping.success,
157
205
  kind: mapping.kind,
158
206
  authority: 'release-manager',
159
207
  transactionId,
160
- id: `update-${Date.now()}-${transactionId.slice(-8)}`,
161
- timestamp: new Date().toISOString(),
208
+ id: `reconciled-${transactionId}`,
209
+ timestamp: firstAt ?? new Date().toISOString(),
162
210
  });
163
- changed = true;
211
+ added += 1;
164
212
  }
165
- if (changed) {
213
+ if (added > 0 || changedByCleanup) {
214
+ // Order by fact time (stable for equal instants), then keep the newest 50.
215
+ history.sort((a, b) => factTimeMs(a.timestamp) - factTimeMs(b.timestamp));
166
216
  if (history.length > 50) {
167
217
  history.splice(0, history.length - 50);
168
218
  }
@@ -96,6 +96,7 @@
96
96
  "deviationCount": "deviation(s)",
97
97
  "evidenceSummary": "Evidence: {{count}} record(s)",
98
98
  "untitledCandidate": "Untitled candidate principle",
99
+ "artifactUnavailableNote": "The draft artifact this approval points to is no longer in the artifact store (likely superseded by a newer revision), so its content cannot be shown. If in doubt, reject it; the raw artifact ID is shown in the edit section's current-artifact field.",
99
100
  "approveAction": "Approve",
100
101
  "rejectAction": "Reject",
101
102
  "editAction": "Edit",
@@ -96,6 +96,7 @@
96
96
  "deviationCount": "条偏差",
97
97
  "evidenceSummary": "证据:{{count}} 条记录",
98
98
  "untitledCandidate": "待命名候选原则",
99
+ "artifactUnavailableNote": "该审批指向的草稿原件已不在工件库中(可能已被新版本取代),内容无法显示。如无法判断,可选择拒绝;原始工件编号可点「编辑」后在「当前工件」一栏查看。",
99
100
  "approveAction": "批准",
100
101
  "rejectAction": "拒绝",
101
102
  "editAction": "编辑",
@@ -9,7 +9,7 @@ import { SectionTitle } from "../../components/layout/section-title.js";
9
9
  import { DailyThoughtCard } from "../../components/focus/daily-thought-card.js";
10
10
  import { ShinyText } from "../../components/ui/shiny-text.js";
11
11
  import { fetchGovernanceQueue, fetchApprovalsGrouped, approveApproval, rejectApproval, editApproval, fetchAllActivations, fetchOwnerDecisions, } from "../../api.js";
12
- import { validateApprovalsGroupedData } from "./focus-validation.js";
12
+ import { selectApprovalGroupDisplayTitle, validateApprovalsGroupedData } from "./focus-validation.js";
13
13
  import { OwnerDecisionCard } from "./OwnerDecisionCard.js";
14
14
  import { localizeApprovalWarning, splitApprovalWarnings } from "../../utils/approval-warning-localization.js";
15
15
  /** PRI-908: how long the decided card stays visible before the pending list refresh removes it. */
@@ -178,8 +178,11 @@ function PendingReviewCard({ group, actionsLockedReason, onDecisionApplied, prom
178
178
  }
179
179
  // Display title: prefer candidateDescription (human-readable) over principleTitle
180
180
  // (which falls back to a fabricated principleId when the principle isn't in ledger).
181
- const displayTitle = group.candidateDescription
182
- ?? group.principleTitle
181
+ // PRI-940: the selection rule lives in focus-validation (contract-tested) —
182
+ // when the pinned artifact is missing, principleTitle IS the machine id
183
+ // (`unlinked:<artifactId>`) and must fall through to the untitled copy; the
184
+ // degradation note below explains why there is no content.
185
+ const displayTitle = selectApprovalGroupDisplayTitle(group)
183
186
  ?? t("pages.focus.untitledCandidate", { defaultValue: "待命名候选原则" });
184
187
  async function applyDecisionToAllRecords(action, reason) {
185
188
  // Only process pending records — skip already-approved/rejected to avoid
@@ -306,7 +309,7 @@ function PendingReviewCard({ group, actionsLockedReason, onDecisionApplied, prom
306
309
  ? "border-green/40 bg-green/10 text-green"
307
310
  : "border-line bg-surface/80 text-ink-3"}`, role: "status", "data-testid": `decided-banner-${group.principleId}`, children: decidedOutcome === "approved"
308
311
  ? t("pages.focus.decidedApproved", { defaultValue: "已批准 ✓ 正在写入生效记录…" })
309
- : t("pages.focus.decidedRejected", { defaultValue: "已拒绝" }) })), _jsxs("div", { className: "flex items-center gap-2 flex-wrap", children: [_jsx("span", { className: "inline-flex items-center border border-line rounded-[2px] px-[7px] py-1 font-mono text-[11px] text-ink-3 bg-surface/80 uppercase", children: channelLabel }), isReversible && (_jsx("span", { className: "inline-flex items-center border border-amber/35 text-amber rounded-[2px] px-[7px] py-1 font-mono text-[11px] uppercase", children: primaryChannel === "prompt" ? t("pages.focus.tagReversible") : t("pages.focus.tagLowRisk") }))] }), _jsx("div", { className: "mt-[14px] mb-2 font-semibold text-ink leading-snug", children: displayTitle }), _jsx("div", { className: "mt-2 px-3 py-2 bg-surface/60 border-l-2 border-gov text-ink-3 text-[13px] leading-snug", children: t("pages.focus.evidenceSummary", { count: group.records.length }) }), actionsLocked && (_jsxs("div", { className: "mt-3 rounded-[3px] border border-amber/40 bg-amber/5 p-3", "data-testid": `pending-actions-locked-${group.principleId}`, children: [_jsx("p", { className: "text-ink-2 text-[12.5px] leading-relaxed", children: actionsLockedReason }), _jsx(Link, { to: "/settings", "data-testid": `pending-actions-locked-settings-${group.principleId}`, className: "mt-2 inline-flex items-center border border-line text-ink bg-surface rounded-[3px] px-[14px] py-[6px] text-[12.5px] hover:border-line-2 transition-colors", children: t("pages.focus.ownerDecision.goSettingsCta") })] })), decidedOutcome === null && primaryChannel === "prompt" && promptInjection?.truncated === true && (_jsx("div", { className: "mt-3 rounded-[3px] border border-amber/40 bg-amber/5 px-3 py-2 text-[12.5px] text-amber", "data-testid": `approve-queue-badge-${group.principleId}`, children: promptInjection.productionRotates === true && group.fitsPromptBudget === true
312
+ : t("pages.focus.decidedRejected", { defaultValue: "已拒绝" }) })), _jsxs("div", { className: "flex items-center gap-2 flex-wrap", children: [_jsx("span", { className: "inline-flex items-center border border-line rounded-[2px] px-[7px] py-1 font-mono text-[11px] text-ink-3 bg-surface/80 uppercase", children: channelLabel }), isReversible && (_jsx("span", { className: "inline-flex items-center border border-amber/35 text-amber rounded-[2px] px-[7px] py-1 font-mono text-[11px] uppercase", children: primaryChannel === "prompt" ? t("pages.focus.tagReversible") : t("pages.focus.tagLowRisk") }))] }), _jsx("div", { className: "mt-[14px] mb-2 font-semibold text-ink leading-snug", children: displayTitle }), group.artifactUnavailable === true && (_jsx("div", { className: "mb-2 text-[12.5px] leading-relaxed text-amber", "data-testid": `artifact-unavailable-${group.principleId}`, children: t("pages.focus.artifactUnavailableNote") })), _jsx("div", { className: "mt-2 px-3 py-2 bg-surface/60 border-l-2 border-gov text-ink-3 text-[13px] leading-snug", children: t("pages.focus.evidenceSummary", { count: group.records.length }) }), actionsLocked && (_jsxs("div", { className: "mt-3 rounded-[3px] border border-amber/40 bg-amber/5 p-3", "data-testid": `pending-actions-locked-${group.principleId}`, children: [_jsx("p", { className: "text-ink-2 text-[12.5px] leading-relaxed", children: actionsLockedReason }), _jsx(Link, { to: "/settings", "data-testid": `pending-actions-locked-settings-${group.principleId}`, className: "mt-2 inline-flex items-center border border-line text-ink bg-surface rounded-[3px] px-[14px] py-[6px] text-[12.5px] hover:border-line-2 transition-colors", children: t("pages.focus.ownerDecision.goSettingsCta") })] })), decidedOutcome === null && primaryChannel === "prompt" && promptInjection?.truncated === true && (_jsx("div", { className: "mt-3 rounded-[3px] border border-amber/40 bg-amber/5 px-3 py-2 text-[12.5px] text-amber", "data-testid": `approve-queue-badge-${group.principleId}`, children: promptInjection.productionRotates === true && group.fitsPromptBudget === true
310
313
  ? t("pages.focus.approveQueueBadgeRotation", {
311
314
  defaultValue: "批准后可能排队:提示词注入位已满({{used}}/{{budget}} 字符)。当前按公平轮转选取,新原则会在后续轮次自行轮到,无需停用旧原则。",
312
315
  used: promptInjection.usedChars,
@@ -4,5 +4,16 @@
4
4
  * kept separate from ui/utils/validators.ts because the page additionally
5
5
  * enforces the pending/approved/rejected status whitelist it renders on.
6
6
  */
7
- import type { ApprovalsGroupedData } from "../../api.js";
7
+ import type { ApprovalsGroupedData, ApprovalGroup } from "../../api.js";
8
8
  export declare function validateApprovalsGroupedData(raw: unknown): ApprovalsGroupedData | null;
9
+ /**
10
+ * PRI-940: which string may the pending-review card render as its title.
11
+ *
12
+ * `principleTitle` degrades to the synthesized `unlinked:<artifactId>` grouping
13
+ * key when the pinned artifact is missing — a machine id that must never reach
14
+ * the Owner as a title (real case: an approval pinned to a superseded scribe
15
+ * revision rendered `unlinked:pi-art-scribe-…` on the card). Returns undefined
16
+ * for that case so the caller falls back to the localized untitled copy; the
17
+ * degradation itself is explained by the artifactUnavailable note, not here.
18
+ */
19
+ export declare function selectApprovalGroupDisplayTitle(group: Pick<ApprovalGroup, "candidateDescription" | "principleTitle" | "artifactUnavailable">): string | undefined;
@@ -64,12 +64,23 @@ function validateApprovalGroup(raw) {
64
64
  return null;
65
65
  fitsPromptBudget = fits;
66
66
  }
67
+ // PRI-940: artifactUnavailable is optional. ERR-009 discipline — present but
68
+ // wrong type fails loud (return null); absent stays undefined, which keeps
69
+ // the card title rendering exactly as before.
70
+ let artifactUnavailable;
71
+ if (Object.hasOwn(raw, "artifactUnavailable")) {
72
+ const { artifactUnavailable: unavailable } = raw;
73
+ if (typeof unavailable !== "boolean")
74
+ return null;
75
+ artifactUnavailable = unavailable;
76
+ }
67
77
  return {
68
78
  principleId,
69
79
  principleTitle,
70
80
  candidateDescription,
71
81
  status,
72
82
  fitsPromptBudget,
83
+ artifactUnavailable,
73
84
  records: validRecords,
74
85
  };
75
86
  }
@@ -101,3 +112,21 @@ export function validateApprovalsGroupedData(raw) {
101
112
  promptInjection: validatePromptInjectionBudgetStatus(raw.promptInjection) ?? undefined,
102
113
  };
103
114
  }
115
+ /**
116
+ * PRI-940: which string may the pending-review card render as its title.
117
+ *
118
+ * `principleTitle` degrades to the synthesized `unlinked:<artifactId>` grouping
119
+ * key when the pinned artifact is missing — a machine id that must never reach
120
+ * the Owner as a title (real case: an approval pinned to a superseded scribe
121
+ * revision rendered `unlinked:pi-art-scribe-…` on the card). Returns undefined
122
+ * for that case so the caller falls back to the localized untitled copy; the
123
+ * degradation itself is explained by the artifactUnavailable note, not here.
124
+ */
125
+ export function selectApprovalGroupDisplayTitle(group) {
126
+ if (group.candidateDescription !== undefined && group.candidateDescription !== "") {
127
+ return group.candidateDescription;
128
+ }
129
+ if (group.artifactUnavailable === true)
130
+ return undefined;
131
+ return group.principleTitle;
132
+ }
@@ -642,6 +642,12 @@ export interface ApprovalGroupData {
642
642
  * "do not promise rotation".
643
643
  */
644
644
  fitsPromptBudget?: boolean;
645
+ /**
646
+ * PRI-940: the pinned artifact is missing from the store, so the card has no
647
+ * human-readable payload and `principleTitle` carries the synthesized
648
+ * `unlinked:<artifactId>` machine id, which must never render as a title.
649
+ */
650
+ artifactUnavailable?: boolean;
645
651
  status: string;
646
652
  records: ApprovalGroupRecordData[];
647
653
  }
@@ -1652,6 +1652,12 @@ function validateApprovalGroup(v) {
1652
1652
  if (Object.hasOwn(v, 'fitsPromptBudget') && typeof v.fitsPromptBudget === 'boolean') {
1653
1653
  result.fitsPromptBudget = v.fitsPromptBudget;
1654
1654
  }
1655
+ // PRI-940: optional artifactUnavailable. Only a real boolean is propagated; a
1656
+ // present-but-wrong value is dropped so the card keeps its plain untitled
1657
+ // fallback rather than rendering an unverified claim.
1658
+ if (Object.hasOwn(v, 'artifactUnavailable') && typeof v.artifactUnavailable === 'boolean') {
1659
+ result.artifactUnavailable = v.artifactUnavailable;
1660
+ }
1655
1661
  return result;
1656
1662
  }
1657
1663
  export function validatePromptInjectionBudgetStatus(v) {