@happyvertical/smrt-ingestion 0.57.47 → 0.57.48

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.
@@ -3,19 +3,19 @@
3
3
  "sensitiveFieldsExcluded": true,
4
4
  "generatedAt": "1970-01-01T00:00:00.000Z",
5
5
  "packageName": "@happyvertical/smrt-ingestion",
6
- "packageVersion": "0.57.47",
6
+ "packageVersion": "0.57.48",
7
7
  "sourceManifestPath": "dist/manifest.json",
8
8
  "agentDocPath": "AGENTS.md",
9
9
  "sourceHashes": {
10
- "manifest": "d060d56c1ebfd22d9a0104e0401f8c767095f068f88485f3e3159378de705f42",
11
- "packageJson": "58e35766115380869680b004fd199250ba7e83f5005e6cb19749a94c24e906a1",
10
+ "manifest": "4b633fbdca6da416a9aecd6e9419284be372febe41e1fca05ebfd09adf2f3a0e",
11
+ "packageJson": "615369a883ca2bb662b1570947a48e9d87cc8e6ae59b976787e746ee6a2ddb11",
12
12
  "agents": "dea14442054ec1de798cba9be1fbdd955f44b0e4672639cbf3db8101d290c1fb",
13
13
  "moduleDoc:agents/execution.md": "8a7c3384e90a41eb0c6f89e5bdafddd57db5e93f0c015849c764716b86ced97f",
14
14
  "moduleDoc:README.md": "d4dfdb4a4c1af3e1def6cf038e315e5bf66ab8d5942dee12a014527737a69748",
15
15
  "moduleDoc:EXTRACTION.md": "7aebb8ab51149e406938502935a46fe96174aa83975bbe5b913940f9b11053e9",
16
16
  "moduleDoc:PROPOSALS.md": "c20953df4742042e8988b19eec54f6757f50567bfee0502c8b0fe29a3a1ae204",
17
17
  "moduleDoc:FEEDBACK.md": "121555edc7e88208a509aa1e983b22a5dfd01d22b600e1ff694799af2aa5ad90",
18
- "moduleDoc:REVIEW.md": "07f34ce3208247cf8b58bdc2a288e7219b5c563feefe89d301a0a00f1b4db613",
18
+ "moduleDoc:REVIEW.md": "4fe42050be02055f43ba56fdbc09b3651fc277ac257f4a44709906d594699ae7",
19
19
  "agentSurface:src/test-support/execution.ts": "cc72288b2605fc687b820da558fa862da1bab799cebe4ee88b32be9e902618b8",
20
20
  "agentSurface:src/test-support/proposals.ts": "a5667e2b0dc52ab41bd202abdc70875594e4d9cc467d84dd54a11fe55ec14f23"
21
21
  },
