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

PieceWhy
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).

Every reclaim bumps 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.
The table contract is SHARED with the Story-1976 relay: its 1-minute cron sweeps 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

CheckResult
ruff checkclean
mypy src/workers/framer_trigger0 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 contract20 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 seal2 passed (isolated Docker test DB)
Alembic upgrade head / downgrade base / upgrade + alembic checkclean 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).