@happyvertical/smrt-ingestion 0.57.19 → 0.57.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/FEEDBACK.md +33 -0
  2. package/PROPOSALS.md +127 -5
  3. package/README.md +42 -2
  4. package/REVIEW.md +339 -3
  5. package/agents/execution.md +15 -0
  6. package/dist/chunks/{execution-NoDh3pjs.js → execution-9CfjxX8I.js} +110 -22
  7. package/dist/chunks/execution-9CfjxX8I.js.map +1 -0
  8. package/dist/chunks/{proposal-errors-VxcTU6Db.js → extraction-CJ-zrbg0.js} +4 -10
  9. package/dist/chunks/extraction-CJ-zrbg0.js.map +1 -0
  10. package/dist/chunks/{feedback-CJB900iG.js → feedback-CuUzWKzP.js} +59 -18
  11. package/dist/chunks/feedback-CuUzWKzP.js.map +1 -0
  12. package/dist/chunks/reviewer-instruction-Db3jDN9a.js +33 -0
  13. package/dist/chunks/reviewer-instruction-Db3jDN9a.js.map +1 -0
  14. package/dist/dto.d.ts +6 -0
  15. package/dist/dto.d.ts.map +1 -1
  16. package/dist/execution-internal.d.ts +2 -0
  17. package/dist/execution-internal.d.ts.map +1 -1
  18. package/dist/execution.d.ts +43 -1
  19. package/dist/execution.d.ts.map +1 -1
  20. package/dist/execution.js +1 -1
  21. package/dist/feedback-dto.d.ts +6 -0
  22. package/dist/feedback-dto.d.ts.map +1 -1
  23. package/dist/feedback.d.ts +7 -0
  24. package/dist/feedback.d.ts.map +1 -1
  25. package/dist/manifest.json +19 -1
  26. package/dist/models.d.ts.map +1 -1
  27. package/dist/models.js +10 -1
  28. package/dist/models.js.map +1 -1
  29. package/dist/presented-filename.d.ts +11 -0
  30. package/dist/presented-filename.d.ts.map +1 -0
  31. package/dist/proposal-contracts.d.ts +31 -6
  32. package/dist/proposal-contracts.d.ts.map +1 -1
  33. package/dist/proposal-dto.d.ts +17 -0
  34. package/dist/proposal-dto.d.ts.map +1 -1
  35. package/dist/proposal-errors.d.ts +16 -0
  36. package/dist/proposal-errors.d.ts.map +1 -1
  37. package/dist/proposal-validation.d.ts +26 -0
  38. package/dist/proposal-validation.d.ts.map +1 -1
  39. package/dist/proposals.d.ts +5 -1
  40. package/dist/proposals.d.ts.map +1 -1
  41. package/dist/proposals.js +261 -69
  42. package/dist/proposals.js.map +1 -1
  43. package/dist/review-dto.d.ts +95 -2
  44. package/dist/review-dto.d.ts.map +1 -1
  45. package/dist/review-host.d.ts +52 -3
  46. package/dist/review-host.d.ts.map +1 -1
  47. package/dist/review-instruction-view.d.ts +12 -0
  48. package/dist/review-instruction-view.d.ts.map +1 -0
  49. package/dist/review-summary.d.ts.map +1 -1
  50. package/dist/reviewer-instruction.d.ts +20 -0
  51. package/dist/reviewer-instruction.d.ts.map +1 -0
  52. package/dist/server.d.ts +171 -4
  53. package/dist/server.d.ts.map +1 -1
  54. package/dist/server.js +835 -49
  55. package/dist/server.js.map +1 -1
  56. package/dist/smrt-knowledge.json +29 -21
  57. package/dist/sources/common.d.ts +11 -0
  58. package/dist/sources/common.d.ts.map +1 -1
  59. package/dist/sources/email.d.ts.map +1 -1
  60. package/dist/sources/http.d.ts.map +1 -1
  61. package/dist/sources/watch-folder.d.ts.map +1 -1
  62. package/dist/svelte/components/ActionReview.svelte +15 -46
  63. package/dist/svelte/components/ActionReview.svelte.d.ts.map +1 -1
  64. package/dist/svelte/components/CandidatePicker.svelte +72 -0
  65. package/dist/svelte/components/CandidatePicker.svelte.d.ts +15 -0
  66. package/dist/svelte/components/CandidatePicker.svelte.d.ts.map +1 -0
  67. package/dist/svelte/components/InboxView.svelte +14 -3
  68. package/dist/svelte/components/InboxView.svelte.d.ts +8 -1
  69. package/dist/svelte/components/InboxView.svelte.d.ts.map +1 -1
  70. package/dist/svelte/components/IntakeInbox.svelte +6 -3
  71. package/dist/svelte/components/IntakeInbox.svelte.d.ts +4 -1
  72. package/dist/svelte/components/IntakeInbox.svelte.d.ts.map +1 -1
  73. package/dist/svelte/components/IntakeReview.svelte +4 -2
  74. package/dist/svelte/components/IntakeReview.svelte.d.ts +2 -0
  75. package/dist/svelte/components/IntakeReview.svelte.d.ts.map +1 -1
  76. package/dist/svelte/components/ReviewView.svelte +403 -62
  77. package/dist/svelte/components/ReviewView.svelte.d.ts +2 -0
  78. package/dist/svelte/components/ReviewView.svelte.d.ts.map +1 -1
  79. package/dist/svelte/i18n.d.ts +17 -0
  80. package/dist/svelte/i18n.d.ts.map +1 -1
  81. package/dist/svelte/i18n.js +17 -0
  82. package/dist/svelte/index.d.ts +1 -1
  83. package/dist/svelte/index.d.ts.map +1 -1
  84. package/dist/svelte/view-utils.d.ts +2 -0
  85. package/dist/svelte/view-utils.d.ts.map +1 -1
  86. package/dist/svelte/view-utils.js +12 -0
  87. package/package.json +13 -13
  88. package/dist/chunks/execution-NoDh3pjs.js.map +0 -1
  89. package/dist/chunks/execution-internal-DWteDHM2.js +0 -7
  90. package/dist/chunks/execution-internal-DWteDHM2.js.map +0 -1
  91. package/dist/chunks/feedback-CJB900iG.js.map +0 -1
  92. package/dist/chunks/proposal-errors-VxcTU6Db.js.map +0 -1
