@happyvertical/smrt-ingestion 0.57.20 → 0.57.22

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 (115) hide show
  1. package/FEEDBACK.md +137 -0
  2. package/PROPOSALS.md +146 -5
  3. package/README.md +168 -2
  4. package/REVIEW.md +611 -7
  5. package/agents/execution.md +87 -0
  6. package/dist/chunks/{execution-NoDh3pjs.js → execution-BzVQax0n.js} +528 -42
  7. package/dist/chunks/execution-BzVQax0n.js.map +1 -0
  8. package/dist/chunks/{feedback-CJB900iG.js → feedback-D3UTF8fm.js} +175 -36
  9. package/dist/chunks/feedback-D3UTF8fm.js.map +1 -0
  10. package/dist/chunks/{proposal-errors-VxcTU6Db.js → proposal-contracts-77Uo1B1j.js} +38 -44
  11. package/dist/chunks/proposal-contracts-77Uo1B1j.js.map +1 -0
  12. package/dist/chunks/reviewer-instruction-CEc3AOuU.js +33 -0
  13. package/dist/chunks/reviewer-instruction-CEc3AOuU.js.map +1 -0
  14. package/dist/dto.d.ts +75 -0
  15. package/dist/dto.d.ts.map +1 -1
  16. package/dist/execution-contracts.d.ts +4 -1
  17. package/dist/execution-contracts.d.ts.map +1 -1
  18. package/dist/execution-dto.d.ts +16 -0
  19. package/dist/execution-dto.d.ts.map +1 -1
  20. package/dist/execution-internal.d.ts +2 -0
  21. package/dist/execution-internal.d.ts.map +1 -1
  22. package/dist/execution.d.ts +101 -4
  23. package/dist/execution.d.ts.map +1 -1
  24. package/dist/execution.js +2 -2
  25. package/dist/feedback-dto.d.ts +18 -4
  26. package/dist/feedback-dto.d.ts.map +1 -1
  27. package/dist/feedback-internal.d.ts +9 -0
  28. package/dist/feedback-internal.d.ts.map +1 -1
  29. package/dist/feedback.d.ts +10 -0
  30. package/dist/feedback.d.ts.map +1 -1
  31. package/dist/index.js +2 -2
  32. package/dist/manifest.json +339 -3
  33. package/dist/models.d.ts +15 -0
  34. package/dist/models.d.ts.map +1 -1
  35. package/dist/models.js +82 -2
  36. package/dist/models.js.map +1 -1
  37. package/dist/policy.d.ts +3 -0
  38. package/dist/policy.d.ts.map +1 -1
  39. package/dist/presented-filename.d.ts +11 -0
  40. package/dist/presented-filename.d.ts.map +1 -0
  41. package/dist/proposal-contracts.d.ts +31 -6
  42. package/dist/proposal-contracts.d.ts.map +1 -1
  43. package/dist/proposal-dto.d.ts +17 -0
  44. package/dist/proposal-dto.d.ts.map +1 -1
  45. package/dist/proposal-errors.d.ts +20 -0
  46. package/dist/proposal-errors.d.ts.map +1 -1
  47. package/dist/proposal-validation.d.ts +26 -0
  48. package/dist/proposal-validation.d.ts.map +1 -1
  49. package/dist/proposals.d.ts +6 -1
  50. package/dist/proposals.d.ts.map +1 -1
  51. package/dist/proposals.js +268 -71
  52. package/dist/proposals.js.map +1 -1
  53. package/dist/redaction.d.ts +9 -0
  54. package/dist/redaction.d.ts.map +1 -0
  55. package/dist/review-dto.d.ts +108 -5
  56. package/dist/review-dto.d.ts.map +1 -1
  57. package/dist/review-host.d.ts +52 -3
  58. package/dist/review-host.d.ts.map +1 -1
  59. package/dist/review-instruction-view.d.ts +12 -0
  60. package/dist/review-instruction-view.d.ts.map +1 -0
  61. package/dist/review-summary.d.ts.map +1 -1
  62. package/dist/reviewer-instruction.d.ts +20 -0
  63. package/dist/reviewer-instruction.d.ts.map +1 -0
  64. package/dist/server.d.ts +252 -13
  65. package/dist/server.d.ts.map +1 -1
  66. package/dist/server.js +1190 -85
  67. package/dist/server.js.map +1 -1
  68. package/dist/smrt-knowledge.json +137 -23
  69. package/dist/sources/common.d.ts +11 -0
  70. package/dist/sources/common.d.ts.map +1 -1
  71. package/dist/sources/email.d.ts.map +1 -1
  72. package/dist/sources/http.d.ts.map +1 -1
  73. package/dist/sources/watch-folder.d.ts.map +1 -1
  74. package/dist/svelte/components/ActionReview.svelte +54 -55
  75. package/dist/svelte/components/ActionReview.svelte.d.ts +8 -1
  76. package/dist/svelte/components/ActionReview.svelte.d.ts.map +1 -1
  77. package/dist/svelte/components/CandidatePicker.svelte +72 -0
  78. package/dist/svelte/components/CandidatePicker.svelte.d.ts +15 -0
  79. package/dist/svelte/components/CandidatePicker.svelte.d.ts.map +1 -0
  80. package/dist/svelte/components/EvidenceViewer.svelte +60 -4
  81. package/dist/svelte/components/EvidenceViewer.svelte.d.ts +12 -1
  82. package/dist/svelte/components/EvidenceViewer.svelte.d.ts.map +1 -1
  83. package/dist/svelte/components/InboxView.svelte +172 -19
  84. package/dist/svelte/components/InboxView.svelte.d.ts +36 -2
  85. package/dist/svelte/components/InboxView.svelte.d.ts.map +1 -1
  86. package/dist/svelte/components/IntakeInbox.svelte +6 -3
  87. package/dist/svelte/components/IntakeInbox.svelte.d.ts +4 -1
  88. package/dist/svelte/components/IntakeInbox.svelte.d.ts.map +1 -1
  89. package/dist/svelte/components/IntakeReview.svelte +4 -2
  90. package/dist/svelte/components/IntakeReview.svelte.d.ts +2 -0
  91. package/dist/svelte/components/IntakeReview.svelte.d.ts.map +1 -1
  92. package/dist/svelte/components/IntakeWorkspace.svelte +27 -0
  93. package/dist/svelte/components/IntakeWorkspace.svelte.d.ts +19 -0
  94. package/dist/svelte/components/IntakeWorkspace.svelte.d.ts.map +1 -0
  95. package/dist/svelte/components/ReviewView.svelte +653 -71
  96. package/dist/svelte/components/ReviewView.svelte.d.ts +20 -1
  97. package/dist/svelte/components/ReviewView.svelte.d.ts.map +1 -1
  98. package/dist/svelte/components/TriageView.svelte +669 -0
  99. package/dist/svelte/components/TriageView.svelte.d.ts +16 -0
  100. package/dist/svelte/components/TriageView.svelte.d.ts.map +1 -0
  101. package/dist/svelte/i18n.d.ts +56 -0
  102. package/dist/svelte/i18n.d.ts.map +1 -1
  103. package/dist/svelte/i18n.js +56 -0
  104. package/dist/svelte/index.d.ts +2 -1
  105. package/dist/svelte/index.d.ts.map +1 -1
  106. package/dist/svelte/index.js +1 -0
  107. package/dist/svelte/view-utils.d.ts +67 -0
  108. package/dist/svelte/view-utils.d.ts.map +1 -1
  109. package/dist/svelte/view-utils.js +157 -0
  110. package/package.json +13 -13
  111. package/dist/chunks/execution-NoDh3pjs.js.map +0 -1
  112. package/dist/chunks/execution-internal-DWteDHM2.js +0 -7
  113. package/dist/chunks/execution-internal-DWteDHM2.js.map +0 -1
  114. package/dist/chunks/feedback-CJB900iG.js.map +0 -1
  115. package/dist/chunks/proposal-errors-VxcTU6Db.js.map +0 -1
