Metadata-Version: 2.4
Name: agent-comms-mcp
Version: 0.8.1
Summary: MCP service for permissioned, structured agent-to-agent communications
Author-email: Redesign Health <tech@redesignhealth.com>
License: MIT
Project-URL: Homepage, https://github.com/redesignhealth/agent-comms-mcp
Project-URL: Repository, https://github.com/redesignhealth/agent-comms-mcp
Project-URL: Issues, https://github.com/redesignhealth/agent-comms-mcp/issues
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: alembic==1.18.5
Requires-Dist: asyncpg==0.31.0
Requires-Dist: fastmcp==3.4.2
Requires-Dist: greenlet==3.5.4
Requires-Dist: httpx==0.28.1
Requires-Dist: py-key-value-aio[filetree]==0.4.5
Requires-Dist: pydantic==2.12.5
Requires-Dist: pyjwt==2.13.0
Requires-Dist: sqlalchemy[asyncio]==2.0.51
Requires-Dist: structlog==25.5.0
Dynamic: license-file

# agent-comms-mcp

MCP service for **permissioned, structured agent-to-agent communications**.
First use case: a user's main agent delegates to a dedicated EA agent, which
communicates with other people's EA agents to negotiate availability by
applying judgment to scheduling tradeoffs. Communications are scoped and
structured: no free text initially. See [`docs/DESIGN.md`](docs/DESIGN.md)
for the full spec (data model, permission model, message schemas). EA agent
logic lives elsewhere. This repo is only the comms layer.

## Layout

```
main.py              # FastMCP server, observability + scope-enforcement middleware
auth.py              # Okta OIDCProxy (humans) + agent-jwt JWTVerifier (agents) via MultiAuth
scopes.py            # TOOL_SCOPES catalog + fail-closed scope helpers
identity.py          # Issuer-gated JWT identity resolution (anti-impersonation guards)
observability.py     # structlog JSON events (tool_call, scope_denial, auth_flow, ...)
providers/comms.py   # Comms provider sub-server — the MCP tools (see below)
models.py            # SQLAlchemy 2.x async ORM models (agents, conversations,
                      #   participants, messages, audit_log — DESIGN.md §5)
db.py                # Async engine/session factory (DATABASE_URL, fail-fast)
schemas.py           # Pydantic message-payload schemas (all registered message types)
state_machine.py     # Conversation/participant state transitions (DESIGN.md §4, §6)
service.py           # Domain/service layer: membership rules, uniform denials, audit
exceptions.py        # Service-layer exception shapes (mapped to ToolError in providers/comms.py)
migrations/          # Alembic migrations (async env.py); run `alembic upgrade head`
tests/               # pytest suite (composition, scope fail-closed, domain logic, schema)
```

## Domain layer

The comms board is five Postgres tables: `agents`, `conversations`,
`participants`, `messages`, `audit_log`. `messages` and
`audit_log` are append-only. An agent self-provisions via `comms_register`,
then either starts a conversation (adding named targets as `invited`) or
gets invited into one. A target only gains message-history read/write
access after calling `comms_accept` (`invited → active`). Declining
(`comms_decline_invite`) is terminal and grants nothing. Task coordination
uses task message types (`task_assign`, `task_report`, `task_complete`,
`task_decline`, `task_cancel`) within ordinary conversations: task state
lives on `conversations.state` alone, with no separate table. Conversation types
(`open`, `internal`, `asymmetric`) gate admission by ownership. Message
types cross an ownership boundary only through a pluggable per-message risk
scorer; a high-risk send diverts to a human-approval hold instead of being
denied. See [`docs/DESIGN.md`](docs/DESIGN.md) §4–§9 for full details.

## MCP tool surface

Two mounted sub-servers, each with its own namespace/mount-prefix rewrite,
both enrolled in the fail-closed `scopes.TOOL_SCOPES` registry:

- **`comms`** (source of truth: `providers/comms.py`) -- e.g. `whoami` is
  exposed as `comms_whoami`. Board comms traffic: registration,
  conversations, messages, invites.