package/FEEDBACK.md CHANGED
@@ -51,6 +51,39 @@ examples. A receiving item's provider grant cannot substitute for a source grant
51
51
  Superseded, revoked, deleted or incompatible sources are excluded. Current explicit human corrections
52
52
  take precedence: a generation carrying one does not retrieve learned examples.
53
53
 
54
+ A judgment recorded on an action whose generation ran under a reviewer
55
+ instruction (REVIEW.md) stores only a reference, `instruction: {id, digest}`,
56
+ after verifying the generation's provenance against the authenticated
57
+ extraction record. **Write side:** the reference is ~100 bytes, so instruction
58
+ text never counts against the judgment's write budget (`feedback.maxBytes` /
59
+ `policy.maxBytes`) and cannot make `recordFeedback` fail with `Feedback
60
+ limit`. `retrieve` re-verifies the source record and joins the text, returning
61
+ `{id, digest, text}`; a stored reference that no longer matches the source's
62
+ record excludes the example. Because one long instruction joined at read time
63
+ could otherwise push every selection that ranks it over budget and deny
64
+ generation scope-wide, `recordFeedback` also builds that single joined example
65
+ exactly as `retrieve` would and refuses with `Feedback instruction exceeds
66
+ retrieval budget` (nothing is written) when it alone exceeds
67
+ `min(feedback.maxBytes, handler policy.maxBytes)`. An example that fits exactly
68
+ records and retrieves. **This guard covers only the single-example case** on the
69
+ judged handler's own budget: retrieval still sums up to `maxExamples` examples
70
+ and takes the minimum across *every* offered handler of the receiving item, so a
71
+ multi-example selection, or a receiving item with a narrower handler budget, can
72
+ still exceed the budget and fails closed as below. It is returned as part of the example so comparable later items see the guidance
73
+ that produced the judged suggestion. It is example content only: it does not
74
+ transfer to the receiving generation's configuration or prompt section, and it
75
+ expands no catalog, candidate, permission or automation eligibility. **Read-side budget
76
+ impact:** joined instruction text (up to 4,000 characters, more bytes for
77
+ non-ASCII) counts toward the exact serialized selection budget — the narrowest
78
+ of `feedback.maxBytes` and each offered handler's `policy.maxBytes` — like any
79
+ other example field. The existing fail-closed rule is unchanged: an over-budget
80
+ selection rejects with `Feedback limit` and generation records
81
+ `provider_error` without provider I/O; examples are not silently trimmed. Hosts
82
+ that enable reviewer instructions together with feedback should size
83
+ `feedback.maxBytes` (and the policy ceilings) for at least
84
+ `maxExamples × (4,000 characters of instruction + the example's own fields)`,
85
+ or lower `maxExamples`.
86
+
54
87
  Full examples exist only in the authorized provider request. The SDK proposal
55
88
  adapter includes that typed selection in its serialized user message when
56
89
  present; disabled feedback leaves the original request shape unchanged. Saved generation
package/PROPOSALS.md CHANGED
@@ -32,7 +32,8 @@ the authorized transaction executor. Query by tenant and confidential scope
32
32
  `limit + 1`) and report `hasMore`. The service projects candidate identity, model,
33
33
  revision and label, then checks every target with the owning execution callback.
34
34
  There are no hidden counts or pagination tokens. Truncated discovery forces
35
- `needs_review` and cannot enter generated preview.
35
+ `needs_review`; a person may still preview it explicitly, and automation never
36
+ becomes eligible.
36
37
 
37
38
  Completed generation reload checks process authority, the current attempt, and
38
39
  owner/candidate visibility in the same final transaction. Database-backed grant
@@ -64,6 +65,12 @@ that declaration and its target authorization semantics.
64
65
  or pass an explicit attempt ID to additionally reject stale client state.
65
66
  Queued/running revisions never return an older completed attempt.
66
67
 
68
+ `analyze` refuses an interpretation configuration whose pinned source is not
69
+ the item's latest extraction, checked under the item lock before anything is
70
+ superseded (`GenerationSourceStaleError`, nothing appended). A preparation
71
+ superseded by a later extraction therefore fails at append time rather than
72
+ after a revision was created.
73
+
67
74
  A generation revision does not rewrite terminal extraction. Reprocessing starts
68
75
  with a new extraction revision, followed by interpretation, while the host keeps
69
76
  the same business intention keys. An interrupted still-current generation lease
@@ -99,6 +106,33 @@ for human preview; automatic eligibility always remains false. The service
99
106
  deadline aborts requests and prevents late publication; it does not claim to terminate computation at a remote
100
107
  provider or isolate native allocations. Extractor process limits remain separate.
101
108
 
109
+ When the pinned extraction carries an authenticated reviewer instruction
110
+ (REVIEW.md), `prepareGeneration` copies it into the interpretation configuration
111
+ and `generateProposals` verifies that copy against the extraction record and its
112
+ own digest before provider disclosure (`integrity` otherwise). The prompt then
113
+ carries it in its own **trusted reviewer guidance** section after the fixed
114
+ system instruction and handler contract: the guidance is named as authenticated
115
+ human input, limited to the offered catalog, and the evidence remains untrusted
116
+ data. `GenerativeProposalInput.reviewerInstruction` exposes `{id, text}` to custom
117
+ generators; `provenance.reviewerInstruction` records `{id, digest}`. The
118
+ instruction never changes `offered`, `catalogDigest`, argument schemas,
119
+ permissions or `automaticActionEligible`; a reply that follows guidance outside
120
+ the catalog fails validation like any other hostile output. **Budget cost:** the instruction counts once against the input budget
121
+ (`maxInputBytes`, each offered handler's `maxBytes` and the live policy
122
+ ceiling), as its JSON-escaped text inside the prompt's guidance section — up
123
+ to 4,000 characters plus escaping and the fixed guidance sentence. The
124
+ `reviewerInstruction` field duplicated for custom generators is not counted
125
+ again. The decision stage also carries it once: its request state holds the
126
+ evidence-side input with the un-guided base instructions under `evidence`, and
127
+ the guidance only under a separate `reviewerGuidance` key, never under
128
+ `evidence`; the decision budget measures that serialized request as sent. Size `maxInputBytes` with that headroom. Learned examples are
129
+ still retrieved for an instructed generation; only a logical split correction
130
+ suppresses them. Every instruction costs a fresh extraction revision before the
131
+ new interpretation: the predecessor-pin contract above (source = immediately
132
+ preceding revision, current-revision reads) is deliberately left intact rather
133
+ than reused for a same-source second interpretation, so OCR/speech hosts budget
134
+ one extraction per instruction, as for split corrections.
135
+
102
136
  Model output can contain only offered handler versions, schema-valid arguments,