package/REVIEW.md CHANGED
@@ -1,9 +1,12 @@
1
1
  # Authenticated review surfaces
2
2
 
3
- `@happyvertical/smrt-ingestion/svelte` exports `IntakeInbox`, `IntakeReview` and
4
- `EvidenceViewer`. The browser-safe DTO entry exports their `IntakeReviewHost`
3
+ `@happyvertical/smrt-ingestion/svelte` exports `IntakeInbox`, `IntakeReview`,
4
+ `IntakeWorkspace` and `EvidenceViewer`. The browser-safe DTO entry exports their `IntakeReviewHost`
5
5
  callback contract. Import styling/theme configuration from smrt-ui in the host.
6
6
  Components use smrt-ui primitives and the `ingestion.*` i18n catalog.
7
+ Existing `IntakeInbox` hosts now keep the previous rows visible, with their
8
+ controls disabled, while a refresh or filter change loads; any failed read
9
+ (Load more included) still clears them.
7
10
 
8
11
  The host authenticates every request, resolves tenant/actor/confidential scope
9
12
  from its session, enforces CSRF on mutations, and constructs `IngestionService`.
@@ -32,6 +35,41 @@ Plan membership contains the exact parent args/version so edits call
32
35
  `previewPlan`, not per-step correction. An item may have more review pages; the
33
36
  host must aggregate state across pages when projecting a production inbox state.
34
37
 