- **`proposals`** (source of truth: `providers/proposals.py`, TECH-6018
  follow-up) -- e.g. `submit` is exposed as `proposals_submit`. The
  bot-facing side of the proposal-holds pipeline (see the
  [Non-MCP HTTP routes](#non-mcp-http-routes) section below and
  docs/DESIGN.md's "proposal submission pipeline" section for the full
  contract).

### `comms` tools

| Tool | Scope | Purpose |
|---|---|---|
| `comms_whoami` | `comms:read` | Return the caller's identity, issuer, caller type, and scopes |
| `comms_register` | `comms:write` (`is_shared=True` on first registration requires no additional scope -- an agent self-declaring its OWN `is_shared` is not a privilege escalation; see docs/DESIGN.md §5) | Idempotently self-provision (or re-bind) the caller's board `Agent` row; rejects a new sibling identity under the same base token (`identity_fork_detected`) unless `confirm_new_identity=True`, and rejects a colliding `display_name` (`display_name_collision`, not bypassable by `confirm_new_identity` -- DB-enforced race-free via a `UNIQUE` partial index, see docs/DESIGN.md §5) |
| `comms_set_agent_shared` | `comms:write` (additionally requires `comms:admin` or an interactive/Okta caller) | Admin override of an existing agent's `is_shared` value, since `comms_register` freezes it against the agent's own re-registration |
| `comms_deregister_agent` | `comms:write` (additionally requires `comms:admin` or an interactive/Okta caller) | Sets an existing agent's `status="suspended"`; one-directional, no reactivate tool |
| `comms_admin_register` | `comms:write` (additionally requires `comms:admin` or an interactive/Okta caller) | On-behalf-of FIRST registration for a `sub` other than the caller's own -- never an upsert (`already_registered` if `sub` already has any board row); `owner_sub`/`owner_email` are explicit caller-supplied parameters, the one deliberate exception to owner identity always being token-derived (see docs/DESIGN.md §4/§5); same sibling-identity-fork guard as `comms_register` |
| `comms_list_agents` | `comms:read` | Paginated board directory; excludes suspended agents by default (opt in via `include_suspended=True`) |
| `comms_lookup_agent_by_email` | `comms:read` | Directory lookup by owner email; returns `{"agent": ..., "found": bool}` |
| `comms_start_conversation` | `comms:write` | Open a conversation with N target agents and post the seq-1 message; accepts an optional human-readable `name` (max 120 chars) |
| `comms_post_message` | `comms:write` | Post a typed, schema-validated message to an active conversation |
| `comms_get_conversation` | `comms:read` | Combined read: conversation + participants + messages; advances the caller's read cursor. **Breaking change (TECH-6197)**: a plain, argument-free call no longer returns full history -- it now defaults to `since = now() - 72h` (plus a 24h anchor-triggered context band immediately before it, flagged `"context": true` on those messages -- see docs/DESIGN.md's table row for the exact rule and worked example). Pass an explicit `since_seq` (continuation, unchanged behavior -- and the sole signal that suppresses the context band, even if `since` is also explicit) or an explicit old `since` (e.g. the Unix epoch, for true full history) to opt out of the new default; `since_was_defaulted` in the response flags which case applied. The in-window band and the context band are each independently capped at `MAX_MESSAGES_PER_GET_CONVERSATION` (500), so a combined response can include up to ~2x that many messages |
| `comms_get_hold_status` | `comms:read` | Poll the status of a message held for human approval (sender-only) |
| `comms_inbox` | `comms:read` | Active conversations with unread messages, plus pending invites. By default excludes the caller's own messages and fully-read conversations -- opt out per-call via `include_own_messages`/`include_read` |
| `comms_list_conversations` | `comms:read` | Paginated list, filterable by role/type/state; newest-first; excludes archived and expired conversations by default (opt in via `include_archived=True`, `include_expired=True`, or explicit `state="expired"`) |
| `comms_accept` | `comms:write` | Flip the caller's participant status `invited → active`, granting history read + posting rights |
| `comms_decline_invite` | `comms:write` | Decline a pending invite — terminal, no access is ever granted |
| `comms_invite` | `comms:write` | Invite another board agent into an active conversation (as `invited`) |
| `comms_rename_conversation` | `comms:write` | Set/replace a conversation's `name`; any `active` participant may call it, not just the owner |
| `comms_leave` | `comms:write` | Leave a conversation the caller is currently `active` in |
| `comms_archive_conversation` | `comms:write` | Archive a conversation (`archived_at`), permanently -- any CURRENT `active` participant may trigger it, not just the owner/creator; blocks `comms_invite`/`comms_post_message`/`comms_accept` afterward (specific `conversation_archived` error), also blocks approving a pending hold via the HTTP approval endpoint (hold stays `pending_human`); never hides history (`comms_get_conversation`, `comms_inbox`, `comms_get_hold_status`), but removes from default `comms_list_conversations` browse listing (recoverable via `include_archived=True`); idempotent, one-directional (no unarchive) |
| `comms_extend_conversation` | `comms:write` | Extend conversation expiry (`expires_at`) by an absolute datetime or relative days (`extend_by_days`, 1..90), rolling up to the 90-day `MAX_CONVERSATION_TTL` ceiling from now; any CURRENT `active` participant may trigger it; cannot shorten expiry; resurrects `expired` conversations back to `active`; rejects `completed`, `canceled`, or `archived` conversations; not idempotent |

