Story 1977: the framer trigger — first bus subscriber + the frame-job claim table
Third pramaan-functions slice of RFD-EVENT-001 (EPIC-1973). Story-1975
landed the envelope and the transactional outbox; the relay (Story-1976)
drains it to the pramaan-events-staging bus. This Story lands
the thing the backbone exists FOR: the first subscriber. When a matter is
created, the framer trigger wakes Vega (a stub seam in the pilot) —
without the app, the matter service, or any human asking.
The rail, end to end
create_matter txn ──> ops.domain_event_outbox ──(relay)──> EventBridge bus
──> rule detail-type=matter.created ──> pramaan-framer-events-staging SQS
──> src/workers/framer_trigger/
parse envelope (HR-21, validated at consume)
──> environment assertion (EB-09)
──> event_id dedupe on agent_run.trigger_event_id (EB-07)
──> firm/subject coherence under firm RLS (EB-17/EB-06)
──> consent gate BEFORE any run row (EB-11)
──> ATOMIC lease claim on ops.matter_frame_jobs (EB-16/EB-18)
──> txn: job claimed + agents.agent_run row ── COMMIT
──> STUB AgentCore invoke {firm_id, matter_id, agent_run_id,
traceparent, tracestate} (EB-02)
The gate ORDER is the contract. Consent runs before any run row may
exist, so a revoked firm leaves no trace on the run rail — the skip is
durable on the job row (last_skip_reason='consent_blocked').
The run row commits before the invoke leaves, so the payload's
agent_run_id is proof of write-before-invoke ordering.
Migration 0145: the claim table and the run-row admissions
| Piece | Why |
|---|---|
ops.matter_frame_jobs — UNIQUE (firm_id, matter_id), run_status ∈ idle|queued|running|failed, rebuild_needed, requested_generation, claimed_generation, claimed_at, skip fields |
One row per matter is the debounce + lease truth for frame builds. RLS clones the outbox pattern (0064/0110/0144): firm isolation + staff worker SELECT/UPDATE for the Story-1976 sweep; DELETE revoked from pramaan_app. |
agents.agent_run.trigger_event_id (+ firm-leading partial index) |
Replays map events → runs (AGENT3 C9), and it IS the consumer's delivery-dedupe key: a redelivered event finds its run row and acks (EB-07). |
agent_key 'vega_matter_frame' joins the three closed CHECKs; scope constraint admits matter-only runs; user_id nullable behind agent_run_user_present |
A frame run has no chat session, no wizard form, and (for a system-created matter) no acting user. Each admission is narrow — every other rail keeps its old constraints. |
The claim: one statement, with a lease
INSERT INTO ops.matter_frame_jobs (…, run_status, …, claimed_at) VALUES (…, 'running', …, now()) ON CONFLICT (firm_id, matter_id) DO UPDATE SET run_status = 'running', requested_generation = matter_frame_jobs.requested_generation + 1, claimed_generation = matter_frame_jobs.requested_generation + 1, claimed_at = now(), rebuild_needed = false, updated_at = now() WHERE matter_frame_jobs.run_status <> 'running' OR matter_frame_jobs.claimed_at < now() - make_interval(secs => :lease) RETURNING claimed_generation
Row returned ⇒ you are the chef, possibly reclaiming a stale corpse whose
lease expired. No row ⇒ a healthy build holds the lease: set
rebuild_needed = true, ack, exit (EB-16 — two simultaneous
events, at most one run). A claim with no lease is a lock a dead process
never releases; the lease (MATTER_FRAME_JOB_LEASE_SECONDS,
the same setting the merged relay sweep reads —
default 900) is what makes a crashed worker recoverable (EB-18).
claimed_generation, and
release_frame_claim is a generation-CAS
UPDATE … WHERE claimed_generation = :mine AND run_status = 'running'.
The zombie that lost its lease carries the OLD generation, matches zero
rows, and its late write loses. The integration test proves both
directions against a real database.
running rows whose claimed_at is older
than the lease and marks them failed — the only
failed-marker for dead workers (a dead Lambda cannot mark its own
failure). src/workers/framer_trigger/AGENTS.md pins this:
do not reshape columns, statuses, or claim semantics without the sibling.
Consent parity — one truth
The consumer runs the SAME firm inference-egress gate the broker runs
(load_egress_consent, fail-closed, firm-scoped —
clerk_worker._load_consent parity) BEFORE creating any run
row. Revoked or absent consent: NO agent_run row, skip
recorded on the job row (EB-11). Run rows never carry consent state.
Tenancy and the poisoned event
The worker always runs non-staff under the ENVELOPE's firm with the
system-worker firm_admin role (document-worker precedent —
matters and agents.agent_run carry record-level RLS per
0077, and a system worker has no user to be assigned). A forged envelope
naming firm B and firm A's matter finds the subject row invisible under
firm B's scope and refuses as a contract violation — no job row, no run
row, no invoke (EB-17). A firm-B session reads nothing of firm A's rail
state (EB-06); both proven against real RLS in
tests/integration/test_framer_trigger_jobs.py.
Trace invariant
New registry span functions.framer_trigger.consume (CONSUMER
kind, required attrs firm_id + event.type;
pramaan-docs ADR-124 closed-registry extension, ratify with an
amendment). The per-record span PARENTS from the envelope's stored
traceparent via context_from_trace_context(),
and the stub invoke payload injects its carrier off that span — the unit
test asserts the trace id inside the consumer equals the producer's
trace id (EB-04). Logs carry event_id + outcome at INFO,
never envelope detail (EB-14).
Verification
| Check | Result |
|---|---|
ruff check | clean |
mypy src/workers/framer_trigger | 0 errors in Story files (remaining findings are pre-existing baseline debt in transitively imported shared modules; mypy is local-only per AGENTS.md) |
| Unit: gate order, dedupe, consent, claim/CAS, handler parse + trace parity, migration contract | 20 passed |
| Integration: real POST → real envelope → consent skip → claim + run row → duplicate → claim-lost → lease reclaim → zombie CAS loss; EB-17 forged firm + EB-06 RLS seal | 2 passed (isolated Docker test DB) |
Alembic upgrade head / downgrade base / upgrade + alembic check | clean roundtrip, no drift |
Full pytest tests/ | green (isolated Docker test DB) |
Deliberately NOT in this Story
The real Vega runtime (the invoke is a logged stub behind
FramerTriggerDeps.invoke_agent); the relay + reconcile sweep
(Story-1976, MERGED on main as PR #543 — its
sweep_stale_matter_frame_jobs is table-presence guarded and
reads the exact contract this migration ships); the deploy workflow for
the framer Lambda shell
(coordinated in the PR body — the Terraform shell from Story-1974 waits
on a functions artifact); and the frame-status UI (the follow-on app
story, EB-03/EB-13).