pramaan-functions · EPIC-2834 W2 · RFD-AGT-013 D10 (T2b-b)

Story-2866 — Command broker: translator + own-access gate + origin tag

Mira's map_command route (pramaan-agents Story-2864) emits a typed command_resolution.v1 — a registered command or a refusal — and never executes anything. This story builds what happens next on the functions side: the resolution is translated into a kind='mira_command' launch on the existing clerk rail, the sender's own matter access decides whether it may land (killing the firm_admin landing backdoor), the draft is tagged channel-originated on the ratify rail, and the terminal result posts back into the same WhatsApp thread.

The path, end to end

command_resolution.v1 (map_command turn, agents side — MERGED)
        │
        ▼
src/domains/channel/command_broker.py            ← NEW (this PR)
  parse_command_resolution   fail-closed: wrong schema / missing block / refused ⇒ no launch
  check_sender_matter_access OWN-ACCESS GATE: business.matters read under the SENDER's
                             real RLS identity (firm + user + membership role, 0077 rule)
  reserve_launch_run_id      request_id = channel-command:<mapping_run_id> (redrive-safe)
  translate_resolution       → AgentCommand kind='mira_command'
                               + channel_origin='whatsapp' + channel_binding_id
        │  (existing clerk-rail SQS, $1.50 command cap)
        ▼
src/clerk_worker/runner.py → mira_command executor (unchanged)
        │ turn.completed
        ▼
src/clerk_worker/mira_command.py                 ← BACKDOOR KILLED
  _set_sender_scope: the landing transaction runs as command.user_id +
  the runner-stamped firm_role — NEVER 'firm_admin'. Record-level RLS
  (business.can_read_matter) refuses any matter the sender cannot access.
        │ landed as a PROPOSED frame version, requires_counsel_approval=true,
        │ run_manifest.channel_origin='whatsapp'   ← ORIGIN TAG on the draft
        ▼
report_channel_command_result (runner hook, fail-open)
  → dispatch_whatsapp_text kind='answer' into the SAME thread:
    "draft ready — review and confirm in the app" (ratify stays in-app, CC-13)

What changed

FileChange
src/domains/channel/command_broker.py NEW — typed resolution parse (fail-closed), translator, own-access gate, refusal dispatch (static, zero-LLM), launch reserve+enqueue, report-back.
src/clerk_worker/mira_command.py _set_firm_scope (hardcoded firm_admin) DELETED → _set_sender_scope (sender's user_id + real role; fail-closed on a missing user). channel_origin threaded into the ingest request.
src/shared/agent_commands.py AgentCommand.channel_origin + channel_binding_id — typed, launch-only, travel as a pair, dropped from the wire when absent so app envelopes stay byte-identical.
src/domains/matter_frame/{dtos,service}.py MatterFrameCommandIngestRequest.channel_origin; stamped into the landed version's provenance.run_manifest.
src/clerk_worker/{runner,handler}.py ClerkDeps.report_channel_command seam: on a channel-originated command, report the landing result (or its honest failure) into the thread — fail-open; the delivery record is its own terminal state (R5).

Why the gate is the RLS rule itself

The broker's pre-enqueue check does not re-spell the access predicate: it sets the sender's scope transaction-locally and reads business.matters — the row is visible exactly when the 0077 can_read_matter record rule says so (firm_admin/firm_editor firm-wide, an associate only via assignment, never cross-firm). The landing then runs under the same sender identity, so even a launch that bypassed the broker cannot land on a matter the sender cannot access. One spelling, two enforcement points, nothing to drift.

Proof (CC-06 / CC-13 / CC-46)

Deliberately out of scope

Zero migrations. Zero OpenAPI drift. No new cloud capability — the path rides the existing clerk-rail queue, sender Lambda invoke, and RLS grants.