### `proposals` tools

Bot-only: an interactive (Okta) caller is rejected outright, unlike every
`comms_*` tool above where interactive callers bypass scope checks. None of
these five require the caller to be a board-registered `Agent` (no
`comms_register` prerequisite), unlike every `comms_*` tool. See
`providers/proposals.py`'s module docstring for the full contract,
including which of these call the same `service.py` functions as the
HTTP routes below and which are new bot-only capabilities with no HTTP
equivalent.

| Tool | Scope | Purpose |
|---|---|---|
| `proposals_submit` | `comms:proposals:write` | Submit a proposal for a bot-initiated action needing human (or TECH-5877 auto-judge) approval; same body shape as `POST /proposals` |
| `proposals_get` | `comms:proposals:write` | Poll a single proposal's status/decision outcome by id, sender-only |
| `proposals_list_pending` | `comms:proposals:write` | List the calling bot's OWN still-`pending` proposals; new capability, no HTTP route equivalent |
| `proposals_list_history` | `comms:proposals:write` | List the calling bot's OWN already-actioned proposals; new capability, no bot-facing HTTP route equivalent (`GET /proposals/history` is the human-facing counterpart, scoped to `owner_sub` rather than the submitting bot) |
| `proposals_withdraw` | `comms:proposals:write` | Retract the calling bot's own still-`pending` proposal; same body shape as `POST /proposals/{id}/withdraw` |

## MCP resource surface

Read-only companion to the tool surface above (TECH-5903 Phase A — no
subscribe/unsubscribe yet). Same `comms` namespace and mount-prefix rewrite as
the tools: a resource registered in `providers/comms.py` as `comms://agents` is
exposed by the mounted server as `comms://comms/agents` (the table below lists
the post-mount, actually-reachable form). Enrolled in the fail-closed
`scopes.RESOURCE_SCOPES`/`scopes.RESOURCE_TEMPLATE_SCOPES` registries (exact vs.
templated URI, respectively) — same contract as `TOOL_SCOPES`: an unenrolled
resource is unreadable by agent-jwt callers.

| Resource | Scope | Purpose |
|---|---|---|
| `comms://comms/conversations/{conversation_id}` | `comms:read` | Identical read shape to `comms_get_conversation` with `since_seq=0`, but never advances the caller's read cursor (unlike the tool, for active-membership callers — neither path advances it for an `invited` caller either way) — a resource read is conventionally idempotent/cacheable and must not have that side effect. Since `since_seq=0` is always explicit here, context is never pulled (see the tool's TECH-6197 rule below) -- truncated at the same `MAX_MESSAGES_PER_GET_CONVERSATION` (500) in-window cap the tool applies, with no pagination parameter on the URI |
| `comms://comms/agents/{agent_id}/inbox` | `comms:read` | Identical read shape to `comms_inbox`. Self-only: `agent_id` must be the caller's own bare base sub or one of its `{base_sub}::` sibling identities — reading another agent's inbox is denied the same as an unknown `agent_id` |
| `comms://comms/agents` | `comms:read` | Static first page of the board directory, identical shape to `comms_list_agents`' default page (suspended agents excluded) |