38
+ A `needs_review` suggestion is completable, not only rejectable (#4208). The
39
+ component shows its `missingFields`, an editable `Suggested arguments (JSON)`
40
+ draft prefilled from the generated arguments, and the candidate picker for
41
+ declared candidate fields; Prepare review stays disabled while required fields
42
+ are still missing from an unchanged draft. An unchanged draft previews the
43
+ generated snapshot as before; an edited draft is sent as `preview({..., args})`,
44
+ which the published host publishes through the ordinary `previewProposal` /
45
+ `previewPlan` with the same `ui:<index>` intention, never by rewriting the
46
+ generation. The handler validates the human arguments again before a
47
+ `waiting_review` action exists, approval remains a separate explicit decision,
48
+ `ActionReview` lists required arguments missing from the current draft while
49
+ editing, and no authority or automation eligibility is added: a failed attempt,
50
+ an unoffered handler or a foreign stage is still refused by the host.
51
+ `preview` throws on every failure, as before. When the handler's argument
52
+ schema or `validate` refused the arguments being previewed (operation preview,
53
+ which also carries corrections, or plan preview), the server throws
54
+ `HandlerArgumentsInvalidError` (`name` set, messages unchanged). Apply-time and
55
+ external-send revalidation and result-schema checks keep a plain `Error`. The component shows an inline message and
56
+ keeps the view and draft when `preview` either rejects with an error whose
57
+ `name` is `HandlerArgumentsInvalidError` or resolves `{ rejected: 'invalid' }`
58
+ (`PreviewRejection`). A transport that crosses a process boundary preserves
59
+ the inline UX by keeping that name (for example re-throwing an `Error` with
60
+ `name = 'HandlerArgumentsInvalidError'` in the browser callback) or by
61
+ returning `{ rejected: 'invalid' }` for that case only, as the maintained
62
+ fixture's `previewForReview` does. Without either, the refusal behaves like
63
+ any other failure: the view is cleared exactly as before this change, and the
64
+ reviewer's in-memory draft returns after an authorized refresh. Every other
65
+ thrown preview or candidate-search failure (denied, revoked, unavailable or
66
+ unknown) clears the view.
67
+ Unsent drafts live only in component memory, keyed by attempt and suggestion
68
+ index. They survive Refresh, a failed callback, an action on another card and a
69
+ transient `unavailable`/`stale` reload; a `revoked` or `expired` result
70
+ discards them, a successful preview clears that card's draft, and an available
71
+ reload drops drafts for attempts or cards no longer offered.
72
+
35
73
  The host's `load` callback supplies a current `ItemReviewView`, using
36
74
  `getCompletedAnalysis` with no historical fallback. Generated suggestions bind
37
75
  the **current generation attempt**, not `generation.source.attemptId` (the prior
@@ -68,6 +106,49 @@ Denied refresh clears prior originals, candidates, arguments and results.
68
106
  Missing confidence is unavailable; page/time links exist only for observed
69
107
  locations. Provider errors and unresolved/partial results remain review states.
70
108
 
109
+ ## Submitter-scoped decisions
110
+
111
+ For trusted internal sources the person who submitted an item may confirm or
112
+ decline their own proposal without the reviewer inbox. `listOwnProposals`
113
+ returns only items whose server-recorded `submitter_id` equals the current
114
+ authenticated actor, each with its bounded saved review page reloaded under
115
+ current item, handler and target authority. An item the actor can no longer
116
+ read (the host `authorize` returns false or throws `IntakeAccessDeniedError`)
117
+ is skipped before projection: the page is filled from readable items only, in
118
+ 50-row batches, and `nextCursor` names the last emitted item and is present
119
+ only when a further readable item was actually found. Neither the entries, the
120
+ page size nor the cursor reveal a hidden item. If one call has to skip 1000
121
+ hidden submissions it fails with `Own proposal scan limit` rather than return a
122
+ short page or a cursor that stops inside the hidden run; this error is the only
123
+ signal, and it says only that such a run exists. Any other `authorize` error
124
+ propagates instead of shrinking the list. An item that is still readable but
125
+ whose reviews are denied is returned as `{ item, reviews: null, unavailable:
126
+ true }`: that covers ingestion's own denials and any throw from the host
127
+ `assertTarget` (deleted or revoked targets), whose text is never returned.
128
+ Integrity, validation and database errors from ingestion itself propagate. The
129
+ reviewer `listReviews` surface keeps propagating host target errors unchanged.
130
+ `submitOwnDecision` takes the same `ReviewInput` CAS fields and request ID as
131
+ `submitDecision` and accepts `approve`, `reject` and `defer` only; `correct`
132
+ belongs to the reviewer surface. It runs under the `review_own` execution
133
+ operation: the host's `authorize` must return `submitter: true`, the resolved
134
+ policy must have `submitterReview` enabled, and the recorded submitter must be
135
+ the current actor and also the receipt's captured execution principal (the
136
+ identity the effect runs as), checked under the item lock and again at apply.
137
+ A receipt captured for a bot or service principal is never self-approvable. Policy enablement, handler/operation allowlists, captured
138
+ ceiling, confidential access, retention and the first-valid-decision lock are
139
+ unchanged. The decision is a human decision by that actor, recorded with
140
+ `mode: 'review_own'`; reviewer decisions carry `mode: 'review'`. In both modes
141
+ `selfDecision` is true exactly when the deciding actor is the item's
142
+ submitter, so a reviewer approving their own submission is never independent
143
+ review. A self-decision is not a reviewer judgment: it never feeds
144
+ automation eligibility (machine authorization remains the separate evaluated
145
+ `kind: 'machine'` path) and its feedback events carry the same marker. The
146
+ `unavailable` entry discloses nothing about the denied handler or target.
147
+ `applyAction` then runs once under the approver's live grants exactly as
148
+ for reviewer decisions. Hosts render this surface from their own authenticated
149
+ session; presentation state confers no authority, and a source payload cannot
150
+ nominate a submitter.
151
+
71
152
  ## Assignment and logical splits
72
153
 
73
154
  Assignment is explicitly host-owned, durable and versioned. `assign` receives
@@ -98,11 +179,365 @@ with `human_split_not_respected`. New proposals require new explicit review.
98
179
  The reference host runs these stages synchronously for browser determinism;
99
180
  production hosts dispatch the existing durable analysis jobs.
100
181
 
182
+ ## Reviewer instructions
183
+
184
+ `recordReviewerInstruction({itemId, attemptId, expectedRevision, text,
185
+ requestId})` records a plain-text instruction for one item ("This is a customer
186
+ PO from Badger; file it, do not bill it") as the second kind of **human
187
+ correction**. It is authorized exactly like `reviseLogicalSplit`: the current
188
+ generation must pass the same-executor catalog/candidate gate and the reviewer
189
+ grant plus policy reviewer list, and authority is checked before mutation and
190
+ again before publication. Process access, `analyze`, and a client payload cannot
191
+ mint one: `analyze` rejects a `reviewerInstruction` key on an extraction
192
+ configuration, and an interpretation copy that differs from the pinned
193
+ extraction's record fails with `Generation evidence changed` before provider
194
+ disclosure. Text is required, trimmed-nonempty and at most 4,000 characters.
195
+
196
+ The record is immutable: `{id, text, actorId, recordedAt, supersedesId, digest}`
197
+ plus the request binding, written into a new extraction configuration beside
198
+ any existing split correction. Recording supersedes current suggestions and
199
+ approvals, queues the normal fenced re-extraction and interpretation through the
200
+ existing dispatch path, and returns immediately; the reviewer keeps working
201
+ while the item reports `processing`. `prepareGeneration` copies the instruction
202
+ into the interpretation configuration, the generator receives it only after its
203
+ digest matches the authoritative extraction record, and saved generation
204
+ provenance records `reviewerInstruction: {id, digest}`. A later instruction
205
+ records `supersedesId` and replaces the earlier one for future interpretations;
206
+ `listReviewerInstructions(itemId)` returns the full history, newest first, and
207
+ history is never deleted. The instruction steers the model only: it cannot add
208
+ handlers, candidates, schemas, permissions or automation eligibility, replies
209
+ are validated as usual, and suggestions still require explicit preview and
210
+ approval. A replay with the same request ID returns the same revision; a changed
211
+ text under the same request ID conflicts.
212
+
213
+ **Cost.** Each recorded instruction appends a new extraction revision, so the
214
+ item is extracted again before it is interpreted again: cheap for embedded-text
215
+ PDFs and plain text, but OCR/speech hosts spend provider budget per instruction,
216
+ exactly as they do per split correction. The pinned extraction is not reused
217
+ because the interpretation contract requires its source to be the immediately
218
+ preceding revision and `getCompletedAnalysis(itemId, attemptId)` serves only
219
+ the current revision (PROPOSALS.md, "Durable stages"); reusing it would mean
220
+ weakening that predecessor pin, which protects every generation from an
221
+ intervening reprocess, so this feature follows the split path instead.
222
+
223
+ **State and failure.** An in-flight stage (queued/running) reports the item as
224
+ `processing` even when earlier, now-stale actions exist. If the re-extraction or
225
+ interpretation fails, `getReinterpretationState(itemId)` returns the latest
226
+ attempt's safe failure category and the latest completed generation attempt;
227
+ the host projects them as `instruction.failure` and `instruction.binding`, and
228
+ `recordReviewerInstruction` accepts that historical attempt as its anchor when
229
+ `expectedRevision` is the item's current (failed) revision and no later
230
+ **interpretation** completed and nothing is queued or running, so the reviewer
231
+ can retry or change the instruction after either stage fails. A completed
232
+ extraction whose own interpretation failed belongs to the failed attempt, not a
233
+ newer result; a completed extraction whose interpretation has **not been
234
+ appended yet** is in flight (the host is between stages) and is refused with
235
+ `ReviewerInstructionStaleError` (`Stale reviewer instruction`), so a second
236
+ reviewer cannot pre-empt it. That gap expires: once the extraction attempt's
237
+ completion plus the item's analysis lease duration (`limits.leaseMs`, the lease
238
+ `claimAnalysis` grants) has passed with no interpretation appended,
239
+ `getReinterpretationState` reports `failure: 'not_started'` with the retry
240
+ binding and the guard allows the retry. `not_started` means **the
241
+ interpretation did not start within the lease window**: the host crashed,
242
+ threw between `prepareGeneration` and `analyze`, or its queue is merely slow.
243
+ Pre-empting a slow but healthy host is safe because `analyze` refuses an
244
+ interpretation whose pinned extraction is no longer the item's latest, under
245
+ the item lock and before anything is superseded
246
+ (`GenerationSourceStaleError`, nothing appended): the slow host's late append
247
+ cannot clobber the retry. Missing or malformed timing (no attempt, no
248
+ `completedAt`, non-finite `leaseMs`) counts as already expired rather than
249
+ holding retries forever. The "still being re-read" note shown after a stale
250
+ result is cleared by the next reload that shows the item settled; the draft
251
+ then carries a persistent "This item changed while you were writing; your
252
+ instruction was not recorded." note (any stale cause: another reinterpretation,
253
+ a split, or a host reprocess) until the reviewer edits or resubmits it. The guard and
254
+ `getReinterpretationState` share one predicate, and the latter's
255
+ `reinterpreting` flag (projected as `instruction.reinterpreting`) is true in
256
+ the gap too, so the component keeps polling and keeps Re-read disabled there.
257
+ The published host maps two typed errors to `instruct` results:
258
+ `ReviewerInstructionStaleError` → `{ stale: true }` (refresh-and-retry: reload
259
+ with a note, draft kept) and `ReviewerInstructionInvalidError` → `{ invalid:
260
+ true }` (empty, whitespace-only or over 4,000 characters: shown inline, draft
261
+ kept). Neither is a denial. Custom hosts should map them the same way; a host
262
+ that lets either propagate as a thrown error makes the component treat it like
263
+ any other failed mutation, i.e. a whole-view deny with the "view unavailable"
264
+ message, as before these results existed. The maintained fixture's
265
+ `/api/instruct` now answers an invalid text with HTTP 200 `{ invalid: true }`
266
+ instead of 409. A retry from an older anchor builds on the **whole configuration**
267
+ of the newest extraction after it (replacing only the instruction), because that
268
+ is the item's current configuration: corrections chain forward through it and a
269
+ host reprocess replaces it. So a split followed by a failed stage is never
270
+ silently dropped, and a failed reprocess's changed configuration is neither
271
+ reverted nor overlaid with a split it dropped. **Concurrency.** `recordReviewerInstruction` and `reviseLogicalSplit` each
272
+ run their whole decision in one transaction under the item row lock: the
273
+ anchor snapshot read takes the lock first, the execution authorization of
274
+ that snapshot takes it again (`UPDATE intake_items` row lock), the guard step
275
+ re-asserts it explicitly, and
276
+ the request-key replay check, the revision and later-work guards, the
277
+ carried-forward split, the history read that sets `supersedesId`, and the
278
+ append all run under it. On PostgreSQL a second reviewer at the same revision
279
+ blocks on that lock and is then refused (`ReviewerInstructionStaleError` /
280
+ `Stale logical split`), so the ledger cannot fork; an instruction racing a
281
+ split (in either order) is refused rather than built from the pre-split
282
+ configuration; and a double-click whose duplicate commits after the unlocked
283
+ replay check is answered as a replay of the committed request, not a conflict
284
+ or a denial.
285
+
286
+ **Host reprocess.** A host reprocess (a plain `analyze()` extraction the host
287
+ configures itself) cannot carry human corrections — `analyze` rejects
288
+ `humanCorrection`/`reviewerInstruction` on extraction configurations — so it
289
+ drops the split and the instruction alike, and neither is applied to its
290
+ interpretations. The instruction is then reported as `dropped`, not pending:
291
+ `getReinterpretationState().instructionInEffect` names the instruction the
292
+ newest extraction carries (verified by digest) and is null after such a
293
+ reprocess. History is unchanged; the reviewer can re-record the instruction,
294
+ which supersedes the dropped one.
295
+
296
+ `ItemReviewView.instruction` exposes `current` (the latest recorded instruction
297
+ still in effect, i.e. carried by the newest extraction), optional `dropped`
298
+ (the latest recorded instruction a reprocess dropped), `applied` (the
299
+ instruction the displayed **generation** ran under; a completed extraction alone
300
+ applies nothing), `stale` (the displayed suggestions predate the current
301
+ instruction), optional `failure` and optional `binding`. Feedback examples that carry instruction
302
+ text count it toward the feedback byte budget (FEEDBACK.md). The optional host callback
303
+ `IntakeReviewHost.instruct` is implemented by the published adapter when the
304
+ service supports it, with an optional `onInstruct(itemId, revision)` wakeup
305
+ mirroring `onSplit`. The review component renders "Instructions for the
306
+ assistant" with "Re-read with these instructions", shows "Reinterpreting…" while
307
+ the item's stage is queued/running, repeats the existing authenticated `load`
308
+ on `pollMs` (default 2 s, backing off to 5×) and patches the view in place —
309
+ drafts, split edits, focus and the evidence viewer survive and no loading
310
+ placeholder is shown — stops when the stage is no longer in flight, and then
311
+ shows the instruction that produced the current suggestions, marking a
312
+ superseded one as stale. An explicit host denial on a poll tick fails closed at
313
+ once; a thrown tick is treated as transient (view and drafts kept, a
314
+ "Couldn't refresh; retrying." note, backoff continues), but because hosts may
315
+ also throw for revoked access, three consecutive thrown ticks fail closed.
316
+ **Hosts must signal revocation by returning `availability: 'revoked'`** (or
317
+ another non-`available` state) from `load`, not by throwing: a returned denial
318
+ clears the view on that tick, while a host that throws on revocation leaves the
319
+ already-displayed content visible until the third thrown tick (about 10 s at the
320
+ default interval and backoff) before the view fails closed.
321
+ Split drafts survive a refresh only when the reviewer edited them. A failed stage shows its safe category with the form
322
+ still available. There is no
323
+ streaming channel; status is read through the existing item/analysis APIs. The
324
+ maintained fixture records the instruction durably and then runs the stages in
325
+ the background so the browser proof exercises the in-place update.
326
+
101
327
  `feedback` is an optional explicit correctness callback, separate from approval
