@@ -132,6 +132,38 @@ export class ExtractionDeadLetterRepo {
132132 } ) ;
133133 }
134134
135+ /**
136+ * A durable "ran, and there was legitimately nothing to do" trace.
137+ *
138+ * {@link markResolved} cannot express this: it no-ops when no row exists, and
139+ * an expected no-op never wrote one. Without a trace, a selection predicate
140+ * keyed on "did this section produce anything" cannot tell a handler that
141+ * legitimately wrote nothing from one that was gated and dropped its work, so
142+ * it re-selects the filing on every sweep forever.
143+ *
144+ * A single `put`, so no reader ever observes a spurious `pending` row
145+ * mid-update, and `first_seen_at` is preserved when an earlier failure left
146+ * one. The entry never reaches `sec extractor dead-letters`, which lists
147+ * pending entries only.
148+ */
149+ async recordResolved ( input : DeadLetterInput ) : Promise < void > {
150+ const now = new Date ( ) . toISOString ( ) ;
151+ const existing = await this . get ( input . extractor_id , input . accession_number , input . section_name ) ;
152+ await this . storage . put ( {
153+ extractor_id : input . extractor_id ,
154+ accession_number : input . accession_number ,
155+ section_name : input . section_name ,
156+ reason_code : input . reason_code ,
157+ detail : input . detail ,
158+ failed_extractor_version : input . failed_extractor_version ,
159+ status : "resolved" ,
160+ attempts : 0 ,
161+ first_seen_at : existing ?. first_seen_at ?? now ,
162+ last_attempt_at : now ,
163+ source_run_id : input . source_run_id ,
164+ } ) ;
165+ }
166+
135167 /** Entries for an extractor carrying a reason code, in any status. */
136168 async listByReasonCode (
137169 extractor_id : string ,
@@ -180,6 +212,42 @@ export class ExtractionDeadLetterRepo {
180212 return out ;
181213 }
182214
215+ /**
216+ * Entries for the given extractors whose accession is in
217+ * `accession_numbers`, in ANY status — a RESOLVED row is the evidence here,
218+ * not noise: an auto-resolved expected negative, or a
219+ * {@link recordResolved} trace, is the only mark a handler that legitimately
220+ * wrote nothing leaves.
221+ *
222+ * Scoped by accession because the caller already holds one issuer's timeline:
223+ * reading every row of an extractor to answer a question about a dozen
224+ * filings costs the whole table per issuer, and a batch pays that per SPAC.
225+ *
226+ * The extractor ids are looped rather than nested as a second `in` list, so
227+ * each query binds one list; an empty list on either side returns
228+ * immediately (`IN ()` is invalid SQL).
229+ */
230+ async listByAccessions (
231+ accession_numbers : readonly string [ ] ,
232+ extractor_ids : readonly string [ ]
233+ ) : Promise < ExtractionDeadLetter [ ] > {
234+ if ( accession_numbers . length === 0 || extractor_ids . length === 0 ) return [ ] ;
235+ const distinct = [ ...new Set ( accession_numbers ) ] ;
236+ const out : ExtractionDeadLetter [ ] = [ ] ;
237+ for ( const extractor_id of extractor_ids ) {
238+ for ( let start = 0 ; start < distinct . length ; start += MAX_IDS_PER_QUERY ) {
239+ const chunk = distinct . slice ( start , start + MAX_IDS_PER_QUERY ) ;
240+ const rows =
241+ ( await this . storage . query ( {
242+ accession_number : { value : chunk , operator : "in" } ,
243+ extractor_id,
244+ } ) ) ?? [ ] ;
245+ out . push ( ...rows ) ;
246+ }
247+ }
248+ return out ;
249+ }
250+
183251 /**
184252 * Pending entries eligible for retry. Four ways in:
185253 *
0 commit comments