See `docs/DESIGN.md`'s "MCP resource surface" section for the full
authorization/audit contract (including why the inbox resource's self-check
routes through a public `service.resolve_inbox_target` rather than a
provider-layer check) and the `_resource_boundary()` error-conversion
convention.

## Non-MCP HTTP routes

A few routes are plain Starlette routes (`mcp.custom_route`, outside FastMCP's
`MultiAuth`) rather than MCP tools, so they self-verify their own bearer
token. `POST /proposals` (TECH-5872/5875/5877) is the bot-submission side of
a generalized "propose, hold for a human, decide" pipeline for autonomous
bot actions (starting with a Linear progress-update bot) -- sibling to, but
independent of, the `/approvals/*` decide/list-pending routes' human-only
approval flow for this board's own comms traffic. It requires an agent-jwt
token carrying `comms:proposals:write`, which this route self-checks
directly (the same scope now also gates the five `proposals_*` MCP tools
above via `TOOL_SCOPES`, but `POST /proposals` itself is a non-MCP route
and isn't dispatched through that registry); `GET /proposals/pending`
reuses `/approvals/pending`'s hard interactive-only gate -- a DIFFERENT,
human-scoped listing than `proposals_list_pending` above, not a duplicate
of it. `GET /proposals/history` (TECH-6030) is `GET /proposals/pending`'s
terminal-status sibling -- same interactive-only, owner_sub-scoped gate,
but every already-decided (or bot-withdrawn) proposal instead of the
still-pending ones; likewise a DIFFERENT, human-scoped listing than
`proposals_list_history` above, not a duplicate of it. `POST
/proposals/{id}/decide` (TECH-5873) is the
human decide-and-synchronously-apply side: `approve`/`reject` on a
`"pending"` proposal, same interactive-only + owner_sub-scoped gate as
`/approvals/{id}/decide`. Approving re-checks the target hasn't drifted
since submission (`"stale"` if it has) before calling the configured
`PROPOSAL_JUDGE` plugin's `apply()`; a failed apply resolves to
`"apply_failed"` rather than an error response. Retrying an
already-`"applied"` hold is a no-op (returns the existing applied state,
no second write) -- but retrying while the hold is still `"applying"`
(another decide call, or the auto-judge, currently has it claimed and is
mid-flight on its own external round-trip) returns 409, not a no-op: this
call never got a chance to decide anything. See docs/DESIGN.md's "The
proposal submission pipeline" section for the create-time dedup key,
per-bot rate limit, pluggable auto-approval judge, and the full
decide/apply status-transition and fingerprint-contract details.

## Auth model

Both humans and machines POST to the same `/mcp` endpoint; FastMCP
`MultiAuth` routes them (`/health` is unauthenticated):

- **Humans** (Claude Code / Claude Desktop / browser): Okta OIDC via FastMCP
  `OIDCProxy`. Identity claims (email) are available to tools via
  `get_access_token().claims`. Interactive callers bypass per-tool scope
  checks via `ScopeEnforcementMiddleware`'s `TOOL_SCOPES` gate below --
  EXCEPT the five `proposals_*` tools, which reject an interactive caller
  outright (`_require_bot_sub`, `providers/proposals.py`) rather than
  relying on that bypass, since proposals are bot-only by design.
- **Agents / services**: HS256 Bearer JWT with `iss="agent-jwt"`, `sub`, and
  `scopes` claims, verified by a `JWTVerifier` keyed to `AGENT_JWT_SECRET`.
  Every tool call is then gated by the `TOOL_SCOPES` catalog in `scopes.py`.
  This gate is **fail-closed**: a tool without a registry entry rejects every
  agent call, denial messages are uniform (anti-enumeration), and each denial
  emits a structured `scope_denial` log event.

When adding a tool, enroll its mounted name (`comms_<tool>`) in
`TOOL_SCOPES` in the same PR: `tests/test_main.py` fails otherwise.

`agents.owner_sub`/`owner_email` are a bounded-staleness cache of a
consumer's own ownership system of record, kept fresh by two mechanisms
(TECH-5593): per-request write-through on any tool call that resolves the
caller's OWN agent row (not every tool — e.g. `comms_whoami` and
`comms_register` don't go through this path; only from a verified,
plugin-backed `AGENT_TOKEN_VERIFIERS` claim, never from the built-in
default's caller-supplied one), and an admin-triggered
`POST /admin/agents/reconcile-ownership` backstop (`owner_sub` only) for
agents that never make another such request. See DESIGN.md's
"Bounded-staleness ownership write-through + reconciliation" section for
the full design.

## Local development

Requires [uv](https://docs.astral.sh/uv/).

```bash
uv sync                      # install deps from uv.lock

# Start Postgres, apply migrations, then run the tests (see "Database /
# migrations" below for why the port is 55432, not 5432)
docker compose up -d postgres
export DATABASE_URL=postgresql://postgres:postgres@localhost:55432/agent_comms
uv run alembic upgrade head
uv run pytest                # tests
uv run ruff check . && uv run ruff format --check .
uv run mypy .                # strict type check

# Run the server (needs real Okta + secret config)
cp .env.example .env         # fill in values; .env is gitignored
uv run python main.py        # http://127.0.0.1:8080/mcp

# Or the full stack (server + Postgres) in Docker
docker compose up --build
```

Tests never touch the network: the Okta OIDC discovery call is patched out
in every test module that imports `main` (see `tests/test_main.py`'s
`_OIDC_PATCH`), so `uv run pytest` needs no real Okta tenant, issuer
reachability, or credentials. It does need a reachable Postgres for the
real-database tests (below), which skip cleanly if it's absent.

### Database / migrations

Postgres is provisioned by `docker-compose.yml`, mapped to **host port
55432** (container-internal port stays the standard 5432). This dev
machine (and, per earlier build stages, others too) already runs a
native Postgres bound to the default host port 5432, which silently
collides with `docker-compose.yml`'s old `5432:5432` mapping (you'd connect
to the wrong database with no error). Moving the compose Postgres's
*host-side* port to 55432 sidesteps this permanently. Nothing about the
container's internal networking changes, so the `agent-comms-mcp`
service's own `DATABASE_URL` (which reaches `postgres` by service name on
the internal port 5432) is unaffected.

