Story 1710: Forums create + archive behind authz + RLS

Why this exists (the catastrophe it prevents). RFD-AGT-002 (D-11) makes forums create/archive the first authorized write Atlas can ever propose — the clean Transport-B proving slice. The tempting shortcut is to let Atlas reach forum mutations by naming any forum operation it likes, or to expose the full forum CRUD — including DELETE — to the agent. That is the catastrophe in a legal product: an agent with a delete verb can quietly destroy a firm's venue catalog, and an agent that can name any tool can step outside the role the human actually holds. This Story makes the forum write surface Atlas sees a closed, role-gated, reversible-only set: create and archive are proposable under settings:write; delete is unreachable; and no firm can mutate another firm's forum.

This is the pramaan-functions half of the forums slice (D-11). The sibling app Story ships the Page Card + confirm-first/undo UI; the agents Story ships the minimal eval gate. The forum mutation endpoints already exist and are firm-scoped; this Story exposes create + archive to Atlas as confirm-first proposals, keeps delete off the agent surface, and proves firm isolation on the mutation path.

What this ships

1. Two reversible forum write tools on the leash — src/shared/agent_tools/registry.py

The server-plane tool registry (Story-1651) is the closed set of tools the executor will ever run. A tool the brain names that is not registered is unreachable. This Story adds exactly two forum tools, both WRITE_PROPOSAL (never auto-executed) and both gated on settings:write — the same permission the existing /app/forums routes require.

SERVER_TOOL_REGISTRY = {
  "query_matters":         ServerToolSpec(READ,           "matters:read"),
  "global_search":         ServerToolSpec(READ,           "matters:read"),
  "propose_matter_write":  ServerToolSpec(WRITE_PROPOSAL, "matters:write"),
  "propose_forum_create":  ServerToolSpec(WRITE_PROPOSAL, "settings:write"),  # NEW
  "propose_forum_archive": ServerToolSpec(WRITE_PROPOSAL, "settings:write"),  # NEW
}
# propose_forum_delete is DELIBERATELY ABSENT -> unreachable, fails closed.

Because the registry is the one authority source, adding these two tools automatically exposes them at both the Story-1647 resolver (which authorizes a turn's intents from SERVER_TOOL_PERMISSIONS) and the Story-1651 executor (which runs/proposes them). The resolver and executor can never disagree about which tool needs which permission.

2. Archive is an update, not a verb

Per the ticket scope and D-11, "archive" is not a dedicated operation and is not a delete — it is PATCH /app/forums/{id} with availability: "archived" (and restore is the same PATCH with availability: "live"). The existing ForumService.update_forum already stamps archived_at/archived_by_user_id on the transition. Atlas's propose_forum_archive rides that same reversible update path on confirm — no new mutation surface is introduced.

3. The real write still rides the existing authorized endpoints

A WRITE_PROPOSAL tool is never executed by the agent plane: the executor returns a proposal for human confirmation and mutates nothing. When the counsel confirms, the app performs the write through its own generated mutation hooks (useAppCreateForum, useAppUpdateForum({ availability: "archived" })) against the existing POST/PATCH /app/forums routes, which carry require_permission("settings:write") and run under the caller's firm RLS. The agent gets no power beyond a human in that role.

The decisions that matter

No delete path is exposed to Atlas

Delete is v2 (D-10/D-11: v1 writes are reversible; delete is a later soft-delete + TTL design). The forum DELETE route still exists for the settings UI, but it is not a registered agent tool, so a propose_forum_delete intent — even from a caller who holds settings:write — fails closed at both the resolver and the executor as an "unknown server tool".

Atlas intentCaller roleBackend outcome
propose_forum_createholds settings:write (admin/editor)proposal — no mutation; human confirms the real write
propose_forum_archiveholds settings:writeproposal — reversible availability update on confirm
propose_forum_createfirm_member (only matters:read)denied — missing settings:write (role caps the verb)
propose_forum_deleteholds settings:writedenied — unknown server tool, fails closed

Authority is the server-resolved role matrix, not the client claim

Permissions come from identity.firm_memberships.role via identity.role_permissions — never the JWT's org_membership.permissions claim (orientation only). A firm_member resolves to {matters:read} and is denied a forum mutation no matter what a client block claims. Role caps the verb; RLS caps the rows.

Firm A cannot mutate firm B

Forum mutations are firm-scoped twice over: ForumService looks up the row under the caller's RLS session and additionally rejects a cross-firm row as not found. A firm A caller attempting to archive, rename, or delete a firm B forum gets 404 pramaan.forum.not_found, and the firm B row is verified untouched (still live, still named). The cross-firm reference invariant (a proceeding cannot point at another firm's forum) is already enforced at the DB level.

No contract drift

The registry is pure data consumed by routes that already exist; no route signature, DTO, or router declaration changed, so make openapi produces no diff in openapi/app-api.json or openapi/admin-api.json. ADR-124: no new executable boundary is introduced — the two tools ride the existing auto-instrumented resolve/execute route spans, so no manual span is added.

Verification

Pure unit tests (tests/unit/agent_tools/test_registry.py) prove forum create + archive are WRITE_PROPOSAL/settings:write with no read handler, that no forum-delete tool is registered and a delete intent fails closed, and that create is denied without settings:write. Resolver tests (tests/unit/test_router_agent_session.py) prove the turn boundary authorizes create + archive with settings:write, never delete, and denies a caller lacking the permission. Integration tests against a real DB through the full ASGI pipeline (tests/integration/test_app_agent_tools.py) prove a forum create/archive proposal returns a proposal and writes zero forum rows (a count check, including the empty-firm first run), that a firm_member is denied and the denial is audited, and that a delete intent fails closed. A forum mutation RLS test (tests/integration/test_app_forums.py) proves a firm A caller cannot archive, rename, or delete a firm B forum (404) and that the firm B row is untouched.

One sentence to remember: Atlas can propose a firm's forum create or archive only if the human holds settings:write, the real write rides the existing firm-scoped endpoints on confirm, and delete is simply not a tool Atlas has.