Story-3289 · bundle trigger · pramaan-functions

Nothing was asking Blake to carve

ADR-180 D15 split Blake in two. Blake-the-carver is invoked once per bundle, drops the member list, and his run ends; Blake-the-clerk then runs once per member. P2 built almost all of that rail — the mint side (Story-3287), the S3 → EventBridge → SQS ingress (Chore-3288), the router and status table (Story-3290), and the carver's own drop (Story-3291, pramaan-agents).

One thing was missing, and it was the first one: nothing ever invoked mode="carve". The 307-page NFRA carve that proved the engine was driven by hand. A rail with no trigger is a rail that never moves, and — this is the part that matters — it looks exactly like a rail that moves and finds nothing. This Story is the trigger.

The signal: deterministic, broker-side, no model

The obvious move is to ask classify. We deliberately did not. RegistrarClassifyOutput is a frozen contract and adding a bundle field to it is a prompt change as well as a schema change — that belongs to the P4 brain ticket, not to a trigger. It would also make the cheapest possible question (is this document long, and does it have annexure covers?) depend on a model call.

So detection reads the page spine the extraction already produced, through the scanner that is already running on this document in the same pass: family_carving.scan_pages, the collation machine's own deterministic marker detector. It is computed before the collation prefilter, not behind it — the prefilter's density floor asks “is there enough extracted text to verify an index against?”, which a scanned bundle fails by definition. Chained behind it, a 30-page scan with two annexure covers and thin OCR produced no signal at all, and the carver was never invoked for exactly the document class that most needs it (PR #928 review). Two thresholds, both config:

KnobDefaultWhy
BUNDLE_CARVE_MIN_PAGES 30 A filed bundle is long. Below this, annexure mentions are cross-references in a letter.
BUNDLE_CARVE_MIN_MARKERS 2 Distinct cover-marker labels, not total hits. A pleading that says “Annexure A-1” on sixteen pages cites one annexure; counting hits would carve it into sixteen — the exact over-split the carve engine itself was built to avoid.

Both must hold. They are ANDed rather than scored because a score is a knob nobody can reason about at 3am; “30 pages and 2 distinct annexure markers” is a sentence a reviewer can check against a real filing.

The thresholds are deliberately loose. A false positive is cheap by construction (D17): the carver returns a single-member family, the drop is recorded, and the document keeps the single-document path it was already on. A false negative is the expensive one — a real bundle stays a 300-page blob nobody can cite. Tune toward over-triggering.

“No new detector” is not a promise in this document; it is a test. test_module_carries_no_detector_of_its_own parses the module with ast and fails if it imports re or carries marker vocabulary in any executable string literal. It strips docstrings first — a text grep fired on the module's own prose explaining that the vocabulary lives elsewhere, which is R14's Bug-3201 lesson arriving one level up, in the checker.

The claim: one carver per bundle, per generation

A registration can be re-driven. SQS redelivers. Two triggers must not become two carvers, so the trigger claims the substrate before it invokes — INSERT ... ON CONFLICT DO NOTHING, the database arbitrating, no application-side “did we already carve this?” read spanning the gap (R14).

Why not the Story-3290 status table

That was the first choice and it is the wrong shape, for a reason that is a column rather than an opinion: agent_step_drop.payload_sha256 is NOT NULL with a hex-64 CHECK. That table records one row per consumed drop — bytes that have landed and been re-digested. A carve trigger fires strictly before the carver runs; there are no bytes. The honest options were to relax that CHECK — which would let a real drop row be written with no digest, deleting the guarantee the column exists for — or to claim where the shape already fits.

It fits in agents.agent_idempotency_key, which is already this repo's claim table for “one agent run per substrate” (claim_registration, boundary registration, migration 0118). Migration 0244 adds a carve_trigger boundary and a partial unique index on (firm_id, input_sha256) WHERE boundary = 'carve_trigger', mirroring migration 0129's operation boundary line for line. Not a second claim path — the same one, one boundary wider.

The digest is sha256("carve:{document}:{extraction}:{generation}"). The generation is in it on purpose: a carve that claims and then fails to dispatch would otherwise own that bundle forever, and ADR-180's own recovery for a bad carve is revert + re-carve, which bumps the generation and so hashes to a fresh, legitimate claim.

The dispatch: direct, never the relay

The named trap on this ticket is wiring detection into the relay path. ops.document_ingest_outbox is drained by a 60-second poll; a relay hop per bundle step is precisely the latency ADR-180 exists to delete. So the carve invoke goes straight through the artifact broker: pending row written and committed first, then mint, then invoke, and the broker returns without reading the response body.

That is asserted, not asserted-in-prose: test_the_trigger_never_touches_the_outbox_relay booby-traps every dispatch_* and insert_* in document_ingest_outbox and fails the test with the rule in the message if the trigger ever reaches one.

The run mints one step envelope — StepDescriptor(step="carve") — because a carver run drops exactly one result, runs/<run>/steps/1-carve.json. That drop then lands on Story-3290's router, where apply_carve records it. Turning a recorded family into real documents is the P3 knife (Story-3294); the main-first clerk fan-out is Story-3295.

What was proven red first

ClaimPlanted negativeResult
Distinct labels, not hits Reverted the set comprehension to a list test_repeated_mentions_of_one_annexure_are_one_marker failed
The claim stops the second carver Replaced if not claimed: with if False: test_a_duplicate_trigger_invokes_no_second_carver failed
The partial index is what arbitrates DROP INDEX agents.uq_agent_idempotency_key_carve_trigger against the live test database All four integration proofs failed; green again after test-db.sh reset + alembic upgrade head
The “no new detector” check can see one A fixture module that imports re and compiles an ^ANNEXURE pattern, with a docstring that mentions the same word Regex seen, docstring correctly ignored

Observability

No new span. The three names this surface ratified (ADR-180 amendment §7) are functions.step_drop.consume, functions.bundle.materialize and functions.bundle.fanout; none of them is a carve trigger. Detection is a pure function over pages already in memory, and the dispatch opens the registry's existing functions.agent_invoke.call inside dispatch_artifact_run. Every non-invoking exit carries a named reason in a structured log line, so “this was not a bundle” and “this bundle silently did not carve” are never the same report.

The open dependency

The mint rail is not wired in infra yet. AGENT_RUNS_SIGNING_ROLE_ARN is set on no Lambda in pramaan-infra, and the signing role's trust policy (environments/staging/agent_runs_iam.tf) names only the clerk-worker role. Until Chore-3293 lands both, this trigger fails closed and loud — a typed ArtifactDeliveryConfigError naming the missing role, caught by the fan-out contract and logged with the document id. Registration is unaffected; the bundle keeps today's single-document path.

Where the code is