102
328
  choice and execution success. No learning, feedback storage or rule promotion is
103
329
  implemented by these components; #3676 owns those services. Do not pass feedback
104
330
  or a feedback callback to readers without the relevant host grant.
105
331
 
332
+ ## Reviewer-chosen actions and "handled, no action"
333
+
334
+ A reviewer is not limited to model suggestions. `ItemReviewView.catalog` carries
335
+ the handlers the item's catalog currently offers (`listHandlers`, discovery media
336
+ types and live preview authority; it is presentation, never a grant). The optional
337
+ `startAction` callback forwards `StartActionInput` (`itemId`, `handlerId@version`,
338
+ explicit `args`, `requestId`, optional `reason`) to `startReviewerAction`, which
339
+ requires the current reviewer grant under that handler, a completed or partial
340
+ attempt on the current analysis revision, and an operation handler the item's
341
+ evidence media types offer. Plan handlers go through `previewPlan`. The server
342
+ creates an action keyed `reviewer:<requestId>` with origin `reviewer-selected`
343
+ (`createAction` refuses that key prefix and origin, so process authority cannot
344
+ mint reviewer provenance; re-previewing such an action with `previewProposal`
345
+ needs the reviewer grant and keeps its handler fixed, so only its arguments can
346
+ be corrected),
347
+ runs the handler's normal `validate`/`preview`, publishes a `waiting_review`
348
+ revision and requires the same explicit approval and apply as a generated
349
+ proposal. Replays with the same request return the same review; changed arguments
350
+ or reason conflict. Such actions are never automation-eligible: `authorizeAutomatic`
351
+ refuses them regardless of policy opt-ins. The UI pre-fills arguments only from a
352
+ same-handler suggestion (for example a `needs_review` one) and a single original's
353
+ evidence references, lists the required fields still missing, and submits exactly
354
+ what the reviewer typed. An item whose current attempt failed outright must be
355
+ reprocessed first; an interpretation that completed with `provider_error`,
356
+ `unknown` or no suggestions can be acted on directly. `ReviewAction.origin`
357
+ exposes the provenance so `ActionReview` can label human-started actions.
358
+
359
+ The optional `resolve` callback forwards `ResolveItemInput`
360
+ (`disposition: 'resolve_no_action'`, `requestId`, optional `reason`) to
361
+ `resolveItem`. It needs reviewer authority under any handler the item's captured
362
+ ceiling offers and refuses while an action is `authorized` with a live approval,
363
+ `executing`, `outcome_unknown` or `succeeded`; waiting, deferred, rejected and
364
+ failed actions, and authorized ones whose approval expired, may remain (neither
365
+ an exhausted failed action nor a lapsed approval can be rejected, so they must
366
+ not keep the queue open). Afterwards only reject remains: those actions can no
367
+ longer be approved, corrected, deferred, re-previewed, retried, applied or
368
+ automated. `submitDecision` refuses every decision but `reject`, no new revision
369
+ is published for the item, `applyAction` rechecks the disposition before any
370
+ effect, and `analyze`, `reviseLogicalSplit` and `createAction` refuse the item
371
+ under its lock before any write, so it is never reanalysed, re-split or given a
372
+ new action. `resolveItem` supersedes any queued or running analysis under the
373
+ same lock and `claimAnalysis` refuses a resolved item, so no provider is called
374
+ after a human closed it; the item's `processingState` keeps its last value (it
375
+ describes analysis, not review), and the inbox state `resolved` is authoritative.
376
+ `publishProposal` (process authority) refuses a resolved item and any
377
+ reviewer-selected action. The UI hides Approve, Defer, Apply, every edit control, suggestion
378
+ preview and split controls on a resolved item.
379
+ Resolved items hide the instruction editor and stop reinterpretation polling, even if an older processing projection is still queued. Recording an instruction cannot append a new revision after resolution.
380
+
381
+ Paged lists project the resolved state and disposition time in the same bounded, scope-filtered query, so state filters and row controls agree with a full item reload.
382
+
383
+ The append-only `intake_dispositions` record is terminal: replaying the same
384
+ request returns the same disposition, any other request conflicts, and
385
+ `startReviewerAction` refuses the item. `IntakeItemDTO.disposition` projects only
386
+ `{ state: 'resolved', at }` to any item reader (`listItems` reads it in the same
387
+ statement as the page); the reviewer and reason are returned only by
388
+ `getItemDisposition`, which requires reviewer authority, and the review hosts put
389
+ them on `ItemReviewView.disposition` (omitted when that read is denied, so the
390
+ view shows only "Handled, no action"). `loadReviewSummary` reports inbox state `resolved` unless an action
391
+ actually succeeded. Privacy deletion and retention expiry redact the reason and
392
+ reviewer (`intake_dispositions.data`) and keep only the kind. The disposition is
393
+ distinct from `reject`, which still means the proposal was wrong. Closing an item
394
+ records `reviewer_routing` feedback immediately; a started action records it only
395
+ when the reviewer approves it (see FEEDBACK.md). Capture is an optional projection
396
+ that never vetoes the decision or disposition.
397
+
398
+ `startReviewerAction` applies the handler's discovery media types and the live
399
+ reviewer grant/policy for that handler. It intentionally does not apply the
400
+ proposal catalog's provider-policy filter or `maxHandlers` slice: those bound what
401
+ a model may be offered, and a human choosing a handler sends nothing to a
402
+ provider. The UI picker shows the offered catalog, so a handler hidden from it is
403
+ only reachable by a reviewer who already holds `review` authority for it. The maintained
404
+ browser fixture serves both operations behind CSRF and the same session scope.
405
+
406
+ ## Single-page triage workspace
407
+
408
+ `IntakeWorkspace` (#4215) composes the existing pieces so a reviewer works the
409
+ whole queue on one page: the paged inbox as a list pane, the review view of the
410
+ selected item beside it (the original above its action form), and the
411
+ reviewer-instruction message box of the item pane. Props: `host`, `contextKey`
412
+ (replace on session change, as for the other components), optional initial
413
+ `itemId`, `pollMs`, `rowAside` and `onselect(itemId | undefined)`, which only
414
+ reports the selection (to keep a URL in step) and never navigates. It adds **no
415
+ host callback and no server authority**: every read is the host's `list` and
416
+ `load`, every action still goes through the handler preview and an explicit
417
+ approval and apply under the approver's live grants, and the message steers the
418
+ model only, exactly as in `IntakeReview`.
419
+
420
+ - **Inline original.** In the item pane `EvidenceViewer` shows one part at a
421
+ time (`layout="pager"`), opening on the first part that renders inline (the
422
+ uploaded file rather than its capture record), with previous/next original.
423
+ A PDF with more than one *observed* page (extraction locations or proposed
424
+ page groups; `pages` prop) gets previous/next page, which reloads the frame
425
+ at the same-origin URL plus `#page=n`; only `evidenceUrl`-approved URLs ever
426
+ reach a frame. `IntakeReview` gets the same page navigation over its
427
+ unchanged stacked list of parts. The chosen part and page belong to the item
428
+ view (bindable `chosen`/`page`), so they survive in-place reloads, Prepare
429
+ review, Approve and Apply.
430
+ - **Keyboard.** J/K and Alt+Down/Alt+Up move to the next/previous row (loading
431
+ the next page at the end), ignored while focus is in a text field, select or
432
+ editable region; "Previous item"/"Next item" buttons carry
433
+ `aria-keyshortcuts`. The selected row is `aria-current`, and every selection
434
+ moves focus to the item pane region, named by the row's label. A navigation
435
+ that waited for the next page is dropped if the reviewer chose another item
436
+ meanwhile.
437
+ - **Unsent input.** The pane holds unsent input when it has instruction text,
438
+ an edited suggestion draft or split draft, a chosen start handler or start
439
+ reason, a no-action reason, or an action editor with edited arguments, a
440
+ reason or a feedback comment. It is read from the drafts themselves, so it
441
+ holds while a reload has cleared the view; while the view is denied nothing
442
+ is shown and nothing counts (the drafts stay in memory and return with an
443
+ authorized refresh, as before). A reviewer-initiated move (J/K, Alt+arrows,
444
+ Next/Previous item, a row's Review button) then asks inline, in a group
445
+ labelled by its note: "Stay on this item" takes focus and returns it to the
446
+ field the reviewer last used; "Discard and open the other item" proceeds.
447
+ When an item is closed without action (here or by another session), input a
448
+ closed item cannot take
449
+ (instruction, suggestion and split drafts, a started action, a no-action
450
+ reason) is discarded.
451
+ - **Live state.** A row's status follows the item: "Interpreting…", "Ready to
452
+ review", "Needs review" with missing argument names or the generation
453
+ outcome, "Awaiting approval", "Approved, not yet applied", "Failed" with a
454
+ safe category, "Handled, no action", or the listed inbox state. Rows the
455
+ reviewer has not opened show the listed state only; reasons come from a
456
+ loaded view, never from server exception text. The open item's own polling
457
+ (`ReviewView`, unchanged backoff and three-failure fail-closed) reports each
458
+ displayed view and the workspace patches that row in place
459
+ (`InboxView.patch`, keeping the listed label and preview, and the row's
460
+ assignment unless the patch carries a newer assignment version, so a view
461
+ read before an assignment never rolls the row or the next expected version
462
+ back), so the row follows each newer read of the form and the list is
463
+ **not** re-listed per tick. An item the
464
+ reviewer leaves while its stage is in flight, or before the pane showed the
465
+ result of its last operation (an instruction just recorded), keeps
466
+ following: the workspace re-reads followed items with the same `load` (first
467
+ review page) on the pane's backoff, at most **5** reads per tick, one after
468
+ another, least recently read first (so every followed row is reached), with
469
+ only one tick running at a time. At most **20** items are followed; beyond
470
+ that the longest-followed one (the earliest started, whatever the read
471
+ rotation) stops. An item left while its pane was mid-operation (an Apply not
472
+ yet answered) keeps following while a read still shows an action authorized
473
+ or executing: exactly **3** more reads when it never answers, the fourth
474
+ ending the follow. That debt belongs to the operation, so it survives
475
+ reopening and leaving the item again until a view shows the operation
476
+ answered or the reads run out; leaving mid-operation again starts a full
477
+ debt for the new operation. Each follow has a
478
+ generation: an answer from an earlier follow of the same item (one asked
479
+ before the reviewer reopened or left it again) is dropped, and its failure
480
+ does not count toward the new follow's three. An item stops being followed
481
+ when it settles, its view is denied, three reads in a row throw, its row
482
+ leaves the displayed list (a filter or a refreshed page removed it), the
483
+ list fails closed, or the cap drops it; a denial, the failure stop or the
484
+ cap drops its cached status and restores its entry exactly as its page read
485
+ returned it (never a patched entry). Opening an item drops its cached
486
+ status until the pane shows a view; a denial in the open pane (a failed
487
+ action, the pane's own fail-closed poll, or a first load that failed) does
488
+ the same as a follow denial, clears the announcement, and the item is not
489
+ followed when left. A settled stage that was seen running is announced in a
490
+ polite status region ("badger-po.pdf: Ready to review"), only while its row
491
+ is displayed; a refreshed page or a failed list clears it.
492
+ Other rows update on Refresh, a filter change or when opened; there is no
493
+ streaming. While a refresh or "Load more" is pending, the displayed rows stay
494
+ (controls disabled) and keep taking patches. Every read (a page, a pane
495
+ view, including an unchanged poll, and a follow read) is numbered when its
496
+ request starts, and each row shows the entry of its newest read, whatever
497
+ the source: a page read before a pane or follow read never regresses the
498
+ row, and a page read after them replaces what they showed. The same rule
499
+ governs a follow: a follow read older than the row's newest read changes
500
+ no status, does not stop the follow and announces nothing (a newer read
501
+ settles it), so the announcement never contradicts the row. Cached status reasons follow the same ordering, even when entries have identical
502
+ fingerprints. "Load more" continues the
503
+ filter the rows were listed under, not filter inputs edited since, and does
504
+ not repeat a row already shown. A failed "Load more" fails closed like any
505
+ failed read (the host cannot tell a transient failure from a revoked
506
+ grant): the rows clear and the list shows its error.
507
+ - **Approve and advance.** After an approve, apply or "Handled, no action" whose
508
+ reload left nothing for the reviewer (closed without action, or an action
509
+ succeeded and none is waiting for approval, waiting to be applied, executing,
510
+ unknown or failed; stale, rejected and deferred actions do not hold it), the
511
+ item's row is patched and leaves the list when it no longer matches the
512
+ listed state/assignee filter, and the next row that is not closed or
513
+ completed opens and takes focus, with no navigation and no re-listing. An
514
+ approval that leaves an apply step keeps the item open with Apply in place.
515
+ Unloaded saved-review pages and an active background stage also prevent
516
+ settlement. Advancing skips completed inbox pages until a reviewable row
517
+ appears; a page that makes no progress stops the walk.
518
+ It never advances over unsent input on the same item: the item stays open,
519
+ the input stays, and a note says it is finished but has unsent input; the
520
+ reviewer moves on explicitly, through the confirmation; input typed while
521
+ the next page loads counts too. When no open row is displayed, the advance
522
+ appends pages of finished rows until one has an open row or no page
523
+ remains, at most **5** appends. After each append, the advance (like
524
+ J/K at the end of the list) finds its place again by id: just after the
525
+ item it left, or, if that row has gone, at the first row that followed it
526
+ and is still listed, or at the first new row, so a row that left the list
527
+ meanwhile never shifts it onto the wrong item. Its notes (and "nothing else
528
+ needs review") are written into one persistent polite status region. The queue is never reported empty when
529
+ that is unknown: past the bound, or when an append cannot start (a refresh
530
+ is pending) or is superseded, the item stays open with a note to try Next
531
+ item again, which still works; when an append fails, the list has failed
532
+ closed (no rows, Next disabled), so the item stays open with a note to
533
+ refresh the list to continue. The open item stays listed while the
534
+ reviewer works on it even if its state stops matching the filter; it
535
+ leaves once they move on.
536
+ When nothing is left the pane says so. The per-row "Handled, no action"
537
+ control is hidden in the workspace (`resolveInRow={false}`) so closing always
538
+ goes through the item pane and advances.
539
+
540
+
106
541
  ## Maintained browser proof
107
542
 
108
543
  `pnpm test:components` runs real Svelte component tests; `pnpm test:e2e` runs the
@@ -143,18 +578,179 @@ Import `createIntakeReviewHost` and `IntakeReviewHostOptions` from
143
578
  options)` with a service constructed from the current authenticated session.
144
579
  The returned `IntakeReviewHost` implements inbox state projection across saved
145
580
  review pages, current generation loading, stable `ui:<index>` intention keys,
146
- action/plan preview CAS lookup, decisions, apply, corrections, candidates, split
147
- and feedback. Repeated preparation of an unchanged authorized suggestion retains
581
+ action/plan preview CAS lookup, decisions, apply, corrections, candidates, split,
582
+ feedback, the offered `catalog`, `startAction` and `resolve`. Any catalog
583
+ failure (not configured, bound exceeded, denied) omits `catalog`, so the picker
584
+ disappears while existing reviews stay reviewable; item, review and evidence
585
+ denials still propagate as before; the reference host (`reference/review-host.ts`)
586
+ degrades the same way. Repeated
587
+ preparation of an unchanged authorized suggestion retains
148
588
  its current review instead of creating a new revision. Changed arguments still
149
589
  require explicit re-preview and a fresh human decision.
150
- Lists scan 20 items per page; filtered pages may be empty with a `nextCursor`.
151
- Continue until the cursor is absent. Listing does not load original bytes.
590
+ `list({ state?, assigneeId?, cursor?, limit? })` returns one page newest receipt
591
+ first (`intake_items.created_at`, id tiebreak) through `service.listItemsPage`.
592
+ `limit` defaults to 20 and is capped at 100; the cursor is an opaque
593
+ (received-at, id) continuation, never a bare id. Tenant, confidential scope,
594
+ retention and the `state` filter run inside the page query, so a page is full
595
+ and `nextCursor` is present only when more rows follow.
596
+
597
+ Authority is rechecked at the same two levels `load` applies, but per page
598
+ rather than per row: the host authorize callback once per distinct captured
599
+ ceiling in a batch, and the execution read gate (policy, ceiling, handler and
600
+ operation allowlists, principal) once per distinct (item, handler) bound by
601
+ the page's saved actions. The list gate is read-only: it evaluates the same
602
+ checks as `listReviews` without the retention write, so an item that the item
603
+ view would narrow past its deadline is simply not listed. The list gate also
604
+ resolves each bound handler and rejects version drift against the saved
605
+ binding and omits a cancelled item, as `listReviews` does; it does not
606
+ evaluate per-action target,
607
+ evidence or plan gates, so an item whose target became unreadable still lists
608
+ and its item view refuses. An item whose bound
609
+ handler the actor cannot currently read is not listed at all, rather than
610
+ listed with a state derived from actions the actor may not see; actions
611
+ without a proposal bind no handler and are not gated, as in `listReviews`.
612
+ Handler bindings (handler id and version, projected in SQL, distinct per
613
+ item) are read once per fetched batch for the rows that passed the item-level
614
+ gate. Note the host cost plainly: the execution `authorize` callback runs once
615
+ per distinct (item, handler) bound by a scanned row that passed the item-level
616
+ gate. Each item is its own authorization subject, so nothing is deduplicated
617
+ across items; the count is bounded by rows scanned (at most
618
+ `ITEM_SCAN_CEILING`) and runs in host code rather than as N+1 database reads. Either host callback, the item-level `authorize` or the execution
619
+ `authorize` behind the handler gate, follows the same rule: a refusal (false,
620
+ or an access check that fails) hides the rows it covers, and a callback that
621
+ throws rejects the whole page (and `listItems()`) with its error, as `load`
622
+ does, so a transient failure never lets paging move past readable rows.
623
+ Denied or unreadable rows are skipped without
624
+ disclosure and the page refills from the next rows; refills grow from the page
625
+ size to 100 rows per query, so a fully denied scope costs about a dozen page
626
+ reads and as many binding reads per call. One service call examines at most
627
+ `ITEM_SCAN_CEILING` (1000) rows, or the caller's smaller `scanLimit`, and
628
+ reports the count as `scanned`; when a mostly-denied scope reaches the ceiling
629
+ the page comes back short with a `nextCursor`. Rows are authorized first and
630
+ the `state` filter is then applied to the fetched batch, so it is bounded by
631
+ the same scan limit: a rare state yields short pages with a cursor rather than
632
+ an unbounded walk inside one query. A row the actor can read whose state does
633
+ not match discloses nothing and may anchor the cursor, so a filter with no
634
+ match among the next 1000 rows returns an empty page with a cursor and still
635
+ pages to the end. The host `list` passes its remaining budget as `scanLimit`, so the
636
+ rows scanned across the service calls of one host call never exceed 1000: a
637
+ short service page with a cursor, or an exhausted budget while filtering by
638
+ assignee, ends the host call with the same cursor and the caller continues.
639
+ Only rows the service wrote are supported: cursors carry the canonical
640
+ millisecond ISO receipt time the service stores. A row whose stored receipt
641
+ time is not that form (on SQLite anything but `YYYY-MM-DDTHH:MM:SS.mmmZ`,
642
+ including a parseable value without milliseconds or with an offset; on
643
+ PostgreSQL a value with a sub-millisecond part) is never listed, whatever the
644
+ actor's access, and counts toward the scan, as does a row whose id is not a
645
+ UUID. On SQLite a non-text stored value (an integer, say) and a receipt value
646
+ or id longer than 64 characters or containing a NUL character are excluded by
647
+ the page query itself: the first sorts after every text value so the scan
648
+ could never move past it, the second could not be carried by any cursor, and
649
+ the third is returned truncated at the NUL, so it would look canonical and
650
+ sort above a legitimate row at the truncated value. Within a call the scan moves
651
+ past a service-foreign row by its exact stored value, and an unsealed cursor
652
+ names the last row before it that the actor can read. A call that finds no readable row within its scan limit (at
653
+ most 1000) fails closed with `IntakeScanLimitError` unless a `cursorKey` is
654
+ configured, as below. The host
655
+ `list` returns what earlier service calls collected, with their cursor, when
656
+ a later call fails that way.
657
+
658
+ `listItems()` is deprecated and kept only for callers of the array-returning
659
+ API. It walks every page, so its cost grows with the scope, including one
660
+ execution authorization per (item, handler) across the whole scope. It follows
661
+ the internal scan position, which never leaves the service, so runs of rows
662
+ the actor cannot read never stop it and it returns `[]` where nothing is
663
+ readable. Items are newest-first and gated exactly as on a page.
664
+
665
+ The page's inbox state is the durable projection of the saved action journal
666
+ and processing state, computed once per scanned row inside the page query;
667
+ `load` remains the authoritative, live-gated projection for one item (it still
668
+ projects individual stale actions, which the list does not). Cost is
669
+ proportional to the page for unfiltered pages and common states, and to the
670
+ number of items scanned until enough matches are found for rare states: the
671
+ state is not persisted, because the action journal is append-only and
672
+ projecting it into an indexed column would need a maintained denormalisation
673
+ in the execution service plus a migration, which this change does not take.
674
+ Listing does not load original bytes.
675
+
676
+ Cursors never disclose an unreadable row. Without a `cursorKey` in the
677
+ service options, every cursor (each row's and `nextCursor`) names a row the
678
+ actor can read (the last one when a page stops early); a page that spends its scan limit before filling hands back
679
+ the last such row, and one that found none fails closed with
680
+ `IntakeScanLimitError` (`Intake scan limit`, no id or time). A run of rows the
681
+ actor cannot read (refused by the captured ceiling or the execution gate, or
682
+ stored with a non-canonical receipt time) at least as long as the scan limit
683
+ therefore stops an unkeyed walk there; readable rows that the `state` filter
684
+ excludes never count toward such a run. With a `cursorKey` (a server secret of at least 32 bytes), every
685
+ cursor is sealed with AES-256-GCM, bound to the tenant, confidential scope and
686
+ actor, and a budget-stopped page returns a sealed position past what it
687
+ skipped, so the walk always progresses. Sealed cursors have a fixed length
688
+ (the plaintext is padded to 1024 bytes) and a fresh nonce per seal, so neither
689
+ their length nor their bytes reveal anything about the stop row. `scanned` is for trusted host
690
+ accounting and must not be sent to clients; a short page with a cursor tells
691
+ a caller only that unlisted rows exist, never which.
692
+
693
+ Newest-first paging relies on `intake_items_received_idx`
694
+ (`tenant_id, confidential_scope_id, created_at, id`), the full ordering key, so
695
+ an unfiltered page is a backward index scan that stops at the page boundary;
696
+ the foundation suite asserts that plan on both engines (SQLite
697
+ `EXPLAIN QUERY PLAN`, PostgreSQL `EXPLAIN`), with the per-row state counters
698
+ served by `intake_actions_item_idx`. On SQLite the page query pins the index with `INDEXED BY` (except under an
699
+ id restriction, where direct id lookups are cheaper), because without planner
700
+ statistics SQLite otherwise picks `intake_items_expiry_idx` plus a sort; the
701
+ SQLite plan check runs without `ANALYZE`. A deployment must have applied the
702
+ manifest migration, or the page query fails with a missing-index error. This package ships no
703
+ migration files: indexes are declared on the model, the build emits them into
704
+ `dist/manifest.json`, and `smrt db:migrate` / the public migration API create
705
+ them, which is how `intake_items_receipt_idx` shipped. Deploy the manifest
706
+ migration before upgrading hosts; `db:status --parity` reports the index as
707
+ missing until then.
152
708
 
153
709
  Supply four application adapters: `upload(files, requestId)` (authenticated
154
710
  receipt plus first extraction request), `assignment(itemId)` (durable read),
155
711
  `assign(input)` (manager authorization, eligible assignees, durable CAS and request
156
712
  replay protection), and `evidence(part)` (safe label/text or authenticated
157
- same-origin URL). Optional `label(item)` changes presentation. Optional
713
+ same-origin URL), which `load` calls for every part and `list` never calls;
714
+ `load` sets no row preview on its entry.
715
+
716
+ Row previews are opt-in through an optional fifth adapter,
717
+ `rowEvidence(rows)`. When it is supplied, `list` reads the page's durable
718
+ evidence metadata with one `service.listEvidence(itemIds)` query (at most 100
719
+ ids; items the scope cannot read, and expired or inactive items, are omitted
720
+ without disclosure; the host `authorize` runs once per distinct captured
721
+ ceiling through the same gate as `listItemsPage`, so a refusal or a
722
+ malformed captured ceiling hides the item and a failing `authorize` rejects
723
+ the page; an item whose stored data does not parse is skipped by both
724
+ `listItemsPage` and `listEvidence`, never fatal). It then calls `rowEvidence`
725
+ once with each row's primary part (`{ itemId, evidence }`) and publishes the
726
+ returned `{ label, viewUrl?, thumbnailUrl? }` as `InboxEntry.evidence`
727
+ (`{ evidence, label, viewUrl?, thumbnailUrl? }`, never text). Build those URLs
728
+ from metadata without reading bytes; if `rowEvidence` throws or returns a
729
+ malformed answer, the page still lists without previews, and a row without
730
+ durable parts never gets one. When it is omitted, rows show no original link.
731
+ Either way `list` reads the page's evidence metadata once, in that same query,
732
+ so the row label can use the original filename; it never reads original bytes
733
+ and never calls `evidence(part)`. The primary part is chosen by role, not media
734
+ type: the shallowest child part when any part has a parent (an upload's file
735
+ under its capture record, an email's first attachment under the message, even
736
+ when that child is JSON), else the first root part (a watched-folder file);
737
+ part ids sort numerically. `IntakeInbox`/`InboxView` accept an
738
+ optional `rowAside` snippet receiving the `InboxEntry`; without it a row renders
739
+ the host's "Open original" link (new tab) and thumbnail when the URLs are
740
+ same-origin; each link's accessible name is "Open original: <row label>", and
741
+ a row shows no wrapper when neither URL is usable. The slot is a plain `div`,
742
+ not an `aside` landmark, so a page does not add one landmark per row. Optional
743
+ `label(item, evidence)` changes presentation; `evidence` is the item's durable
744
+ parts with the primary part first, and the default label is the primary part's
745
+ own original `filename`, else `Intake <8 hex>`. Optional
746
+ `assignments(itemIds)` reads one page's assignments in one batch (otherwise
747
+ `assignment` runs per row), and optional `assignedItemIds(assigneeId)` lets
748
+ `list` push the assignee filter into the page query (junk ids are dropped, not
749
+ fatal); without it an assignee filter scans further pages and filters by
750
+ `assignment`, which stays correct but costs more rows. The page's continuation
751
+ is always the last row actually returned, so an assignment that changes
752
+ between the page read and the assignment read costs at most one extra scan and
753
+ never skips or repeats a row. Optional
158
754
  `enableFeedback: true` exposes the optional feedback callback when the service
159
755
  has feedback configured. Optional `onSplit(itemId, revision)` wakes the host worker after the durable extraction
160
756
  request; repair dispatches if wakeup fails. The factory creates no assignment
@@ -182,3 +778,11 @@ choosing the same local file explicitly starts a new upload identity. Opening
182
778
  then dismissing the chooser preserves the separately retained originals and
183
779
  request identity for retry; it does not clear the shared FilePicker's native-form
184
780
  behavior. Successful upload and host/context disposal still discard retry state.
781
+
782
+ Workspace ordering also protects an open item during an awaited automatic page walk:
783
+ the selected row remains until navigation succeeds, except that a failed list read
784
+ clears the inbox. Applying an action is disabled
785
+ while that action card holds unsent arguments, reason, or feedback comment. A list
786
+ refresh retires earlier follow snapshots; only follow reads started after that
787
+ page request may override its rows, so a newer refresh cannot be regressed by an
788
+ already settled or older in-flight follow read.