# The lawyer’s cockpit

Status: interaction design and Discord payload builders delivered; the authenticated transmit/receive bridge is not installed or running. The public `/cockpit.html` is a read-only interaction preview, not an approval application. No message has been sent by this build.

The experience is one conversation: **read the change → reply in your own words → confirm your position → review the client’s words**. The emotional moment is a lawyer confirming a reviewed position, followed by actual client drafts appearing on the board. Do not simulate that transition while the bridge is missing.

## The four moments

| Moment | Discord surface | What appears | What the lawyer does | What happens next |
|---|---|---|---|---|
| Briefing | `#lex-positions`, one thread per law-change/cohort | Law and publication/effect dates, official source, changed articles, cohort count and two examples, questions for the lawyer. Full before/after text is an attached briefing, not a 20,000-character message. | Opens the evidence, then replies in the same thread. | The reply remains bound to this briefing’s exact scope. |
| Voice or text | Same briefing thread | “Reply with your interpretation, the next action, timing, limits, and the provisions you rely on.” Voice has a visible transcription/review state; text gets the same confirmation card. | Speaks or types naturally. No JSON, model names or tool commands. | The bridge proposes a structured position; missing fields become questions. Nothing is confirmed yet. |
| Confirmation | Same thread | Original words/transcript, interpretation, recommended action, do-not-say list, tone, statute references and temporal basis, cohort/route, named lawyer and position version. | **Confirm position** or **Edit position**. A changed transcript or position must be shown again. | A transactional named confirmation unlocks only the matching eligible matters. The receipt states the actual queued count, not the number of all affected assessments. |
| Draft review | `#lex-approvals` | Client, language, purpose, exact draft/version, position/version/lawyer, citation references, checks and any objections. | **Approve vN**, **Request edit**, or **Reject**. | Approval is recorded for that exact body/version/hash. During this demo the receipt says “Approved · delivery locked”, never “Sent”. |

The twice-daily digest belongs in `#lex-digest` at 08:00 and 18:00 in the configured demonstration timezone (Europe/Lisbon for this build). `/digest` produces the same snapshot on demand. Its four sections are **Ready to approve**, **Needs you**, **Overdue**, and **Since the last digest**. The lawyer can open each draft or review the full ready set before approving the snapshot. Later arrivals are never added to that approval.

Do not show a long content dump. Show the decision first, then the evidence behind an explicit action. Do not hide the identity, scope, version, or exclusions behind an action. On a phone, the first screen should tell the lawyer what is being asked and why.

## What exists and what must be connected

All server writes must continue through `legal.db.Database` and the existing transactional guards. No schema or view change is required for the private cockpit workflow below. No service key is ever available to the browser.

| Adapter entry point | Existing implementation | Bridge work still required |
|---|---|---|
| Build the interpretation brief | `pipeline.interpretation_brief(db, change, cohorts)` | Fetch the exact persisted law change/cohorts, build `cockpit.briefing_card`, post the card and the complete brief attachment to the configured channel, create a reply thread, and persist the channel/message/thread/scope mapping in an audit event. |
| Build the pending position | `human.submit_position(...)` accepts reviewed structured JSON | Map an authorized text/voice reply to its briefing; screen text; transcribe voice with the existing authorized Whisper capability; normalize the lawyer’s own words into the five required fields; ask for missing data. The builder must not invent a legal interpretation, source article or date. |
| Confirm a position | `human.handle_interaction` → `legal_confirm_position` RPC | Validate the actor, scope and displayed version; call the existing action only after the full confirmation card was reviewed. Enqueue `legal_draft` for the actual matching impacts after the RPC succeeds. |
| Review an individual draft | `cards.draft_card`, `digest.ready`, `human.handle_interaction` | Load the current matter/position/latest verification; bind the displayed message to the exact draft hash; send the private card. Recheck the binding and `ready()` on click. |
| Request a draft edit | Existing `legal:edit:<draft_id>` marks the draft stale | First open a feedback modal. On submission, audit the lawyer’s reviewed instruction and mark stale. A feedback-aware redraft entry point still needs wiring: the current `Loop.draft` creates its own feedback list and does not accept the lawyer’s edit instruction. Do not claim that the edit was applied until the revised, reverified version exists. |
| Build and approve a digest | `digest.generate` saves `ready_draft_ids`; `human.handle_interaction` rechecks them | Post the generated snapshot, persist `discord_message_id`, supply a full read view for lengthy sections, and bind the confirmation to exactly that digest ID/membership. Update the card with actual approved/excluded counts. |
| Final send | Existing build-locked send gate | No connection to an email send action during this build. Keep the lock. A post-build delivery rehearsal is a separate operator-authorized step. |

