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