@anthusai/papyrus 5.36.0 → 5.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1015,15 +1015,22 @@ class DynamoReaderStore:
1015
1015
  raise
1016
1016
  return True
1017
1017
 
1018
- def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str) -> bool:
1018
+ def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str, *, expected_updated_at: str | None = None) -> bool:
1019
+ """Apply ``changes`` if the state (and, when given, ``updatedAt``) is still what the caller saw; a value of ``None`` removes the attribute."""
1019
1020
  names = {"#st": "state", "#u": "updatedAt", **{f"#c{index}": key for index, key in enumerate(changes)}}
1020
- assignments = ", ".join(f"#c{index} = :c{index}" for index in range(len(changes)))
1021
- values = {":expected": expected_state, ":now": now_iso, **{f":c{index}": value for index, value in enumerate(changes.values())}}
1021
+ sets = ", ".join(f"#c{index} = :c{index}" for index, value in enumerate(changes.values()) if value is not None)
1022
+ removes = ", ".join(f"#c{index}" for index, value in enumerate(changes.values()) if value is None)
1023
+ values = {":expected": expected_state, ":now": now_iso, **{f":c{index}": value for index, value in enumerate(changes.values()) if value is not None}}
1024
+ condition = "#st = :expected"
1025
+ if expected_updated_at is not None:
1026
+ condition += " AND #u = :seen"
1027
+ values[":seen"] = expected_updated_at
1028
+ expression = f"SET {sets + ', ' if sets else ''}#u = :now" + (f" REMOVE {removes}" if removes else "")
1022
1029
  try:
1023
1030
  self._table("proposal").update_item(
1024
1031
  Key={"id": proposal_id},
1025
- UpdateExpression=f"SET {assignments}, #u = :now",
1026
- ConditionExpression="#st = :expected",
1032
+ UpdateExpression=expression,
1033
+ ConditionExpression=condition,
1027
1034
  ExpressionAttributeNames=names,
1028
1035
  ExpressionAttributeValues=values,
1029
1036
  )
@@ -6,7 +6,7 @@ and steered by votes; an editor decides. Staff reach this through the ``fileProp
6
6
  mutations (editors and admins; the function checks the groups again). An operator's IAM role in the function's own account
7
7
  (an unattended automation or an agent without a browser sign-in) may also file an AI proposal and move it from draft to
8
8
  in_steering or declined, and nothing else: approving, deciding after review and overriding stay with editors and admins.
9
- Every state change is appended to the proposal's
9
+ ``editProposal`` is the editor tier (pin, hold, exclude and restore votes): editors and admins only, never an operator. Every state change is appended to the proposal's
10
10
  ``history`` in the same conditional write that changes the state, so the history and the state cannot disagree.
11
11
 
12
12
  Nothing here knows a person id. The proposer is ``"ai"`` or a reader's siteSubject, which is stored and never published; the
@@ -56,7 +56,19 @@ TARGET_COUNTS: dict[str, tuple[int, int]] = {
56
56
  }
57
57
  TARGET_TYPES = ("concept", "entity", "column", "story", "cluster")
58
58
 
59
- MUTATIONS = ("fileProposal", "transitionProposal")
59
+ MUTATIONS = ("fileProposal", "transitionProposal", "editProposal")
60
+
61
+ #: What ``editProposal`` does beyond moving the state. Editors and admins only: an operator is refused every one of them.
62
+ PIN, UNPIN, HOLD, RELEASE, EXCLUDE_VOTES, UNDO_EXCLUSION = "pin", "unpin", "hold", "release", "exclude_votes", "undo_exclusion"
63
+ EDIT_ACTIONS = (PIN, UNPIN, HOLD, RELEASE, EXCLUDE_VOTES, UNDO_EXCLUSION)
64
+ FINAL_STATES = (APPROVED, DECLINED, OVERRIDDEN_BY_EDITOR)
65
+ #: The batch rules for excluding votes. A rule excludes the votes on one proposal that were already cast, by accounts that were
66
+ #: younger than ``hours`` when they voted; the snapshot applies it (votes are not counted here). ``label`` is the public wording.
67
+ EXCLUSION_RULES: dict[str, dict[str, Any]] = {
68
+ "new-accounts-24h": {"hours": 24, "label": "accounts under 24 h"},
69
+ }
70
+ EXCLUSION_ID = re.compile(r"exc-[0-9a-f]{12}")
71
+ MAXIMUM_EDITOR_LOG = 200
60
72
 
61
73
 
62
74
  def event_field_name(event: dict[str, Any]) -> str | None:
@@ -87,6 +99,11 @@ MAXIMUM_BODY = 50_000
87
99
  MAXIMUM_REASON = 500
88
100
  MAXIMUM_EVIDENCE_SOURCE = 200
89
101
  MAXIMUM_EVIDENCE_SIGNALS = 1_000_000
102
+ #: An optional figure spec kept in ``evidence.figure``: a small chart of cited series (no image), approved with the copy.
103
+ FIGURE_TYPES = ("line", "bar")
104
+ MAXIMUM_FIGURE_POINTS = 60
105
+ MAXIMUM_FIGURE_SERIES = 6
106
+ MAXIMUM_FIGURE_BYTES = 8_000
90
107
 
91
108
 
92
109
  @dataclass
@@ -109,7 +126,7 @@ class ProposalStore(Protocol):
109
126
 
110
127
  def create_proposal(self, row: dict[str, Any]) -> bool: ...
111
128
 
112
- def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str) -> bool: ...
129
+ def change_proposal(self, proposal_id: str, changes: dict[str, Any], expected_state: str, now_iso: str, *, expected_updated_at: str | None = None) -> bool: ...
113
130
 
114
131
 
115
132
  def staff_subject(identity: dict[str, Any]) -> str | None:
@@ -178,18 +195,53 @@ def clean_targets(kind: str, raw: Any) -> list[dict[str, str]] | Outcome:
178
195
  return targets
179
196
 
180
197
 
198
+ def clean_figure(raw: Any) -> dict[str, Any] | None:
199
+ """A chart spec ``{type, title, caption, unit, source, x, series: [{signal, label, values}]}``, or None when it is malformed.
200
+
201
+ Unknown keys are dropped. Every series has exactly one value per ``x`` label; values are whole or decimal numbers, never text."""
202
+ if not isinstance(raw, dict) or raw.get("type") not in FIGURE_TYPES:
203
+ return None
204
+ title, caption, unit, source = (clean_text(raw.get(key), limit) for key, limit in (("title", 200), ("caption", 300), ("unit", 60), ("source", MAXIMUM_EVIDENCE_SOURCE)))
205
+ x, series = raw.get("x"), raw.get("series")
206
+ if None in (title, caption, unit, source) or not isinstance(x, list) or not isinstance(series, list):
207
+ return None
208
+ if not 2 <= len(x) <= MAXIMUM_FIGURE_POINTS or not 1 <= len(series) <= MAXIMUM_FIGURE_SERIES:
209
+ return None
210
+ labels = [clean_text(label, 20) for label in x]
211
+ if None in labels:
212
+ return None
213
+ cleaned = []
214
+ for entry in series:
215
+ if not isinstance(entry, dict):
216
+ return None
217
+ signal, label, values = clean_text(entry.get("signal"), 130), clean_text(entry.get("label"), 120), entry.get("values")
218
+ if signal is None or label is None or not isinstance(values, list) or len(values) != len(labels):
219
+ return None
220
+ if any(isinstance(value, bool) or not isinstance(value, (int, float)) or not 0 <= value <= 1_000_000_000 for value in values):
221
+ return None
222
+ cleaned.append({"signal": signal, "label": label, "values": list(values)})
223
+ figure = {"type": raw["type"], "title": title, "caption": caption, "unit": unit, "source": source, "x": labels, "series": cleaned}
224
+ return figure if len(json.dumps(figure)) <= MAXIMUM_FIGURE_BYTES else None
225
+
226
+
181
227
  def clean_evidence(raw: Any) -> dict[str, Any] | None | Outcome:
182
228
  parsed = parse_json(raw) if raw is not None else None
183
229
  if parsed is None:
184
230
  return None
185
231
  if not isinstance(parsed, dict):
186
- return refused("invalid_evidence", "evidence must be {signals, source}.")
232
+ return refused("invalid_evidence", "evidence must be {signals, source} with an optional figure.")
187
233
  signals, source = parsed.get("signals"), parsed.get("source")
188
234
  if not isinstance(signals, int) or isinstance(signals, bool) or not 0 <= signals <= MAXIMUM_EVIDENCE_SIGNALS:
189
235
  return refused("invalid_evidence", "evidence.signals must be a whole number of signals.")
190
236
  if source is not None and clean_text(source, MAXIMUM_EVIDENCE_SOURCE) is None:
191
237
  return refused("invalid_evidence", "evidence.source must be short text.")
192
- return {"signals": signals, "source": source.strip() if isinstance(source, str) else None}
238
+ evidence = {"signals": signals, "source": source.strip() if isinstance(source, str) else None}
239
+ if parsed.get("figure") is not None:
240
+ figure = clean_figure(parsed["figure"])
241
+ if figure is None:
242
+ return refused("invalid_evidence", "evidence.figure must be a small chart spec: type line or bar, title, caption, unit, source, x labels and series with one value per label.")
243
+ evidence["figure"] = figure
244
+ return evidence
193
245
 
194
246
 
195
247
  def file_proposal(arguments: dict[str, Any], *, actor_sub: str, now_iso: str, proposal_id: str | None = None) -> tuple[dict[str, Any] | None, Outcome | None]:
@@ -238,6 +290,8 @@ def transition_plan(row: dict[str, Any], to_state: Any, reason: Any, *, actor_su
238
290
  return refused("invalid_state", f"state must be one of {', '.join(STATES)}.")
239
291
  if to_state not in TRANSITIONS.get(state, ()):
240
292
  return refused("transition_not_allowed", f"A proposal in {state} cannot move to {to_state}.")
293
+ if row.get("held"):
294
+ return refused("proposal_held", "That proposal is on hold. Release it before it moves.")
241
295
  text = None
242
296
  if reason is not None and str(reason).strip():
243
297
  text = clean_text(reason, MAXIMUM_REASON)
@@ -254,7 +308,11 @@ def transition_plan(row: dict[str, Any], to_state: Any, reason: Any, *, actor_su
254
308
  if result is not None:
255
309
  entry["result"] = result
256
310
  history = [*(parse_json(row.get("history")) or []), entry]
257
- return {"state": to_state, "history": json.dumps(history)}
311
+ changes: dict[str, Any] = {"state": to_state, "history": json.dumps(history)}
312
+ if to_state == OVERRIDDEN_BY_EDITOR:
313
+ # Stored on the proposal beside the community result, which stays as it was: readers see both.
314
+ changes["override"] = json.dumps({"result": result, "reason": text, "at": now_iso})
315
+ return changes
258
316
 
259
317
 
260
318
  def apply_transition(
@@ -285,8 +343,77 @@ def apply_transition(
285
343
  return Outcome("ok", id=proposal_id)
286
344
 
287
345
 
346
+ def log_entry(action: str, actor_sub: str, now_iso: str, detail: dict[str, Any] | None = None) -> dict[str, Any]:
347
+ entry: dict[str, Any] = {"action": action, "by": BY_STAFF, "actor": actor_sub, "at": now_iso}
348
+ if detail:
349
+ entry["detail"] = detail
350
+ return entry
351
+
352
+
353
+ def edit_plan(row: dict[str, Any], action: Any, *, rule: Any = None, exclusion_id: Any = None, actor_sub: str, now_iso: str) -> dict[str, Any] | Outcome:
354
+ """The changes for a pin, hold or vote-exclusion action on ``row``, or why it may not be done.
355
+
356
+ Every action is appended to the private ``editorLog`` in the same write. Changing the exclusions clears the frozen
357
+ ``communityResult`` so the next snapshot fixes it again with the exclusions applied; the function never counts votes itself.
358
+ """
359
+ if action not in EDIT_ACTIONS:
360
+ return refused("invalid_action", f"action must be one of {', '.join(EDIT_ACTIONS)}.")
361
+ detail: dict[str, Any] | None = None
362
+ changes: dict[str, Any] = {}
363
+ if action in (PIN, UNPIN):
364
+ if bool(row.get("pinned")) == (action == PIN):
365
+ return refused("already_pinned" if action == PIN else "not_pinned", "That proposal is already pinned." if action == PIN else "That proposal is not pinned.")
366
+ changes["pinned"] = action == PIN
367
+ elif action in (HOLD, RELEASE):
368
+ if action == HOLD and row.get("state") in FINAL_STATES:
369
+ return refused("proposal_decided", "A decided proposal cannot be put on hold.")
370
+ if bool(row.get("held")) == (action == HOLD):
371
+ return refused("already_held" if action == HOLD else "not_held", "That proposal is already on hold." if action == HOLD else "That proposal is not on hold.")
372
+ changes["held"] = action == HOLD
373
+ else:
374
+ exclusions = parse_json(row.get("exclusions")) or []
375
+ if action == EXCLUDE_VOTES:
376
+ if rule not in EXCLUSION_RULES:
377
+ return refused("invalid_rule", f"rule must be one of {', '.join(EXCLUSION_RULES)}.")
378
+ identifier = "exc-" + uuid.uuid4().hex[:12]
379
+ exclusions = [*exclusions, {"id": identifier, "rule": rule, "at": now_iso, "actor": actor_sub}]
380
+ detail = {"exclusion": identifier, "rule": rule}
381
+ else:
382
+ if not isinstance(exclusion_id, str) or not EXCLUSION_ID.fullmatch(exclusion_id):
383
+ return refused("invalid_exclusion", "That is not an exclusion id.")
384
+ found = next((entry for entry in exclusions if entry.get("id") == exclusion_id), None)
385
+ if found is None:
386
+ return refused("unknown_exclusion", "That proposal has no such exclusion.")
387
+ if found.get("undoneAt"):
388
+ return refused("already_undone", "That exclusion was already undone.")
389
+ found["undoneAt"], found["undoneBy"] = now_iso, actor_sub
390
+ detail = {"exclusion": exclusion_id, "rule": found.get("rule")}
391
+ changes["exclusions"] = json.dumps(exclusions)
392
+ if row.get("communityResult"):
393
+ changes["communityResult"] = None # removed: the next snapshot fixes it again without the excluded votes
394
+ log = [*(parse_json(row.get("editorLog")) or []), log_entry(action, actor_sub, now_iso, detail)][-MAXIMUM_EDITOR_LOG:]
395
+ changes["editorLog"] = json.dumps(log)
396
+ return changes
397
+
398
+
399
+ def apply_edit(store: ProposalStore, *, proposal_id: Any, action: Any, rule: Any, exclusion_id: Any, actor_sub: str, now_iso: str) -> Outcome:
400
+ if not isinstance(proposal_id, str) or not PROPOSAL_ID.fullmatch(proposal_id):
401
+ return refused("invalid_proposal", "That is not a proposal id.")
402
+ row = store.get_proposal(proposal_id)
403
+ if row is None:
404
+ return refused("not_found", "There is no such proposal.")
405
+ plan = edit_plan(row, action, rule=rule, exclusion_id=exclusion_id, actor_sub=actor_sub, now_iso=now_iso)
406
+ if isinstance(plan, Outcome):
407
+ return plan
408
+ if not store.change_proposal(proposal_id, plan, row["state"], now_iso, expected_updated_at=row.get("updatedAt")):
409
+ return refused("target_changed", "Someone else changed that proposal first.")
410
+ # An exclusion's id is what undo_exclusion takes; the pin and hold receipts carry the proposal id.
411
+ created = json.loads(plan["exclusions"])[-1]["id"] if action == EXCLUDE_VOTES else proposal_id
412
+ return Outcome("ok", id=created)
413
+
414
+
288
415
  def handle_proposal_event(event: dict[str, Any], store: ProposalStore, *, now_iso: str, account_id: str | None = None) -> dict[str, Any]:
289
- """``fileProposal`` or ``transitionProposal`` from AppSync. Answers a receipt, never an exception.
416
+ """``fileProposal``, ``transitionProposal`` or ``editProposal`` from AppSync. Answers a receipt, never an exception.
290
417
 
291
418
  Editors and admins act as themselves. An IAM principal of ``account_id`` (the function's own account) acts as an operator:
292
419
  it files AI proposals and moves draft to in_steering or declined, recorded as ``operator:<ARN>``. Anyone else is refused.
@@ -299,6 +426,18 @@ def handle_proposal_event(event: dict[str, Any], store: ProposalStore, *, now_is
299
426
  return refused("not_staff", "Only editors, admins and operators of this account can file proposals; only editors and admins decide them.").to_dict()
300
427
  arguments = event.get("arguments") or {}
301
428
  field = event_field_name(event)
429
+ if field == "editProposal":
430
+ if staff is None:
431
+ return refused("operator_not_allowed", "Pinning, holding and excluding votes are for editors and admins; an operator can only file proposals and move a draft to in_steering or declined.").to_dict()
432
+ return apply_edit(
433
+ store,
434
+ proposal_id=arguments.get("proposalId"),
435
+ action=arguments.get("action"),
436
+ rule=arguments.get("rule"),
437
+ exclusion_id=arguments.get("exclusionId"),
438
+ actor_sub=staff,
439
+ now_iso=now_iso,
440
+ ).to_dict()
302
441
  if field == "fileProposal":
303
442
  row, problem = file_proposal(arguments, actor_sub=actor, now_iso=now_iso)
304
443
  if problem is not None:
@@ -15,7 +15,7 @@ export const readerSteeringManifest = {
15
15
  name: "reader-steering",
16
16
  contract: 1,
17
17
  models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
18
- mutations: ["moderateReader", "fileProposal", "transitionProposal"],
18
+ mutations: ["moderateReader", "fileProposal", "transitionProposal", "editProposal"],
19
19
  guest: { mutations: [] },
20
20
  newsroomPanels: [{ id: "moderation", label: "Moderation" }],
21
21
  staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
@@ -22,7 +22,7 @@ export const readerSteeringManifest: PluginManifest = {
22
22
  name: "reader-steering",
23
23
  contract: 1,
24
24
  models: ["ReaderProfile", "ReaderHandle", "ReaderVote", "ReaderFlag", "ReaderSubmission", "ReaderComment", "ReaderReport", "ModerationAction", "Proposal"],
25
- mutations: ["moderateReader", "fileProposal", "transitionProposal"],
25
+ mutations: ["moderateReader", "fileProposal", "transitionProposal", "editProposal"],
26
26
  guest: { mutations: [] },
27
27
  newsroomPanels: [{ id: "moderation", label: "Moderation" }],
28
28
  staticAssets: ["_plugins/papyrus-reader@1.js", "_plugins/papyrus-reader@1.css"],
@@ -148,6 +148,12 @@ export function readerSteeringSchemaDefinitions() {
148
148
  state: a.string().required(),
149
149
  history: a.json().required(),
150
150
  communityResult: a.json(),
151
+ pinned: a.boolean(),
152
+ held: a.boolean(),
153
+ override: a.json(),
154
+ exclusions: a.json(),
155
+ editorLog: a.json(),
156
+ needsLook: a.json(),
151
157
  createdAt: a.datetime().required(),
152
158
  })
153
159
  .disableOperations(["subscriptions"])
@@ -188,6 +194,19 @@ export function readerSteeringSchemaDefinitions() {
188
194
  .returns(a.ref("ProposalReceipt"))
189
195
  .authorization((allow) => [allow.group("editor"), allow.group("admin")])
190
196
  .handler(functionHandler("readerActions", readerActions)),
197
+ // Editors and admins only, and the function refuses an IAM operator: pin, unpin, hold, release, exclude_votes and
198
+ // undo_exclusion. `rule` is a batch rule for exclude_votes; `exclusionId` names the exclusion undo_exclusion reverses.
199
+ editProposal: a
200
+ .mutation()
201
+ .arguments({
202
+ proposalId: a.string().required(),
203
+ action: a.string().required(),
204
+ rule: a.string(),
205
+ exclusionId: a.string(),
206
+ })
207
+ .returns(a.ref("ProposalReceipt"))
208
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
209
+ .handler(functionHandler("readerActions", readerActions)),
191
210
  ModerationReceipt: a.customType({
192
211
  status: a.string().required(),
193
212
  id: a.string(),
@@ -150,6 +150,12 @@ export function readerSteeringSchemaDefinitions() {
150
150
  state: a.string().required(),
151
151
  history: a.json().required(),
152
152
  communityResult: a.json(),
153
+ pinned: a.boolean(),
154
+ held: a.boolean(),
155
+ override: a.json(),
156
+ exclusions: a.json(),
157
+ editorLog: a.json(),
158
+ needsLook: a.json(),
153
159
  createdAt: a.datetime().required(),
154
160
  })
155
161
  .disableOperations(["subscriptions"])
@@ -190,6 +196,19 @@ export function readerSteeringSchemaDefinitions() {
190
196
  .returns(a.ref("ProposalReceipt"))
191
197
  .authorization((allow) => [allow.group("editor"), allow.group("admin")])
192
198
  .handler(functionHandler("readerActions", readerActions)),
199
+ // Editors and admins only, and the function refuses an IAM operator: pin, unpin, hold, release, exclude_votes and
200
+ // undo_exclusion. `rule` is a batch rule for exclude_votes; `exclusionId` names the exclusion undo_exclusion reverses.
201
+ editProposal: a
202
+ .mutation()
203
+ .arguments({
204
+ proposalId: a.string().required(),
205
+ action: a.string().required(),
206
+ rule: a.string(),
207
+ exclusionId: a.string(),
208
+ })
209
+ .returns(a.ref("ProposalReceipt"))
210
+ .authorization((allow) => [allow.group("editor"), allow.group("admin")])
211
+ .handler(functionHandler("readerActions", readerActions)),
193
212
  ModerationReceipt: a.customType({
194
213
  status: a.string().required(),
195
214
  id: a.string(),
@@ -15,6 +15,7 @@ import {
15
15
  statusText,
16
16
  storyHref,
17
17
  triggerText,
18
+ toolsText,
18
19
  whoText,
19
20
  type AutomationOutsideRun,
20
21
  type AutomationRecord,
@@ -46,8 +47,10 @@ function RunLine({ run, demo }: { run: AutomationRun; demo: boolean }) {
46
47
  {run.runRecord === "unavailable" ? " (the run record could not be read)" : ""}
47
48
  </span>
48
49
  <span>
49
- {run.storyId ? <>Story: <Link href={storyHref(run.storyId, demo)} data-automation-story-link>{run.storyTitle ?? run.storyId}</Link></> : "Story: none recorded"}
50
+ {run.storyId && run.storyExists === false ? <>Story: {run.storyTitle ?? run.storyId} (story deleted)</>
51
+ : run.storyId ? <>Story: <Link href={storyHref(run.storyId, demo)} data-automation-story-link>{run.storyTitle ?? run.storyId}</Link></> : "Story: none recorded"}
50
52
  </span>
53
+ <span data-automation-run-tools>{toolsText(run)}</span>
51
54
  <span data-automation-run-cost data-over-budget={run.overBudget || undefined}>
52
55
  <span aria-hidden="true">{run.overBudget ? "! " : ""}</span>{costText(run)}
53
56
  </span>
@@ -218,7 +218,7 @@ export function EditorialChecksAdminPanel() {
218
218
  {policies.map((policy) => (
219
219
  <tr data-editorial-policy-row={policy.id} key={policy.id}>
220
220
  <td>{policy.title}<br /><small>{policy.policyKey} · v{policy.version}</small></td>
221
- <td>{policy.scope === "publication" ? "Whole publication" : `Desk: ${policy.scope}`}</td>
221
+ <td>{policy.scope === "publication" ? "Whole publication" : `Section: ${policy.scope}`}</td>
222
222
  <td>{policy.severity}</td>
223
223
  <td>{policy.detectors.length === 0 ? "Guidance only" : policy.detectors.length}</td>
224
224
  <td>
@@ -251,7 +251,7 @@ export function EditorialChecksAdminPanel() {
251
251
  <label>Key <Input disabled={!draft.isNew} onChange={(event) => patchDraft({ policyKey: event.target.value })} value={draft.record.policyKey} /></label>
252
252
  <label>Title <Input onChange={(event) => patchDraft({ title: event.target.value })} value={draft.record.title} /></label>
253
253
  <label>
254
- Applies to (publication, or a desk key){" "}
254
+ Applies to (publication, or a section key){" "}
255
255
  <Input disabled={!draft.isNew} onChange={(event) => patchDraft({ scope: event.target.value.trim() || "publication" })} value={draft.record.scope} />
256
256
  </label>
257
257
  <label>
@@ -57,7 +57,7 @@ export function NewsDeskAccessGate({ shell, showSectionTabs = false }: { shell:
57
57
  }
58
58
 
59
59
  function formatAccessTitle(state: NewsDeskShellState | null): string {
60
- if (!state || state.phase === "checkingAccess") return "Checking Desk Credentials";
60
+ if (!state || state.phase === "checkingAccess") return "Checking Credentials";
61
61
  if (state.phase === "loadingDesk") return "Loading Newsroom Records";
62
62
  if (state.phase === "forbidden") return "Editor Role Required";
63
63
  if (state.phase === "error") return "Newsroom Unavailable";
@@ -670,21 +670,21 @@ export function NewsroomArticleEditor({ articleId, demo, embedded = false }: { a
670
670
  </Select>
671
671
  </label>
672
672
  <label className="space-y-1 text-sm font-medium">
673
- <span>Desk</span>
673
+ <span>Section</span>
674
674
  <Select
675
675
  onValueChange={(value) => {
676
676
  if (typeof value === "string") patchEditor({ section: value === NO_DESK_VALUE ? "" : value });
677
677
  }}
678
678
  value={deskOptions.some(option => option.value === editor.section) ? editor.section : NO_DESK_VALUE}
679
679
  >
680
- <SelectTrigger aria-label="Desk" className="w-full" data-newsroom-article-section>
681
- <SelectValue>{deskOptions.find(option => option.value === editor.section)?.label ?? "No desk"}</SelectValue>
680
+ <SelectTrigger aria-label="Section" className="w-full" data-newsroom-article-section>
681
+ <SelectValue>{deskOptions.find(option => option.value === editor.section)?.label ?? "No section"}</SelectValue>
682
682
  </SelectTrigger>
683
683
  <SelectContent>
684
- <SelectItem value={NO_DESK_VALUE}>No desk</SelectItem>
684
+ <SelectItem value={NO_DESK_VALUE}>No section</SelectItem>
685
685
  {(["core", "rotating"] as const).map((kind) => deskOptions.some(option => option.kind === kind) ? (
686
686
  <SelectGroup key={kind}>
687
- <SelectLabel>{kind === "core" ? "Core desks" : "Rotating desks"}</SelectLabel>
687
+ <SelectLabel>{kind === "core" ? "Core sections" : "Rotating sections"}</SelectLabel>
688
688
  {deskOptions.filter(option => option.kind === kind).map((option) => (
689
689
  <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
690
690
  ))}
@@ -36,7 +36,7 @@ export function NewsroomAssignmentDetail({ assignment, deskOptions = [], onDeskC
36
36
  const changeDesk = (value: string) => {
37
37
  if (!onDeskChange) return;
38
38
  setSavingDesk(true); setDeskError(null);
39
- onDeskChange(value === NO_DESK_VALUE ? null : value).catch((reason: unknown) => setDeskError(reason instanceof Error ? reason.message : "Could not save the desk.")).finally(() => setSavingDesk(false));
39
+ onDeskChange(value === NO_DESK_VALUE ? null : value).catch((reason: unknown) => setDeskError(reason instanceof Error ? reason.message : "Could not save the section.")).finally(() => setSavingDesk(false));
40
40
  };
41
41
  const facts: DetailField[] = [
42
42
  { label: "Status", value: assignment.status },
@@ -58,14 +58,14 @@ export function NewsroomAssignmentDetail({ assignment, deskOptions = [], onDeskC
58
58
  {facts.map(field => <div key={field.label}><dt>{field.label}</dt><dd>{field.value}</dd></div>)}
59
59
  </dl>
60
60
  {onDeskChange && <label className="newsroom-assignment-detail-desk">
61
- <span>Desk</span>
61
+ <span>Section</span>
62
62
  <Select onValueChange={(value) => { if (typeof value === "string") changeDesk(value); }} value={deskChoices.some(option => option.value === currentDesk) ? currentDesk : NO_DESK_VALUE} disabled={savingDesk}>
63
- <SelectTrigger aria-label="Desk" className="w-full" data-newsroom-assignment-desk>
64
- <SelectValue>{deskChoices.find(option => option.value === currentDesk)?.label ?? "No desk"}</SelectValue>
63
+ <SelectTrigger aria-label="Section" className="w-full" data-newsroom-assignment-desk>
64
+ <SelectValue>{deskChoices.find(option => option.value === currentDesk)?.label ?? "No section"}</SelectValue>
65
65
  </SelectTrigger>
66
66
  <SelectContent>
67
- <SelectItem value={NO_DESK_VALUE}>No desk</SelectItem>
68
- {(["core", "rotating"] as const).map(kind => deskChoices.some(option => option.kind === kind) ? <SelectGroup key={kind}><SelectLabel>{kind === "core" ? "Core desks" : "Rotating desks"}</SelectLabel>{deskChoices.filter(option => option.kind === kind).map(option => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}</SelectGroup> : null)}
67
+ <SelectItem value={NO_DESK_VALUE}>No section</SelectItem>
68
+ {(["core", "rotating"] as const).map(kind => deskChoices.some(option => option.kind === kind) ? <SelectGroup key={kind}><SelectLabel>{kind === "core" ? "Core sections" : "Rotating sections"}</SelectLabel>{deskChoices.filter(option => option.kind === kind).map(option => <SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}</SelectGroup> : null)}
69
69
  </SelectContent>
70
70
  </Select>
71
71
  {currentDesk && !deskChoices.some(option => option.value === currentDesk) ? <small className="newsroom-desk-legacy-value">Old section value: {currentDesk}</small> : null}
@@ -76,7 +76,7 @@ export function NewsroomOpsOverview({
76
76
  data-newsroom-ops-home="true"
77
77
  >
78
78
  <div className="space-y-8" data-newsroom-overview-feeds>
79
- <div aria-label="Notification Center feed" className="newsroom-overview-feed-frame overflow-y-auto rounded-xl border bg-card p-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" data-newsroom-overview-notifications role="region" tabIndex={0}>
79
+ <div aria-label="Notification Center feed" className="newsroom-overview-feed-frame rounded-xl border bg-card p-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" data-newsroom-overview-notifications role="region" tabIndex={0}>
80
80
  <NotificationCenter
81
81
  actorLabel={actorLabel}
82
82
  decisions={decisions}
@@ -145,9 +145,9 @@ export function NewsroomOpsOverview({
145
145
  </section>
146
146
 
147
147
  {desks.length ? (
148
- <section aria-label="Desks" className="space-y-3" data-newsroom-ops-desks>
149
- <h2 className="text-sm font-medium text-muted-foreground">Desks</h2>
150
- {coreDesks.length ? <h3 className="newsroom-desk-group-heading" data-newsroom-ops-desk-group="core">Core desks</h3> : null}
148
+ <section aria-label="Sections" className="space-y-3" data-newsroom-ops-desks>
149
+ <h2 className="text-sm font-medium text-muted-foreground">Sections</h2>
150
+ {coreDesks.length ? <h3 className="newsroom-desk-group-heading" data-newsroom-ops-desk-group="core">Core sections</h3> : null}
151
151
  <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
152
152
  {coreDesks.map((section) => (
153
153
  <Link
@@ -167,7 +167,7 @@ export function NewsroomOpsOverview({
167
167
  </Link>
168
168
  ))}
169
169
  </div>
170
- {rotatingDesks.length ? <h3 className="newsroom-desk-group-heading" data-newsroom-ops-desk-group="rotating">Rotating desks <small>appear for a while, then rotate out</small></h3> : null}
170
+ {rotatingDesks.length ? <h3 className="newsroom-desk-group-heading" data-newsroom-ops-desk-group="rotating">Rotating sections <small>appear for a while, then rotate out</small></h3> : null}
171
171
  {rotatingDesks.length ? <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
172
172
  {rotatingDesks.map((section) => (
173
173
  <Link
@@ -23,8 +23,8 @@ body:has(.newsroom-organizer)>[data-staging-banner]{flex-shrink:0}
23
23
  .organizer-sidebar-head{display:flex;align-items:flex-start;justify-content:space-between;gap:8px}.organizer-sidebar-head .organizer-brand{flex:1;min-width:0}
24
24
  .organizer-header-actions{display:flex;align-items:center;gap:8px;min-width:0;max-width:45%;overflow-x:auto}
25
25
  .organizer-main{min-width:0;min-height:0;overflow:auto;overscroll-behavior:contain;flex:1;padding:16px 0}
26
- .newsroom-overview-feed-frame{height:32rem}
27
- @media(min-width:640px){.newsroom-overview-feed-frame{height:38rem}}
26
+ .newsroom-overview-feed-frame{height:auto}
27
+ @media(min-width:640px){.newsroom-overview-feed-frame{height:38rem;overflow-y:auto}}
28
28
  .organizer-main>[data-category-steering]{min-height:0}.organizer-demo-notice{font-size:11px;color:var(--org-muted);margin-bottom:12px}
29
29
  .organizer-account{display:flex;align-items:center;gap:10px;min-height:44px;flex-shrink:0;border:1px solid var(--org-line);padding:8px;text-align:left;border-radius:3px}
30
30
  .organizer-account span{min-width:0}.organizer-account strong{display:block;font:600 11px var(--kb-theme-ui-font);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.organizer-account small{display:block;font-size:9px;color:var(--org-muted);margin-top:2px}
@@ -7,7 +7,7 @@ import { Button } from "./ui/button";
7
7
  import { getNewsroomNavHref } from "../lib/newsroom-nav";
8
8
  import {
9
9
  cyclotronAutoDecided, cyclotronHeldForReview, formatWhen, isUnread, modeLabel, modeMark, nextText, sectionReviews,
10
- steeringText, subjectHref, summarize, summaryText,
10
+ steeringText, subjectHref, subjectLabel, summarize, summaryText,
11
11
  type AnswerInput, type CyclotronEntry, type ReviewRequest,
12
12
  } from "../lib/review-requests";
13
13
  import type { ReferenceRecord } from "../lib/category-repository";
@@ -22,6 +22,7 @@ type ViewProps = {
22
22
  readIds: ReadonlySet<string>;
23
23
  held: CyclotronEntry[];
24
24
  autoDecided: CyclotronEntry[];
25
+ storyTitles?: Readonly<Record<string, string>>;
25
26
  demo?: boolean;
26
27
  busyId?: string | null;
27
28
  error?: string | null;
@@ -30,8 +31,8 @@ type ViewProps = {
30
31
  onMarkAllRead?: () => void;
31
32
  };
32
33
 
33
- function Entry({ request, unread, demo, busy, onAnswer, onMarkRead }: {
34
- request: ReviewRequest; unread: boolean; demo: boolean; busy: boolean;
34
+ function Entry({ request, unread, demo, busy, titles, onAnswer, onMarkRead }: {
35
+ request: ReviewRequest; unread: boolean; demo: boolean; busy: boolean; titles: Readonly<Record<string, string>>;
35
36
  onAnswer?: ViewProps["onAnswer"]; onMarkRead?: ViewProps["onMarkRead"];
36
37
  }) {
37
38
  const [note, setNote] = useState("");
@@ -48,7 +49,7 @@ function Entry({ request, unread, demo, busy, onAnswer, onMarkRead }: {
48
49
  </div>
49
50
  <p className="text-xs text-muted-foreground">
50
51
  Raised by {request.raisedBy} on {formatWhen(request.raisedAt)}
51
- {" · "}About: {href ? <Link href={href} onClick={() => onMarkRead?.(request.id)}>{request.subject.type.replace("_", " ")} {request.subject.id}</Link> : <span>{request.subject.type} {request.subject.id}</span>}
52
+ {" · "}About: {href ? <Link href={href} onClick={() => onMarkRead?.(request.id)}>{subjectLabel(request.subject, titles)}</Link> : <span>{subjectLabel(request.subject, titles)}</span>}
52
53
  </p>
53
54
  {request.ask ? <p className="text-sm">{request.ask}</p> : null}
54
55
  {request.links.length ? (
@@ -124,12 +125,12 @@ function Section({ id, title, count, children, empty }: { id: string; title: str
124
125
  }
125
126
 
126
127
  /** The CMS console home: what needs a person, what will proceed on its own, and what the system decided. Ignore-safe by design. */
127
- export function NotificationCenterView({ loadState, requests, readIds, held, autoDecided, demo = false, busyId = null, error = null, onAnswer, onMarkRead, onMarkAllRead }: ViewProps) {
128
+ export function NotificationCenterView({ loadState, requests, readIds, held, autoDecided, demo = false, storyTitles = {}, busyId = null, error = null, onAnswer, onMarkRead, onMarkAllRead }: ViewProps) {
128
129
  const sections = useMemo(() => sectionReviews(requests), [requests]);
129
130
  const unreadCount = requests.filter((request) => isUnread(request, readIds)).length;
130
131
  const summary = summarize(sections, held, autoDecided, unreadCount);
131
132
  const entry = (request: ReviewRequest) => (
132
- <Entry busy={busyId === request.id} demo={demo} key={request.id} onAnswer={onAnswer} onMarkRead={onMarkRead} request={request} unread={isUnread(request, readIds)} />
133
+ <Entry busy={busyId === request.id} demo={demo} key={request.id} titles={storyTitles} onAnswer={onAnswer} onMarkRead={onMarkRead} request={request} unread={isUnread(request, readIds)} />
133
134
  );
134
135
  if (loadState === "loading") return (
135
136
  <section aria-label="Loading Notification Center" className="space-y-4" data-notification-state="loading" role="status">
@@ -204,6 +205,7 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
204
205
  const [userKey, setUserKey] = useState<string | null>(null);
205
206
  const [busyId, setBusyId] = useState<string | null>(null);
206
207
  const [error, setError] = useState<string | null>(null);
208
+ const [storyTitles, setStoryTitles] = useState<Record<string, string>>({});
207
209
 
208
210
  const refresh = useCallback(async () => {
209
211
  if (demo) {
@@ -223,6 +225,8 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
223
225
  setReadIds(new Set(loaded.readIds));
224
226
  setUserKey(loaded.userKey);
225
227
  setLoadState("ready");
228
+ const { loadStoryTitles } = await import("../lib/review-client");
229
+ setStoryTitles(await loadStoryTitles(loaded.requests.filter((request) => request.subject.type === "story").map((request) => request.subject.id)).catch(() => ({})));
226
230
  } catch {
227
231
  setLoadState("unavailable");
228
232
  }
@@ -266,7 +270,7 @@ export function NotificationCenter({ references, decisions, humanCuratedReferenc
266
270
 
267
271
  return (
268
272
  <NotificationCenterView
269
- autoDecided={autoDecided} busyId={busyId} demo={demo} error={error} held={held} loadState={loadState}
273
+ autoDecided={autoDecided} busyId={busyId} storyTitles={storyTitles} demo={demo} error={error} held={held} loadState={loadState}
270
274
  onAnswer={answer} onMarkAllRead={markAllRead} onMarkRead={markRead} readIds={readIds} requests={requests}
271
275
  />
272
276
  );
@@ -667,7 +667,7 @@ export function NewsDeskWorkspace({
667
667
  editorShellReady
668
668
  initialSelection={initialSelection}
669
669
  initialTab={initialTab}
670
- authState={{ status: "signedIn", label: "Demo Desk" }}
670
+ authState={{ status: "signedIn", label: "Demo section" }}
671
671
  isRefreshing={false}
672
672
  shellError={null}
673
673
  />
@@ -3903,7 +3903,7 @@ function NewsroomSectionRail({
3903
3903
  <aside className="newsroom-section-rail" data-newsroom-section-rail aria-labelledby="newsroom-section-rail-title" ref={railRef}>
3904
3904
  <header className="newsroom-section-rail__header">
3905
3905
  <p className="story-label">Sections</p>
3906
- <h2 id="newsroom-section-rail-title">Section Desk</h2>
3906
+ <h2 id="newsroom-section-rail-title">Section</h2>
3907
3907
  </header>
3908
3908
  <div className="newsroom-section-rail__body">
3909
3909
  {canonicalSections.length ? (
@@ -5743,7 +5743,7 @@ function DesksDeskView({
5743
5743
  <div className="news-desk-columns news-desk-columns--administration news-desk-columns--taxonomy" data-news-desk-section="desks">
5744
5744
  <div className="news-desk-main-column">
5745
5745
  <section className="category-steering-section category-steering-section--lead" aria-labelledby="newsroom-desks-title">
5746
- <SectionHeader title="News Desks" detail={`${roots.length} root topic desks`} />
5746
+ <SectionHeader title="News sections" detail={`${roots.length} root topic sections`} />
5747
5747
  <div className="news-desk-desk-list">
5748
5748
  {roots.length ? roots.map((root) => {
5749
5749
  const rootNode = root.node ?? categoryToCategoryTreeNode(root.category);
@@ -5763,11 +5763,11 @@ function DesksDeskView({
5763
5763
  >
5764
5764
  <span>{rootNode.shortTitle ?? deriveShortTitle(rootNode.displayName)}</span>
5765
5765
  <strong>{rootNode.displayName}</strong>
5766
- <p>{rootNode.description ?? "No desk description yet."}</p>
5766
+ <p>{rootNode.description ?? "No section description yet."}</p>
5767
5767
  <small>{referenceCount} refs / {assignmentCount} assignments / {root.subcategorys.length} subtopics / {doctrineStatus.savedCount} of 2 doctrine slots</small>
5768
5768
  </Link>
5769
5769
  );
5770
- }) : <EmptyRow label="No root topic desks are available in the canonical category set" />}
5770
+ }) : <EmptyRow label="No root topic sections are available in the canonical category set" />}
5771
5771
  </div>
5772
5772
  </section>
5773
5773
 
@@ -5788,18 +5788,18 @@ function DesksDeskView({
5788
5788
 
5789
5789
  <aside className="news-desk-rail-column">
5790
5790
  <section className="category-steering-section" aria-labelledby="desk-index-notes-title">
5791
- <SectionHeader title="Desk Rules" detail="Root topics only" />
5791
+ <SectionHeader title="Section rules" detail="Root topics only" />
5792
5792
  <div className="news-desk-ledger-list">
5793
5793
  <article className="news-desk-ledger-item">
5794
5794
  <header>
5795
- <strong>Desk Identity</strong>
5795
+ <strong>Section identity</strong>
5796
5796
  <span>Category</span>
5797
5797
  </header>
5798
5798
  <p>Official name, short title, subtitle, and description stay on the accepted root Category record.</p>
5799
5799
  </article>
5800
5800
  <article className="news-desk-ledger-item">
5801
5801
  <header>
5802
- <strong>Desk Doctrine</strong>
5802
+ <strong>Section doctrine</strong>
5803
5803
  <span>Private Item</span>
5804
5804
  </header>
5805
5805
  <p>Mission and policies are private doctrine Items tied to the root category lineage.</p>
@@ -5849,7 +5849,7 @@ function DeskDetailPanel({
5849
5849
  <section className="category-steering-section news-desk-desk-detail" aria-labelledby="selected-desk-title">
5850
5850
  <header className="news-desk-topic-detail__header">
5851
5851
  <div>
5852
- <p className="story-label">News Desk</p>
5852
+ <p className="story-label">News section</p>
5853
5853
  <h3 id="selected-desk-title">{rootNode.displayName}</h3>
5854
5854
  <span>{rootNode.shortTitle ?? deriveShortTitle(rootNode.displayName)}</span>
5855
5855
  </div>
@@ -5932,7 +5932,7 @@ function CategoryDoctrineEditorCard({
5932
5932
  <article className="news-desk-doctrine-card" data-news-desk-doctrine={definition.slug}>
5933
5933
  <header className="news-desk-doctrine-card__header">
5934
5934
  <div>
5935
- <p className="story-label">Desk Doctrine</p>
5935
+ <p className="story-label">Section doctrine</p>
5936
5936
  <h3>{definition.label}</h3>
5937
5937
  </div>
5938
5938
  <span>{record ? "Saved record" : "Empty slot"}</span>
@@ -12297,7 +12297,7 @@ function AssignmentRow({
12297
12297
  {context ? (
12298
12298
  <>
12299
12299
  <p className="news-desk-assignment-row__angle">
12300
- <span>{context.deskTitle ?? context.deskKey ?? "Desk"}</span>
12300
+ <span>{context.deskTitle ?? context.deskKey ?? "Section"}</span>
12301
12301
  {`${context.focusTitle ?? context.focusKey ?? "focus"} / ${context.targetSystemType ?? context.contextProfile ?? "context"}`}
12302
12302
  </p>
12303
12303
  {context.contextProfile || context.contextTokenBudget ? (
@@ -12412,7 +12412,7 @@ function normalizeAnalysisReindexMode(value: string | null | undefined): Analysi
12412
12412
  function formatAnalysisReindexModeLabel(mode: AnalysisReindexMode): string {
12413
12413
  if (mode === "online-update") return "Online update - new or changed accepted references";
12414
12414
  if (mode === "classifier-retrain") return "Classifier retrain - rebuild topic classifier";
12415
- if (mode === "scoped-topic-rebuild") return "Scoped topic rebuild - one desk or focus";
12415
+ if (mode === "scoped-topic-rebuild") return "Scoped topic rebuild - one section or focus";
12416
12416
  if (mode === "entity-graph-rebuild") return "Entity graph rebuild - generated graph outputs";
12417
12417
  return "Generated analysis rebuild - clear and rebuild targeted generated outputs";
12418
12418
  }
@@ -12442,7 +12442,7 @@ function mergeAnalysisCorpora(configuredCorpora: CategorySteeringCorpus[], liveC
12442
12442
  function analysisReindexModeHelp(mode: AnalysisReindexMode): string {
12443
12443
  if (mode === "generated-analysis-rebuild") return "Use this for a full generated-output rebuild for the selected profile. It still creates an assignment only; the worker/operator executes it later.";
12444
12444
  if (mode === "classifier-retrain") return "Use this when authoritative labels or accepted references should retrain the semi-supervised topic classifier.";
12445
- if (mode === "scoped-topic-rebuild") return "Use this when proposals for a desk or focus topic should be regenerated from the accepted taxonomy plus steering feedback.";
12445
+ if (mode === "scoped-topic-rebuild") return "Use this when proposals for a section or focus topic should be regenerated from the accepted taxonomy plus steering feedback.";
12446
12446
  if (mode === "entity-graph-rebuild") return "Use this when generated ontology/entity graph records should be rebuilt from accepted references.";
12447
12447
  return "Use this for incremental processing of new or changed accepted references without clearing broader generated outputs.";
12448
12448
  }
@@ -13092,7 +13092,7 @@ function formatDeskSectionHeadline(section: NewsDeskTab): string {
13092
13092
 
13093
13093
  function formatDeskSectionLede(section: NewsDeskTab): string {
13094
13094
  if (section === "search") return "Search the newsroom knowledge base with semantic and ontology context.";
13095
- if (section === "desks") return "The desks and topic categories that organize coverage, with each desk's mission and policies.";
13095
+ if (section === "desks") return "The sections and topic categories that organize coverage, with each section's mission and policies.";
13096
13096
  if (section === "topics") return "Review suggested changes to the subject areas the newsroom covers.";
13097
13097
  if (section === "concepts") return "Browse people, organizations, places, and ideas found in the source material.";
13098
13098
  if (section === "references") return "Review source materials before they become usable evidence.";
@@ -14,7 +14,9 @@ export type AutomationUsage = {
14
14
  };
15
15
 
16
16
  export type AutomationRun = {
17
- origin: "monitor" | "manual";
17
+ origin: "monitor" | "manual" | "stage";
18
+ /** The stage a story entered to start this run (origin "stage"). */
19
+ stage?: string | null;
18
20
  requestedBy: string | null;
19
21
  reason: string | null;
20
22
  sourceId: string | null;
@@ -28,6 +30,10 @@ export type AutomationRun = {
28
30
  error: string | null;
29
31
  runRecord: "found" | "missing" | "unavailable" | "none";
30
32
  storyId: string | null;
33
+ /** False when no version of the story remains (it was deleted); null when this was not looked up. */
34
+ storyExists?: boolean | null;
35
+ /** Tool name to number of calls the procedure made, when the run recorded them. */
36
+ toolsUsed?: Record<string, number> | null;
31
37
  storyVersionId: string | null;
32
38
  storyTitle: string | null;
33
39
  costUsd: number | null;
@@ -164,7 +170,13 @@ export function costText(run: Pick<AutomationRun, "costUsd" | "costNote" | "over
164
170
  return run.overBudget && run.budgetUsd != null ? `${base}. Over the budget of ${dollars(run.budgetUsd)} per run` : base;
165
171
  }
166
172
 
167
- export function whoText(run: Pick<AutomationRun, "origin" | "requestedBy" | "sourceId">): string {
173
+ export function toolsText(run: Pick<AutomationRun, "toolsUsed">): string {
174
+ const tools = Object.entries(run.toolsUsed ?? {}).sort(([a], [b]) => a.localeCompare(b));
175
+ return tools.length ? `Tools used: ${tools.map(([name, count]) => `${name} x${count}`).join(", ")}` : "Tools used: none recorded";
176
+ }
177
+
178
+ export function whoText(run: Pick<AutomationRun, "origin" | "requestedBy" | "sourceId"> & { stage?: string | null }): string {
179
+ if (run.origin === "stage") return `Story entered ${run.stage ?? "a stage"}`;
168
180
  return run.origin === "manual" ? `Run now by ${run.requestedBy ?? "someone"}` : `Item: ${run.sourceId ?? "unknown"}`;
169
181
  }
170
182
 
@@ -1504,7 +1504,7 @@ export function createDemoCategorySteeringDashboard(
1504
1504
  riskFlags: ["Avoid promotional framing."],
1505
1505
  coverageGaps: ["Need a second independent source."],
1506
1506
  openQuestions: ["Which users are affected first?"],
1507
- copywriterBrief: "Use accepted evidence and recent desk memory before drafting.",
1507
+ copywriterBrief: "Use accepted evidence and recent section memory before drafting.",
1508
1508
  },
1509
1509
  },
1510
1510
  },
@@ -1654,8 +1654,8 @@ export function createDemoCategorySteeringDashboard(
1654
1654
  slug: "editorial-doctrine-policy",
1655
1655
  title: "Editorial Policy",
1656
1656
  body: [
1657
- "Publication doctrine is the global inclusion standard; desk doctrine sets local source priorities and framing.",
1658
- "News reporting should prioritize current, journalistic, and official sources; desk workflows may use academic research as background unless a desk policy explicitly makes research the primary evidence class.",
1657
+ "Publication doctrine is the global inclusion standard; section doctrine sets local source priorities and framing.",
1658
+ "News reporting should prioritize current, journalistic, and official sources; section workflows may use academic research as background unless a section policy explicitly makes research the primary evidence class.",
1659
1659
  "High-risk domains, including health-related claims, require stricter sourcing, explicit uncertainty handling, and clear separation between verified facts and interpretation.",
1660
1660
  ],
1661
1661
  editorial: { kind: "policy" },
@@ -41,7 +41,7 @@ export function formatConsoleWebContextMessage(
41
41
  webPath ? `Web path: ${webPath}` : null,
42
42
  objectUri ? `Focused object: ${objectUri}` : null,
43
43
  label ? `Label: ${label}` : null,
44
- tab ? `Desk: ${tab}` : null,
44
+ tab ? `Section: ${tab}` : null,
45
45
  viewMode ? `View: ${viewMode}` : null,
46
46
  ].filter((line): line is string => Boolean(line));
47
47
 
@@ -237,7 +237,7 @@ export function applyPilobolusDemoDashboardContent(
237
237
  ...record,
238
238
  title: "Pilobolus Editorial Policy",
239
239
  body: [
240
- "Publication doctrine is the global inclusion standard for Pilobol.us; desk doctrine sets local intake priorities for field notes and source intake.",
240
+ "Publication doctrine is the global inclusion standard for Pilobol.us; section doctrine sets local intake priorities for field notes and source intake.",
241
241
  "Field-notes reporting should prioritize accepted Pilobolus references and annotated intake artifacts—not the Papyrus platform knowledge base.",
242
242
  "High-risk claims about host takeover or network control require explicit uncertainty handling and accepted source linkage.",
243
243
  ],
@@ -21,7 +21,7 @@ export type NewsroomNavItem = {
21
21
  };
22
22
 
23
23
  export const NEWSROOM_OPS_NAV: NewsroomNavItem[] = [
24
- { id: "overview", label: "Overview", detail: "Desk home", href: newsroomHref() },
24
+ { id: "overview", label: "Overview", detail: "Newsroom home", href: newsroomHref() },
25
25
  { id: "articles", label: getContentItemName().plural, detail: "Drafts & published", href: newsroomHref("articles") },
26
26
  { id: "assignments", label: "Assignments", detail: "Work queue", href: newsroomHref("assignments") },
27
27
  { id: "references", label: "References", detail: "Knowledge base", href: newsroomHref("references") },
@@ -98,12 +98,13 @@ type SignInFailureBranch = "pending_cookie" | "provider_error" | "missing_parame
98
98
 
99
99
  /**
100
100
  * One structured line per failed callback: `prospectus_sign_in_failed {"code":...,"branch":...}`. It carries only the
101
- * code, the branch, and (for errors from completing the sign-in) the HTTP status, Prospectus error code and error name.
101
+ * code, the branch, and (for errors from completing the sign-in) the HTTP status, Prospectus error code, its rule text
102
+ * (`prospectusMessage`, which names the broken rule and carries no personal data) and error name.
102
103
  * Never tokens, authorization codes, state, emails or subjects.
103
104
  */
104
105
  function logSignInFailure(
105
106
  options: ProspectusSignInRouteOptions,
106
- entry: { code: string; branch: SignInFailureBranch; httpStatus?: number; prospectusError?: string; errorName?: string },
107
+ entry: { code: string; branch: SignInFailureBranch; httpStatus?: number; prospectusError?: string; prospectusMessage?: string; errorName?: string },
107
108
  ): void {
108
109
  (options.log ?? console.error)(`prospectus_sign_in_failed ${JSON.stringify(entry)}`);
109
110
  }
@@ -186,7 +187,7 @@ async function completeCallback(
186
187
  options: ProspectusSignInRouteOptions,
187
188
  nowInSeconds: number,
188
189
  ): Promise<NextResponse> {
189
- const failed = (code: string, branch: SignInFailureBranch, detail: { httpStatus?: number; prospectusError?: string; errorName?: string } = {}) => {
190
+ const failed = (code: string, branch: SignInFailureBranch, detail: { httpStatus?: number; prospectusError?: string; prospectusMessage?: string; errorName?: string } = {}) => {
190
191
  logSignInFailure(options, { code, branch, ...detail });
191
192
  return signInFailedRedirect(request, options, pendingSignIn?.returnPath ?? null, code);
192
193
  };
@@ -220,7 +221,7 @@ async function completeCallback(
220
221
  );
221
222
  return response;
222
223
  } catch (error) {
223
- if (error instanceof ProspectusAccountsError) return failed(error.code, "completion", { httpStatus: error.status, prospectusError: error.code });
224
+ if (error instanceof ProspectusAccountsError) return failed(error.code, "completion", { httpStatus: error.status, prospectusError: error.code, prospectusMessage: error.message.slice(0, 300) });
224
225
  return failed("sign_in_failed", "unexpected", { errorName: error instanceof Error ? error.name : "error" });
225
226
  }
226
227
  }
@@ -132,19 +132,28 @@ export function signedProspectusTransport(options: SignedProspectusTransportOpti
132
132
  export class ProspectusSiteAccounts {
133
133
  constructor(private readonly transport: ProspectusTransport) {}
134
134
 
135
- requestGoogleSignInChallenge(): Promise<{ challengeId: string; nonce: string; expiresAt: number }> {
135
+ requestGoogleSignInChallenge(): Promise<{ challengeId: string; nonce: string; expiresAt: number; challengeToken?: string }> {
136
136
  return this.transport({ path: "/v1/identities/google/challenge", method: "POST", body: {} }) as Promise<{
137
137
  challengeId: string;
138
138
  nonce: string;
139
139
  expiresAt: number;
140
+ challengeToken?: string;
140
141
  }>;
141
142
  }
142
143
 
143
- resolveGoogleSignIn(challengeId: string, humanAccessToken: string, identityToken: string): Promise<{ person: ProspectusPerson }> {
144
+ /**
145
+ * When Prospectus signs its challenges it stores nothing, so the signed `challengeToken` is the only proof the
146
+ * challenge exists and must be sent back unchanged. Without a token (a service that stores challenges) the ID is sent.
147
+ */
148
+ resolveGoogleSignIn(
149
+ challenge: { challengeId: string; challengeToken?: string },
150
+ humanAccessToken: string,
151
+ identityToken: string,
152
+ ): Promise<{ person: ProspectusPerson }> {
144
153
  return this.transport({
145
154
  path: "/v1/identities/google/resolve",
146
155
  method: "POST",
147
- body: { challengeId },
156
+ body: challenge.challengeToken ? { challengeToken: challenge.challengeToken } : { challengeId: challenge.challengeId },
148
157
  humanAccessToken,
149
158
  identityToken,
150
159
  }) as Promise<{ person: ProspectusPerson }>;
@@ -197,6 +206,8 @@ export interface GoogleOnlyCognitoClient {
197
206
  export interface PendingProspectusSignIn {
198
207
  challengeId: string;
199
208
  nonce: string;
209
+ /** The signed challenge from Prospectus, present when it signs challenges instead of storing them. */
210
+ challengeToken?: string;
200
211
  state: string;
201
212
  codeVerifier: string;
202
213
  expiresAt: number;
@@ -242,7 +253,13 @@ export async function beginProspectusGoogleSignIn(
242
253
  }).toString();
243
254
  return {
244
255
  authorizationUrl: authorizationUrl.toString(),
245
- pendingSignIn: { challengeId: challenge.challengeId, nonce: challenge.nonce, state, codeVerifier, expiresAt: challenge.expiresAt },
256
+ pendingSignIn: {
257
+ challengeId: challenge.challengeId,
258
+ nonce: challenge.nonce,
259
+ ...(challenge.challengeToken ? { challengeToken: challenge.challengeToken } : {}),
260
+ state, codeVerifier,
261
+ expiresAt: challenge.expiresAt,
262
+ },
246
263
  };
247
264
  }
248
265
 
@@ -303,7 +320,7 @@ export async function completeProspectusGoogleSignIn(
303
320
  expiresInSeconds: Number(tokenBody.expires_in) || 0,
304
321
  };
305
322
  await (input.verifyCognitoTokens ?? cognitoTokenVerifier(input.cognitoClient))(tokens, input.pendingSignIn.nonce);
306
- const { person } = await input.accounts.resolveGoogleSignIn(input.pendingSignIn.challengeId, tokens.accessToken, tokens.idToken);
323
+ const { person } = await input.accounts.resolveGoogleSignIn(input.pendingSignIn, tokens.accessToken, tokens.idToken);
307
324
  return {
308
325
  person,
309
326
  accessToken: tokens.accessToken,
@@ -97,3 +97,21 @@ export async function markReviewRead(userKey: string, requestId: string, now: Da
97
97
  await run(`mutation Mark($input: UpdateReviewReadMarkInput!) { updateReviewReadMark(input: $input) { id } }`, { input });
98
98
  }
99
99
  }
100
+
101
+ const MAX_TITLE_LOOKUPS = 40;
102
+
103
+ /** Titles of the stories that review requests are about, by lineage id. A story that cannot be read simply has no entry (the card falls back to the id). */
104
+ export async function loadStoryTitles(ids: string[]): Promise<Record<string, string>> {
105
+ const titles: Record<string, string> = {};
106
+ await Promise.all([...new Set(ids)].slice(0, MAX_TITLE_LOOKUPS).map(async (id) => {
107
+ const result = await run(
108
+ `query StoryTitle($lineageId: ID!) { listItemsByLineageAndVersion(lineageId: $lineageId, limit: 50) { items { versionNumber headline title } } }`,
109
+ { lineageId: id },
110
+ );
111
+ const items = (result.data?.listItemsByLineageAndVersion?.items ?? []).filter(Boolean) as { versionNumber?: number; headline?: string; title?: string }[];
112
+ const newest = items.sort((a, b) => (b.versionNumber ?? 0) - (a.versionNumber ?? 0))[0];
113
+ const title = newest?.headline || newest?.title;
114
+ if (title) titles[id] = title;
115
+ }));
116
+ return titles;
117
+ }
@@ -145,6 +145,12 @@ export function steeringText(request: ReviewRequest): string | null {
145
145
  return `You steered ${said}. ${request.raisedBy} has not used it yet; it will on its next run.`;
146
146
  }
147
147
 
148
+ /** What a request is about, in words: a story shows its title (the id when the title is not known), anything else its type and id. */
149
+ export function subjectLabel(subject: { type: string; id: string }, titles: Readonly<Record<string, string>> = {}): string {
150
+ if (subject.type === "story") return titles[subject.id] ?? `story ${subject.id}`;
151
+ return `${subject.type.replace("_", " ")} ${subject.id}`;
152
+ }
153
+
148
154
  export function subjectHref(subject: { type: string; id: string }, demo = false): string | null {
149
155
  const id = encodeURIComponent(subject.id);
150
156
  if (subject.type === "story") return getNewsroomNavHref(`/newsroom/articles/${id}`, demo);
package/package.json CHANGED
@@ -448,7 +448,7 @@
448
448
  "optional": true
449
449
  }
450
450
  },
451
- "version": "5.36.0",
451
+ "version": "5.38.0",
452
452
  "dependencies": {
453
453
  "@aws-amplify/adapter-nextjs": "^1.7.3",
454
454
  "@aws-crypto/sha256-js": "5.2.0",
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "5.36.0",
2
+ "version": "5.38.0",
3
3
  "routes": [
4
4
  {
5
5
  "file": "app/%5F_preview/[[...path]]/route.ts",