103
137
  retained evidence locations and offered alternative handler/candidate keys.
104
138
  Candidate IDs must occur in the offered set, and evidence IDs in retained source
@@ -108,6 +142,26 @@ as `missingFields`, with `needs_review`; values are never invented to satisfy a
108
142
  schema. Observed page/time/source granularity is preserved. Logical scan splits
109
143
  use `proposeDocumentSplits`; originals and hashes remain intact.
110
144
 
145
+ A rejected reply ends the attempt as `provider_error` with the `malformed_output`
146
+ warning and a bounded, value-free `provenance.failure` `{category, reason}` (#4207).
147
+ The reason lists schema-owned paths, keywords and schema constants, for example
148
+ `/outcome enum (allowedValues=proposals|unknown|...)` and
149
+ `/suggestions/0 required (missingProperty=evidence)`, or the fixed internal check
150
+ and its path, such as `Unoffered handler: /suggestions/0`. Path segments are
151
+ kept only when they are keys declared under the schema's `properties` or
152
+ indexes into a value that actually is an array in the rejected reply; any other
153
+ segment, numeric or not (for example a key under an open
154
+ `additionalProperties` argument sub-schema, or a numeric key of an object),
155
+ becomes `?`, and an extra property's name is never echoed.
156
+ It never contains document text, argument values, evidence content or
157
+ model-chosen keys, is cut at 1000 characters,
158
+ and is dropped before the output-limit fallback. Hosts read it from the
159
+ completed/partial analysis at `analysis.result.output.proposals.provenance.failure`.
160
+ The SDK adapter constrains replies with a `json_schema` response format built
161
+ from the validator's schema and the fixed instructions describe the shape; a
162
+ custom `ProposalGenerator` receives no schema, so its raw reply meets the
163
+ validator directly and the same reason is recorded.
164
+
111
165
  `createSDKProposalDecisionClient(client)` adapts an existing SDK client’s optional
112
166
  capability without changing its errors or protocol. Optional `decision` uses public core `executeDecision` and the existing SDK
113
167
  predicate, choice, score and batch contracts. Supported-effect predicates,
@@ -117,15 +171,76 @@ separately from generative provenance and unknown OCR certainty. The complete
117
171
  serialized decision request, including suggestions and questions, must fit the
118
172
  minimum configured/current policy byte ceiling before capability probing and
119
173
  again before deciding. A policy change between these boundaries fails closed.
120
- Ties, support probability or selected-handler confidence below
121
- `minimumDecisionProbability`, and actually incomplete extraction require review.
122
- Unknown provider
174
+ Actually incomplete extraction always requires review. Unknown provider
123
175
  completion metadata is preserved as `source_completion_unknown`; it does not
124
176
  block otherwise schema-valid **human** preview or imply completeness. Automatic
125
177
  action eligibility remains false. An unconfigured decision path
126
178
  is recorded explicitly. Configured errors produce a durable safe failure outcome;
127
179
  they never silently switch to a generative decision fallback.
128
180
 
181
+ ### Decision readiness gate (#4209)
182
+
183
+ For every suggestion `i` the request asks `supported_i` (predicate),
184
+ `route_i` (choice over the offered `handlerId@version` keys plus `none`),
185
+ `risk_i` (ordered score over `clear`, `uncertain`, `ambiguous`) and one
186
+ `entity_i_<field>` choice per candidate-reference argument. The validated
187
+ answers are always persisted under `provenance.decision.answers`. With the
188
+ default `decisionReadiness: 'gate'` they are read as follows, and any failing
189
+ check sets the suggestion to `needs_review` (a tie also sets the outcome to
190
+ `ambiguous`):
191
+
192
+ - `supported.probability >= minimumDecisionProbability` (default `0.8`).
193
+ - `route.choice` equals the suggestion's own `handlerId@handlerVersion`,
194
+ `route.confidence >= minimumDecisionProbability`, and the gap between the two
195
+ largest route probabilities is greater than `tieMargin` (default `0.1`).
196
+ - `risk.score < 1`: only the `clear` level passes; `uncertain` and `ambiguous`
197
+ require review.
198
+ - Each entity match chooses the suggested candidate's key with confidence at or
199
+ above the threshold and a gap above `tieMargin`.
200
+
201
+ These values are provider outputs read as if they were calibrated
202
+ probabilities; nothing in this package calibrates them for this use. The
203
+ teamworks-os measurement in #4209 (self-hosted Laya `typed-decisions`) found no
204
+ separation between right and wrong suggestions: a correctly read vendor invoice
205
+ scored `supported` 0.37, garbage input 0.45–0.50, a wrongly proposed purchase
206
+ order 0.51, route confidence 0.003–0.15 and `risk` ≥ 0.94, so the gate sent
207
+ every suggestion to review. Until a labelled sample shows separation, treat the
208
+ answers as diagnostics and configure:
209
+
210
+ - `decisionMaxLen`: positive integer forwarded unchanged as the per-request
211
+ `maxLen` of the decision client (the SDK Laya provider's token window; the
212
+ checkpoint default is 1024 and the serialized evidence plus suggestions is
213
+ otherwise truncated). Other clients ignore it.
214
+ - `decisionReadiness: 'record'`: the decision still runs, is bounded and
215
+ authorized exactly as before and its answers, usage, `readiness` and `maxLen`
216
+ are recorded, but the gate never changes `outcome` or `disposition`; readiness
217
+ comes from the validator alone (`missingFields`, outcome, source completeness).
218
+ A client error or timeout is recorded as the `decision_unavailable`
219
+ warning. A reply that comes back and then fails the core `executeDecision`
220
+ identity or answer-shape check is recorded as `decision_malformed` with the
221
+ reply's usage; this is the path for injected `DecisionClient`s. SDK clients,
222
+ including the Laya provider behind `createSDKProposalDecisionClient`,
223
+ validate the reply's shape inside `client.decide` and throw, so a malformed
224
+ SDK reply is recorded as `decision_unavailable` with no usage. Every case
225
+ records the `provenance.decision` identity. The generated suggestions stay
226
+ as they were. The serialized request exceeding the byte budget is a
227
+ configuration failure, not a provider answer: it ends the attempt as `limit`
228
+ in both modes, and in `gate` mode every failure above still ends the attempt
229
+ as `provider_error`. Lease, catalog, policy and provider-allowlist revocation
230
+ fail closed in both modes.
231
+
232
+ Successful output also records `provenance.decision.readiness` (`'gate'` or
233
+ `'record'`) and `maxLen` when set; configuration digests are unchanged, but
234
+ persisted output is therefore not byte-identical to earlier releases.
235
+
236
+ Both settings are public configuration: they are part of
237
+ `ProposalReadConfiguration`, the frozen `GenerationStageConfiguration.proposals`
238
+ (present only when set, so existing digests are unchanged) and the completed
239
+ snapshot authority check. Invalid values fail at proposal service construction.
240
+ Neither mode affects automation: `automaticActionEligible` stays `false`, and
241
+ `authorizeAutomatic` reads only the host's `evaluateAutomatic` callback and
242
+ policy, never these answers.
243
+
129
244
  Output records zero, one or multiple suggestions, alternatives, missing fields,
130
245
  source links, proposed splits, versions, allowlisted usage, warnings and explicit
131
246
  `unknown`, `ambiguous`, `no_action`, `needs_review` or `provider_error` outcomes.
@@ -154,7 +269,14 @@ await service.previewGeneratedProposals({
154
269
 
155
270
  `intentionKey` is the host's stable business-intention identity across generation
156
271
  revisions. Never derive a fresh key from model output, suggestion order or a retry.
157
- Selections must reference current persisted validated `ready_for_review` output.
272
+ Selections must reference current persisted validated `ready_for_review` or
273
+ `needs_review` output (#4208); a `provider_error` attempt, an unoffered handler or
274
+ a non-interpretation stage is rejected. A `needs_review` suggestion whose
275
+ `missingFields` are still absent fails the handler schema inside the ordinary
276
+ preview (`Invalid handler payload`) and publishes nothing; the reviewer supplies
277
+ the missing values through the explicit human path (`previewProposal` /
278
+ `previewPlan`, as the published review host does for `preview({args})`), and the
279
+ handler's `validate` and `preview` run again on the completed arguments.
158
280
  Each batch requires distinct suggestion indexes and intention keys; duplicate
159
281
  indexes are rejected before any operation or plan preview is written.
160
282
  The service rechecks current catalog/schema/target/source provenance, then calls
package/README.md CHANGED
@@ -10,7 +10,25 @@ methods through their authenticated application transport.
10
10
 
11
11
  Before starting a service, deploy the package manifest through the public s-m-r-t
12
12
  migration APIs or `smrt db:migrate`. `assertReady()` only checks existing tables.
13
- SQLite and PostgreSQL are supported; other engines fail construction.
13
+ SQLite and PostgreSQL are supported; other engines fail construction. Index
14
+ additions ship the same way: `intake_items_received_idx` (newest-first inbox
15
+ paging on `tenant_id, confidential_scope_id, created_at, id`) is declared on the model and reaches existing deployments through the
16
+ next manifest migration; there are no hand-written migration files in this
17
+ package, and `smrt doctor --db` / `db:status --parity` report the index until
18
+ it is applied.
19
+
20
+ `listItemsPage({ limit?, cursor?, state?, itemIds?, scanLimit? })` returns
21
+ `{ items: [{ item, state, cursor }], scanned, nextCursor? }`: one authorized
22
+ inbox page newest receipt first with each row's durable inbox state and
23
+ continuation cursor, and the number of rows the call examined. `listItems()`
24
+ remains as a deprecated array-returning method that walks the private page
25
+ scan to the end. Without the optional `cursorKey` service option every cursor
26
+ names the last row the actor can read, and a page that finds no readable row
27
+ within its scan limit fails with `IntakeScanLimitError`; with a server secret
28
+ of at least 32 bytes, cursors are sealed and such a page resumes past what it
29
+ skipped. `scanned` is for host
30
+ accounting only. See [REVIEW.md](REVIEW.md) for the paging and authorization
31
+ contract.
14
32
 
15
33
  A host supplies a database, AssetRuntime, authenticated tenant/actor/confidential
16
34
  scope, a live authorization callback, a registered jobs target, and an idempotent
@@ -20,7 +38,29 @@ transaction executor; it must intersect current grants with that ceiling. Never
20
38
  construct the scope, retention policy or budgets from incoming document labels.
21
39
 
22
40
  `receive` accepts bounded original byte buffers, with stable source part IDs,
23
- parent relationships and optional upstream identity/version. Source adapters own
41
+ parent relationships, optional upstream identity/version and an optional
42
+ original `filename` (kept for labels on `IntakeEvidenceDTO.filename`, never
43
+ part of receipt identity). The multipart upload source, email attachments and
44
+ the watched folder all supply it, as may a host `snapshotReference` part or a
45
+ vendor webhook's verified parts. Every source delivers through one place that
46
+ removes control, format (bidi, zero-width, tag), line-separator and
47
+ default-ignorable characters (Unicode properties, see
48
+ `src/presented-filename.ts`) and drops a name that is then empty or over 512
49
+ characters, so an odd name falls back to the default label instead of failing
50
+ the receipt (`receive` itself rejects such a name from a direct caller as
51
+ `invalid`, and names are cleaned again when read back). Removing every format
52
+ and default-ignorable character has display-only effects, since nothing uses
53
+ the name as identity: emoji ZWJ sequences and presentation selectors flatten,
54
+ ZWNJ/ZWJ are removed from Persian and Indic text (letters may join
55
+ differently), and ideographic variation selectors are removed (a CJK variant
56
+ shows its base glyph). Unicode normalization
57
+ (NFC/NFKC) and confusable look-alike characters are out of scope: the name is
58
+ still untrusted text, so render it as text, never as markup or a path. The
59
+ upstream `sourceReference` is stored but not exposed on the DTO: its ids are
60
+ upstream locators, not display text, and they are never cleaned. For email
61
+ they are the provider's message locator (plus the attachment part id); for the
62
+ watched folder the id is the raw folder entry name, whose cleaned form is what
63
+ `filename` exposes. Source adapters own
24
64
  streaming and upstream snapshot retrieval. Receipt identity is tenant + source +
25
65
  delivery key; changing bytes or authenticated scope conflicts. Content equality
26
66
  in a different conversation does not deduplicate an arrival. The returned item
package/REVIEW.md CHANGED
@@ -32,6 +32,41 @@ Plan membership contains the exact parent args/version so edits call
32
32
  `previewPlan`, not per-step correction. An item may have more review pages; the
33
33
  host must aggregate state across pages when projecting a production inbox state.
34
34
 
35
+ A `needs_review` suggestion is completable, not only rejectable (#4208). The
36
+ component shows its `missingFields`, an editable `Suggested arguments (JSON)`
37
+ draft prefilled from the generated arguments, and the candidate picker for
38
+ declared candidate fields; Prepare review stays disabled while required fields
39
+ are still missing from an unchanged draft. An unchanged draft previews the
40
+ generated snapshot as before; an edited draft is sent as `preview({..., args})`,
41
+ which the published host publishes through the ordinary `previewProposal` /
42
+ `previewPlan` with the same `ui:<index>` intention, never by rewriting the
43
+ generation. The handler validates the human arguments again before a
44
+ `waiting_review` action exists, approval remains a separate explicit decision,
45
+ `ActionReview` lists required arguments missing from the current draft while
46
+ editing, and no authority or automation eligibility is added: a failed attempt,
47
+ an unoffered handler or a foreign stage is still refused by the host.
48
+ `preview` throws on every failure, as before. When the handler's argument
49
+ schema or `validate` refused the arguments being previewed (operation preview,
50
+ which also carries corrections, or plan preview), the server throws
51
+ `HandlerArgumentsInvalidError` (`name` set, messages unchanged). Apply-time and
52
+ external-send revalidation and result-schema checks keep a plain `Error`. The component shows an inline message and
53
+ keeps the view and draft when `preview` either rejects with an error whose
54
+ `name` is `HandlerArgumentsInvalidError` or resolves `{ rejected: 'invalid' }`
55
+ (`PreviewRejection`). A transport that crosses a process boundary preserves
56
+ the inline UX by keeping that name (for example re-throwing an `Error` with
57
+ `name = 'HandlerArgumentsInvalidError'` in the browser callback) or by
58
+ returning `{ rejected: 'invalid' }` for that case only, as the maintained
59
+ fixture's `previewForReview` does. Without either, the refusal behaves like
60
+ any other failure: the view is cleared exactly as before this change, and the
61
+ reviewer's in-memory draft returns after an authorized refresh. Every other
62
+ thrown preview or candidate-search failure (denied, revoked, unavailable or
63
+ unknown) clears the view.
64
+ Unsent drafts live only in component memory, keyed by attempt and suggestion
65
+ index. They survive Refresh, a failed callback, an action on another card and a
66
+ transient `unavailable`/`stale` reload; a `revoked` or `expired` result
67
+ discards them, a successful preview clears that card's draft, and an available
68
+ reload drops drafts for attempts or cards no longer offered.
69
+
35
70
  The host's `load` callback supplies a current `ItemReviewView`, using
36
71
  `getCompletedAnalysis` with no historical fallback. Generated suggestions bind
37
72
  the **current generation attempt**, not `generation.source.attemptId` (the prior
@@ -98,6 +133,151 @@ with `human_split_not_respected`. New proposals require new explicit review.
98
133
  The reference host runs these stages synchronously for browser determinism;
99
134
  production hosts dispatch the existing durable analysis jobs.
100
135
 
136
+ ## Reviewer instructions
137
+
138
+ `recordReviewerInstruction({itemId, attemptId, expectedRevision, text,
139
+ requestId})` records a plain-text instruction for one item ("This is a customer
140
+ PO from Badger; file it, do not bill it") as the second kind of **human
141
+ correction**. It is authorized exactly like `reviseLogicalSplit`: the current
142
+ generation must pass the same-executor catalog/candidate gate and the reviewer
143
+ grant plus policy reviewer list, and authority is checked before mutation and
144
+ again before publication. Process access, `analyze`, and a client payload cannot
145
+ mint one: `analyze` rejects a `reviewerInstruction` key on an extraction
146
+ configuration, and an interpretation copy that differs from the pinned
147
+ extraction's record fails with `Generation evidence changed` before provider
148
+ disclosure. Text is required, trimmed-nonempty and at most 4,000 characters.
149
+
150
+ The record is immutable: `{id, text, actorId, recordedAt, supersedesId, digest}`
151
+ plus the request binding, written into a new extraction configuration beside
152
+ any existing split correction. Recording supersedes current suggestions and
153
+ approvals, queues the normal fenced re-extraction and interpretation through the
154
+ existing dispatch path, and returns immediately; the reviewer keeps working
155
+ while the item reports `processing`. `prepareGeneration` copies the instruction
156
+ into the interpretation configuration, the generator receives it only after its
157
+ digest matches the authoritative extraction record, and saved generation
158
+ provenance records `reviewerInstruction: {id, digest}`. A later instruction
159
+ records `supersedesId` and replaces the earlier one for future interpretations;
160
+ `listReviewerInstructions(itemId)` returns the full history, newest first, and
161
+ history is never deleted. The instruction steers the model only: it cannot add
162
+ handlers, candidates, schemas, permissions or automation eligibility, replies
163
+ are validated as usual, and suggestions still require explicit preview and
164
+ approval. A replay with the same request ID returns the same revision; a changed
165
+ text under the same request ID conflicts.
166
+
167
+ **Cost.** Each recorded instruction appends a new extraction revision, so the
168
+ item is extracted again before it is interpreted again: cheap for embedded-text
169
+ PDFs and plain text, but OCR/speech hosts spend provider budget per instruction,
170
+ exactly as they do per split correction. The pinned extraction is not reused
171
+ because the interpretation contract requires its source to be the immediately
172
+ preceding revision and `getCompletedAnalysis(itemId, attemptId)` serves only
173
+ the current revision (PROPOSALS.md, "Durable stages"); reusing it would mean
174
+ weakening that predecessor pin, which protects every generation from an
175
+ intervening reprocess, so this feature follows the split path instead.
176
+
177
+ **State and failure.** An in-flight stage (queued/running) reports the item as
178
+ `processing` even when earlier, now-stale actions exist. If the re-extraction or
179
+ interpretation fails, `getReinterpretationState(itemId)` returns the latest
180
+ attempt's safe failure category and the latest completed generation attempt;
181
+ the host projects them as `instruction.failure` and `instruction.binding`, and
182
+ `recordReviewerInstruction` accepts that historical attempt as its anchor when
183
+ `expectedRevision` is the item's current (failed) revision and no later
184
+ **interpretation** completed and nothing is queued or running, so the reviewer
185
+ can retry or change the instruction after either stage fails. A completed
186
+ extraction whose own interpretation failed belongs to the failed attempt, not a
187
+ newer result; a completed extraction whose interpretation has **not been
188
+ appended yet** is in flight (the host is between stages) and is refused with
189
+ `ReviewerInstructionStaleError` (`Stale reviewer instruction`), so a second
190
+ reviewer cannot pre-empt it. That gap expires: once the extraction attempt's
191
+ completion plus the item's analysis lease duration (`limits.leaseMs`, the lease
192
+ `claimAnalysis` grants) has passed with no interpretation appended,
193
+ `getReinterpretationState` reports `failure: 'not_started'` with the retry
194
+ binding and the guard allows the retry. `not_started` means **the
195
+ interpretation did not start within the lease window**: the host crashed,
196
+ threw between `prepareGeneration` and `analyze`, or its queue is merely slow.
197
+ Pre-empting a slow but healthy host is safe because `analyze` refuses an
198
+ interpretation whose pinned extraction is no longer the item's latest, under
199
+ the item lock and before anything is superseded
200
+ (`GenerationSourceStaleError`, nothing appended): the slow host's late append
201
+ cannot clobber the retry. Missing or malformed timing (no attempt, no
202
+ `completedAt`, non-finite `leaseMs`) counts as already expired rather than
203
+ holding retries forever. The "still being re-read" note shown after a stale
204
+ result is cleared by the next reload that shows the item settled; the draft
205
+ then carries a persistent "This item changed while you were writing; your
206
+ instruction was not recorded." note (any stale cause: another reinterpretation,
207
+ a split, or a host reprocess) until the reviewer edits or resubmits it. The guard and
208
+ `getReinterpretationState` share one predicate, and the latter's
209
+ `reinterpreting` flag (projected as `instruction.reinterpreting`) is true in
210
+ the gap too, so the component keeps polling and keeps Re-read disabled there.
211
+ The published host maps two typed errors to `instruct` results:
212
+ `ReviewerInstructionStaleError` → `{ stale: true }` (refresh-and-retry: reload
213
+ with a note, draft kept) and `ReviewerInstructionInvalidError` → `{ invalid:
214
+ true }` (empty, whitespace-only or over 4,000 characters: shown inline, draft
215
+ kept). Neither is a denial. Custom hosts should map them the same way; a host
216
+ that lets either propagate as a thrown error makes the component treat it like
217
+ any other failed mutation, i.e. a whole-view deny with the "view unavailable"
218
+ message, as before these results existed. The maintained fixture's
219
+ `/api/instruct` now answers an invalid text with HTTP 200 `{ invalid: true }`
220
+ instead of 409. A retry from an older anchor builds on the **whole configuration**
221
+ of the newest extraction after it (replacing only the instruction), because that
222
+ is the item's current configuration: corrections chain forward through it and a
223
+ host reprocess replaces it. So a split followed by a failed stage is never
224
+ silently dropped, and a failed reprocess's changed configuration is neither
225
+ reverted nor overlaid with a split it dropped. **Concurrency.** `recordReviewerInstruction` and `reviseLogicalSplit` each
226
+ run their whole decision in one transaction under the item row lock: the
227
+ anchor snapshot read takes the lock first, the execution authorization of
228
+ that snapshot takes it again (`UPDATE intake_items` row lock), the guard step
229
+ re-asserts it explicitly, and
230
+ the request-key replay check, the revision and later-work guards, the
231
+ carried-forward split, the history read that sets `supersedesId`, and the
232
+ append all run under it. On PostgreSQL a second reviewer at the same revision
233
+ blocks on that lock and is then refused (`ReviewerInstructionStaleError` /
234
+ `Stale logical split`), so the ledger cannot fork; an instruction racing a
235
+ split (in either order) is refused rather than built from the pre-split
236
+ configuration; and a double-click whose duplicate commits after the unlocked
237
+ replay check is answered as a replay of the committed request, not a conflict
238
+ or a denial.
239
+
240
+ **Host reprocess.** A host reprocess (a plain `analyze()` extraction the host
241
+ configures itself) cannot carry human corrections — `analyze` rejects
242
+ `humanCorrection`/`reviewerInstruction` on extraction configurations — so it
243
+ drops the split and the instruction alike, and neither is applied to its
244
+ interpretations. The instruction is then reported as `dropped`, not pending:
245
+ `getReinterpretationState().instructionInEffect` names the instruction the
246
+ newest extraction carries (verified by digest) and is null after such a
247
+ reprocess. History is unchanged; the reviewer can re-record the instruction,
248
+ which supersedes the dropped one.
249
+
250
+ `ItemReviewView.instruction` exposes `current` (the latest recorded instruction
251
+ still in effect, i.e. carried by the newest extraction), optional `dropped`
252
+ (the latest recorded instruction a reprocess dropped), `applied` (the
253
+ instruction the displayed **generation** ran under; a completed extraction alone
254
+ applies nothing), `stale` (the displayed suggestions predate the current
255
+ instruction), optional `failure` and optional `binding`. Feedback examples that carry instruction
256
+ text count it toward the feedback byte budget (FEEDBACK.md). The optional host callback
257
+ `IntakeReviewHost.instruct` is implemented by the published adapter when the
258
+ service supports it, with an optional `onInstruct(itemId, revision)` wakeup
259
+ mirroring `onSplit`. The review component renders "Instructions for the
260
+ assistant" with "Re-read with these instructions", shows "Reinterpreting…" while
261
+ the item's stage is queued/running, repeats the existing authenticated `load`
262
+ on `pollMs` (default 2 s, backing off to 5×) and patches the view in place —
263
+ drafts, split edits, focus and the evidence viewer survive and no loading
264
+ placeholder is shown — stops when the stage is no longer in flight, and then
265
+ shows the instruction that produced the current suggestions, marking a
266
+ superseded one as stale. An explicit host denial on a poll tick fails closed at
267
+ once; a thrown tick is treated as transient (view and drafts kept, a
268
+ "Couldn't refresh; retrying." note, backoff continues), but because hosts may
269
+ also throw for revoked access, three consecutive thrown ticks fail closed.
270
+ **Hosts must signal revocation by returning `availability: 'revoked'`** (or
271
+ another non-`available` state) from `load`, not by throwing: a returned denial
272
+ clears the view on that tick, while a host that throws on revocation leaves the
273
+ already-displayed content visible until the third thrown tick (about 10 s at the
274
+ default interval and backoff) before the view fails closed.
275
+ Split drafts survive a refresh only when the reviewer edited them. A failed stage shows its safe category with the form
276
+ still available. There is no
277
+ streaming channel; status is read through the existing item/analysis APIs. The
278
+ maintained fixture records the instruction durably and then runs the stages in
279
+ the background so the browser proof exercises the in-place update.
280
+
101
281
  `feedback` is an optional explicit correctness callback, separate from approval
102
282
  choice and execution success. No learning, feedback storage or rule promotion is
103
283
  implemented by these components; #3676 owns those services. Do not pass feedback
@@ -147,14 +327,170 @@ action/plan preview CAS lookup, decisions, apply, corrections, candidates, split
147
327
  and feedback. Repeated preparation of an unchanged authorized suggestion retains
148
328
  its current review instead of creating a new revision. Changed arguments still
149
329
  require explicit re-preview and a fresh human decision.
150
- Lists scan 20 items per page; filtered pages may be empty with a `nextCursor`.
151
- Continue until the cursor is absent. Listing does not load original bytes.
330
+ `list({ state?, assigneeId?, cursor?, limit? })` returns one page newest receipt
331
+ first (`intake_items.created_at`, id tiebreak) through `service.listItemsPage`.
332
+ `limit` defaults to 20 and is capped at 100; the cursor is an opaque
333
+ (received-at, id) continuation, never a bare id. Tenant, confidential scope,
334
+ retention and the `state` filter run inside the page query, so a page is full
335
+ and `nextCursor` is present only when more rows follow.
336
+
337
+ Authority is rechecked at the same two levels `load` applies, but per page
338
+ rather than per row: the host authorize callback once per distinct captured
339
+ ceiling in a batch, and the execution read gate (policy, ceiling, handler and
340
+ operation allowlists, principal) once per distinct (item, handler) bound by
341
+ the page's saved actions. The list gate is read-only: it evaluates the same
342
+ checks as `listReviews` without the retention write, so an item that the item
343
+ view would narrow past its deadline is simply not listed. The list gate also
344
+ resolves each bound handler and rejects version drift against the saved
345
+ binding and omits a cancelled item, as `listReviews` does; it does not
346
+ evaluate per-action target,
347
+ evidence or plan gates, so an item whose target became unreadable still lists
348
+ and its item view refuses. An item whose bound
349
+ handler the actor cannot currently read is not listed at all, rather than
350
+ listed with a state derived from actions the actor may not see; actions
351
+ without a proposal bind no handler and are not gated, as in `listReviews`.
352
+ Handler bindings (handler id and version, projected in SQL, distinct per
353
+ item) are read once per fetched batch for the rows that passed the item-level
354
+ gate. Note the host cost plainly: the execution `authorize` callback runs once
355
+ per distinct (item, handler) bound by a scanned row that passed the item-level
356
+ gate. Each item is its own authorization subject, so nothing is deduplicated
357
+ across items; the count is bounded by rows scanned (at most
358
+ `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
359
+ `authorize` behind the handler gate, follows the same rule: a refusal (false,
360
+ or an access check that fails) hides the rows it covers, and a callback that
361
+ throws rejects the whole page (and `listItems()`) with its error, as `load`
362
+ does, so a transient failure never lets paging move past readable rows.
363
+ Denied or unreadable rows are skipped without
364
+ disclosure and the page refills from the next rows; refills grow from the page
365
+ size to 100 rows per query, so a fully denied scope costs about a dozen page
366
+ reads and as many binding reads per call. One service call examines at most
367
+ `ITEM_SCAN_CEILING` (1000) rows, or the caller's smaller `scanLimit`, and
368
+ reports the count as `scanned`; when a mostly-denied scope reaches the ceiling
369
+ the page comes back short with a `nextCursor`. Rows are authorized first and
370
+ the `state` filter is then applied to the fetched batch, so it is bounded by
371
+ the same scan limit: a rare state yields short pages with a cursor rather than
372
+ an unbounded walk inside one query. A row the actor can read whose state does
373
+ not match discloses nothing and may anchor the cursor, so a filter with no
374
+ match among the next 1000 rows returns an empty page with a cursor and still
375
+ pages to the end. The host `list` passes its remaining budget as `scanLimit`, so the
376
+ rows scanned across the service calls of one host call never exceed 1000: a
377
+ short service page with a cursor, or an exhausted budget while filtering by
378
+ assignee, ends the host call with the same cursor and the caller continues.
379
+ Only rows the service wrote are supported: cursors carry the canonical
380
+ millisecond ISO receipt time the service stores. A row whose stored receipt
381
+ time is not that form (on SQLite anything but `YYYY-MM-DDTHH:MM:SS.mmmZ`,
382
+ including a parseable value without milliseconds or with an offset; on
383
+ PostgreSQL a value with a sub-millisecond part) is never listed, whatever the
384
+ actor's access, and counts toward the scan, as does a row whose id is not a
385
+ UUID. On SQLite a non-text stored value (an integer, say) and a receipt value
386
+ or id longer than 64 characters or containing a NUL character are excluded by
387
+ the page query itself: the first sorts after every text value so the scan
388
+ could never move past it, the second could not be carried by any cursor, and
389
+ the third is returned truncated at the NUL, so it would look canonical and
390
+ sort above a legitimate row at the truncated value. Within a call the scan moves
391
+ past a service-foreign row by its exact stored value, and an unsealed cursor
392
+ names the last row before it that the actor can read. A call that finds no readable row within its scan limit (at
393
+ most 1000) fails closed with `IntakeScanLimitError` unless a `cursorKey` is
394
+ configured, as below. The host
395
+ `list` returns what earlier service calls collected, with their cursor, when
396
+ a later call fails that way.
397
+
398
+ `listItems()` is deprecated and kept only for callers of the array-returning
399
+ API. It walks every page, so its cost grows with the scope, including one
400
+ execution authorization per (item, handler) across the whole scope. It follows
401
+ the internal scan position, which never leaves the service, so runs of rows
402
+ the actor cannot read never stop it and it returns `[]` where nothing is
403
+ readable. Items are newest-first and gated exactly as on a page.
404
+
405
+ The page's inbox state is the durable projection of the saved action journal
406
+ and processing state, computed once per scanned row inside the page query;
407
+ `load` remains the authoritative, live-gated projection for one item (it still
408
+ projects individual stale actions, which the list does not). Cost is
409
+ proportional to the page for unfiltered pages and common states, and to the
410
+ number of items scanned until enough matches are found for rare states: the
411
+ state is not persisted, because the action journal is append-only and
412
+ projecting it into an indexed column would need a maintained denormalisation
413
+ in the execution service plus a migration, which this change does not take.
414
+ Listing does not load original bytes.
415
+
416
+ Cursors never disclose an unreadable row. Without a `cursorKey` in the
417
+ service options, every cursor (each row's and `nextCursor`) names a row the
418
+ actor can read (the last one when a page stops early); a page that spends its scan limit before filling hands back
419
+ the last such row, and one that found none fails closed with
420
+ `IntakeScanLimitError` (`Intake scan limit`, no id or time). A run of rows the
421
+ actor cannot read (refused by the captured ceiling or the execution gate, or
422
+ stored with a non-canonical receipt time) at least as long as the scan limit
423
+ therefore stops an unkeyed walk there; readable rows that the `state` filter
424
+ excludes never count toward such a run. With a `cursorKey` (a server secret of at least 32 bytes), every
425
+ cursor is sealed with AES-256-GCM, bound to the tenant, confidential scope and
426
+ actor, and a budget-stopped page returns a sealed position past what it
427
+ skipped, so the walk always progresses. Sealed cursors have a fixed length
428
+ (the plaintext is padded to 1024 bytes) and a fresh nonce per seal, so neither
429
+ their length nor their bytes reveal anything about the stop row. `scanned` is for trusted host
430
+ accounting and must not be sent to clients; a short page with a cursor tells
431
+ a caller only that unlisted rows exist, never which.
432
+
433
+ Newest-first paging relies on `intake_items_received_idx`
434
+ (`tenant_id, confidential_scope_id, created_at, id`), the full ordering key, so
435
+ an unfiltered page is a backward index scan that stops at the page boundary;
436
+ the foundation suite asserts that plan on both engines (SQLite
437
+ `EXPLAIN QUERY PLAN`, PostgreSQL `EXPLAIN`), with the per-row state counters
438
+ served by `intake_actions_item_idx`. On SQLite the page query pins the index with `INDEXED BY` (except under an
439
+ id restriction, where direct id lookups are cheaper), because without planner
440
+ statistics SQLite otherwise picks `intake_items_expiry_idx` plus a sort; the
441
+ SQLite plan check runs without `ANALYZE`. A deployment must have applied the
442
+ manifest migration, or the page query fails with a missing-index error. This package ships no
443
+ migration files: indexes are declared on the model, the build emits them into
444
+ `dist/manifest.json`, and `smrt db:migrate` / the public migration API create
445
+ them, which is how `intake_items_receipt_idx` shipped. Deploy the manifest
446
+ migration before upgrading hosts; `db:status --parity` reports the index as
447
+ missing until then.
152
448
 
153
449
  Supply four application adapters: `upload(files, requestId)` (authenticated
154
450
  receipt plus first extraction request), `assignment(itemId)` (durable read),
155
451
  `assign(input)` (manager authorization, eligible assignees, durable CAS and request
156
452
  replay protection), and `evidence(part)` (safe label/text or authenticated
157
- same-origin URL). Optional `label(item)` changes presentation. Optional
453
+ same-origin URL), which `load` calls for every part and `list` never calls;
454
+ `load` sets no row preview on its entry.
455
+
456
+ Row previews are opt-in through an optional fifth adapter,
457
+ `rowEvidence(rows)`. When it is supplied, `list` reads the page's durable
458
+ evidence metadata with one `service.listEvidence(itemIds)` query (at most 100
459
+ ids; items the scope cannot read, and expired or inactive items, are omitted
460
+ without disclosure; the host `authorize` runs once per distinct captured
461
+ ceiling through the same gate as `listItemsPage`, so a refusal or a
462
+ malformed captured ceiling hides the item and a failing `authorize` rejects
463
+ the page; an item whose stored data does not parse is skipped by both
464
+ `listItemsPage` and `listEvidence`, never fatal). It then calls `rowEvidence`
465
+ once with each row's primary part (`{ itemId, evidence }`) and publishes the
466
+ returned `{ label, viewUrl?, thumbnailUrl? }` as `InboxEntry.evidence`
467
+ (`{ evidence, label, viewUrl?, thumbnailUrl? }`, never text). Build those URLs
468
+ from metadata without reading bytes; if `rowEvidence` throws or returns a
469
+ malformed answer, the page still lists without previews, and a row without
470
+ durable parts never gets one. When it is omitted, rows show no original link.
471
+ Either way `list` reads the page's evidence metadata once, in that same query,
472
+ so the row label can use the original filename; it never reads original bytes
473
+ and never calls `evidence(part)`. The primary part is chosen by role, not media
474
+ type: the shallowest child part when any part has a parent (an upload's file
475
+ under its capture record, an email's first attachment under the message, even
476
+ when that child is JSON), else the first root part (a watched-folder file);
477
+ part ids sort numerically. `IntakeInbox`/`InboxView` accept an
478
+ optional `rowAside` snippet receiving the `InboxEntry`; without it a row renders
479
+ the host's "Open original" link (new tab) and thumbnail when the URLs are
480
+ same-origin; each link's accessible name is "Open original: <row label>", and
481
+ a row shows no wrapper when neither URL is usable. The slot is a plain `div`,
482
+ not an `aside` landmark, so a page does not add one landmark per row. Optional
483
+ `label(item, evidence)` changes presentation; `evidence` is the item's durable
484
+ parts with the primary part first, and the default label is the primary part's
485
+ own original `filename`, else `Intake <8 hex>`. Optional
486
+ `assignments(itemIds)` reads one page's assignments in one batch (otherwise
487
+ `assignment` runs per row), and optional `assignedItemIds(assigneeId)` lets
488
+ `list` push the assignee filter into the page query (junk ids are dropped, not
489
+ fatal); without it an assignee filter scans further pages and filters by
490
+ `assignment`, which stays correct but costs more rows. The page's continuation
491
+ is always the last row actually returned, so an assignment that changes
492
+ between the page read and the assignment read costs at most one extra scan and
493
+ never skips or repeats a row. Optional
158
494
  `enableFeedback: true` exposes the optional feedback callback when the service
159
495
  has feedback configured. Optional `onSplit(itemId, revision)` wakes the host worker after the durable extraction
160
496
  request; repair dispatches if wakeup fails. The factory creates no assignment