Delivered `l2/legal/cockpit.py` builders produce briefing and position-confirmation cards. The updated `l2/legal/cards.py` produces versioned draft-review cards and digest cards with explicit review gates. They are pure payload builders: no network access, no credentials, no database mutation. Oversize approval content fails closed instead of silently omitting evidence.

## Exact receive/dispatch contract

Use the dedicated LEGAL bot’s **existing authenticated Discord gateway**, not a new public browser endpoint or a tunnel. The bot needs access only to the configured LEGAL guild/channels and the message/attachment content required for replies. Enabling privileged intents, adding credentials and installing/restarting the service remain operator steps.

For each event:

1. Validate the configured guild and channel (or a thread whose parent is the configured channel). Resolve the Discord user ID through the configured **named-lawyer allowlist**; reject unknown actors and bot-authored messages. Never take a lawyer name, cohort, channel, draft hash, or scope from free text.
2. Resolve the source message/thread through a persisted `cockpit_message_published` audit event. This mapping includes only IDs: message ID, channel ID, thread ID, law-change/cohort or route scope, object ID, version and, for a review card, draft hash. Do not recover context from a title or the currently newest object.
3. Deduplicate received interaction/message IDs. The existing approval path deduplicates on draft/interaction ID; position submission and delivery of follow-up cards need bridge-level idempotency and serialized handling per scope. Use the existing audit records for mappings/receipts; if the existing runtime cannot supply a durable, serialized dispatch mechanism, return `blocked` to Felix before adding a schema or another orchestrator.
4. Acknowledge promptly, then do model/transcription work off the interaction deadline. Discord requires an initial interaction response within three seconds; follow-ups have a limited token lifetime. Use a normal thread message for longer-running results. [Discord interaction documentation](https://docs.discord.com/developers/interactions/receiving-and-responding).
5. Dispatch only the allowlisted action below. Re-fetch the referenced objects and verify their scope/version before the mutation. Persist a successful receipt; on failure keep the card actionable for a safe retry and say what is still missing.

| Custom ID / incoming event | Required private-bridge behavior |
|---|---|
| Authorized reply to briefing thread | Text goes through screening; voice attachment goes through screening/transcription/review. Resolve the scope from the published briefing mapping. |
| `legal:brief:<law_change_id>` | Show the complete source briefing and exact cohort scope privately. This is a new presentation handler; it must not confirm anything. |
| `legal:position_edit:<position_id>` | Open an edit modal bound to the current pending position. A confirmed position is revised as a new pending version using `supersedes`, never overwritten. Show the complete new confirmation card. |
| `legal:confirm:<position_id>` | After actor, scope and displayed-content validation, normalize `{id, channel_id, user_id, custom_id}` and call `human.handle_interaction`. Normalize an allowed child thread to its verified parent channel for the existing channel check. |
| `legal:approve:<draft_id>` | Check the published hash/version still equals the stored draft, then call the existing human handler. Show “Approved · delivery locked” with lawyer, version and time. |
| `legal:edit:<draft_id>` | Show the edit modal first; do not discard the review on the initial click. On submitted feedback, audit the instruction and invoke the existing edit action, then the required feedback-aware redraft adapter. |
| `legal:reject:<draft_id>` | Capture an optional reason, then invoke the existing reject action. Persist the reason through `legal.db` and refresh the card. |
| `legal:review_digest:<digest_id>` | Render the complete ready list and the other sections, paginating if needed. Only after showing all included drafts expose the confirmation button for this snapshot. |
| `legal:digest:<digest_id>` | Revalidate the snapshot and each member through the existing handler. Report exclusions by name and reason; do not retry excluded drafts into approval. |
| `/digest` or the two scheduled jobs | Call `digest.generate` with the requested kind, post the resulting `digest_card`, save its Discord message ID with an audited update. |

Discord’s embed/component limits must be enforced before transmission. Delivered builders reject oversized decision cards; the bridge must send the full content as a reviewed attachment or paginated private view and only then offer the bound decision. Never truncate legal advice and leave an approval button below it. [Discord message/embed limits](https://docs.discord.com/developers/resources/message#embed-limits).

### Voice → confirmation, without originating advice

The attachment must belong to an authorized reply in the mapped LEGAL briefing thread. Reject unexpected types and oversize uploads before invoking transcription. Keep temporary media inside the LEGAL workspace; do not scrape an inbox or unrelated channels.

The transcription is displayed verbatim for the lawyer to correct. The normalization step is extraction, not legal research: its only evidence is the lawyer’s reviewed reply plus the supplied briefing and selected statute text. Required output: `interpretation`, `recommended_action`, `do_not_say[]`, `tone`, `statute_refs[{law,article,temporal_basis}]`. Every reference and date must be selected or explicitly confirmed by the lawyer. Missing scope, timing or references yields a question, not a guessed position.

`submit_position` currently stores its structured JSON argument as `raw_input` (and as `transcript` for voice). The bridge must preserve provenance after creation via audited updates: original screened reply in `raw_input`, corrected speech transcript in `transcript`, and structured fields in their existing columns. Record the source message/attachment ID in audit detail without logging tokens or arbitrary raw payloads. No source provenance is fabricated from the structured position.

On confirm, emit a private receipt with the actual affected/cohort count. Then queue only eligible matters for that confirmed scope. Confirmation is not permission to send. An affected assessment outside the selected cohort does not inherit this position.

## Safe public board contract

The board consumes only `vboard.v_board_columns`, `vboard.v_inbox`, `vboard.v_audit_tail`, and the operator-approved `vboard.v_reforms` and `vboard.v_positions`, using anon/publishable credentials and read operations. It does not read private base tables, draft bodies, approvals, attachments, or raw inbound mail. The new approved `v_positions` projection supplies the confirmed interpretation/action, named lawyer, version and affected count; the board displays that text verbatim. It does not mutate any table or call an RPC.

Six display lanes group the existing eight stages: Horizon/Watch; Impact; Interpret; Draft/Verify; Approve; Sent. Card detail shows the complete existing `summary`. Counts represent stage records except “matters assessed”, which deduplicates the projected `matter_ref`. The board explicitly says counts are not additive. It does not silently select a “latest” draft: the projection has no version/timestamp to justify that choice.

Motion occurs only when the **same record ID** changes its actual `column_name` across received snapshots. A movement notice names both stages. New drafts and historical impact records are distinct records; the UI does not animate one into another or erase history. Reduced-motion users receive the same textual update without motion. Polling remains available when realtime is unavailable; offline state retains its timestamp and is labelled as a saved snapshot.

Resolved during this build: Felix supplied `v_reforms` and `v_positions`, both verified with anon credentials. The header uses the newest reform by `detected_at` and the latest confirmed position by `confirmed_at`. The source record and position evidence are real. The board still contains records across all reforms: the original `v_board_columns` lacks `law_change_id` and draft lineage/version, so it cannot attribute every displayed assessment to that newest reform or show a verified causal chain to a particular draft. No such association is inferred. A future relation/filter projection would make that proof stronger.

## Rehearsal and acceptance, after the bridge is connected

1. Start with a real cached reform and a lawyer-reviewed synthetic matter. Confirm that the live board’s record and source IDs agree with the private brief. No fixture is represented as a live run.
2. Post the compact briefing and its full evidence. On a phone, the lawyer can identify the requested decision before opening the long source text.
3. Reply with a 45-second voice message; inspect/correct the actual transcript. Show the extracted confirmation card. Drafting must still be blocked at this point.
4. Confirm the actual position. Demonstrate one specific client’s draft appearing, its citations and the shared position version. Time the observed transition; do not play an unrelated animation.
5. Request one edit. Show a new version and fresh checks, and prove the old approval cannot authorize the new body.
6. Approve that exact version. Show the named audit receipt and **delivery locked**. Do not describe a populated approval table as email delivery.
7. Produce a digest, add a later draft through the real pipeline, and verify that the later arrival is excluded from the earlier snapshot’s bulk action. Unclear and escalated records must remain excluded.
8. Verify all four digest sections and actor/channel rejection. Interrupt the Mac connection: the hosted board must keep its last timestamped snapshot, not pretend the loop is still moving.