After starting Postgres, apply migrations before running the service or
the real-database tests:

```bash
docker compose up -d postgres      # start Postgres only (host port 55432)
export DATABASE_URL=postgresql://postgres:postgres@localhost:55432/agent_comms
uv run alembic upgrade head        # create/upgrade the 5-table schema
```

If you still hit a conflict (e.g. something else is bound to 55432), check
with `lsof -i :55432` and either free the port or change the host-side
number in `docker-compose.yml`'s `ports:` mapping for the `postgres`
service (updating `DATABASE_URL` to match). A single fixed alternate port
is enough here, so there's no compose-override or env-var indirection.

To generate a new migration after changing `models.py`:

```bash
uv run alembic revision --autogenerate -m "<description>"
```

`tests/test_db_models.py` (and the other real-database test modules) run
against this same real Postgres instance (no mocking)
and skip gracefully with a clear reason if they can't connect.

Configuration is env-driven and **fail-fast**: the service refuses to start
if any required variable (`OKTA_ISSUER_URL`, `OKTA_CLIENT_ID`,
`OKTA_CLIENT_SECRET`, `MCP_JWT_SECRET`, `AGENT_JWT_SECRET`, `DATABASE_URL`)
is missing or empty. See `.env.example` for the full list. No secrets are committed
anywhere in this repo.

## CI and PR review