@@ -1730,7 +1730,7 @@
1730
1730
  {
1731
1731
  "path": "REVIEW.md",
1732
1732
  "module": "REVIEW",
1733
- "content": "# Authenticated review surfaces\n\n`@happyvertical/smrt-ingestion/svelte` exports `IntakeInbox`, `IntakeReview`,\n`IntakeWorkspace` and `EvidenceViewer`. The browser-safe DTO entry exports their `IntakeReviewHost`\ncallback contract. Import styling/theme configuration from smrt-ui in the host.\nComponents use smrt-ui primitives and the `ingestion.*` i18n catalog.\nExisting `IntakeInbox` hosts now keep the previous rows visible, with their\ncontrols disabled, while a refresh or filter change loads; any failed read\n(Load more included) still clears them.\n\nThe host authenticates every request, resolves tenant/actor/confidential scope\nfrom its session, enforces CSRF on mutations, and constructs `IngestionService`.\nNever accept scope, reviewer, permissions or a principal from callback JSON.\nOnly supply authorized evidence URLs and escaped text; original URLs are\nsame-origin authenticated handlers that recheck `readEvidence` on every read.\nCandidate discovery uses `findCandidates` with current grants. Presentation\nstate and disabled buttons provide no server authority.\n\n`listReviews(itemId, {cursor?,limit?})` reloads saved action identities and their\ncurrent plan membership without browser storage. It returns at most 50 actions,\nwith an opaque action-ID cursor. Every payload requires current item, handler,\nparent plan and target access under the owning transaction. Denied reads fail\nclosed. Target reads check current authorization separately from historical\nrevision freshness: unfinished drift returns state-only stale reviews without\nremoving unrelated entries; succeeded results remain readable after authorized\ntarget changes. Apply still enforces exact bound target revisions. Expired entries\ncontain only review identity/state. Stale entries never disclose old args or target\nlabels; an authorized parent plan additionally exposes `stalePlan` identity, key,\ncurrent plan revision and handler identity. Hosts must collect fresh explicit args\nfrom the reviewer/current authorized evidence and call `previewPlan` with that\nexact plan CAS and the current completed analysis attempt. Action revision is not\nplan revision. Re-expansion preserves action identity and requires fresh approval.\nSuccess results are rechecked through the durable result reader.\nPlan membership contains the exact parent args/version so edits call\n`previewPlan`, not per-step correction. An item may have more review pages; the\nhost must aggregate state across pages when projecting a production inbox state.\n\nA `needs_review` suggestion is completable, not only rejectable (#4208). That\nincludes a suggestion kept because its arguments missed only required fields\n(#4284); nested misses appear as JSON Pointers such as `/vendor/name`. The\ncomponent shows its `missingFields`, an editable `Suggested arguments (JSON)`\ndraft prefilled from the generated arguments, and the candidate picker for\ndeclared candidate fields; Prepare review stays disabled while required fields\nare still missing from an unchanged draft. An unchanged draft previews the\ngenerated snapshot as before; an edited draft is sent as `preview({..., args})`,\nwhich the published host publishes through the ordinary `previewProposal` /\n`previewPlan` with the same `ui:<index>` intention, never by rewriting the\ngeneration. The handler validates the human arguments again before a\n`waiting_review` action exists, approval remains a separate explicit decision,\n`ActionReview` lists top-level required arguments missing from the current\ndraft while editing (nested misses named by JSON Pointer in `missingFields` are\nnot re-checked in the browser; the server's revalidation refuses them and the\ncomponent shows that inline as `HandlerArgumentsInvalidError`), and no authority or automation eligibility is added: a failed attempt,\nan unoffered handler or a foreign stage is still refused by the host.\n`preview` throws on every failure, as before. When the handler's argument\nschema or `validate` refused the arguments being previewed (operation preview,\nwhich also carries corrections, a reviewer-started action, or plan preview),\nthe server throws `HandlerArgumentsInvalidError` (`name` set, messages\nunchanged). An argument-schema refusal also carries `issues`: a frozen list of\nat most `HANDLER_ARGUMENT_ISSUE_LIMIT` (20) `{ path, keyword? }` entries\n(`HandlerArgumentIssue`). `path` is built only from schema-declared property\nnames, `required` names and real array indexes and is cut at\n`HANDLER_ARGUMENT_PATH_LIMIT` (160)\ncharacters (never splitting a `~0`/`~1` escape). Segments are escaped per RFC\n6901 (`~` as `~0`, `/` as `~1`), with two departures: `/` is the whole\narguments object (never a key `\"\"`), and `?` stands for a data-named key. A\n`required` refusal appends the missing schema name to the parent's path. The\nformat relates to a suggestion's `missingFields` as follows: a root path\n`/name` is the bare `missingFields` entry `name` (unescaped), and a nested\nentry is the same escaped pointer in both (for example `/vendor/a~1b~0c`).\nThe bundled view shows a root issue without its leading `/`, so its refused\nfields read like the missing-information list. `ProposalValidationError`\nreasons share the schema-owned segment rule but stay unescaped.\n`keyword` is the failing schema keyword. Argument values, Ajv\n`params`/messages and handler messages are never included. A `validate`\nrefusal has no `issues` (the handler contract returns only `ok`). The size and\nshape preconditions checked before the schema runs (arguments over the policy\nbyte limit, or a handler schema that is not a closed `type: 'object'` with\n`additionalProperties: false`) stay a plain `Error`: they fail closed as a\ndenial, never as the inline refusal. Apply-time and\nexternal-send revalidation and result-schema checks keep a plain `Error`. The component shows an inline message and\nkeeps the view and draft when `preview` either rejects with an error whose\n`name` is `HandlerArgumentsInvalidError` or resolves `{ rejected: 'invalid' }`\n(`PreviewRejection`, optionally with the same `issues`). A transport that\ncrosses a process boundary preserves the inline UX by keeping that name and\n`issues` (for example re-throwing an `Error` with\n`name = 'HandlerArgumentsInvalidError'` in the browser callback) or by\nreturning `{ rejected: 'invalid', issues? }` for that case only, as the\nmaintained fixture's `previewForReview` and `startActionForReview` do. Since\n#4281 the fixture's rejection body also carries `issues` when the schema\nrefused; read `rejected === 'invalid'` rather than comparing the whole body\nwith strict equality. Without either, the refusal behaves like\nany other failure: the view is cleared exactly as before this change, and the\nreviewer's in-memory draft returns after an authorized refresh. Every other\nthrown preview or candidate-search failure (denied, revoked, unavailable or\nunknown) clears the view.\nUnsent drafts live only in component memory, keyed by attempt and suggestion\nindex. They survive Refresh, a failed callback, an action on another card and a\ntransient `unavailable`/`stale` reload; a `revoked` or `expired` result\ndiscards them, a successful preview clears that card's draft, and an available\nreload drops drafts for attempts or cards no longer offered.\n\nThe host's `load` callback supplies a current `ItemReviewView`, using\n`getCompletedAnalysis` with no historical fallback. Generated suggestions bind\nthe **current generation attempt**, not `generation.source.attemptId` (the prior\nextraction). `previewGeneratedProposals` publishes exact reviews and continues to reject stale\ngeneration candidate pins. When `listReviews` reports `generationStale`, the host\nomits obsolete generation output and renders authorized saved reviews/results.\nCompleted saved actions do not hide healthy unprepared suggestions. The saved-binding hint is not\nan authoritative generation-availability check: a fresh human preview can bind a\ncurrent target while the immutable generation still pins its former revision.\n`getCompletedAnalysis` remains strict and throws server-only\n`GenerationSnapshotStaleError` only after every current catalog/provider/candidate\nread is authorized and an immutable candidate revision differs. The maintained\nhost catches only that class, reacquires `listReviews` under current owning\nauthority, and returns that new page without generation output. It never reuses\nthe earlier page after an error. Denials and unknown failures propagate; healthy\ngeneration still exposes unprepared sibling suggestions. Stale entries expose handler\nand attempt identity only when that attempt is still the current completed/partial\nanalysis. An empty-args editor submits fresh explicit human arguments through\n`previewProposal` or `previewPlan` with the saved CAS; it never reuses old generated\narguments, rewrites the generation snapshot or transfers approval. Analysis-stale\nentries without an attempt require normal reprocessing. Approve,\nreject, defer and correct call `submitDecision`; corrected args create a new\nrevision which requires a separate human approval. After an approve/defer/reject\ndecision, optional `editAction` calls `previewProposal` with the current action\nrevision to publish a new review; completed/executing/unknown actions cannot be\nrevised. Local dirty args disable Apply but confer no authority. Plan edits re-expand through\n`previewPlan` with the current **plan** revision. `applyAction` accepts only an\naction ID. Unknown external outcomes require server reconciliation; the UI\nnever offers a blind resend. Stable result IDs survive reload and retry.\n\nReplace `contextKey` whenever authenticated tenant/session context changes.\nChanging it or itemId destroys the old view and fences late callback results.\nDenied refresh clears prior originals, candidates, arguments and results.\nMissing confidence is unavailable; page/time links exist only for observed\nlocations. Provider errors and unresolved/partial results remain review states.\nA host that enabled `proposals.failureCapture` may fetch a failed attempt's\nredacted model output with `getFailedAttemptCapture(itemId, attemptId?)`.\nRetaining text requires a configured host redaction hook; a missing or failed\nhook yields only `redaction_failed` metadata. The host owns that redaction policy.\nThe getter requires current reviewer authority for that item and fails closed\notherwise;\n`load`, inbox rows and review pages never include it, so a host shows it only\nbehind its own explicit diagnostic action (see\n[PROPOSALS.md](PROPOSALS.md#failed-attempt-capture-opt-in-4285)).\n\n## Submitter-scoped decisions\n\nFor trusted internal sources the person who submitted an item may confirm or\ndecline their own proposal without the reviewer inbox. `listOwnProposals`\nreturns only items whose server-recorded `submitter_id` equals the current\nauthenticated actor, each with its bounded saved review page reloaded under\ncurrent item, handler and target authority. An item the actor can no longer\nread (the host `authorize` returns false or throws `IntakeAccessDeniedError`)\nis skipped before projection: the page is filled from readable items only, in\n50-row batches, and `nextCursor` names the last emitted item and is present\nonly when a further readable item was actually found. Neither the entries, the\npage size nor the cursor reveal a hidden item. If one call has to skip 1000\nhidden submissions it fails with `Own proposal scan limit` rather than return a\nshort page or a cursor that stops inside the hidden run; this error is the only\nsignal, and it says only that such a run exists. Any other `authorize` error\npropagates instead of shrinking the list. An item that is still readable but\nwhose reviews are denied is returned as `{ item, reviews: null, unavailable:\ntrue }`: that covers ingestion's own denials and any throw from the host\n`assertTarget` (deleted or revoked targets), whose text is never returned.\nIntegrity, validation and database errors from ingestion itself propagate. The\nreviewer `listReviews` surface keeps propagating host target errors unchanged.\n`submitOwnDecision` takes the same `ReviewInput` CAS fields and request ID as\n`submitDecision` and accepts `approve`, `reject` and `defer` only; `correct`\nbelongs to the reviewer surface. It runs under the `review_own` execution\noperation: the host's `authorize` must return `submitter: true`, the resolved\npolicy must have `submitterReview` enabled, and the recorded submitter must be\nthe current actor and also the receipt's captured execution principal (the\nidentity the effect runs as), checked under the item lock and again at apply.\nA receipt captured for a bot or service principal is never self-approvable. Policy enablement, handler/operation allowlists, captured\nceiling, confidential access, retention and the first-valid-decision lock are\nunchanged. The decision is a human decision by that actor, recorded with\n`mode: 'review_own'`; reviewer decisions carry `mode: 'review'`. In both modes\n`selfDecision` is true exactly when the deciding actor is the item's\nsubmitter, so a reviewer approving their own submission is never independent\nreview. A self-decision is not a reviewer judgment: it never feeds\nautomation eligibility (machine authorization remains the separate evaluated\n`kind: 'machine'` path) and its feedback events carry the same marker. The\n`unavailable` entry discloses nothing about the denied handler or target.\n`applyAction` then runs once under the approver's live grants exactly as\nfor reviewer decisions. Hosts render this surface from their own authenticated\nsession; presentation state confers no authority, and a source payload cannot\nnominate a submitter.\n\n## Assignment and logical splits\n\nAssignment is explicitly host-owned, durable and versioned. `assign` receives\nan expected assignment version and request ID; the host must authorize the\nmanager, constrain eligible assignees, CAS the durable record and reject stale\nwrites. Assignment does not grant read, review or execution rights. The maintained\nfixture provisions its own SQLite assignment table before serving requests; no\nproduction ingestion table or migration is introduced for assignment.\n\n`reviseLogicalSplit` authorizes the current generation through the same-executor\ncatalog/candidate gate and the existing reviewer grant and policy reviewer list.\nProcess access alone cannot mint or replay a human correction; reviewer authority\nis checked before mutation and again before publication. A generation without a\nreviewable catalogue cannot be corrected through this reviewer API.\nIt requires all observed pages exactly once, an exact\ncurrent attempt/revision and a request ID. It records the authenticated actor\nand predecessor as a **human correction** in a new immutable extraction\nconfiguration, queues re-extraction and immediately makes previous suggestions\nand approvals stale. It never changes original bytes/evidence or labels a human\nedit as model output. The host processes the new extraction, then calls\n`prepareGeneration` and the normal fenced interpretation stage. The supported\nimmediate-predecessor extraction pin is unchanged. The generator receives a\nhuman correction only after its digest matches the authoritative source. Ordinary\n`analyze` calls cannot mint an extraction correction; the owning split API is the\nauthenticated origin, and forged interpretation corrections fail before provider\ndisclosure. If its proposed grouping differs from the correction, suggestions are withheld\nwith `human_split_not_respected`. New proposals require new explicit review.\nThe reference host runs these stages synchronously for browser determinism;\nproduction hosts dispatch the existing durable analysis jobs.\n\n## Reviewer instructions\n\n`recordReviewerInstruction({itemId, attemptId, expectedRevision, text,\nrequestId})` records a plain-text instruction for one item (\"This is a customer\nPO from Badger; file it, do not bill it\") as the second kind of **human\ncorrection**. It is authorized exactly like `reviseLogicalSplit`: the current\ngeneration must pass the same-executor catalog/candidate gate and the reviewer\ngrant plus policy reviewer list, and authority is checked before mutation and\nagain before publication. Process access, `analyze`, and a client payload cannot\nmint one: `analyze` rejects a `reviewerInstruction` key on an extraction\nconfiguration, and an interpretation copy that differs from the pinned\nextraction's record fails with `Generation evidence changed` before provider\ndisclosure. Text is required, trimmed-nonempty and at most 4,000 characters.\n\nThe record is immutable: `{id, text, actorId, recordedAt, supersedesId, digest}`\nplus the request binding, written into a new extraction configuration beside\nany existing split correction. Recording supersedes current suggestions and\napprovals, queues the normal fenced re-extraction and interpretation through the\nexisting dispatch path, and returns immediately; the reviewer keeps working\nwhile the item reports `processing`. `prepareGeneration` copies the instruction\ninto the interpretation configuration, the generator receives it only after its\ndigest matches the authoritative extraction record, and saved generation\nprovenance records `reviewerInstruction: {id, digest}`. A later instruction\nrecords `supersedesId` and replaces the earlier one for future interpretations;\n`listReviewerInstructions(itemId)` returns the full history, newest first, and\nhistory is never deleted. The instruction steers the model only: it cannot add\nhandlers, candidates, schemas, permissions or automation eligibility, replies\nare validated as usual, and suggestions still require explicit preview and\napproval. A replay with the same request ID returns the same revision; a changed\ntext under the same request ID conflicts.\n\n**Cost.** Each recorded instruction appends a new extraction revision, so the\nitem is extracted again before it is interpreted again: cheap for embedded-text\nPDFs and plain text, but OCR/speech hosts spend provider budget per instruction,\nexactly as they do per split correction. The pinned extraction is not reused\nbecause the interpretation contract requires its source to be the immediately\npreceding revision and `getCompletedAnalysis(itemId, attemptId)` serves only\nthe current revision (PROPOSALS.md, \"Durable stages\"); reusing it would mean\nweakening that predecessor pin, which protects every generation from an\nintervening reprocess, so this feature follows the split path instead.\n\n**State and failure.** An in-flight stage (queued/running) reports the item as\n`processing` even when earlier, now-stale actions exist. If the re-extraction or\ninterpretation fails, `getReinterpretationState(itemId)` returns the latest\nattempt's safe failure category and the latest completed generation attempt;\nthe host projects them as `instruction.failure` and `instruction.binding`, and\n`recordReviewerInstruction` accepts that historical attempt as its anchor when\n`expectedRevision` is the item's current (failed) revision and no later\n**interpretation** completed and nothing is queued or running, so the reviewer\ncan retry or change the instruction after either stage fails. A completed\nextraction whose own interpretation failed belongs to the failed attempt, not a\nnewer result; a completed extraction whose interpretation has **not been\nappended yet** is in flight (the host is between stages) and is refused with\n`ReviewerInstructionStaleError` (`Stale reviewer instruction`), so a second\nreviewer cannot pre-empt it. That gap expires: once the extraction attempt's\ncompletion plus the item's analysis lease duration (`limits.leaseMs`, the lease\n`claimAnalysis` grants) has passed with no interpretation appended,\n`getReinterpretationState` reports `failure: 'not_started'` with the retry\nbinding and the guard allows the retry. `not_started` means **the\ninterpretation did not start within the lease window**: the host crashed,\nthrew between `prepareGeneration` and `analyze`, or its queue is merely slow.\nPre-empting a slow but healthy host is safe because `analyze` refuses an\ninterpretation whose pinned extraction is no longer the item's latest, under\nthe item lock and before anything is superseded\n(`GenerationSourceStaleError`, nothing appended): the slow host's late append\ncannot clobber the retry. Missing or malformed timing (no attempt, no\n`completedAt`, non-finite `leaseMs`) counts as already expired rather than\nholding retries forever. The \"still being re-read\" note shown after a stale\nresult is cleared by the next reload that shows the item settled; the draft\nthen carries a persistent \"This item changed while you were writing; your\ninstruction was not recorded.\" note (any stale cause: another reinterpretation,\na split, or a host reprocess) until the reviewer edits or resubmits it. The guard and\n`getReinterpretationState` share one predicate, and the latter's\n`reinterpreting` flag (projected as `instruction.reinterpreting`) is true in\nthe gap too, so the component keeps polling and keeps Re-read disabled there.\nThe published host maps two typed errors to `instruct` results:\n`ReviewerInstructionStaleError` → `{ stale: true }` (refresh-and-retry: reload\nwith a note, draft kept) and `ReviewerInstructionInvalidError` → `{ invalid:\ntrue }` (empty, whitespace-only or over 4,000 characters: shown inline, draft\nkept). Neither is a denial. Custom hosts should map them the same way; a host\nthat lets either propagate as a thrown error makes the component treat it like\nany other failed mutation, i.e. a whole-view deny with the \"view unavailable\"\nmessage, as before these results existed. The maintained fixture's\n`/api/instruct` now answers an invalid text with HTTP 200 `{ invalid: true }`\ninstead of 409. A retry from an older anchor builds on the **whole configuration**\nof the newest extraction after it (replacing only the instruction), because that\nis the item's current configuration: corrections chain forward through it and a\nhost reprocess replaces it. So a split followed by a failed stage is never\nsilently dropped, and a failed reprocess's changed configuration is neither\nreverted nor overlaid with a split it dropped. **Concurrency.** `recordReviewerInstruction` and `reviseLogicalSplit` each\nrun their whole decision in one transaction under the item row lock: the\nanchor snapshot read takes the lock first, the execution authorization of\nthat snapshot takes it again (`UPDATE intake_items` row lock), the guard step\nre-asserts it explicitly, and\nthe request-key replay check, the revision and later-work guards, the\ncarried-forward split, the history read that sets `supersedesId`, and the\nappend all run under it. On PostgreSQL a second reviewer at the same revision\nblocks on that lock and is then refused (`ReviewerInstructionStaleError` /\n`Stale logical split`), so the ledger cannot fork; an instruction racing a\nsplit (in either order) is refused rather than built from the pre-split\nconfiguration; and a double-click whose duplicate commits after the unlocked\nreplay check is answered as a replay of the committed request, not a conflict\nor a denial.\n\n**Host reprocess.** A host reprocess (a plain `analyze()` extraction the host\nconfigures itself) cannot carry human corrections — `analyze` rejects\n`humanCorrection`/`reviewerInstruction` on extraction configurations — so it\ndrops the split and the instruction alike, and neither is applied to its\ninterpretations. The instruction is then reported as `dropped`, not pending:\n`getReinterpretationState().instructionInEffect` names the instruction the\nnewest extraction carries (verified by digest) and is null after such a\nreprocess. History is unchanged; the reviewer can re-record the instruction,\nwhich supersedes the dropped one.\n\n`ItemReviewView.instruction` exposes `current` (the latest recorded instruction\nstill in effect, i.e. carried by the newest extraction), optional `dropped`\n(the latest recorded instruction a reprocess dropped), `applied` (the\ninstruction the displayed **generation** ran under; a completed extraction alone\napplies nothing), `stale` (the displayed suggestions predate the current\ninstruction), optional `failure` and optional `binding`. Feedback examples that carry instruction\ntext count it toward the feedback byte budget (FEEDBACK.md). The optional host callback\n`IntakeReviewHost.instruct` is implemented by the published adapter when the\nservice supports it, with an optional `onInstruct(itemId, revision)` wakeup\nmirroring `onSplit`. The review component renders \"Instructions for the\nassistant\" with \"Re-read with these instructions\", shows \"Reinterpreting…\" while\nthe item's stage is queued/running, repeats the existing authenticated `load`\non `pollMs` (default 2 s, backing off to 5×) and patches the view in place —\ndrafts, split edits, focus and the evidence viewer survive and no loading\nplaceholder is shown — stops when the stage is no longer in flight, and then\nshows the instruction that produced the current suggestions, marking a\nsuperseded one as stale. An explicit host denial on a poll tick fails closed at\nonce; a thrown tick is treated as transient (view and drafts kept, a\n\"Couldn't refresh; retrying.\" note, backoff continues), but because hosts may\nalso throw for revoked access, three consecutive thrown ticks fail closed.\n**Hosts must signal revocation by returning `availability: 'revoked'`** (or\nanother non-`available` state) from `load`, not by throwing: a returned denial\nclears the view on that tick, while a host that throws on revocation leaves the\nalready-displayed content visible until the third thrown tick (about 10 s at the\ndefault interval and backoff) before the view fails closed.\nSplit drafts survive a refresh only when the reviewer edited them. A failed stage shows its safe category with the form\nstill available. There is no\nstreaming channel; status is read through the existing item/analysis APIs. The\nmaintained fixture records the instruction durably and then runs the stages in\nthe background so the browser proof exercises the in-place update.\n\n**Try again (#4504).** `retryInterpretation({itemId, attemptId,\nexpectedRevision, requestId})` re-runs an item whose interpretation failed (the\ndisplayed generation is `provider_error`, or later work failed or never\nstarted) with no new instruction. It uses the same anchor, guards, `process`\nauthority and fresh extraction plus interpretation as an instruction, carries\nthe item's current configuration (an instruction or split already in effect\nincluded) unchanged, and records nothing in the instruction history. Superseded\nanchors, work in flight and items that did not fail throw\n`ReviewerInstructionStaleError`. A replay of the same intent (item, anchor,\nrevision, `requestId`, actor) returns the first append under current `process`\nauthority; the same `requestId` with another intent is a request conflict.\n`createIntakeReviewHost` exposes it as `IntakeReviewHost.retry` (stale resolves\n`{ stale: true }`) and wakes the worker through `onRetry`, else `onInstruct`.\n`ReviewView` draws a \"Try again\" button (`ingestion.tryAgain`) beside the\ninstruction box when `host.retry` exists, the item is open, nothing is in\nflight and the interpretation failed; it binds like the instruction\n(`instruction.binding`, else the displayed generation).\n\n`feedback` is an optional explicit correctness callback, separate from approval\nchoice and execution success. No learning, feedback storage or rule promotion is\nimplemented by these components; #3676 owns those services. Do not pass feedback\nor a feedback callback to readers without the relevant host grant.\n\n## Reviewer-chosen actions and \"handled, no action\"\n\nA reviewer is not limited to model suggestions. `ItemReviewView.catalog` carries\nthe handlers the item's catalog currently offers (`listHandlers`, discovery media\ntypes and live preview authority; it is presentation, never a grant). The optional\n`startAction` callback forwards `StartActionInput` (`itemId`, `handlerId@version`,\nexplicit `args`, `requestId`, optional `reason`) to `startReviewerAction`, which\nrequires the current reviewer grant under that handler, a completed or partial\nattempt on the current analysis revision, and an operation handler the item's\nevidence media types offer. Plan handlers go through `previewPlan`. The server\ncreates an action keyed `reviewer:<requestId>` with origin `reviewer-selected`\n(`createAction` refuses that key prefix and origin, so process authority cannot\nmint reviewer provenance; re-previewing such an action with `previewProposal`\nneeds the reviewer grant and keeps its handler fixed, so only its arguments can\nbe corrected),\nruns the handler's normal `validate`/`preview`, publishes a `waiting_review`\nrevision and requires the same explicit approval and apply as a generated\nproposal. The reviewer typed these arguments, so a handler argument-schema or\n`validate` refusal throws `HandlerArgumentsInvalidError` with the value-free\n`issues` described above, and the rolled-back transaction leaves no action row\n(#4281). Every other refusal (input shape, access, handler unavailable, item\nresolved, identity or request conflict, normalization mismatch) stays a plain\ndenial. `startAction` follows the `preview` contract: it throws, or a transport\nmay resolve `{ rejected: 'invalid', issues? }` for that one case. The component\nkeeps the start form, handler, arguments and reason, shows an inline refusal and\nlists any field paths; any other failure clears the view as before. Replays\nwith the same request return the same review; changed arguments or reason\nconflict. Such actions are never automation-eligible: `authorizeAutomatic`\nrefuses them regardless of policy opt-ins. The UI pre-fills arguments only from a\nsame-handler suggestion (for example a `needs_review` one) and a single original's\nevidence references, lists the required fields still missing, and submits exactly\nwhat the reviewer typed. An item whose current attempt failed outright must be\nreprocessed first; an interpretation that completed with `provider_error`,\n`unknown` or no suggestions can be acted on directly. `ReviewAction.origin`\nexposes the provenance so `ActionReview` can label human-started actions.\n\nThe optional `resolve` callback forwards `ResolveItemInput`\n(`disposition: 'resolve_no_action'`, `requestId`, optional `reason`) to\n`resolveItem`. It needs reviewer authority under any handler the item's captured\nceiling offers and refuses while an action is `authorized` with a live approval,\n`executing`, `outcome_unknown` or `succeeded`; waiting, deferred, rejected and\nfailed actions, and authorized ones whose approval expired, may remain (neither\nan exhausted failed action nor a lapsed approval can be rejected, so they must\nnot keep the queue open). Afterwards only reject remains: those actions can no\nlonger be approved, corrected, deferred, re-previewed, retried, applied or\nautomated. `submitDecision` refuses every decision but `reject`, no new revision\nis published for the item, `applyAction` rechecks the disposition before any\neffect, and `analyze`, `reviseLogicalSplit` and `createAction` refuse the item\nunder its lock before any write, so it is never reanalysed, re-split or given a\nnew action. `resolveItem` supersedes any queued or running analysis under the\nsame lock and `claimAnalysis` refuses a resolved item, so no provider is called\nafter a human closed it; the item's `processingState` keeps its last value (it\ndescribes analysis, not review), and the inbox state `resolved` is authoritative.\n`publishProposal` (process authority) refuses a resolved item and any\nreviewer-selected action. The UI hides Approve, Defer, Apply, every edit control, suggestion\npreview and split controls on a resolved item.\nResolved 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.\n\nPaged 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.\n\nThe append-only `intake_dispositions` record is terminal: replaying the same\nrequest returns the same disposition, any other request conflicts, and\n`startReviewerAction` refuses the item. `IntakeItemDTO.disposition` projects only\n`{ state: 'resolved', at }` to any item reader (`listItems` reads it in the same\nstatement as the page); the reviewer and reason are returned only by\n`getItemDisposition`, which requires reviewer authority, and the review hosts put\nthem on `ItemReviewView.disposition` (omitted when that read is denied, so the\nview shows only \"Handled, no action\"). `loadReviewSummary` reports inbox state `resolved` unless an action\nactually succeeded. Privacy deletion and retention expiry redact the reason and\nreviewer (`intake_dispositions.data`) and keep only the kind. The disposition is\ndistinct from `reject`, which still means the proposal was wrong. Closing an item\nrecords `reviewer_routing` feedback immediately; a started action records it only\nwhen the reviewer approves it (see FEEDBACK.md). Capture is an optional projection\nthat never vetoes the decision or disposition.\n\n`startReviewerAction` applies the handler's discovery media types and the live\nreviewer grant/policy for that handler. It intentionally does not apply the\nproposal catalog's provider-policy filter or `maxHandlers` slice: those bound what\na model may be offered, and a human choosing a handler sends nothing to a\nprovider. The UI picker shows the offered catalog, so a handler hidden from it is\nonly reachable by a reviewer who already holds `review` authority for it. The maintained\nbrowser fixture serves both operations behind CSRF and the same session scope.\n\n## Review screen presentation (#4503)\n\nAll presentation only; no new authority.\n\n- **Handler labels.** A handler registration may declare `label`. It is carried\n on the reviewer catalog only (`listHandlers`, `ItemReviewView.catalog`), never\n on generation input, catalog digests or bindings, so adding one never stales\n existing work. The proposal heading, action heading and \"Start action as…\"\n select draw it, falling back to the handler id.\n- **State labels.** Review states draw `ingestion.reviewState.<state>` and\n generation outcomes `ingestion.outcome.<outcome>`; an unknown value is drawn\n as is.\n- **Arguments as fields.** Where a handler `argsSchema` declares top-level\n `string` (plain, `date`, `date-time`), `number`, `integer`, `boolean` or\n enum properties, or `references` declares a candidate/evidence field, the\n suggestion draft, the action's arguments and the start form draw those as\n fields (`title` as the label, `*` when required). A candidate reference is a\n search box over `host.candidates`, filtered to the reference's model, showing\n the candidate's label (the generation's offered candidates label the current\n value); an evidence reference picks among the item's originals. The JSON text\n stays the source of truth: fields rewrite it, and it remains editable under a\n collapsed \"Advanced\" disclosure for everything else (objects, arrays,\n unions). Without such properties the plain JSON textarea (and the\n destination picker) are drawn as before. Server validation is unchanged.\n- **Effects.** `action.review.display` is drawn as a `<dl>`: the handler's\n preview fields (`display.preview`, or `display.step.preview` for a plan step;\n a flat record as is), then non-empty `dependencies`/`plan` metadata as JSON,\n named `metadata.<key>` when a preview field already has that name\n (`displayEntries` in `view-utils`).\n- **Assignees.** An optional `IntakeReviewHost.assignees(query)` (adapter\n option `assignees`) answers `{ id, label }[]`; the inbox then draws a search\n picker for the filter and each row instead of the free-text \"Assignee ID\".\n `assign` still receives the id and still authorizes. A failed search offers\n nothing.\n- **Feedback state.** `ReviewAction.feedback` (see FEEDBACK.md) presses the\n current judgment; a further judgment supersedes it and\n `ingestion.feedbackSent` confirms it.\n- **Confidence.** Nothing is drawn when the provider reports no confidence.\n\n## Single-page triage workspace\n\n`IntakeWorkspace` (#4215) composes the existing pieces so a reviewer works the\nwhole queue on one page: the paged inbox as a list pane, the review view of the\nselected item beside it (the original above its action form), and the\nreviewer-instruction message box of the item pane. Props: `host`, `contextKey`\n(replace on session change, as for the other components), optional initial\n`itemId`, `pollMs`, `rowAside` and `onselect(itemId | undefined)`, which only\nreports the selection (to keep a URL in step) and never navigates. It adds **no\nhost callback and no server authority**: every read is the host's `list` and\n`load`, every action still goes through the handler preview and an explicit\napproval and apply under the approver's live grants, and the message steers the\nmodel only, exactly as in `IntakeReview`.\n\n- **Inline original.** In the item pane `EvidenceViewer` shows one part at a\n time (`layout=\"pager\"`), opening on the first part that renders inline (the\n uploaded file rather than its capture record), with previous/next original.\n A PDF with more than one *observed* page (extraction locations or proposed\n page groups; `pages` prop) gets previous/next page, which reloads the frame\n at the same-origin URL plus `#page=n`; only `evidenceUrl`-approved URLs ever\n reach a frame. `IntakeReview` gets the same page navigation over its\n unchanged stacked list of parts. The chosen part and page belong to the item\n view (bindable `chosen`/`page`), so they survive in-place reloads, Prepare\n review, Approve and Apply.\n- **Keyboard.** J/K and Alt+Down/Alt+Up move to the next/previous row (loading\n the next page at the end), ignored while focus is in a text field, select or\n editable region; \"Previous item\"/\"Next item\" buttons carry\n `aria-keyshortcuts`. The selected row is `aria-current`, and every selection\n moves focus to the item pane region, named by the row's label. A navigation\n that waited for the next page is dropped if the reviewer chose another item\n meanwhile.\n- **Unsent input.** The pane holds unsent input when it has instruction text,\n an edited suggestion draft or split draft, a chosen start handler or start\n reason, a no-action reason, or an action editor with edited arguments, a\n reason or a feedback comment. It is read from the drafts themselves, so it\n holds while a reload has cleared the view; while the view is denied nothing\n is shown and nothing counts (the drafts stay in memory and return with an\n authorized refresh, as before). A reviewer-initiated move (J/K, Alt+arrows,\n Next/Previous item, a row's Review button) then asks inline, in a group\n labelled by its note: \"Stay on this item\" takes focus and returns it to the\n field the reviewer last used; \"Discard and open the other item\" proceeds.\n When an item is closed without action (here or by another session), input a\n closed item cannot take\n (instruction, suggestion and split drafts, a started action, a no-action\n reason) is discarded.\n- **Live state.** A row's status follows the item: \"Interpreting…\", \"Ready to\n review\", \"Needs review\" with missing argument names or the generation\n outcome, \"Awaiting approval\", \"Approved, not yet applied\", \"Failed\" with a\n safe category, \"Handled, no action\", or the listed inbox state. Rows the\n reviewer has not opened show the listed state only; reasons come from a\n loaded view, never from server exception text. The open item's own polling\n (`ReviewView`, unchanged backoff and three-failure fail-closed) reports each\n displayed view and the workspace patches that row in place\n (`InboxView.patch`, keeping the listed label and preview, and the row's\n assignment unless the patch carries a newer assignment version, so a view\n read before an assignment never rolls the row or the next expected version\n back), so the row follows each newer read of the form and the list is\n **not** re-listed per tick. An item the\n reviewer leaves while its stage is in flight, or before the pane showed the\n result of its last operation (an instruction just recorded), keeps\n following: the workspace re-reads followed items with the same `load` (first\n review page) on the pane's backoff, at most **5** reads per tick, one after\n another, least recently read first (so every followed row is reached), with\n only one tick running at a time. At most **20** items are followed; beyond\n that the longest-followed one (the earliest started, whatever the read\n rotation) stops. An item left while its pane was mid-operation (an Apply not\n yet answered) keeps following while a read still shows an action authorized\n or executing: exactly **3** more reads when it never answers, the fourth\n ending the follow. That debt belongs to the operation, so it survives\n reopening and leaving the item again until a view shows the operation\n answered or the reads run out; leaving mid-operation again starts a full\n debt for the new operation. Each follow has a\n generation: an answer from an earlier follow of the same item (one asked\n before the reviewer reopened or left it again) is dropped, and its failure\n does not count toward the new follow's three. An item stops being followed\n when it settles, its view is denied, three reads in a row throw, its row\n leaves the displayed list (a filter or a refreshed page removed it), the\n list fails closed, or the cap drops it; a denial, the failure stop or the\n cap drops its cached status and restores its entry exactly as its page read\n returned it (never a patched entry). Opening an item drops its cached\n status until the pane shows a view; a denial in the open pane (a failed\n action, the pane's own fail-closed poll, or a first load that failed) does\n the same as a follow denial, clears the announcement, and the item is not\n followed when left. A settled stage that was seen running is announced in a\n polite status region (\"badger-po.pdf: Ready to review\"), only while its row\n is displayed; a refreshed page or a failed list clears it.\n Other rows update on Refresh, a filter change or when opened; there is no\n streaming. While a refresh or \"Load more\" is pending, the displayed rows stay\n (controls disabled) and keep taking patches. Every read (a page, a pane\n view, including an unchanged poll, and a follow read) is numbered when its\n request starts, and each row shows the entry of its newest read, whatever\n the source: a page read before a pane or follow read never regresses the\n row, and a page read after them replaces what they showed. The same rule\n governs a follow: a follow read older than the row's newest read changes\n no status, does not stop the follow and announces nothing (a newer read\n settles it), so the announcement never contradicts the row. Cached status reasons follow the same ordering, even when entries have identical\n fingerprints. \"Load more\" continues the\n filter the rows were listed under, not filter inputs edited since, and does\n not repeat a row already shown. A failed \"Load more\" fails closed like any\n failed read (the host cannot tell a transient failure from a revoked\n grant): the rows clear and the list shows its error.\n- **Approve and advance.** After an approve, apply or \"Handled, no action\" whose\n reload left nothing for the reviewer (closed without action, or an action\n succeeded and none is waiting for approval, waiting to be applied, executing,\n unknown or failed; stale, rejected and deferred actions do not hold it), the\n item's row is patched and leaves the list when it no longer matches the\n listed state/assignee filter, and the next row that is not closed or\n completed opens and takes focus, with no navigation and no re-listing. An\n approval that leaves an apply step keeps the item open with Apply in place.\n Unloaded saved-review pages and an active background stage also prevent\n settlement. Advancing skips completed inbox pages until a reviewable row\n appears; a page that makes no progress stops the walk.\n It never advances over unsent input on the same item: the item stays open,\n the input stays, and a note says it is finished but has unsent input; the\n reviewer moves on explicitly, through the confirmation; input typed while\n the next page loads counts too. When no open row is displayed, the advance\n appends pages of finished rows until one has an open row or no page\n remains, at most **5** appends. After each append, the advance (like\n J/K at the end of the list) finds its place again by id: just after the\n item it left, or, if that row has gone, at the first row that followed it\n and is still listed, or at the first new row, so a row that left the list\n meanwhile never shifts it onto the wrong item. Its notes (and \"nothing else\n needs review\") are written into one persistent polite status region. The queue is never reported empty when\n that is unknown: past the bound, or when an append cannot start (a refresh\n is pending) or is superseded, the item stays open with a note to try Next\n item again, which still works; when an append fails, the list has failed\n closed (no rows, Next disabled), so the item stays open with a note to\n refresh the list to continue. The open item stays listed while the\n reviewer works on it even if its state stops matching the filter; it\n leaves once they move on.\n When nothing is left the pane says so. The per-row \"Handled, no action\"\n control is hidden in the workspace (`resolveInRow={false}`) so closing always\n goes through the item pane and advances.\n\n\n**List following and refresh (#4504).** Every listed row (other than the open\nitem) whose entry shows a stage in flight is followed like an item left\nmid-stage, up to `WATCH_CAP` rows, `WATCH_LIMIT` reads per tick on the pane's\nbackoff, so rows no longer keep \"Interpreting…\" until Refresh. Opt-in\n`listPollMs` on `IntakeWorkspace`/`IntakeInbox` also re-reads the listed page\nunder its listed filter while any row is processing (skipped while a read or\noperation is pending or Load more appended pages). Both components export\n`refresh()` (`bind:this`), which re-reads the first page like the Refresh\nbutton and resolves `{ items, result: 'shown' | 'failed' | 'pending' }`.\n\n## Maintained browser proof\n\n`pnpm test:components` runs real Svelte component tests; `pnpm test:e2e` runs the\npackage-local authenticated Vite application under Chromium. The fixture uses\nHttpOnly same-site sessions, CSRF, real SQLite/assets/ingestion/content APIs,\nproduction unpdf child extraction and a deterministic proposal generator. Its\nhardcoded test login is fixture-only and is never a production auth provider.\nImage/audio originals remain inspectable when no OCR/speech provider is\nconfigured; the suite makes no recognition-quality claim and spends no provider\nbudget. `reference/review-host.ts` is a maintained development fixture, not a\npublished server entry. Browser tests show actual approved args and persisted\nContentDocument results, stale tabs, reload, retries and negative access.\n\nRun full package `test`, `test:postgres`, `test:providers`, `build`, `typecheck`,\n`test:components`, `test:e2e`; use the root Node/pnpm versions. Set a short\n`CI_TEST_TMPDIR`/`TMPDIR`, and optionally\n`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` to the supported installed Chromium.\n`pnpm test:evaluation` checks repository evaluation tooling contracts; measured\nheld-out quality remains a separate frozen-protocol, budget-gated #3677 run.\n\nThe existing CI browser job includes ingestion changes and shared UI primitive\nchanges in affected mode, and runs the ingestion browser suite in full mode. Its\nnormal dependency build precedes the suite and failed runs upload browser evidence.\n\nThe maintained host derives inbox state by traversing bounded public review pages,\nauthorizing every action/parent/target before classification. It retains only\nscalar totals and the requested page; an off-page denial denies the whole view.\nThe component's Load more and subsequent refresh/mutation reload every displayed\npage using fresh cursors, then publish the combined authorized set together. Old\npage payloads are cleared while loading; any unavailable page clears all payloads.\nThis keeps plan controls and earlier steps visible across page boundaries without\nreusing a prior authorization decision.\n\n## Published server adapter\n\nImport `createIntakeReviewHost` and `IntakeReviewHostOptions` from\n`@happyvertical/smrt-ingestion/server`. Call `createIntakeReviewHost(service,\noptions)` with a service constructed from the current authenticated session.\nThe returned `IntakeReviewHost` implements inbox state projection across saved\nreview pages, current generation loading, stable `ui:<index>` intention keys,\naction/plan preview CAS lookup, decisions, apply, corrections, candidates, split,\nfeedback, the offered `catalog`, `startAction` and `resolve`. Any catalog\nfailure (not configured, bound exceeded, denied) omits `catalog`, so the picker\ndisappears while existing reviews stay reviewable; item, review and evidence\ndenials still propagate as before; the reference host (`reference/review-host.ts`)\ndegrades the same way. Repeated\npreparation of an unchanged authorized suggestion retains\nits current review instead of creating a new revision. Changed arguments still\nrequire explicit re-preview and a fresh human decision.\n`list({ state?, assigneeId?, cursor?, limit? })` returns one page newest receipt\nfirst (`intake_items.created_at`, id tiebreak) through `service.listItemsPage`.\n`limit` defaults to 20 and is capped at 100; the cursor is an opaque\n(received-at, id) continuation, never a bare id. Tenant, confidential scope,\nretention and the `state` filter run inside the page query, so a page is full\nand `nextCursor` is present only when more rows follow.\n\nAuthority is rechecked at the same two levels `load` applies, but per page\nrather than per row: the host authorize callback once per distinct captured\nceiling in a batch, and the execution read gate (policy, ceiling, handler and\noperation allowlists, principal) once per distinct (item, handler) bound by\nthe page's saved actions. The list gate is read-only: it evaluates the same\nchecks as `listReviews` without the retention write, so an item that the item\nview would narrow past its deadline is simply not listed. The list gate also\nresolves each bound handler and rejects version drift against the saved\nbinding and omits a cancelled item, as `listReviews` does; it does not\nevaluate per-action target,\nevidence or plan gates, so an item whose target became unreadable still lists\nand its item view refuses. An item whose bound\nhandler the actor cannot currently read is not listed at all, rather than\nlisted with a state derived from actions the actor may not see; actions\nwithout a proposal bind no handler and are not gated, as in `listReviews`.\nHandler bindings (handler id and version, projected in SQL, distinct per\nitem) are read once per fetched batch for the rows that passed the item-level\ngate. Note the host cost plainly: the execution `authorize` callback runs once\nper distinct (item, handler) bound by a scanned row that passed the item-level\ngate. Each item is its own authorization subject, so nothing is deduplicated\nacross items; the count is bounded by rows scanned (at most\n`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\n`authorize` behind the handler gate, follows the same rule: a refusal (false,\nor an access check that fails) hides the rows it covers, and a callback that\nthrows rejects the whole page (and `listItems()`) with its error, as `load`\ndoes, so a transient failure never lets paging move past readable rows.\nDenied or unreadable rows are skipped without\ndisclosure and the page refills from the next rows; refills grow from the page\nsize to 100 rows per query, so a fully denied scope costs about a dozen page\nreads and as many binding reads per call. One service call examines at most\n`ITEM_SCAN_CEILING` (1000) rows, or the caller's smaller `scanLimit`, and\nreports the count as `scanned`; when a mostly-denied scope reaches the ceiling\nthe page comes back short with a `nextCursor`. Rows are authorized first and\nthe `state` filter is then applied to the fetched batch, so it is bounded by\nthe same scan limit: a rare state yields short pages with a cursor rather than\nan unbounded walk inside one query. A row the actor can read whose state does\nnot match discloses nothing and may anchor the cursor, so a filter with no\nmatch among the next 1000 rows returns an empty page with a cursor and still\npages to the end. The host `list` passes its remaining budget as `scanLimit`, so the\nrows scanned across the service calls of one host call never exceed 1000: a\nshort service page with a cursor, or an exhausted budget while filtering by\nassignee, ends the host call with the same cursor and the caller continues.\nOnly rows the service wrote are supported: cursors carry the canonical\nmillisecond ISO receipt time the service stores. A row whose stored receipt\ntime is not that form (on SQLite anything but `YYYY-MM-DDTHH:MM:SS.mmmZ`,\nincluding a parseable value without milliseconds or with an offset; on\nPostgreSQL a value with a sub-millisecond part) is never listed, whatever the\nactor's access, and counts toward the scan, as does a row whose id is not a\nUUID. On SQLite a non-text stored value (an integer, say) and a receipt value\nor id longer than 64 characters or containing a NUL character are excluded by\nthe page query itself: the first sorts after every text value so the scan\ncould never move past it, the second could not be carried by any cursor, and\nthe third is returned truncated at the NUL, so it would look canonical and\nsort above a legitimate row at the truncated value. Within a call the scan moves\npast a service-foreign row by its exact stored value, and an unsealed cursor\nnames the last row before it that the actor can read. A call that finds no readable row within its scan limit (at\nmost 1000) fails closed with `IntakeScanLimitError` unless a `cursorKey` is\nconfigured, as below. The host\n`list` returns what earlier service calls collected, with their cursor, when\na later call fails that way.\n\n`listItems()` is deprecated and kept only for callers of the array-returning\nAPI. It walks every page, so its cost grows with the scope, including one\nexecution authorization per (item, handler) across the whole scope. It follows\nthe internal scan position, which never leaves the service, so runs of rows\nthe actor cannot read never stop it and it returns `[]` where nothing is\nreadable. Items are newest-first and gated exactly as on a page.\n\nThe page's inbox state is the durable projection of the saved action journal\nand processing state, computed once per scanned row inside the page query;\n`load` remains the authoritative, live-gated projection for one item (it still\nprojects individual stale actions, which the list does not). Cost is\nproportional to the page for unfiltered pages and common states, and to the\nnumber of items scanned until enough matches are found for rare states: the\nstate is not persisted, because the action journal is append-only and\nprojecting it into an indexed column would need a maintained denormalisation\nin the execution service plus a migration, which this change does not take.\nListing does not load original bytes.\n\nCursors never disclose an unreadable row. Without a `cursorKey` in the\nservice options, every cursor (each row's and `nextCursor`) names a row the\nactor can read (the last one when a page stops early); a page that spends its scan limit before filling hands back\nthe last such row, and one that found none fails closed with\n`IntakeScanLimitError` (`Intake scan limit`, no id or time). A run of rows the\nactor cannot read (refused by the captured ceiling or the execution gate, or\nstored with a non-canonical receipt time) at least as long as the scan limit\ntherefore stops an unkeyed walk there; readable rows that the `state` filter\nexcludes never count toward such a run. With a `cursorKey` (a server secret of at least 32 bytes), every\ncursor is sealed with AES-256-GCM, bound to the tenant, confidential scope and\nactor, and a budget-stopped page returns a sealed position past what it\nskipped, so the walk always progresses. Sealed cursors have a fixed length\n(the plaintext is padded to 1024 bytes) and a fresh nonce per seal, so neither\ntheir length nor their bytes reveal anything about the stop row. `scanned` is for trusted host\naccounting and must not be sent to clients; a short page with a cursor tells\na caller only that unlisted rows exist, never which.\n\nNewest-first paging relies on `intake_items_received_idx`\n(`tenant_id, confidential_scope_id, created_at, id`), the full ordering key, so\nan unfiltered page is a backward index scan that stops at the page boundary;\nthe foundation suite asserts that plan on both engines (SQLite\n`EXPLAIN QUERY PLAN`, PostgreSQL `EXPLAIN`), with the per-row state counters\nserved by `intake_actions_item_idx`. On SQLite the page query pins the index with `INDEXED BY` (except under an\nid restriction, where direct id lookups are cheaper), because without planner\nstatistics SQLite otherwise picks `intake_items_expiry_idx` plus a sort; the\nSQLite plan check runs without `ANALYZE`. A deployment must have applied the\nmanifest migration, or the page query fails with a missing-index error. This package ships no\nmigration files: indexes are declared on the model, the build emits them into\n`dist/manifest.json`, and `smrt db:migrate` / the public migration API create\nthem, which is how `intake_items_receipt_idx` shipped. Deploy the manifest\nmigration before upgrading hosts; `db:status --parity` reports the index as\nmissing until then.\n\nSupply four application adapters: `upload(files, requestId)` (authenticated\nreceipt plus first extraction request), `assignment(itemId)` (durable read),\n`assign(input)` (manager authorization, eligible assignees, durable CAS and request\nreplay protection), and `evidence(part)` (safe label/text or authenticated\nsame-origin URL), which `load` calls for every part and `list` never calls;\n`load` sets no row preview on its entry.\n\nRow previews are opt-in through an optional fifth adapter,\n`rowEvidence(rows)`. When it is supplied, `list` reads the page's durable\nevidence metadata with one `service.listEvidence(itemIds)` query (at most 100\nids; items the scope cannot read, and expired or inactive items, are omitted\nwithout disclosure; the host `authorize` runs once per distinct captured\nceiling through the same gate as `listItemsPage`, so a refusal or a\nmalformed captured ceiling hides the item and a failing `authorize` rejects\nthe page; an item whose stored data does not parse is skipped by both\n`listItemsPage` and `listEvidence`, never fatal). It then calls `rowEvidence`\nonce with each row's primary part (`{ itemId, evidence }`) and publishes the\nreturned `{ label, viewUrl?, thumbnailUrl? }` as `InboxEntry.evidence`\n(`{ evidence, label, viewUrl?, thumbnailUrl? }`, never text). Build those URLs\nfrom metadata without reading bytes; if `rowEvidence` throws or returns a\nmalformed answer, the page still lists without previews, and a row without\ndurable parts never gets one. When it is omitted, rows show no original link.\nEither way `list` reads the page's evidence metadata once, in that same query,\nso the row label can use the original filename; it never reads original bytes\nand never calls `evidence(part)`. The primary part is chosen by role, not media\ntype: the shallowest child part when any part has a parent (an upload's file\nunder its capture record, an email's first attachment under the message, even\nwhen that child is JSON), else the first root part (a watched-folder file);\npart ids sort numerically. `IntakeInbox`/`InboxView` accept an\noptional `rowAside` snippet receiving the `InboxEntry`; without it a row renders\nthe host's \"Open original\" link (new tab) and thumbnail when the URLs are\nsame-origin; each link's accessible name is \"Open original: <row label>\", and\na row shows no wrapper when neither URL is usable. The slot is a plain `div`,\nnot an `aside` landmark, so a page does not add one landmark per row. Optional\n`label(item, evidence)` changes presentation; `evidence` is the item's durable\nparts with the primary part first, and the default label is the primary part's\nown original `filename`, else `Intake <8 hex>`. Optional\n`assignments(itemIds)` reads one page's assignments in one batch (otherwise\n`assignment` runs per row), and optional `assignedItemIds(assigneeId)` lets\n`list` push the assignee filter into the page query (junk ids are dropped, not\nfatal); without it an assignee filter scans further pages and filters by\n`assignment`, which stays correct but costs more rows. The page's continuation\nis always the last row actually returned, so an assignment that changes\nbetween the page read and the assignment read costs at most one extra scan and\nnever skips or repeats a row. Optional\n`enableFeedback: true` exposes the optional feedback callback when the service\nhas feedback configured. Optional `onSplit(itemId, revision)` wakes the host worker after the durable extraction\nrequest; repair dispatches if wakeup fails. The factory creates no assignment\nschema and grants no permissions. Create it per session, authenticate every HTTP\ncall and enforce CSRF before dispatching a mutation.\n\n`GenerationAnalysisOutput` is exported by `/dto` and describes the interpretation\nwrapper `{ proposals: GenerationOutput }` under `analysis.result.output`.\nThe factory unwraps this into `ItemReviewView.generation`. Stale generation is\nomitted while authorized saved reviews remain available; access denials propagate.\nThe reference application still owns its fixture provisioning and HTTP transport.\n\n## Upload refusals and accepted types (#4502)\n\n`IntakeWorkspace`/`IntakeInbox` take `accept` (an `<input accept>` value)\nfor their file picker, defaulting to `IntakeReviewHost.uploadAccept` (adapter\noption `uploadAccept`); it is a hint only, `upload` still enforces the host's\nallowlist. When `upload` throws `IntakeUploadRefusedError` (from `/svelte`) or\nany error with a string `userMessage` (or an error named\n`IntakeUploadRefusedError` / with `code: 'INTAKE_UPLOAD_REFUSED'`, whose\n`message` is then shown), the inbox shows that reason (trimmed, at most 500\ncharacters) and keeps its rows; `uploadRefusalMessage(error)` applies the same\nrule. Any other error still fails the list closed with `ingestion.error`.\n\n## Explicit upload retries\n\nThe inbox retains selected original files and the receive request identity when\nupload fails or acknowledgment is lost. Retry Upload explicitly with the same\nselection; Refresh does not replace that identity. Selecting files again starts a\nnew receipt identity, even when names match. Successful upload clears the selection\nand identity. Replacing the authenticated host/context or destroying the component\ndiscards both and fences late completion from the prior context. No automatic\nupload retry or authorization bypass occurs.\n\nThe inbox clears only its native file-input value when opening the picker, so\nchoosing the same local file explicitly starts a new upload identity. Opening\nthen dismissing the chooser preserves the separately retained originals and\nrequest identity for retry; it does not clear the shared FilePicker's native-form\nbehavior. Successful upload and host/context disposal still discard retry state.\n\nWorkspace ordering also protects an open item during an awaited automatic page walk:\nthe selected row remains until navigation succeeds, except that a failed list read\nclears the inbox. Applying an action is disabled\nwhile that action card holds unsent arguments, reason, or feedback comment. A list\nrefresh retires earlier follow snapshots; only follow reads started after that\npage request may override its rows, so a newer refresh cannot be regressed by an\nalready settled or older in-flight follow read.\n"
1733
+ "content": "# Authenticated review surfaces\n\n`@happyvertical/smrt-ingestion/svelte` exports `IntakeInbox`, `IntakeReview`,\n`IntakeWorkspace` and `EvidenceViewer`. The browser-safe DTO entry exports their `IntakeReviewHost`\ncallback contract. Import styling/theme configuration from smrt-ui in the host.\nComponents use smrt-ui primitives and the `ingestion.*` i18n catalog.\nExisting `IntakeInbox` hosts now keep the previous rows visible, with their\ncontrols disabled, while a refresh or filter change loads; any failed read\n(Load more included) still clears them.\n\nThe host authenticates every request, resolves tenant/actor/confidential scope\nfrom its session, enforces CSRF on mutations, and constructs `IngestionService`.\nNever accept scope, reviewer, permissions or a principal from callback JSON.\nOnly supply authorized evidence URLs and escaped text; original URLs are\nsame-origin authenticated handlers that recheck `readEvidence` on every read.\nCandidate discovery uses `findCandidates` with current grants. Presentation\nstate and disabled buttons provide no server authority.\n\n`listReviews(itemId, {cursor?,limit?})` reloads saved action identities and their\ncurrent plan membership without browser storage. It returns at most 50 actions,\nwith an opaque action-ID cursor. Every payload requires current item, handler,\nparent plan and target access under the owning transaction. Denied reads fail\nclosed. Target reads check current authorization separately from historical\nrevision freshness: unfinished drift returns state-only stale reviews without\nremoving unrelated entries; succeeded results remain readable after authorized\ntarget changes. Apply still enforces exact bound target revisions. Expired entries\ncontain only review identity/state. Stale entries never disclose old args or target\nlabels; an authorized parent plan additionally exposes `stalePlan` identity, key,\ncurrent plan revision and handler identity. Hosts must collect fresh explicit args\nfrom the reviewer/current authorized evidence and call `previewPlan` with that\nexact plan CAS and the current completed analysis attempt. Action revision is not\nplan revision. Re-expansion preserves action identity and requires fresh approval.\nSuccess results are rechecked through the durable result reader.\nPlan membership contains the exact parent args/version so edits call\n`previewPlan`, not per-step correction. An item may have more review pages; the\nhost must aggregate state across pages when projecting a production inbox state.\n\nA `needs_review` suggestion is completable, not only rejectable (#4208). That\nincludes a suggestion kept because its arguments missed only required fields\n(#4284); nested misses appear as JSON Pointers such as `/vendor/name`. The\ncomponent shows its `missingFields`, an editable `Suggested arguments (JSON)`\ndraft prefilled from the generated arguments, and the candidate picker for\ndeclared candidate fields; Prepare review stays disabled while required fields\nare still missing from an unchanged draft. An unchanged draft previews the\ngenerated snapshot as before; an edited draft is sent as `preview({..., args})`,\nwhich the published host publishes through the ordinary `previewProposal` /\n`previewPlan` with the same `ui:<index>` intention, never by rewriting the\ngeneration. The handler validates the human arguments again before a\n`waiting_review` action exists, approval remains a separate explicit decision,\n`ActionReview` lists top-level required arguments missing from the current\ndraft while editing (nested misses named by JSON Pointer in `missingFields` are\nnot re-checked in the browser; the server's revalidation refuses them and the\ncomponent shows that inline as `HandlerArgumentsInvalidError`), and no authority or automation eligibility is added: a failed attempt,\nan unoffered handler or a foreign stage is still refused by the host.\n`preview` throws on every failure, as before. When the handler's argument\nschema or `validate` refused the arguments being previewed (operation preview,\nwhich also carries corrections, a reviewer-started action, or plan preview),\nthe server throws `HandlerArgumentsInvalidError` (`name` set, messages\nunchanged). An argument-schema refusal also carries `issues`: a frozen list of\nat most `HANDLER_ARGUMENT_ISSUE_LIMIT` (20) `{ path, keyword? }` entries\n(`HandlerArgumentIssue`). `path` is built only from schema-declared property\nnames, `required` names and real array indexes and is cut at\n`HANDLER_ARGUMENT_PATH_LIMIT` (160)\ncharacters (never splitting a `~0`/`~1` escape). Segments are escaped per RFC\n6901 (`~` as `~0`, `/` as `~1`), with two departures: `/` is the whole\narguments object (never a key `\"\"`), and `?` stands for a data-named key. A\n`required` refusal appends the missing schema name to the parent's path. The\nformat relates to a suggestion's `missingFields` as follows: a root path\n`/name` is the bare `missingFields` entry `name` (unescaped), and a nested\nentry is the same escaped pointer in both (for example `/vendor/a~1b~0c`).\nThe bundled view shows a root issue without its leading `/`, so its refused\nfields read like the missing-information list. `ProposalValidationError`\nreasons share the schema-owned segment rule but stay unescaped.\n`keyword` is the failing schema keyword. Argument values, Ajv\n`params`/messages and handler messages are never included. A `validate`\nrefusal has no `issues` (the handler contract returns only `ok`). The size and\nshape preconditions checked before the schema runs (arguments over the policy\nbyte limit, or a handler schema that is not a closed `type: 'object'` with\n`additionalProperties: false`) stay a plain `Error`: they fail closed as a\ndenial, never as the inline refusal. Apply-time and\nexternal-send revalidation and result-schema checks keep a plain `Error`. The component shows an inline message and\nkeeps the view and draft when `preview` either rejects with an error whose\n`name` is `HandlerArgumentsInvalidError` or resolves `{ rejected: 'invalid' }`\n(`PreviewRejection`, optionally with the same `issues`). A transport that\ncrosses a process boundary preserves the inline UX by keeping that name and\n`issues` (for example re-throwing an `Error` with\n`name = 'HandlerArgumentsInvalidError'` in the browser callback) or by\nreturning `{ rejected: 'invalid', issues? }` for that case only, as the\nmaintained fixture's `previewForReview` and `startActionForReview` do. Since\n#4281 the fixture's rejection body also carries `issues` when the schema\nrefused; read `rejected === 'invalid'` rather than comparing the whole body\nwith strict equality. Without either, the refusal behaves like\nany other failure: the view is cleared exactly as before this change, and the\nreviewer's in-memory draft returns after an authorized refresh. Every other\nthrown preview or candidate-search failure (denied, revoked, unavailable or\nunknown) clears the view.\nUnsent drafts live only in component memory, keyed by attempt and suggestion\nindex. They survive Refresh, a failed callback, an action on another card and a\ntransient `unavailable`/`stale` reload; a `revoked` or `expired` result\ndiscards them, a successful preview clears that card's draft, and an available\nreload drops drafts for attempts or cards no longer offered.\n\nThe host's `load` callback supplies a current `ItemReviewView`, using\n`getCompletedAnalysis` with no historical fallback. Generated suggestions bind\nthe **current generation attempt**, not `generation.source.attemptId` (the prior\nextraction). `previewGeneratedProposals` publishes exact reviews and continues to reject stale\ngeneration candidate pins. When `listReviews` reports `generationStale`, the host\nomits obsolete generation output and renders authorized saved reviews/results.\nCompleted saved actions do not hide healthy unprepared suggestions. The saved-binding hint is not\nan authoritative generation-availability check: a fresh human preview can bind a\ncurrent target while the immutable generation still pins its former revision.\n`getCompletedAnalysis` remains strict and throws server-only\n`GenerationSnapshotStaleError` only after every current catalog/provider/candidate\nread is authorized and an immutable candidate revision differs. The maintained\nhost catches only that class, reacquires `listReviews` under current owning\nauthority, and returns that new page without generation output. It never reuses\nthe earlier page after an error. Denials and unknown failures propagate; healthy\ngeneration still exposes unprepared sibling suggestions. Stale entries expose handler\nand attempt identity only when that attempt is still the current completed/partial\nanalysis. An empty-args editor submits fresh explicit human arguments through\n`previewProposal` or `previewPlan` with the saved CAS; it never reuses old generated\narguments, rewrites the generation snapshot or transfers approval. Analysis-stale\nentries without an attempt require normal reprocessing. Approve,\nreject, defer and correct call `submitDecision`; corrected args create a new\nrevision which requires a separate human approval. After an approve/defer/reject\ndecision, optional `editAction` calls `previewProposal` with the current action\nrevision to publish a new review; completed/executing/unknown actions cannot be\nrevised. Local dirty args disable Apply but confer no authority. Plan edits re-expand through\n`previewPlan` with the current **plan** revision. `applyAction` accepts only an\naction ID. Unknown external outcomes require server reconciliation; the UI\nnever offers a blind resend. Stable result IDs survive reload and retry.\n\nReplace `contextKey` whenever authenticated tenant/session context changes.\nChanging it or itemId destroys the old view and fences late callback results.\nDenied refresh clears prior originals, candidates, arguments and results.\nMissing confidence is unavailable; page/time links exist only for observed\nlocations. Provider errors and unresolved/partial results remain review states.\nA host that enabled `proposals.failureCapture` may fetch a failed attempt's\nredacted model output with `getFailedAttemptCapture(itemId, attemptId?)`.\nRetaining text requires a configured host redaction hook; a missing or failed\nhook yields only `redaction_failed` metadata. The host owns that redaction policy.\nThe getter requires current reviewer authority for that item and fails closed\notherwise;\n`load`, inbox rows and review pages never include it, so a host shows it only\nbehind its own explicit diagnostic action (see\n[PROPOSALS.md](PROPOSALS.md#failed-attempt-capture-opt-in-4285)).\n\n## Submitter-scoped decisions\n\nFor trusted internal sources the person who submitted an item may confirm or\ndecline their own proposal without the reviewer inbox. `listOwnProposals`\nreturns only items whose server-recorded `submitter_id` equals the current\nauthenticated actor, each with its bounded saved review page reloaded under\ncurrent item, handler and target authority. An item the actor can no longer\nread (the host `authorize` returns false or throws `IntakeAccessDeniedError`)\nis skipped before projection: the page is filled from readable items only, in\n50-row batches, and `nextCursor` names the last emitted item and is present\nonly when a further readable item was actually found. Neither the entries, the\npage size nor the cursor reveal a hidden item. If one call has to skip 1000\nhidden submissions it fails with `Own proposal scan limit` rather than return a\nshort page or a cursor that stops inside the hidden run; this error is the only\nsignal, and it says only that such a run exists. Any other `authorize` error\npropagates instead of shrinking the list. An item that is still readable but\nwhose reviews are denied is returned as `{ item, reviews: null, unavailable:\ntrue }`: that covers ingestion's own denials and any throw from the host\n`assertTarget` (deleted or revoked targets), whose text is never returned.\nIntegrity, validation and database errors from ingestion itself propagate. The\nreviewer `listReviews` surface keeps propagating host target errors unchanged.\n`submitOwnDecision` takes the same `ReviewInput` CAS fields and request ID as\n`submitDecision` and accepts `approve`, `reject` and `defer` only; `correct`\nbelongs to the reviewer surface. It runs under the `review_own` execution\noperation: the host's `authorize` must return `submitter: true`, the resolved\npolicy must have `submitterReview` enabled, and the recorded submitter must be\nthe current actor and also the receipt's captured execution principal (the\nidentity the effect runs as), checked under the item lock and again at apply.\nA receipt captured for a bot or service principal is never self-approvable. Policy enablement, handler/operation allowlists, captured\nceiling, confidential access, retention and the first-valid-decision lock are\nunchanged. The decision is a human decision by that actor, recorded with\n`mode: 'review_own'`; reviewer decisions carry `mode: 'review'`. In both modes\n`selfDecision` is true exactly when the deciding actor is the item's\nsubmitter, so a reviewer approving their own submission is never independent\nreview. A self-decision is not a reviewer judgment: it never feeds\nautomation eligibility (machine authorization remains the separate evaluated\n`kind: 'machine'` path) and its feedback events carry the same marker. The\n`unavailable` entry discloses nothing about the denied handler or target.\n`applyAction` then runs once under the approver's live grants exactly as\nfor reviewer decisions. Hosts render this surface from their own authenticated\nsession; presentation state confers no authority, and a source payload cannot\nnominate a submitter.\n\n## Assignment and logical splits\n\nAssignment is explicitly host-owned, durable and versioned. `assign` receives\nan expected assignment version and request ID; the host must authorize the\nmanager, constrain eligible assignees, CAS the durable record and reject stale\nwrites. Assignment does not grant read, review or execution rights. The maintained\nfixture provisions its own SQLite assignment table before serving requests; no\nproduction ingestion table or migration is introduced for assignment.\n\n`reviseLogicalSplit` authorizes the current generation through the same-executor\ncatalog/candidate gate and the existing reviewer grant and policy reviewer list.\nProcess access alone cannot mint or replay a human correction; reviewer authority\nis checked before mutation and again before publication. A generation without a\nreviewable catalogue cannot be corrected through this reviewer API.\nIt requires all observed pages exactly once, an exact\ncurrent attempt/revision and a request ID. It records the authenticated actor\nand predecessor as a **human correction** in a new immutable extraction\nconfiguration, queues re-extraction and immediately makes previous suggestions\nand approvals stale. It never changes original bytes/evidence or labels a human\nedit as model output. The host processes the new extraction, then calls\n`prepareGeneration` and the normal fenced interpretation stage. The supported\nimmediate-predecessor extraction pin is unchanged. The generator receives a\nhuman correction only after its digest matches the authoritative source. Ordinary\n`analyze` calls cannot mint an extraction correction; the owning split API is the\nauthenticated origin, and forged interpretation corrections fail before provider\ndisclosure. If its proposed grouping differs from the correction, suggestions are withheld\nwith `human_split_not_respected`. New proposals require new explicit review.\nThe reference host runs these stages synchronously for browser determinism;\nproduction hosts dispatch the existing durable analysis jobs.\n\n## Reviewer instructions\n\n`recordReviewerInstruction({itemId, attemptId, expectedRevision, text,\nrequestId})` records a plain-text instruction for one item (\"This is a customer\nPO from Badger; file it, do not bill it\") as the second kind of **human\ncorrection**. It is authorized exactly like `reviseLogicalSplit`: the current\ngeneration must pass the same-executor catalog/candidate gate and the reviewer\ngrant plus policy reviewer list, and authority is checked before mutation and\nagain before publication. Process access, `analyze`, and a client payload cannot\nmint one: `analyze` rejects a `reviewerInstruction` key on an extraction\nconfiguration, and an interpretation copy that differs from the pinned\nextraction's record fails with `Generation evidence changed` before provider\ndisclosure. Text is required, trimmed-nonempty and at most 4,000 characters.\n\nThe record is immutable: `{id, text, actorId, recordedAt, supersedesId, digest}`\nplus the request binding, written into a new extraction configuration beside\nany existing split correction. Recording supersedes current suggestions and\napprovals, queues the normal fenced re-extraction and interpretation through the\nexisting dispatch path, and returns immediately; the reviewer keeps working\nwhile the item reports `processing`. `prepareGeneration` copies the instruction\ninto the interpretation configuration, the generator receives it only after its\ndigest matches the authoritative extraction record, and saved generation\nprovenance records `reviewerInstruction: {id, digest}`. A later instruction\nrecords `supersedesId` and replaces the earlier one for future interpretations;\n`listReviewerInstructions(itemId)` returns the full history, newest first, and\nhistory is never deleted. The instruction steers the model only: it cannot add\nhandlers, candidates, schemas, permissions or automation eligibility, replies\nare validated as usual, and suggestions still require explicit preview and\napproval. A replay with the same request ID returns the same revision; a changed\ntext under the same request ID conflicts.\n\n**Cost.** Each recorded instruction appends a new extraction revision, so the\nitem is extracted again before it is interpreted again: cheap for embedded-text\nPDFs and plain text, but OCR/speech hosts spend provider budget per instruction,\nexactly as they do per split correction. The pinned extraction is not reused\nbecause the interpretation contract requires its source to be the immediately\npreceding revision and `getCompletedAnalysis(itemId, attemptId)` serves only\nthe current revision (PROPOSALS.md, \"Durable stages\"); reusing it would mean\nweakening that predecessor pin, which protects every generation from an\nintervening reprocess, so this feature follows the split path instead.\n\n**State and failure.** An in-flight stage (queued/running) reports the item as\n`processing` even when earlier, now-stale actions exist. If the re-extraction or\ninterpretation fails, `getReinterpretationState(itemId)` returns the latest\nattempt's safe failure category and the latest completed generation attempt;\nthe host projects them as `instruction.failure` and `instruction.binding`, and\n`recordReviewerInstruction` accepts that historical attempt as its anchor when\n`expectedRevision` is the item's current (failed) revision and no later\n**interpretation** completed and nothing is queued or running, so the reviewer\ncan retry or change the instruction after either stage fails. A completed\nextraction whose own interpretation failed belongs to the failed attempt, not a\nnewer result; a completed extraction whose interpretation has **not been\nappended yet** is in flight (the host is between stages) and is refused with\n`ReviewerInstructionStaleError` (`Stale reviewer instruction`), so a second\nreviewer cannot pre-empt it. That gap expires: once the extraction attempt's\ncompletion plus the item's analysis lease duration (`limits.leaseMs`, the lease\n`claimAnalysis` grants) has passed with no interpretation appended,\n`getReinterpretationState` reports `failure: 'not_started'` with the retry\nbinding and the guard allows the retry. `not_started` means **the\ninterpretation did not start within the lease window**: the host crashed,\nthrew between `prepareGeneration` and `analyze`, or its queue is merely slow.\nPre-empting a slow but healthy host is safe because `analyze` refuses an\ninterpretation whose pinned extraction is no longer the item's latest, under\nthe item lock and before anything is superseded\n(`GenerationSourceStaleError`, nothing appended): the slow host's late append\ncannot clobber the retry. Missing or malformed timing (no attempt, no\n`completedAt`, non-finite `leaseMs`) counts as already expired rather than\nholding retries forever. The \"still being re-read\" note shown after a stale\nresult is cleared by the next reload that shows the item settled; the draft\nthen carries a persistent \"This item changed while you were writing; your\ninstruction was not recorded.\" note (any stale cause: another reinterpretation,\na split, or a host reprocess) until the reviewer edits or resubmits it. The guard and\n`getReinterpretationState` share one predicate, and the latter's\n`reinterpreting` flag (projected as `instruction.reinterpreting`) is true in\nthe gap too, so the component keeps polling and keeps Re-read disabled there.\nThe published host maps two typed errors to `instruct` results:\n`ReviewerInstructionStaleError` → `{ stale: true }` (refresh-and-retry: reload\nwith a note, draft kept) and `ReviewerInstructionInvalidError` → `{ invalid:\ntrue }` (empty, whitespace-only or over 4,000 characters: shown inline, draft\nkept). Neither is a denial. Custom hosts should map them the same way; a host\nthat lets either propagate as a thrown error makes the component treat it like\nany other failed mutation, i.e. a whole-view deny with the \"view unavailable\"\nmessage, as before these results existed. The maintained fixture's\n`/api/instruct` now answers an invalid text with HTTP 200 `{ invalid: true }`\ninstead of 409. A retry from an older anchor builds on the **whole configuration**\nof the newest extraction after it (replacing only the instruction), because that\nis the item's current configuration: corrections chain forward through it and a\nhost reprocess replaces it. So a split followed by a failed stage is never\nsilently dropped, and a failed reprocess's changed configuration is neither\nreverted nor overlaid with a split it dropped. **Concurrency.** `recordReviewerInstruction` and `reviseLogicalSplit` each\nrun their whole decision in one transaction under the item row lock: the\nanchor snapshot read takes the lock first, the execution authorization of\nthat snapshot takes it again (`UPDATE intake_items` row lock), the guard step\nre-asserts it explicitly, and\nthe request-key replay check, the revision and later-work guards, the\ncarried-forward split, the history read that sets `supersedesId`, and the\nappend all run under it. On PostgreSQL a second reviewer at the same revision\nblocks on that lock and is then refused (`ReviewerInstructionStaleError` /\n`Stale logical split`), so the ledger cannot fork; an instruction racing a\nsplit (in either order) is refused rather than built from the pre-split\nconfiguration; and a double-click whose duplicate commits after the unlocked\nreplay check is answered as a replay of the committed request, not a conflict\nor a denial.\n\n**Host reprocess.** A host reprocess (a plain `analyze()` extraction the host\nconfigures itself) cannot carry human corrections — `analyze` rejects\n`humanCorrection`/`reviewerInstruction` on extraction configurations — so it\ndrops the split and the instruction alike, and neither is applied to its\ninterpretations. The instruction is then reported as `dropped`, not pending:\n`getReinterpretationState().instructionInEffect` names the instruction the\nnewest extraction carries (verified by digest) and is null after such a\nreprocess. History is unchanged; the reviewer can re-record the instruction,\nwhich supersedes the dropped one.\n\n`ItemReviewView.instruction` exposes `current` (the latest recorded instruction\nstill in effect, i.e. carried by the newest extraction), optional `dropped`\n(the latest recorded instruction a reprocess dropped), `applied` (the\ninstruction the displayed **generation** ran under; a completed extraction alone\napplies nothing), `stale` (the displayed suggestions predate the current\ninstruction), optional `failure` and optional `binding`. Feedback examples that carry instruction\ntext count it toward the feedback byte budget (FEEDBACK.md). The optional host callback\n`IntakeReviewHost.instruct` is implemented by the published adapter when the\nservice supports it, with an optional `onInstruct(itemId, revision)` wakeup\nmirroring `onSplit`. The review component renders \"Instructions for the\nassistant\" with \"Re-read with these instructions\", shows \"Reinterpreting…\" while\nthe item's stage is queued/running, repeats the existing authenticated `load`\non `pollMs` (default 2 s, backing off to 5×) and patches the view in place —\ndrafts, split edits, focus and the evidence viewer survive and no loading\nplaceholder is shown — stops when the stage is no longer in flight, and then\nshows the instruction that produced the current suggestions, marking a\nsuperseded one as stale. An explicit host denial on a poll tick fails closed at\nonce; a thrown tick is treated as transient (view and drafts kept, a\n\"Couldn't refresh; retrying.\" note, backoff continues), but because hosts may\nalso throw for revoked access, three consecutive thrown ticks fail closed.\n**Hosts must signal revocation by returning `availability: 'revoked'`** (or\nanother non-`available` state) from `load`, not by throwing: a returned denial\nclears the view on that tick, while a host that throws on revocation leaves the\nalready-displayed content visible until the third thrown tick (about 10 s at the\ndefault interval and backoff) before the view fails closed.\nSplit drafts survive a refresh only when the reviewer edited them. A failed stage shows its safe category with the form\nstill available. There is no\nstreaming channel; status is read through the existing item/analysis APIs. The\nmaintained fixture records the instruction durably and then runs the stages in\nthe background so the browser proof exercises the in-place update.\n\n**Try again (#4504).** `retryInterpretation({itemId, attemptId,\nexpectedRevision, requestId})` re-runs an item whose interpretation failed (the\ndisplayed generation is `provider_error`, or later work failed or never\nstarted) with no new instruction. It uses the same anchor, guards, `process`\nauthority and fresh extraction plus interpretation as an instruction, carries\nthe item's current configuration (an instruction or split already in effect\nincluded) unchanged, and records nothing in the instruction history. Superseded\nanchors, work in flight and items that did not fail throw\n`ReviewerInstructionStaleError`. A replay of the same intent (item, anchor,\nrevision, `requestId`, actor) returns the first append under current `process`\nauthority; the same `requestId` with another intent is a request conflict.\n`createIntakeReviewHost` exposes it as `IntakeReviewHost.retry` (stale resolves\n`{ stale: true }`) and wakes the worker through `onRetry`, else `onInstruct`.\n`ReviewView` draws a \"Try again\" button (`ingestion.tryAgain`) beside the\ninstruction box when `host.retry` exists, the item is open, nothing is in\nflight and the interpretation failed; it binds like the instruction\n(`instruction.binding`, else the displayed generation).\n\n`feedback` is an optional explicit correctness callback, separate from approval\nchoice and execution success. No learning, feedback storage or rule promotion is\nimplemented by these components; #3676 owns those services. Do not pass feedback\nor a feedback callback to readers without the relevant host grant.\n\n## Reviewer-chosen actions and \"handled, no action\"\n\nA reviewer is not limited to model suggestions. `ItemReviewView.catalog` carries\nthe handlers the item's catalog currently offers (`listHandlers`, discovery media\ntypes and live preview authority; it is presentation, never a grant). The optional\n`startAction` callback forwards `StartActionInput` (`itemId`, `handlerId@version`,\nexplicit `args`, `requestId`, optional `reason`) to `startReviewerAction`, which\nrequires the current reviewer grant under that handler, a completed or partial\nattempt on the current analysis revision, and an operation handler the item's\nevidence media types offer. Plan handlers go through `previewPlan`. The server\ncreates an action keyed `reviewer:<requestId>` with origin `reviewer-selected`\n(`createAction` refuses that key prefix and origin, so process authority cannot\nmint reviewer provenance; re-previewing such an action with `previewProposal`\nneeds the reviewer grant and keeps its handler fixed, so only its arguments can\nbe corrected),\nruns the handler's normal `validate`/`preview`, publishes a `waiting_review`\nrevision and requires the same explicit approval and apply as a generated\nproposal. The reviewer typed these arguments, so a handler argument-schema or\n`validate` refusal throws `HandlerArgumentsInvalidError` with the value-free\n`issues` described above, and the rolled-back transaction leaves no action row\n(#4281). Every other refusal (input shape, access, handler unavailable, item\nresolved, identity or request conflict, normalization mismatch) stays a plain\ndenial. `startAction` follows the `preview` contract: it throws, or a transport\nmay resolve `{ rejected: 'invalid', issues? }` for that one case. The component\nkeeps the start form, handler, arguments and reason, shows an inline refusal and\nlists any field paths; any other failure clears the view as before. Replays\nwith the same request return the same review; changed arguments or reason\nconflict. Such actions are never automation-eligible: `authorizeAutomatic`\nrefuses them regardless of policy opt-ins. The UI pre-fills arguments only from a\nsame-handler suggestion (for example a `needs_review` one) and a single original's\nevidence references, lists the required fields still missing, and submits exactly\nwhat the reviewer typed. An item whose current attempt failed outright must be\nreprocessed first; an interpretation that completed with `provider_error`,\n`unknown` or no suggestions can be acted on directly. `ReviewAction.origin`\nexposes the provenance so `ActionReview` can label human-started actions.\n\nThe optional `resolve` callback forwards `ResolveItemInput`\n(`disposition: 'resolve_no_action'`, `requestId`, optional `reason`) to\n`resolveItem`. It needs reviewer authority under any handler the item's captured\nceiling offers and refuses while an action is `authorized` with a live approval,\n`executing`, `outcome_unknown` or `succeeded`; waiting, deferred, rejected and\nfailed actions, and authorized ones whose approval expired, may remain (neither\nan exhausted failed action nor a lapsed approval can be rejected, so they must\nnot keep the queue open). Afterwards only reject remains: those actions can no\nlonger be approved, corrected, deferred, re-previewed, retried, applied or\nautomated. `submitDecision` refuses every decision but `reject`, no new revision\nis published for the item, `applyAction` rechecks the disposition before any\neffect, and `analyze`, `reviseLogicalSplit` and `createAction` refuse the item\nunder its lock before any write, so it is never reanalysed, re-split or given a\nnew action. `resolveItem` supersedes any queued or running analysis under the\nsame lock and `claimAnalysis` refuses a resolved item, so no provider is called\nafter a human closed it; the item's `processingState` keeps its last value (it\ndescribes analysis, not review), and the inbox state `resolved` is authoritative.\n`publishProposal` (process authority) refuses a resolved item and any\nreviewer-selected action. The UI hides Approve, Defer, Apply, every edit control, suggestion\npreview and split controls on a resolved item.\nResolved 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.\n\nPaged 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.\n\nThe append-only `intake_dispositions` record is terminal: replaying the same\nrequest returns the same disposition, any other request conflicts, and\n`startReviewerAction` refuses the item. `IntakeItemDTO.disposition` projects only\n`{ state: 'resolved', at }` to any item reader (`listItems` reads it in the same\nstatement as the page); the reviewer and reason are returned only by\n`getItemDisposition`, which requires reviewer authority, and the review hosts put\nthem on `ItemReviewView.disposition` (omitted when that read is denied, so the\nview shows only \"Handled, no action\"). `loadReviewSummary` reports inbox state `resolved` unless an action\nactually succeeded. Privacy deletion and retention expiry redact the reason and\nreviewer (`intake_dispositions.data`) and keep only the kind. The disposition is\ndistinct from `reject`, which still means the proposal was wrong. Closing an item\nrecords `reviewer_routing` feedback immediately; a started action records it only\nwhen the reviewer approves it (see FEEDBACK.md). Capture is an optional projection\nthat never vetoes the decision or disposition.\n\n`startReviewerAction` applies the handler's discovery media types and the live\nreviewer grant/policy for that handler. It intentionally does not apply the\nproposal catalog's provider-policy filter or `maxHandlers` slice: those bound what\na model may be offered, and a human choosing a handler sends nothing to a\nprovider. The UI picker shows the offered catalog, so a handler hidden from it is\nonly reachable by a reviewer who already holds `review` authority for it. The maintained\nbrowser fixture serves both operations behind CSRF and the same session scope.\n\n## Review screen presentation (#4503)\n\nAll presentation only; no new authority.\n\n- **Handler labels.** A handler registration may declare `label`. It is carried\n on the reviewer catalog only (`listHandlers`, `ItemReviewView.catalog`), never\n on generation input, catalog digests or bindings, so adding one never stales\n existing work. The proposal heading, action heading and \"Start action as…\"\n select draw it, falling back to the handler id.\n- **State labels.** Review states draw `ingestion.reviewState.<state>` and\n generation outcomes `ingestion.outcome.<outcome>`; an unknown value is drawn\n as is.\n- **Arguments as fields.** Where a handler `argsSchema` declares top-level\n `string` (plain, `date`, `date-time`), `number`, `integer`, `boolean` or\n enum properties, or `references` declares a candidate/evidence field, the\n suggestion draft, the action's arguments and the start form draw those as\n fields (`title` as the label, `*` when required). A candidate reference is a\n search box over `host.candidates`, filtered to the reference's model, showing\n the candidate's label (the generation's offered candidates label the current\n value); an evidence reference picks among the item's originals. The JSON text\n stays the source of truth: fields rewrite it, and it remains editable under a\n collapsed \"Advanced\" disclosure for everything else (objects, arrays,\n unions). Without such properties the plain JSON textarea (and the\n destination picker) are drawn as before. Server validation is unchanged.\n- **Effects.** `action.review.display` is drawn as a `<dl>`: the handler's\n preview fields (`display.preview`, or `display.step.preview` for a plan step;\n a flat record as is), then non-empty `dependencies`/`plan` metadata as JSON,\n named `metadata.<key>` when a preview field already has that name\n (`displayEntries` in `view-utils`).\n- **Assignees.** An optional `IntakeReviewHost.assignees(query)` (adapter\n option `assignees`) answers `{ id, label }[]`; the inbox then draws a search\n picker for the filter and each row instead of the free-text \"Assignee ID\".\n `assign` still receives the id and still authorizes. A failed search offers\n nothing.\n- **Feedback state.** `ReviewAction.feedback` (see FEEDBACK.md) presses the\n current judgment; a further judgment supersedes it and\n `ingestion.feedbackSent` confirms it.\n- **Confidence.** Nothing is drawn when the provider reports no confidence.\n\n## Single-page triage workspace\n\n`IntakeWorkspace` (#4215) composes the existing pieces so a reviewer works the\nwhole queue on one page: the paged inbox as a list pane, the review view of the\nselected item beside it (the original above its action form), and the\nreviewer-instruction message box of the item pane. Props: `host`, `contextKey`\n(replace on session change, as for the other components), optional initial\n`itemId`, `pollMs`, `rowAside` and `onselect(itemId | undefined)`, which only\nreports the selection (to keep a URL in step) and never navigates. It adds **no\nhost callback and no server authority**: every read is the host's `list` and\n`load`, every action still goes through the handler preview and an explicit\napproval and apply under the approver's live grants, and the message steers the\nmodel only, exactly as in `IntakeReview`.\n\n- **Inline original.** In the item pane `EvidenceViewer` shows one part at a\n time (`layout=\"pager\"`), opening on the first part that renders inline (the\n uploaded file rather than its capture record), with previous/next original.\n A PDF with more than one *observed* page (extraction locations or proposed\n page groups; `pages` prop) gets previous/next page, which reloads the frame\n at the same-origin URL plus `#page=n`; only `evidenceUrl`-approved URLs ever\n reach a frame. `IntakeReview` gets the same page navigation over its\n unchanged stacked list of parts. The chosen part and page belong to the item\n view (bindable `chosen`/`page`), so they survive in-place reloads, Prepare\n review, Approve and Apply.\n- **Keyboard.** J/K and Alt+Down/Alt+Up move to the next/previous row (loading\n the next page at the end), ignored while focus is in a text field, select or\n editable region; \"Previous item\"/\"Next item\" buttons carry\n `aria-keyshortcuts`. The selected row is `aria-current`, and every selection\n moves focus to the item pane region, named by the row's label. A navigation\n that waited for the next page is dropped if the reviewer chose another item\n meanwhile.\n- **Unsent input.** The pane holds unsent input when it has instruction text,\n an edited suggestion draft or split draft, a chosen start handler or start\n reason, a no-action reason, or an action editor with edited arguments, a\n reason or a feedback comment. It is read from the drafts themselves, so it\n holds while a reload has cleared the view; while the view is denied nothing\n is shown and nothing counts (the drafts stay in memory and return with an\n authorized refresh, as before). A reviewer-initiated move (J/K, Alt+arrows,\n Next/Previous item, a row's Review button) then asks inline, in a group\n labelled by its note: \"Stay on this item\" takes focus and returns it to the\n field the reviewer last used; \"Discard and open the other item\" proceeds.\n When an item is closed without action (here or by another session), input a\n closed item cannot take\n (instruction, suggestion and split drafts, a started action, a no-action\n reason) is discarded.\n- **Live state.** A row's status follows the item: \"Interpreting…\", \"Ready to\n review\", \"Needs review\" with missing argument names or the generation\n outcome, \"Awaiting approval\", \"Approved, not yet applied\", \"Failed\" with a\n safe category, \"Handled, no action\", or the listed inbox state. Rows the\n reviewer has not opened show the listed state only; reasons come from a\n loaded view, never from server exception text. The open item's own polling\n (`ReviewView`, unchanged backoff and three-failure fail-closed) reports each\n displayed view and the workspace patches that row in place\n (`InboxView.patch`, keeping the listed label and preview, and the row's\n assignment unless the patch carries a newer assignment version, so a view\n read before an assignment never rolls the row or the next expected version\n back), so the row follows each newer read of the form and the list is\n **not** re-listed per tick. An item the\n reviewer leaves while its stage is in flight, or before the pane showed the\n result of its last operation (an instruction just recorded), keeps\n following: the workspace re-reads followed items with the same `load` (first\n review page) on the pane's backoff, at most **5** reads per tick, one after\n another, least recently read first (so every followed row is reached), with\n only one tick running at a time. At most **20** items are followed; beyond\n that the longest-followed one (the earliest started, whatever the read\n rotation) stops. An item left while its pane was mid-operation (an Apply not\n yet answered) keeps following while a read still shows an action authorized\n or executing: exactly **3** more reads when it never answers, the fourth\n ending the follow. That debt belongs to the operation, so it survives\n reopening and leaving the item again until a view shows the operation\n answered or the reads run out; leaving mid-operation again starts a full\n debt for the new operation. Each follow has a\n generation: an answer from an earlier follow of the same item (one asked\n before the reviewer reopened or left it again) is dropped, and its failure\n does not count toward the new follow's three. An item stops being followed\n when it settles, its view is denied, three reads in a row throw, its row\n leaves the displayed list (a filter or a refreshed page removed it), the\n list fails closed, or the cap drops it; a denial, the failure stop or the\n cap drops its cached status and restores its entry exactly as its page read\n returned it (never a patched entry). Opening an item drops its cached\n status until the pane shows a view; a denial in the open pane (a failed\n action, the pane's own fail-closed poll, or a first load that failed) does\n the same as a follow denial, clears the announcement, and the item is not\n followed when left. A settled stage that was seen running is announced in a\n polite status region (\"badger-po.pdf: Ready to review\"), only while its row\n is displayed; a refreshed page or a failed list clears it.\n Other rows update on Refresh, a filter change or when opened; there is no\n streaming. While a refresh or \"Load more\" is pending, the displayed rows stay\n (controls disabled) and keep taking patches. Every read (a page, a pane\n view, including an unchanged poll, and a follow read) is numbered when its\n request starts, and each row shows the entry of its newest read, whatever\n the source: a page read before a pane or follow read never regresses the\n row, and a page read after them replaces what they showed. The same rule\n governs a follow: a follow read older than the row's newest read changes\n no status, does not stop the follow and announces nothing (a newer read\n settles it), so the announcement never contradicts the row. Cached status reasons follow the same ordering, even when entries have identical\n fingerprints. \"Load more\" continues the\n filter the rows were listed under, not filter inputs edited since, and does\n not repeat a row already shown. A failed \"Load more\" fails closed like any\n failed read (the host cannot tell a transient failure from a revoked\n grant): the rows clear and the list shows its error.\n- **Approve and advance.** After an approve, apply or \"Handled, no action\" whose\n reload left nothing for the reviewer (closed without action, or an action\n succeeded and none is waiting for approval, waiting to be applied, executing,\n unknown or failed; stale, rejected and deferred actions do not hold it), the\n item's row is patched and leaves the list when it no longer matches the\n listed state/assignee filter, and the next row that is not closed or\n completed opens and takes focus, with no navigation and no re-listing. An\n approval that leaves an apply step keeps the item open with Apply in place.\n Unloaded saved-review pages and an active background stage also prevent\n settlement. Advancing skips completed inbox pages until a reviewable row\n appears; a page that makes no progress stops the walk.\n It never advances over unsent input on the same item: the item stays open,\n the input stays, and a note says it is finished but has unsent input; the\n reviewer moves on explicitly, through the confirmation; input typed while\n the next page loads counts too. When no open row is displayed, the advance\n appends pages of finished rows until one has an open row or no page\n remains, at most **5** appends. After each append, the advance (like\n J/K at the end of the list) finds its place again by id: just after the\n item it left, or, if that row has gone, at the first row that followed it\n and is still listed, or at the first new row, so a row that left the list\n meanwhile never shifts it onto the wrong item. Its notes (and \"nothing else\n needs review\") are written into one persistent polite status region. The queue is never reported empty when\n that is unknown: past the bound, or when an append cannot start (a refresh\n is pending) or is superseded, the item stays open with a note to try Next\n item again, which still works; when an append fails, the list has failed\n closed (no rows, Next disabled), so the item stays open with a note to\n refresh the list to continue. The open item stays listed while the\n reviewer works on it even if its state stops matching the filter; it\n leaves once they move on.\n When nothing is left the pane says so. The per-row \"Handled, no action\"\n control is hidden in the workspace (`resolveInRow={false}`) so closing always\n goes through the item pane and advances.\n\n\n**List following and refresh (#4504).** Every listed row (other than the open\nitem) whose entry shows a stage in flight is followed like an item left\nmid-stage, up to `WATCH_CAP` rows, `WATCH_LIMIT` reads per tick on the pane's\nbackoff, so rows no longer keep \"Interpreting…\" until Refresh. Opt-in\n`listPollMs` on `IntakeWorkspace`/`IntakeInbox` also re-reads the listed page\nunder its listed filter while any row is processing (skipped while a read or\noperation is pending or Load more appended pages). Both components export\n`refresh()` (`bind:this`), which re-reads the first page like the Refresh\nbutton and resolves `{ items, result: 'shown' | 'failed' | 'pending' }`.\n\n## Maintained browser proof\n\n`pnpm test:components` runs real Svelte component tests; `pnpm test:e2e` runs the\npackage-local authenticated Vite application under Chromium. The fixture uses\nHttpOnly same-site sessions, CSRF, real SQLite/assets/ingestion/content APIs,\nproduction unpdf child extraction and a deterministic proposal generator. Its\nhardcoded test login is fixture-only and is never a production auth provider.\nImage/audio originals remain inspectable when no OCR/speech provider is\nconfigured; the suite makes no recognition-quality claim and spends no provider\nbudget. `reference/review-host.ts` is a maintained development fixture, not a\npublished server entry. Browser tests show actual approved args and persisted\nContentDocument results, stale tabs, reload, retries and negative access.\n\nRun full package `test`, `test:postgres`, `test:providers`, `build`, `typecheck`,\n`test:components`, `test:e2e`; use the root Node/pnpm versions. Set a short\n`CI_TEST_TMPDIR`/`TMPDIR`, and optionally\n`PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` to the supported installed Chromium.\n`pnpm test:evaluation` checks repository evaluation tooling contracts; measured\nheld-out quality remains a separate frozen-protocol, budget-gated #3677 run.\n\nThe existing CI browser job includes ingestion changes and shared UI primitive\nchanges in affected mode, and runs the ingestion browser suite in full mode. Its\nnormal dependency build precedes the suite and failed runs upload browser evidence.\n\nThe maintained host derives inbox state by traversing bounded public review pages,\nauthorizing every action/parent/target before classification. It retains only\nscalar totals and the requested page; an off-page denial denies the whole view.\nThe component's Load more and subsequent refresh/mutation reload every displayed\npage using fresh cursors, then publish the combined authorized set together. Old\npage payloads are cleared while loading; any unavailable page clears all payloads.\nThis keeps plan controls and earlier steps visible across page boundaries without\nreusing a prior authorization decision.\n\n## Published server adapter\n\nImport `createIntakeReviewHost` and `IntakeReviewHostOptions` from\n`@happyvertical/smrt-ingestion/server`. Call `createIntakeReviewHost(service,\noptions)` with a service constructed from the current authenticated session.\nThe returned `IntakeReviewHost` implements inbox state projection across saved\nreview pages, current generation loading, stable `ui:<index>` intention keys,\naction/plan preview CAS lookup, decisions, apply, corrections, candidates, split,\nfeedback, the offered `catalog`, `startAction` and `resolve`. Any catalog\nfailure (not configured, bound exceeded, denied) omits `catalog`, so the picker\ndisappears while existing reviews stay reviewable; item, review and evidence\ndenials still propagate as before; the reference host (`reference/review-host.ts`)\ndegrades the same way. Repeated\npreparation of an unchanged authorized suggestion retains\nits current review instead of creating a new revision. Changed arguments still\nrequire explicit re-preview and a fresh human decision.\n`list({ state?, assigneeId?, cursor?, limit? })` returns one page newest receipt\nfirst (`intake_items.created_at`, id tiebreak) through `service.listItemsPage`.\n`limit` defaults to 20 and is capped at 100; the cursor is an opaque\n(received-at, id) continuation, never a bare id. Tenant, confidential scope,\nretention and the `state` filter run inside the page query, so a page is full\nand `nextCursor` is present only when more rows follow.\n\nAuthority is rechecked at the same two levels `load` applies, but per page\nrather than per row: the host authorize callback once per distinct captured\nceiling in a batch, and the execution read gate (policy, ceiling, handler and\noperation allowlists, principal) once per distinct (item, handler) bound by\nthe page's saved actions. The list gate is read-only: it evaluates the same\nchecks as `listReviews` without the retention write, so an item that the item\nview would narrow past its deadline is simply not listed. The list gate also\nresolves each bound handler and rejects version drift against the saved\nbinding and omits a cancelled item, as `listReviews` does; it does not\nevaluate per-action target,\nevidence or plan gates, so an item whose target became unreadable still lists\nand its item view refuses. An item whose bound\nhandler the actor cannot currently read is not listed at all, rather than\nlisted with a state derived from actions the actor may not see; actions\nwithout a proposal bind no handler and are not gated, as in `listReviews`.\nHandler bindings (handler id and version, projected in SQL, distinct per\nitem) are read once per fetched batch for the rows that passed the item-level\ngate. Note the host cost plainly: the execution `authorize` callback runs once\nper distinct (item, handler) bound by a scanned row that passed the item-level\ngate. Each item is its own authorization subject, so nothing is deduplicated\nacross items; the count is bounded by rows scanned (at most\n`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\n`authorize` behind the handler gate, follows the same rule: a refusal (false,\nor an access check that fails) hides the rows it covers, and a callback that\nthrows rejects the whole page (and `listItems()`) with its error, as `load`\ndoes, so a transient failure never lets paging move past readable rows.\nDenied or unreadable rows are skipped without\ndisclosure and the page refills from the next rows; refills grow from the page\nsize to 100 rows per query, so a fully denied scope costs about a dozen page\nreads and as many binding reads per call. One service call examines at most\n`ITEM_SCAN_CEILING` (1000) rows, or the caller's smaller `scanLimit`, and\nreports the count as `scanned`; when a mostly-denied scope reaches the ceiling\nthe page comes back short with a `nextCursor`. Rows are authorized first and\nthe `state` filter is then applied to the fetched batch, so it is bounded by\nthe same scan limit: a rare state yields short pages with a cursor rather than\nan unbounded walk inside one query. A row the actor can read whose state does\nnot match discloses nothing and may anchor the cursor, so a filter with no\nmatch among the next 1000 rows returns an empty page with a cursor and still\npages to the end. The host `list` passes its remaining budget as `scanLimit`, so the\nrows scanned across the service calls of one host call never exceed 1000: a\nshort service page with a cursor, or an exhausted budget while filtering by\nassignee, ends the host call with the same cursor and the caller continues.\nOnly rows the service wrote are supported: cursors carry the canonical\nmillisecond ISO receipt time the service stores. A row whose stored receipt\ntime is not that form (on SQLite anything but `YYYY-MM-DDTHH:MM:SS.mmmZ`,\nincluding a parseable value without milliseconds or with an offset; on\nPostgreSQL a value with a sub-millisecond part) is never listed, whatever the\nactor's access, and counts toward the scan, as does a row whose id is not a\nUUID. On SQLite a non-text stored value (an integer, say) and a receipt value\nor id longer than 64 characters or containing a NUL character are excluded by\nthe page query itself: the first sorts after every text value so the scan\ncould never move past it, the second could not be carried by any cursor, and\nthe third is returned truncated at the NUL, so it would look canonical and\nsort above a legitimate row at the truncated value. Within a call the scan moves\npast a service-foreign row by its exact stored value, and an unsealed cursor\nnames the last row before it that the actor can read. A call that finds no readable row within its scan limit (at\nmost 1000) fails closed with `IntakeScanLimitError` unless a `cursorKey` is\nconfigured, as below. The host\n`list` returns what earlier service calls collected, with their cursor, when\na later call fails that way.\n\n`listItems()` is deprecated and kept only for callers of the array-returning\nAPI. It walks every page, so its cost grows with the scope, including one\nexecution authorization per (item, handler) across the whole scope. It follows\nthe internal scan position, which never leaves the service, so runs of rows\nthe actor cannot read never stop it and it returns `[]` where nothing is\nreadable. Items are newest-first and gated exactly as on a page.\n\nThe page's inbox state is the durable projection of the saved action journal\nand processing state, computed once per scanned row inside the page query;\n`load` remains the authoritative, live-gated projection for one item (it still\nprojects individual stale actions, which the list does not). Cost is\nproportional to the page for unfiltered pages and common states, and to the\nnumber of items scanned until enough matches are found for rare states: the\nstate is not persisted, because the action journal is append-only and\nprojecting it into an indexed column would need a maintained denormalisation\nin the execution service plus a migration, which this change does not take.\nListing does not load original bytes.\n\nCursors never disclose an unreadable row. Without a `cursorKey` in the\nservice options, every cursor (each row's and `nextCursor`) names a row the\nactor can read (the last one when a page stops early); a page that spends its scan limit before filling hands back\nthe last such row, and one that found none fails closed with\n`IntakeScanLimitError` (`Intake scan limit`, no id or time). A run of rows the\nactor cannot read (refused by the captured ceiling or the execution gate, or\nstored with a non-canonical receipt time) at least as long as the scan limit\ntherefore stops an unkeyed walk there; readable rows that the `state` filter\nexcludes never count toward such a run. With a `cursorKey` (a server secret of at least 32 bytes), every\ncursor is sealed with AES-256-GCM, bound to the tenant, confidential scope and\nactor, and a budget-stopped page returns a sealed position past what it\nskipped, so the walk always progresses. Sealed cursors have a fixed length\n(the plaintext is padded to 1024 bytes) and a fresh nonce per seal, so neither\ntheir length nor their bytes reveal anything about the stop row. `scanned` is for trusted host\naccounting and must not be sent to clients; a short page with a cursor tells\na caller only that unlisted rows exist, never which.\n\nNewest-first paging relies on `intake_items_received_idx`\n(`tenant_id, confidential_scope_id, created_at, id`), the full ordering key, so\nan unfiltered page is a backward index scan that stops at the page boundary;\nthe foundation suite asserts that plan on both engines (SQLite\n`EXPLAIN QUERY PLAN`, PostgreSQL `EXPLAIN`), with the per-row state counters\nserved by `intake_actions_item_idx`. On SQLite the page query pins the index with `INDEXED BY` (except under an\nid restriction, where direct id lookups are cheaper), because without planner\nstatistics SQLite otherwise picks `intake_items_expiry_idx` plus a sort; the\nSQLite plan check runs without `ANALYZE`. A deployment must have applied the\nmanifest migration, or the page query fails with a missing-index error. This package ships no\nmigration files: indexes are declared on the model, the build emits them into\n`dist/manifest.json`, and `smrt db:migrate` / the public migration API create\nthem, which is how `intake_items_receipt_idx` shipped. Deploy the manifest\nmigration before upgrading hosts; `db:status --parity` reports the index as\nmissing until then.\n\nSupply four application adapters: `upload(files, requestId)` (authenticated\nreceipt plus first extraction request), `assignment(itemId)` (durable read),\n`assign(input)` (manager authorization, eligible assignees, durable CAS and request\nreplay protection), and `evidence(part)` (safe label/text or authenticated\nsame-origin URL), which `load` calls for every part and `list` never calls;\n`load` sets no row preview on its entry.\n\nRow previews are opt-in through an optional fifth adapter,\n`rowEvidence(rows)`. When it is supplied, `list` reads the page's durable\nevidence metadata with one `service.listEvidence(itemIds)` query (at most 100\nids; items the scope cannot read, and expired or inactive items, are omitted\nwithout disclosure; the host `authorize` runs once per distinct captured\nceiling through the same gate as `listItemsPage`, so a refusal or a\nmalformed captured ceiling hides the item and a failing `authorize` rejects\nthe page; an item whose stored data does not parse is skipped by both\n`listItemsPage` and `listEvidence`, never fatal). It then calls `rowEvidence`\nonce with each row's primary part (`{ itemId, evidence }`) and publishes the\nreturned `{ label, viewUrl?, thumbnailUrl? }` as `InboxEntry.evidence`\n(`{ evidence, label, viewUrl?, thumbnailUrl? }`, never text). Build those URLs\nfrom metadata without reading bytes; if `rowEvidence` throws or returns a\nmalformed answer, the page still lists without previews, and a row without\ndurable parts never gets one. When it is omitted, rows show no original link.\nEither way `list` reads the page's evidence metadata once, in that same query,\nso the row label can use the original filename; it never reads original bytes\nand never calls `evidence(part)`. The primary part is chosen by role, not media\ntype: the shallowest child part when any part has a parent (an upload's file\nunder its capture record, an email's first attachment under the message, even\nwhen that child is JSON), else the first root part (a watched-folder file);\npart ids sort numerically. `IntakeInbox`/`InboxView` accept an\noptional `rowAside` snippet receiving the `InboxEntry`; without it a row renders\nthe host's \"Open original\" link (new tab) and thumbnail when the URLs are\nsame-origin; each link's accessible name is \"Open original: <row label>\", and\na row shows no wrapper when neither URL is usable. The slot is a plain `div`,\nnot an `aside` landmark, so a page does not add one landmark per row. Optional\n`label(item, evidence)` changes presentation; `evidence` is the item's durable\nparts with the primary part first, and the default label is the primary part's\nown original `filename`, else `Intake <8 hex>`. Optional\n`assignments(itemIds)` reads one page's assignments in one batch (otherwise\n`assignment` runs per row), and optional `assignedItemIds(assigneeId)` lets\n`list` push the assignee filter into the page query (junk ids are dropped, not\nfatal); without it an assignee filter scans further pages and filters by\n`assignment`, which stays correct but costs more rows. The page's continuation\nis always the last row actually returned, so an assignment that changes\nbetween the page read and the assignment read costs at most one extra scan and\nnever skips or repeats a row. Optional\n`enableFeedback: true` exposes the optional feedback callback when the service\nhas feedback configured. Optional `onSplit(itemId, revision)` wakes the host worker after the durable extraction\nrequest; repair dispatches if wakeup fails. The factory creates no assignment\nschema and grants no permissions. Create it per session, authenticate every HTTP\ncall and enforce CSRF before dispatching a mutation.\n\n`GenerationAnalysisOutput` is exported by `/dto` and describes the interpretation\nwrapper `{ proposals: GenerationOutput }` under `analysis.result.output`.\nThe factory unwraps this into `ItemReviewView.generation`. Stale generation is\nomitted while authorized saved reviews remain available; access denials propagate.\nThe reference application still owns its fixture provisioning and HTTP transport.\n\n## Upload drop zone (#4587)\n\nThe inbox's upload leads the view, before the state filter and the rows, so it\nis found on an empty inbox too, and it is a drop zone by default:\n`IntakeWorkspace`/`IntakeInbox` (and `InboxView`) take `dropzone` (default\n`true`), which gives smrt-ui `FilePicker` its full-width, dashed drop-area\npresentation (the picker accepted dropped files before; it just did not look\nlike it). Files dropped on it are selected exactly as picked ones (same retained originals, request\nidentity and refusal handling below), click-to-pick still opens the native\nchooser, and `accept` / `uploadAccept` and the host's allowlist apply unchanged.\nIts description is `ingestion.filesDrop` (\"Drop files here, or browse.\")\nfollowed by `ingestion.filesHelp`, so a host that overrides the help text with\nits own allowlist wording keeps it. `dropzone={false}` keeps the plain picker,\nwith `ingestion.filesHelp` alone, in the same place.\n\n## Upload refusals and accepted types (#4502)\n\n`IntakeWorkspace`/`IntakeInbox` take `accept` (an `<input accept>` value)\nfor their file picker, defaulting to `IntakeReviewHost.uploadAccept` (adapter\noption `uploadAccept`); it is a hint only, `upload` still enforces the host's\nallowlist. When `upload` throws `IntakeUploadRefusedError` (from `/svelte`) or\nany error with a string `userMessage` (or an error named\n`IntakeUploadRefusedError` / with `code: 'INTAKE_UPLOAD_REFUSED'`, whose\n`message` is then shown), the inbox shows that reason (trimmed, at most 500\ncharacters) and keeps its rows; `uploadRefusalMessage(error)` applies the same\nrule. Any other error still fails the list closed with `ingestion.error`.\n\n## Explicit upload retries\n\nThe inbox retains selected original files and the receive request identity when\nupload fails or acknowledgment is lost. Retry Upload explicitly with the same\nselection; Refresh does not replace that identity. Selecting files again starts a\nnew receipt identity, even when names match. Successful upload clears the selection\nand identity. Replacing the authenticated host/context or destroying the component\ndiscards both and fences late completion from the prior context. No automatic\nupload retry or authorization bypass occurs.\n\nThe inbox clears only its native file-input value when opening the picker, so\nchoosing the same local file explicitly starts a new upload identity. Opening\nthen dismissing the chooser preserves the separately retained originals and\nrequest identity for retry; it does not clear the shared FilePicker's native-form\nbehavior. Successful upload and host/context disposal still discard retry state.\n\nWorkspace ordering also protects an open item during an awaited automatic page walk:\nthe selected row remains until navigation succeeds, except that a failed list read\nclears the inbox. Applying an action is disabled\nwhile that action card holds unsent arguments, reason, or feedback comment. A list\nrefresh retires earlier follow snapshots; only follow reads started after that\npage request may override its rows, so a newer refresh cannot be regressed by an\nalready settled or older in-flight follow read.\n"
1734
1734
  }
