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:
| Knob | Default | Why |
|---|---|---|
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
| Claim | Planted negative | Result |
|---|---|---|
| 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
- Trigger —
src/shared/context/bundle_trigger.py - Signal source —
src/shared/context/collation.py(reportsbundle_signal; invokes nothing) - Caller —
src/shared/context/fanout.py, after the collation sub-step - Claim — migration 0244,
agents.agent_idempotency_keyboundarycarve_trigger - Thresholds —
BUNDLE_CARVE_MIN_PAGES/BUNDLE_CARVE_MIN_MARKERSinsrc/shared/settings.py