`main` is protected by a GitHub ruleset requiring 1 approving review (no
bypass actors). That review can come from either a human, or an automated
Argus code-review APPROVE verdict: `.github/workflows/auto-approve.yml`
fires on CI completion via `workflow_run` (listening for the `CI` workflow,
i.e. `.github/workflows/ci.yml`'s `name:`), or manually via
`workflow_dispatch`. Once CI (`.github/workflows/ci.yml`'s
`All checks passed` check) has passed and
the shared Argus review-storage API reports an APPROVE verdict at the PR's
exact head SHA (from a `/argus-review-loop <pr_number>` run in a Claude Code
session), it submits an approving review itself, pinned to that SHA. If Argus
hasn't approved yet, the workflow instead posts one of two comments on the
PR, and re-checks automatically when CI completes after the next push to
this PR:

- **"Argus Approval Required"** -- no Argus APPROVE verdict was found for
  this SHA yet. Run `/argus-review-loop <pr_number>` in a Claude Code session
  and push again.
- **"Argus Auto-Approval Unavailable"** -- an infra failure (bad/missing AWS
  credentials, a misconfigured Argus API key, or the Argus API being
  unreachable, returning a server error (5xx), or returning a
  malformed/unparseable response body) prevented the check from running at
  all. Running `/argus-review-loop` again will not help; this needs
  platform-team attention, or a human reviewer in the meantime.

Both comments share a single per-SHA marker, so a later run updates the
existing comment in place rather than posting a second, contradictory one.

> [!WARNING]
> **Do not switch `auto-approve.yml` to trigger on `pull_request` directly.**
> Triggering on `workflow_run` ensures that the job executes under default-branch
> ref context (`refs/heads/main`), which is required to satisfy the companion
> IAM role's branch-ref-only trust policy and prevent untrusted PR code from
> assuming the role to access production secrets.

**IAM and secret dependencies:** This workflow reuses this repo's existing
`AWS_ACCOUNT_ID` secret to assume the shared `rh-argus-gate-2` IAM role via
OIDC (`arn:aws:iam::<AWS_ACCOUNT_ID>:role/rh-argus-gate-2`), pinned directly
in the workflow file (no separate `AWS_ROLE_ARN_ARGUS_GATE` secret is needed).
The role and its branch-ref-only trust policy are provisioned via
[redesignhealth/rh-data-platform#8896](https://github.com/redesignhealth/rh-data-platform/pull/8896).
The role grants access to read the Argus review-storage API key from the
`/general/prod/api-secret-key` SSM parameter (with KMS decryption). Until that
role is applied, the workflow's AWS credential configuration step continues on
error, the "Argus Auto-Approval Unavailable" comment above is posted on the
PR, and PRs fall back to requiring a human review -- a safe, fail-closed
default.

## Observability

Structured JSON logs via `structlog` to stdout. Events follow the schema in
`observability.py` (`tool_call`, `user_active`, `auth_flow`, `auth_rejected`,
`scope_denial`). Message content and attacker-controlled claim values are
never logged.

## Deployment

The service is a standard Python HTTP process backed by PostgreSQL.

**Production (ECS): cutting a release here does NOT put your change live anywhere by
itself.** Creating a GitHub release triggers `.github/workflows/deploy.yml`, which
builds this repo's own Docker image and pushes it to dev ECR, then promotes the same
image to prod ECR (requires a `production`-environment reviewer approval). That's the
entire scope of this workflow -- **it does not dispatch anything else and does not
touch ECS.** (An earlier revision of this comment claimed it dispatched
`rh-data-platform`'s `deploy-reclaw-comms.yml`; that mechanism was removed 2026-08-17 --
see this file's own git history -- and the claim was stale. Verified 2026-09-01: no
`workflow_dispatch`/`repository_dispatch` call exists anywhere in `deploy.yml` today.)

**The image this repo builds is not the image the live board actually runs.** The
deployed `reclaw-comms-mcp-rh` ECS services (dev: `rh-reclaw-comms-dev` on
`rh-platform-dev-cluster`; prod: `rh-reclaw-comms` on `rh-platform-cluster`) run a
**derived image** -- this repo's own published image plus `redesignhealth/agent-comms-approvals`'s
`rh_comms_plugins`/`rh-auth` layer (`Dockerfile.board-derived` in that repo) -- pinned
to a specific `sha-<hex>` tag from this repo's image, not tracking any floating tag.
Getting a merged/released change here actually live requires, in order:

1. Cut the release as above (base image only, per [docs/RELEASING.md](docs/RELEASING.md)).
2. Manually trigger `agent-comms-approvals`' `deploy.yml` via `workflow_dispatch`,
   passing `base_image` = the new `sha-<hex>` tag from step 1. This builds and pushes
   the derived board image to dev ECR, then promotes it to prod ECR, in one dispatch
   (no gate between dev and prod in this step -- see that repo's own workflow
   comments). The base image's commit must be at or after `agent-comms-mcp` commit
   `5e9a375` (`AGENT_TOKEN_VERIFIERS`) or the dispatch fails closed (TECH-5689).
3. Open a Terraform PR against `redesignhealth/rh-data-platform` bumping the pinned
   image tag/floor SHA in `infrastructure/environments/{dev,prod}/reclaw_comms.tf`'s
   `tfvars` (mirroring PR #8407's pattern for that same file). Get it through CI/Argus.
4. Merge -- `dev`'s Terraform apply runs automatically on push to `main`. **Prod's
   apply does not** -- it's `workflow_dispatch`-only with `dry_run=false`, run manually.

Full step-by-step walkthrough, including why deploying the plain base image alone
already changes live message-holding behavior (before any of the wiring above lands):
`agent-comms-approvals`' [docs/TECH-5389-ROLLOUT-RUNBOOK.md](https://github.com/redesignhealth/agent-comms-approvals/blob/main/docs/TECH-5389-ROLLOUT-RUNBOOK.md)
(written for the initial TECH-5389 rollout specifically -- re-verify its "current
state" table before trusting it, it's an explicit point-in-time snapshot, not a live
dashboard -- but §2-§5's mechanics are the general, still-current pattern for any
future change here too).

**Local / self-hosted (Docker Compose):**

```bash
cp .env.example .env   # fill in real values
docker compose up --build
```

**Required environment variables** (see `.env.example`):

| Variable | Purpose |
|---|---|
| `OKTA_ISSUER_URL` | Okta OIDC issuer URL for interactive callers |
| `OKTA_CLIENT_ID` | Okta app client ID |
| `OKTA_CLIENT_SECRET` | Okta app client secret |
| `MCP_JWT_SECRET` | Signing secret for FastMCP's internal OAuth JWTs |
| `AGENT_JWT_SECRET` | Shared HS256 secret for agent JWT verification |
| `DATABASE_URL` | PostgreSQL connection string |

**Optional environment variables:**

| Variable | Purpose |
|---|---|
| `DECISION_PAGE_BASE_URL` | Base URL of the separate `agent-comms-approvals-decision-page` service. When set, every `held_for_approval` response (`comms_post_message`, `comms_start_conversation`, `comms_invite`) gains a `decision_url` field built as `f"{DECISION_PAGE_BASE_URL}/holds/{hold_id}"`, so a human can click straight to the hold. Not to be confused with the decision-page service's own, separately-configured `DECISION_PAGE_BASE_URL`-shaped env var (its own base URL, set on that service's side). Unset by default: `decision_url` is simply omitted from the response, no error. |
| `PROPOSAL_JUDGE` | Which `ProposalJudge` implementation judges/applies a submitted `proposal_holds` proposal (`POST /proposals`) -- a name from `plugins.PROPOSAL_JUDGES`, or a `"pkg.module:factory"` import path to plug in your own without forking this repo (see `docs/DESIGN.md`'s "Configuration: pluggable seams" section). Default: `escalate_all_proposals` -- accepts any kind at low priority, never fingerprints a real target, never auto-approves, and never writes anywhere. Redesign Health's Linear/GitHub-backed rules live in `agent-comms-approvals`' `rh_comms_plugins.proposal_judge` instead of this repo. |

> [!WARNING]
> **Deployment prerequisites for the `PROPOSAL_JUDGE` seam.** In deployed ECS
> environments, environment variables and credentials are provisioned via SSM
> by `rh-data-platform`'s Terraform -- a separate repo and deploy process from
> this one. Landing this repo's code does not itself activate an organization's
> real judge:
>
> - `PROPOSAL_JUDGE` must point at a real implementation (provisioned via SSM at
>   `/reclaw-comms/{env}/proposal-judge`, e.g. `rh_comms_plugins.proposal_judge:get_proposal_judge`).
>   Unset or empty, the board safely defaults to `escalate_all_proposals`, which never
>   auto-approves or applies any proposal. For `PROPOSAL_JUDGE` specifically, an empty string
>   falls back to the default. Other seams treat an empty env var as an unknown plugin name
>   and crash at boot -- this is intentional.
>   `docker-compose.yml` passes `PROPOSAL_JUDGE: ${PROPOSAL_JUDGE:-}` through from the local
>   environment so developers can set `PROPOSAL_JUDGE` in `.env` (or pass `-e PROPOSAL_JUDGE=...`)
>   for local testing with a custom judge implementation without affecting the safe default
>   when unset.
> - For Redesign Health, `agent-comms-approvals` (PR #62) must be deployed with the
>   concrete judge implementation before this service's release runs with `PROPOSAL_JUDGE`
>   configured, or the import will fail at boot. Switch ECS task definition to the derived
>   image AND set `PROPOSAL_JUDGE` in SSM in the same Terraform apply. Do not set `PROPOSAL_JUDGE`
>   while the task definition still references the base image.
> - **Behavioral regression window**: until the Terraform provisioning and deployment chain
>   completes, proposals running under `escalate_all_proposals` will never auto-approve,
>   and any human approval will resolve to `apply_failed` because the default judge does
>   not perform external writes. Note that `apply_failed` returns HTTP 200 -- a verification pass
>   that checks only the status code will incorrectly conclude the apply succeeded. Check the
>   response body's `status` field.
> - **SSM parameter removal ordering hazard (TECH-6155)**: when cleaning up legacy
>   env vars/SSM parameters (e.g. `LINEAR_API_TOKEN`, `GITHUB_TOKEN`, `PROPOSAL_OPEN_TICKET_TEAM_ALLOWLIST`),
>   always update the ECS task definition to remove the SSM parameter reference FIRST, deploy that revision,
>   and only THEN delete the parameter from SSM. ECS resolves SSM parameter ARNs at task-launch
>   time; deleting a parameter while a task definition still references it causes every
>   subsequent task launch to crash with a parameter-resolution failure.

`entrypoint.sh` runs `alembic upgrade head` automatically on every container
start, so migrations apply before the server accepts traffic.

### Minting agent-jwt tokens

`agent-comms-mcp-mint-token` (installed alongside the other console
scripts) mints agent-jwt Bearer tokens against `AGENT_JWT_SECRET`:

```bash
# Human-owned agent
agent-comms-mcp-mint-token --sub ea-agent-svc --scopes "comms:read comms:write" \
  --owner-email alice@example.com

# Self-owned agent (no human principal)
agent-comms-mcp-mint-token --sub notifier-bot --scopes comms:write --self-owned

# A bot submitting proposals via POST /proposals (TECH-5872) -- MUST be
# human-owned via --owner-email, NOT --self-owned. What --self-owned does
# depends on whether the bot is already registered:
#   - UNregistered self-owned bot: owner_sub is unresolvable, so
#     POST /proposals returns 422.
#   - Already-registered self-owned bot: POST /proposals returns 200 and
#     silently stores the proposal with owner_sub = bot_sub -- but it is
#     then permanently invisible via GET /proposals/pending, which scopes
#     to the CALLER's own Okta sub, not to a bot's.
# Either way, a proposal-submitting bot's owner_sub must resolve to an
# Okta identity that can actually call GET /proposals/pending.
agent-comms-mcp-mint-token --sub linear-progress-bot \
  --scopes comms:proposals:write --owner-email alice@example.com
```

`--owner-email`/`--self-owned` are mutually exclusive and one is required:
skipping this choice is exactly how an agent silently becomes self-owned
instead of human-owned, which later makes anything requiring that human's
approval unsatisfiable until the agent is re-minted with the correct
owner. See
[docs/TECH-5389-APPROVAL-PIPELINE.md](docs/TECH-5389-APPROVAL-PIPELINE.md)
§15 for the full rationale.

## License

MIT. See [LICENSE](LICENSE).