1735
1735
  ],
1736
1736
  "agentSurface": {
@@ -52,6 +52,16 @@ export interface Props {
52
52
  * only: the host's upload still enforces its own allowlist.
53
53
  */
54
54
  accept?: string;
55
+ /**
56
+ * Show the upload as a drop zone (the picker's dashed, full-width
57
+ * presentation; it accepts dropped files either way) at the top of the
58
+ * inbox, before the filter and the rows and whether or not the inbox is
59
+ * empty (#4587). Files dropped on it are selected exactly as picked ones, and the description
60
+ * leads with `ingestion.filesDrop` before the (host-overridable)
61
+ * `ingestion.filesHelp`. `false` keeps the plain picker. Click-to-pick
62
+ * works either way.
63
+ */
64
+ dropzone?: boolean;
55
65
  /**
56
66
  * Re-read the listed page on this interval (ms) while any displayed row is
57
67
  * still processing, under the filter it was listed with. Off when absent
@@ -71,6 +81,7 @@ let {
71
81
  resolveInRow = true,
72
82
  stamp,
73
83
  accept,
84
+ dropzone = true,
74
85
  listPollMs,
75
86
  }: Props = $props();
76
87
  const { t } = useI18n();
@@ -372,16 +383,18 @@ onMount(() => {
372
383
  </script>
373
384
  <section data-testid="intake-inbox" aria-busy={pending}>
374
385
  <h1>{t(M['ingestion.inbox'])}</h1>
386
+ <!-- The upload leads the inbox so it is found on an empty one too (#4587). Keep retry Files separately; an unchanged native FileList must not suppress a deliberate reselection. -->
387
+ <div class="upload" data-testid="intake-upload">
388
+ <FilePicker bind:files {dropzone} onclick={(event) => { event.currentTarget.value = ''; }} onchangefiles={() => { uploadRequestId = undefined; refusal = undefined; }} accept={accept ?? host.uploadAccept} multiple label={t(M['ingestion.files'])} description={dropzone ? `${t(M['ingestion.filesDrop'])} ${t(M['ingestion.filesHelp'])}` : t(M['ingestion.filesHelp'])} disabled={pending} />
389
+ <Button disabled={pending || !files.length} onclick={upload}>{t(M['ingestion.upload'])}</Button>
390
+ {#if refusal}<p role="alert" data-testid="upload-refused">{refusal}</p>{/if}
391
+ </div>
375
392
  <div class="toolbar">
376
393
  <Select aria-label={t(M['ingestion.filter'])} bind:value={selectedState} disabled={pending} onchange={() => load()}><option value="">{t(M['ingestion.all'])}</option>{#each states as value}<option {value}>{t(M[`ingestion.${value}`])}</option>{/each}</Select>
377
394
  {#if host.assignees}<Combobox label={t(M['ingestion.assigneeName'])} options={pickerOptions(t(M['ingestion.anyAssignee']))} bind:value={assignee} valueLabel={assigneeLabels[assignee] ?? assignee} filter={false} busy={assigneeSearching} status={assigneeSearching ? t(M['ingestion.searching']) : undefined} onquery={findAssignees} disabled={pending} />{:else}<Input aria-label={t(M['ingestion.assignee'])} bind:value={assignee} disabled={pending} />{/if}
378
395
  <Button onclick={() => load()} disabled={pending}>{t(M['ingestion.refresh'])}</Button>
379
396
  </div>
380
- <!-- Keep retry Files separately; an unchanged native FileList must not suppress a deliberate reselection. -->
381
- <FilePicker bind:files onclick={(event) => { event.currentTarget.value = ''; }} onchangefiles={() => { uploadRequestId = undefined; refusal = undefined; }} accept={accept ?? host.uploadAccept} multiple label={t(M['ingestion.files'])} description={t(M['ingestion.filesHelp'])} disabled={pending} />
382
- <Button disabled={pending || !files.length} onclick={upload}>{t(M['ingestion.upload'])}</Button>
383
397
  {#if error}<p role="alert">{t(M['ingestion.error'])}</p>{/if}
384
- {#if refusal}<p role="alert" data-testid="upload-refused">{refusal}</p>{/if}
385
398
  {#if pending}<p role="status">{t(M['ingestion.loading'])}</p>{/if}
386
399
  {#if !pending && !error && !items.length}<p>{t(M['ingestion.empty'])}</p>{/if}
387
400
  <ul>{#each items as entry (entry.item.id)}<li data-item-id={entry.item.id} aria-current={entry.item.id === selectedId ? 'true' : undefined} class:selected={entry.item.id === selectedId}>
@@ -403,6 +416,6 @@ onMount(() => {
403
416
  <style>
404
417
  @layer smrt.reset, smrt.structure, smrt.theme;
405
418
  @layer smrt.theme {
406
- section{max-width:72rem;margin-inline:auto;padding:1rem;color:var(--smrt-color-on-surface)}.toolbar{display:flex;flex-wrap:wrap;gap:.75rem;margin-block:1rem}ul{list-style:none;padding:0}li{padding:1rem;border-block-end:var(--smrt-hairline);display:grid;gap:.75rem}li.selected{border-inline-start:.25rem solid var(--smrt-color-primary)}h2{font-size:1.1rem}.row-aside{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem}.row-aside img{max-block-size:6rem;max-inline-size:100%;object-fit:contain}
419
+ section{max-width:72rem;margin-inline:auto;padding:1rem;color:var(--smrt-color-on-surface)}.upload{display:grid;gap:.5rem;justify-items:start;margin-block:1rem}.toolbar{display:flex;flex-wrap:wrap;gap:.75rem;margin-block:1rem}ul{list-style:none;padding:0}li{padding:1rem;border-block-end:var(--smrt-hairline);display:grid;gap:.75rem}li.selected{border-inline-start:.25rem solid var(--smrt-color-primary)}h2{font-size:1.1rem}.row-aside{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem}.row-aside img{max-block-size:6rem;max-inline-size:100%;object-fit:contain}
407
420
  }
408
421
  </style>
@@ -36,6 +36,16 @@ export interface Props {
36
36
  * only: the host's upload still enforces its own allowlist.
37
37
  */
38
38
  accept?: string;
39
+ /**
40
+ * Show the upload as a drop zone (the picker's dashed, full-width
41
+ * presentation; it accepts dropped files either way) at the top of the
42
+ * inbox, before the filter and the rows and whether or not the inbox is
43
+ * empty (#4587). Files dropped on it are selected exactly as picked ones, and the description
44
+ * leads with `ingestion.filesDrop` before the (host-overridable)
45
+ * `ingestion.filesHelp`. `false` keeps the plain picker. Click-to-pick
46
+ * works either way.
47
+ */
48
+ dropzone?: boolean;
39
49
  /**
40
50
  * Re-read the listed page on this interval (ms) while any displayed row is
41
51
  * still processing, under the filter it was listed with. Off when absent
@@ -1 +1 @@
1
- {"version":3,"file":"InboxView.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/InboxView.svelte.ts"],"names":[],"mappings":"AAWA,OAAO,EAAsB,KAAK,OAAO,EAAE,MAAM,QAAQ,CAAC;AAC1D,OAAO,KAAK,EACV,UAAU,EAEV,gBAAgB,EACjB,MAAM,qBAAqB,CAAC;AAK7B,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,gBAAgB,CAAC;IACvB,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC;IACjC,2DAA2D;IAC3D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,CACR,KAAK,EAAE,UAAU,EAAE,EACnB,IAAI,EAAE,OAAO,EACb,IAAI,CAAC,EAAE;QAAE,OAAO,EAAE,UAAU,EAAE,CAAC;QAAC,QAAQ,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,KACjE,IAAI,CAAC;IACV,qFAAqF;IACrF,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IACzB,sFAAsF;IACtF,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,MAAM,GAAG,SAAS,CAAC;IACnD,6FAA6F;IAC7F,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,MAAM,MAAM,CAAC;IACrB;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAmWD,QAAA,MAAM,SAAS;mBA9RQ,UAAU,qBAAiB,UAAU,EAAE;mBAkCnC,OAAO,CAAC;QACjC,KAAK,EAAE,UAAU,EAAE,CAAC;QACpB,MAAM,EAAE,OAAO,GAAG,QAAQ,GAAG,SAAS,CAAC;KACxC,CAAC;gBA8BsB,OAAO,CAAC;QAC9B,KAAK,EAAE,UAAU,EAAE,CAAC;QACpB,MAAM,EAAE,UAAU,GAAG,MAAM,GAAG,SAAS,GAAG,QAAQ,CAAC;KACpD,CAAC;MAwNqD,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
1
+ {"version":3,"file":"InboxView.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/InboxView.svelte.ts"],"names":[],"mappings":"AAWA,OAAO,EAAsB,KAAK,OAAO,EAAE,MAAM,QAAQ,CAAC;AAC1D,OAAO,KAAK,EACV,UAAU,EAEV,gBAAgB,EACjB,MAAM,qBAAqB,CAAC;AAK7B,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,gBAAgB,CAAC;IACvB,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC;;;;OAIG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC;IACjC,2DAA2D;IAC3D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;OAKG;IACH,OAAO,CAAC,EAAE,CACR,KAAK,EAAE,UAAU,EAAE,EACnB,IAAI,EAAE,OAAO,EACb,IAAI,CAAC,EAAE;QAAE,OAAO,EAAE,UAAU,EAAE,CAAC;QAAC,QAAQ,EAAE,OAAO,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,CAAA;KAAE,KACjE,IAAI,CAAC;IACV,qFAAqF;IACrF,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IACzB,sFAAsF;IACtF,MAAM,CAAC,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,MAAM,GAAG,SAAS,CAAC;IACnD,6FAA6F;IAC7F,YAAY,CAAC,EAAE,OAAO,CAAC;IACvB,6EAA6E;IAC7E,KAAK,CAAC,EAAE,MAAM,MAAM,CAAC;IACrB;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;;;;;OAQG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAsWD,QAAA,MAAM,SAAS;mBAhSQ,UAAU,qBAAiB,UAAU,EAAE;mBAkCnC,OAAO,CAAC;QACjC,KAAK,EAAE,UAAU,EAAE,CAAC;QACpB,MAAM,EAAE,OAAO,GAAG,QAAQ,GAAG,SAAS,CAAC;KACxC,CAAC;gBA8BsB,OAAO,CAAC;QAC9B,KAAK,EAAE,UAAU,EAAE,CAAC;QACpB,MAAM,EAAE,UAAU,GAAG,MAAM,GAAG,SAAS,GAAG,QAAQ,CAAC;KACpD,CAAC;MA0NqD,CAAC;AACxD,KAAK,SAAS,GAAG,UAAU,CAAC,OAAO,SAAS,CAAC,CAAC;AAC9C,eAAe,SAAS,CAAC"}
@@ -10,6 +10,8 @@ export interface Props {
10
10
  rowAside?: Snippet<[InboxEntry]>;
11
11
  /** File types the upload picker offers; defaults to the host's `uploadAccept`. */
12
12
  accept?: string;
13
+ /** Offer the upload as a drop zone at the top of the inbox (default); `false` keeps the plain picker (#4587). */
14
+ dropzone?: boolean;
13
15
  /** Re-read the list on this interval (ms) while a listed row is still processing; off when absent. */
14
16
  listPollMs?: number;
15
17
  }
@@ -19,6 +21,7 @@ let {
19
21
  onselect,
20
22
  rowAside,
21
23
  accept,
24
+ dropzone,
22
25
  listPollMs,
23
26
  }: Props = $props();
24
27
  let inbox = $state<ReturnType<typeof InboxView>>();
@@ -35,4 +38,4 @@ export function refresh(): Promise<{
35
38
  : Promise.resolve({ items: [], result: 'pending' as const });
36
39
  }
37
40
  </script>
38
- {#key host}{#key contextKey}<InboxView bind:this={inbox} {host} {onselect} {rowAside} {accept} {listPollMs} />{/key}{/key}
41
+ {#key host}{#key contextKey}<InboxView bind:this={inbox} {host} {onselect} {rowAside} {accept} {dropzone} {listPollMs} />{/key}{/key}
@@ -8,6 +8,8 @@ export interface Props {
8
8
  rowAside?: Snippet<[InboxEntry]>;
9
9
  /** File types the upload picker offers; defaults to the host's `uploadAccept`. */
10
10
  accept?: string;
11
+ /** Offer the upload as a drop zone at the top of the inbox (default); `false` keeps the plain picker (#4587). */
12
+ dropzone?: boolean;
11
13
  /** Re-read the list on this interval (ms) while a listed row is still processing; off when absent. */
12
14
  listPollMs?: number;
13
15
  }
@@ -1 +1 @@
1
- {"version":3,"file":"IntakeInbox.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/IntakeInbox.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAGxE,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,gBAAgB,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,gFAAgF;IAChF,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC;IACjC,kFAAkF;IAClF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sGAAsG;IACtG,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAgCD,QAAA,MAAM,WAAW;mBAbI,OAAO,CAAC;QAC3B,KAAK,EAAE,UAAU,EAAE,CAAC;QACpB,MAAM,EAAE,OAAO,GAAG,QAAQ,GAAG,SAAS,CAAC;KACxC,CAAC;MAUuD,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
1
+ {"version":3,"file":"IntakeInbox.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/IntakeInbox.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAGxE,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,gBAAgB,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC,gFAAgF;IAChF,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC;IACjC,kFAAkF;IAClF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iHAAiH;IACjH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sGAAsG;IACtG,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAiCD,QAAA,MAAM,WAAW;mBAbI,OAAO,CAAC;QAC3B,KAAK,EAAE,UAAU,EAAE,CAAC;QACpB,MAAM,EAAE,OAAO,GAAG,QAAQ,GAAG,SAAS,CAAC;KACxC,CAAC;MAUuD,CAAC;AAC1D,KAAK,WAAW,GAAG,UAAU,CAAC,OAAO,WAAW,CAAC,CAAC;AAClD,eAAe,WAAW,CAAC"}
@@ -16,6 +16,8 @@ export interface Props {
16
16
  rowAside?: Snippet<[InboxEntry]>;
17
17
  /** File types the upload picker offers; defaults to the host's `uploadAccept`. */
18
18
  accept?: string;
19
+ /** Offer the upload as a drop zone at the top of the inbox (default); `false` keeps the plain picker (#4587). */
20
+ dropzone?: boolean;
19
21
  /**
20
22
  * Re-read the list on this interval (ms) while a listed row is still
21
23
  * processing. Off when absent; processing rows are followed either way.
@@ -30,6 +32,7 @@ let {
30
32
  onselect,
31
33
  rowAside,
32
34
  accept,
35
+ dropzone,
33
36
  listPollMs,
34
37
  }: Props = $props();
35
38
  let triage = $state<ReturnType<typeof TriageView>>();
@@ -46,4 +49,4 @@ export function refresh(): Promise<{
46
49
  : Promise.resolve({ items: [], result: 'pending' as const });
47
50
  }
48
51
  </script>
49
- {#key host}{#key contextKey}<TriageView bind:this={triage} {host} {itemId} {pollMs} {onselect} {rowAside} {accept} {listPollMs} />{/key}{/key}
52
+ {#key host}{#key contextKey}<TriageView bind:this={triage} {host} {itemId} {pollMs} {onselect} {rowAside} {accept} {dropzone} {listPollMs} />{/key}{/key}
@@ -14,6 +14,8 @@ export interface Props {
14
14
  rowAside?: Snippet<[InboxEntry]>;
15
15
  /** File types the upload picker offers; defaults to the host's `uploadAccept`. */
16
16
  accept?: string;
17
+ /** Offer the upload as a drop zone at the top of the inbox (default); `false` keeps the plain picker (#4587). */
18
+ dropzone?: boolean;
17
19
  /**
18
20
  * Re-read the list on this interval (ms) while a listed row is still
19
21
  * processing. Off when absent; processing rows are followed either way.
@@ -1 +1 @@
1
- {"version":3,"file":"IntakeWorkspace.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/IntakeWorkspace.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAGxE,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,gBAAgB,CAAC;IACvB,yEAAyE;IACzE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uEAAuE;IACvE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uFAAuF;IACvF,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,CAAC;IAChD,gFAAgF;IAChF,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC;IACjC,kFAAkF;IAClF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAkCD,QAAA,MAAM,eAAe;mBAbA,OAAO,CAAC;QAC3B,KAAK,EAAE,UAAU,EAAE,CAAC;QACpB,MAAM,EAAE,OAAO,GAAG,QAAQ,GAAG,SAAS,CAAC;KACxC,CAAC;MAU2D,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
1
+ {"version":3,"file":"IntakeWorkspace.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/IntakeWorkspace.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,KAAK,EAAE,UAAU,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAGxE,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,gBAAgB,CAAC;IACvB,yEAAyE;IACzE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uEAAuE;IACvE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uFAAuF;IACvF,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,CAAC;IAChD,gFAAgF;IAChF,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC;IACjC,kFAAkF;IAClF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iHAAiH;IACjH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAmCD,QAAA,MAAM,eAAe;mBAbA,OAAO,CAAC;QAC3B,KAAK,EAAE,UAAU,EAAE,CAAC;QACpB,MAAM,EAAE,OAAO,GAAG,QAAQ,GAAG,SAAS,CAAC;KACxC,CAAC;MAU2D,CAAC;AAC9D,KAAK,eAAe,GAAG,UAAU,CAAC,OAAO,eAAe,CAAC,CAAC;AAC1D,eAAe,eAAe,CAAC"}
@@ -30,6 +30,8 @@ export interface Props {
30
30
  rowAside?: Snippet<[InboxEntry]>;
31
31
  /** File types the upload picker offers; defaults to the host's `uploadAccept`. */
32
32
  accept?: string;
33
+ /** Offer the upload as a drop zone at the top of the inbox (default); `false` keeps the plain picker (#4587). */
34
+ dropzone?: boolean;
33
35
  /** Re-read the listed page on this interval while a row is processing; off when absent. */
34
36
  listPollMs?: number;
35
37
  }
@@ -40,6 +42,7 @@ let {
40
42
  onselect,
41
43
  rowAside,
42
44
  accept,
45
+ dropzone,
43
46
  listPollMs,
44
47
  }: Props = $props();
45
48
  const { t } = useI18n();
@@ -683,7 +686,7 @@ function keydown(event: KeyboardEvent) {
683
686
  <svelte:window onkeydown={keydown} />
684
687
  <div class="workspace" bind:this={root} data-testid="intake-workspace">
685
688
  <div class="list-pane">
686
- <InboxView bind:this={inbox} {host} {rowAside} {accept} {listPollMs} selectedId={selected} onselect={(id) => request(id)} onitems={listed} onreadstart={readStarted} {stamp} status={rowStatus} resolveInRow={false} />
689
+ <InboxView bind:this={inbox} {host} {rowAside} {accept} {dropzone} {listPollMs} selectedId={selected} onselect={(id) => request(id)} onitems={listed} onreadstart={readStarted} {stamp} status={rowStatus} resolveInRow={false} />
687
690
  </div>
688
691
  <section class="item-pane" bind:this={pane} tabindex="-1" aria-label={label ?? t(M['ingestion.selectedItem'])} data-testid="workspace-item" onfocusin={noteField}>
689
692
  <div class="queue-nav" role="group" aria-label={t(M['ingestion.workspace'])}>
@@ -11,6 +11,8 @@ export interface Props {
11
11
  rowAside?: Snippet<[InboxEntry]>;
12
12
  /** File types the upload picker offers; defaults to the host's `uploadAccept`. */
13
13
  accept?: string;
14
+ /** Offer the upload as a drop zone at the top of the inbox (default); `false` keeps the plain picker (#4587). */
15
+ dropzone?: boolean;
14
16
  /** Re-read the listed page on this interval while a row is processing; off when absent. */
15
17
  listPollMs?: number;
16
18
  }
@@ -1 +1 @@
1
- {"version":3,"file":"TriageView.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/TriageView.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,EAAa,KAAK,OAAO,EAAiB,MAAM,QAAQ,CAAC;AAChE,OAAO,KAAK,EACV,UAAU,EACV,gBAAgB,EAEjB,MAAM,qBAAqB,CAAC;AAc7B,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,gBAAgB,CAAC;IACvB,uEAAuE;IACvE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sFAAsF;IACtF,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,CAAC;IAChD,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC;IACjC,kFAAkF;IAClF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,2FAA2F;IAC3F,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AA6qBD,QAAA,MAAM,UAAU;mBA1DW,OAAO,CAAC;QACjC,KAAK,EAAE,UAAU,EAAE,CAAC;QACpB,MAAM,EAAE,OAAO,GAAG,QAAQ,GAAG,SAAS,CAAC;KACxC,CAAC;MAuDsD,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
1
+ {"version":3,"file":"TriageView.svelte.d.ts","sourceRoot":"","sources":["../../../src/svelte/components/TriageView.svelte.ts"],"names":[],"mappings":"AAKA,OAAO,EAAa,KAAK,OAAO,EAAiB,MAAM,QAAQ,CAAC;AAChE,OAAO,KAAK,EACV,UAAU,EACV,gBAAgB,EAEjB,MAAM,qBAAqB,CAAC;AAc7B,MAAM,WAAW,KAAK;IACpB,IAAI,EAAE,gBAAgB,CAAC;IACvB,uEAAuE;IACvE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,qEAAqE;IACrE,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sFAAsF;IACtF,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,KAAK,IAAI,CAAC;IAChD,QAAQ,CAAC,EAAE,OAAO,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC;IACjC,kFAAkF;IAClF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,iHAAiH;IACjH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,2FAA2F;IAC3F,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AA8qBD,QAAA,MAAM,UAAU;mBA1DW,OAAO,CAAC;QACjC,KAAK,EAAE,UAAU,EAAE,CAAC;QACpB,MAAM,EAAE,OAAO,GAAG,QAAQ,GAAG,SAAS,CAAC;KACxC,CAAC;MAuDsD,CAAC;AACzD,KAAK,UAAU,GAAG,UAAU,CAAC,OAAO,UAAU,CAAC,CAAC;AAChD,eAAe,UAAU,CAAC"}
@@ -15,6 +15,7 @@ export declare const M: {
15
15
  readonly 'ingestion.upload': "ingestion.upload";
16
16
  readonly 'ingestion.files': "ingestion.files";
17
17
  readonly 'ingestion.filesHelp': "ingestion.filesHelp";
18
+ readonly 'ingestion.filesDrop': "ingestion.filesDrop";
18
19
  readonly 'ingestion.more': "ingestion.more";
19
20
  readonly 'ingestion.open': "ingestion.open";
20
21
  readonly 'ingestion.processing': "ingestion.processing";
@@ -1 +1 @@
1
- {"version":3,"file":"i18n.d.ts","sourceRoot":"","sources":["../../src/svelte/i18n.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmLZ,CAAC;AACH;;;GAGG;AACH,wBAAgB,UAAU,CACxB,MAAM,EAAE,wBAAwB,GAAG,oBAAoB,EACvD,KAAK,EAAE,MAAM,GACZ,MAAM,OAAO,CAAC,GAAG,SAAS,CAG5B"}
1
+ {"version":3,"file":"i18n.d.ts","sourceRoot":"","sources":["../../src/svelte/i18n.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAoLZ,CAAC;AACH;;;GAGG;AACH,wBAAgB,UAAU,CACxB,MAAM,EAAE,wBAAwB,GAAG,oBAAoB,EACvD,KAAK,EAAE,MAAM,GACZ,MAAM,OAAO,CAAC,GAAG,SAAS,CAG5B"}
@@ -16,6 +16,7 @@ export const M = defineMessages({
16
16
  'ingestion.upload': 'Upload',
17
17
  'ingestion.files': 'Choose intake files',
18
18
  'ingestion.filesHelp': 'Select documents, images, email, or audio.',
19
+ 'ingestion.filesDrop': 'Drop files here, or browse.',
19
20
  'ingestion.more': 'Load more',
20
21
  'ingestion.open': 'Review',
21
22
  'ingestion.processing': 'Processing',