diff --git a/docs/memory/architecture/api-endpoints.md b/docs/memory/architecture/api-endpoints.md
index bc8391aa2..a8784691b 100644
--- a/docs/memory/architecture/api-endpoints.md
+++ b/docs/memory/architecture/api-endpoints.md
@@ -43,6 +43,7 @@
| POST | `/api/agents/{name}/a2a/task` | Poll a remote A2A task by id on the same registered endpoint (#736). Same gates; deliberately NOT `effect_guard`-wrapped — a poll is a read, and deduping it would answer "has it finished yet?" from a snapshot of the last time it had not |
| GET | `/api/agents/{name}/metrics` | The agent's declared metrics joined to their RECORDED points, with freshness (ent#479) — the ONE read of a business number. Same URL as the `metrics.json` proxy it replaces; **store-only, no container**, so a STOPPED agent answers identically (the proxy returned "Agent must be running", making every number vanish exactly when an operator wanted its last value). `AuthorizedAgentByName` (uniform 404) **then** the metric-read gate — an agent key reads its own numbers, or another agent's while holding an `agent_permissions` grant on it (ent#727; charged to the reader's 240/min budget and audited hourly per reader/target/actor); 240/min per agent. `window ∈ {auto,24h,7d,30d,90d}` + `since`/`until`; `auto` = `max(24h, 12 x cadence)` capped at 90d, because a cadence spans 60s–1y and a fixed 24h shows a weekly metric four points. Bucketed by default (≤120/series); raw `points` only on the single-`metric=` path, truncation keeping the NEWEST. 422 `window_invalid` / `metric_undeclared` (a retired name names `include_retired`); 503 `metric_store_unavailable` + `Retry-After`. NOT 404 for an undeclared metric: the MCP classifier reads 404 as `not_authorized` per the #186 convention, which would tell an agent it lacks access to itself. Echoes the persisted D-010 `metrics.json`-superseded finding with `findings_evaluated_at`, so an empty list before the first compat run reads as *not evaluated* rather than *clean* |
| GET | `/api/agents/{name}/objectives` | The agent's objectives joined to its metrics (ent#666) — target, actual, freshness and the gap, computed in ONE place so the role card, the project view (ent#661 v3) and proactivity cannot disagree. Gate order is `/metrics`'s verbatim: `AuthorizedAgentByName` (uniform 404) → agent self-gate (403; an agent key reads only its own) → limiter on the VALIDATED name. **Its own rate knob** `OBJECTIVES_READ_RATE_LIMIT` (default 60/min, a quarter of `/metrics`; ONE bucket per agent, spelled in `services/objectives_read_budget.py` and shared with the Workspace role card's objective read, which leaves the objectives out instead of answering 429 — ent#676) because unlike that store-only read this one drives the CONTAINER: a directory listing plus up to 100 small file reads through the agent door, ≤ 2 in flight, aborting to `unavailable: agent_unreachable` on the first typed transport failure rather than driving open the circuit chat rides on — plus a 30 s wall-clock budget (`objectives_dir: "timeout"`) and a 5 s per-read timeout, because an agent-server that answers SLOWLY raises no typed error and the abort alone would not bound it. Not store-only by design — files are truth and they live in the container (E7/E13), so a stopped agent answers `unavailable: agent_stopped` with copy naming the fix, never a cached number. `gap.status ∈ {behind, on_target, ahead, off_target, not_computable}` is POSITION relative to target given direction, never pace. 503 `metric_store_unavailable` + `Retry-After: 30` is the only non-2xx below the gates — every other failure is a NAMED field on a 200 (`unavailable`, `source.*`, `findings[]`), because "this agent is stopped" is an answer, not an error. `response_model=ObjectiveJoinRead` with a key-parity test, so an additive service field fails the build instead of being silently filtered out |
+| GET | `/api/agents/{name}/canon/roles` | The seats the agent's canon defines — `/roles/*.yaml` as `{id, title, updated}` (trinity-enterprise#817), read through the same agent door and the same per-agent `objectives_read_budget` bucket as `/objectives`, same gate order (uniform 404 → agent self-gate 403 → limiter). Every empty answer is named (`unavailable: agent_stopped` / `reason: no_canon|absent|unreadable|timeout`), never a blank; `response_model=CanonRolesRead`. OSS-core; its only consumer is the gated assignments section's seat picker and drift check (`services/canon_roles_service.py`) |
| GET | `/api/agents/{name}/metrics/definitions` | The agent's DECLARED metric registry (ent#477) — what `template.yaml metrics:` declares, as the backend reconciled it, NOT what it has measured. `AuthorizedAgentByName` then the same metric-read gate as `/metrics` (ent#727 — before it, any agent key read any owner-accessible registry); `?include_retired` serves definitions the template no longer declares (kept so points stored under their name stay interpretable). Answers on a STOPPED agent (it is a DB read). Carries `declared` + a next-action `message` for the empty state, `type_conflict` for a refused type change, and the retention/cap `policy` — live values resolved through the settings chain, `enforced: true` and a `source` per knob since ent#478 |
| POST | `/api/agents/{name}/metrics/points` | Record a batch of observations of the agent's DECLARED metrics (ent#478) — the ONLY metric write path. `routers/metric_points.py`, registered before the agents router's single-segment routes. `AuthorizedAgent` + the #918 self-gate (an agent key records only as itself), then rate limit → size (2 MiB; a point cap is not a size cap — 1000 points x 10 dims x 128 chars is ~1.4 MB of legal input) → batch idempotency → validate → daily cap. **All-or-nothing**: 422 `{reason: "invalid_points", errors: [{index, metric, code, message, hint?}]}` with the frozen reason-code list (requirements §48.4). 429 splits into per-minute `rate_limited` and `daily_point_cap_exceeded` (with `Retry-After` to the next UTC midnight), because the remedies differ. DB failures are CLASSIFIED — connectivity is 503 + `Retry-After`, a content error is a non-retryable 500, since an agent told to retry a permanently unstorable batch retries forever. Every non-2xx exit after the idempotency claim goes through one `_reject` choke point that releases it, or one bad batch wedges the caller's key for 24 h. Replay returns the first snapshot with `X-Idempotent-Replay`; an in-flight duplicate is 409. A different value at an existing `(metric, ts, dims)` restates the row (ent#729): the 201 counts `recorded` / `deduplicated` / `corrected` separately (requirements §48.9) |
| POST | `/api/agents/{name}/metrics/definitions/refresh` | Re-read the agent's live `template.yaml` and reconcile its registry (ent#477). `AuthorizedAgentByName` — a **use**, not a grant (the same principal can already `pull`; an agent's own scoped key may refresh itself). The third trigger beside creation and the git hooks, because an agent that edits its own block in-container and auto-syncs PUSHES it, so no backend `pull` ever fires. **Running agent only**: 409 `agent_not_running` (the stopped-agent read spawns a throwaway container, and no request-triggered route may create one as a side effect of a read), 503 `template_unreadable` — both named on `X-Refresh-Unavailable`, and both leave the registry untouched (#2196). Idempotent by construction, so Invariant #18 does not apply |
diff --git a/docs/memory/architecture/backend.md b/docs/memory/architecture/backend.md
index 48d0fc439..b71038133 100644
--- a/docs/memory/architecture/backend.md
+++ b/docs/memory/architecture/backend.md
@@ -133,6 +133,7 @@
- `metric_access_service.py` - Who may read another agent's declared metrics (ent#727): `can_read_agent_metrics(reader, target)` — the ONE predicate, self or an `agent_permissions` grant (the `chat_with_agent` edge); ent#80 replaces its body, never adds a second gate — and `audit_cross_agent_read`, one `authorization` row per (reader, target, route, actor) per hour, marker set only after the row is written.
- `objective_join_service.py` - The objective ↔ metric join (ent#666) — the ONE place a target and an actual are put side by side, so the role card (ent#527), the project view (ent#661 v3) and proactivity (ent#605) consume it instead of each deriving a gap. Two halves in one module: the PURE half (`canon_root`, `parse_objective`, `objective_concerns`, `objective_is_active`, `select_objectives`, `resolve_direction`, `gap`, `join_objectives`) imports at module top with no store or Docker behind it, and the async half (`read_objective_files`, `read_objective_join`) goes through the agent door, because objective files live in the agent's own canon and nothing is copied platform-side (E7/E13) — a stopped agent answers `unavailable: agent_stopped`, never a cached number. **`gap.status` is position, never pace**: `behind` says the number is on the wrong side of the target now, not that the agent is late against `by`; `by` and `horizon` ride every row so a consumer can judge pace itself, and `hold` has its own arm (`on_target` within `tolerance`, else `off_target`) because a value that should be held has no good side to be on. Direction is the registry's first, falling through to the objective file's `up`/`down`/`hold` when the registry says `neutral` — that is the column DEFAULT, indistinguishable from silence, and no bundled template declares one, so registry-only would ship a feature where every gap is `not_computable` on day one; `direction_source` is what tells a declared `hold` apart from a default. **Stale is orthogonal to gap** (a stale metric keeps its gap and carries `stale: true`) and every failure is a NAMED finding with the fix attached — except a supporting-only agent's undeclared metric, which gets `metric_not_declared_here` rather than "declare it and refresh", advice it cannot take (the registry is per-agent; cross-agent reads are ent#80). The fan-out is deliberately narrow: ≤ 2 reads in flight, aborting to `agent_unreachable` on the first typed transport failure, because `AgentClient`'s circuit trips at three and it is the breaker chat rides on — and it does its own transport rather than `AgentClient.read_file`, which flattens every `AgentClientError` into `{"success": False}` and so cannot tell "this file is unreadable" from "this agent is gone". Scans 100 files, filters, then caps the OUTPUT at 20: capping the listing first hides an agent's own objective behind twenty foreign ones in a shared fleet canon
- `objectives_read_budget.py` - The per-agent budget on the objective fan-out, spelled once (ent#676): `enforce` (429) for `GET /api/agents/{name}/objectives`, `admit` (never raises) for the Workspace role card — two doors, one bucket. Detail: `feature-flows/workspace-role-card.md`.
+- `canon_roles_service.py` - `read_canon_roles(agent_name)` lists `/roles/*.yaml` with each file's id, `title` and `updated` stamp through the objective join's agent door (trinity-enterprise#817). Never raises and never blank: a stopped/missing/unreachable agent is `unavailable`, and no canon / no `roles/` / unlisted / timeout is a named `reason`. Backs `GET /api/agents/{name}/canon/roles`; the gated assignments section reads it for its seat picker and for drift checked on read (a row's recorded stamp vs the file's current one).
- `metric_registry.py` - Declared-metric reconcile service (ent#477). Two entry points: `reconcile_metric_definitions(name, template_dict, source=…)` for the creation path (the resolver already parsed the template — re-reading would be a second source that can disagree) and `refresh_from_running_agent(name, source=…)` for every live trigger, plus `spawn_refresh_from_running_agent` (the fire-and-forget container-start hook, `spawn_gitignore_merge_after_clone`'s shape). The live read is `docker_service.execute_command_in_container` with a **fixed argv** (`head -c 256K /home/developer/template.yaml`, bounded by the primitive's enforced `timeout`, #2969) — the compatibility collector's own door, capped in-container so an oversized file never crosses the socket — then the backend's hardened loader, i.e. the SAME parse creation used. `/api/template-info` is not a door for this (it does not return `metrics`) and neither is `/api/metrics` (the superseded path). **Never retires on absence of evidence** (#2196): exec failure, empty output, oversize, unparseable YAML and a stopped container all raise `RefreshUnavailable` with a named `reason` and leave the registry untouched — only a template that PARSED and carries no block retires rows. Imports `{template_metrics, database, docker_service, utils.safe_yaml}`; never `services.agent_service` (#1991)
- `agent_client/` (package, #1028 — `circuit` / `http_pool` / `client`) - HTTP client for agent container communication (chat, session, injection); hosts the transport circuit breaker — see [Circuit Breakers](execution.md#circuit-breakers-transport--dispatch-526)
- `settings_service.py` - Centralized settings retrieval (API keys, ops config, agent quotas). ent#582 adds the per-call resolvers for the first-run keys: `get_gemini_api_key` (encrypted `google_api_key` → `config.GEMINI_API_KEY`, i.e. `GEMINI_API_KEY` → `GOOGLE_API_KEY`), `get_resend_api_key`, `get_email_provider` (a Resend key saved in Settings selects Resend over `EMAIL_PROVIDER`) and `get/set/clear_email_from_address` (`email_from_address` → `SMTP_FROM`). Every platform Gemini consumer (voice, portal voice, VoIP, Brain Orb voice, Telegram transcription, image/avatar generation, feature flags, `/api/version`) calls the resolver instead of the import-frozen `config.GEMINI_API_KEY`
@@ -142,7 +143,7 @@
- `ask_service.py` - The ask sink (trinity-enterprise#611): the one path that raises an ask (`raise_ask`, the native create behind `ask_operator`; the gate path calls it with `raised_by="gate"`); a `gate` raise may pass `addressee=` to reach exactly one person, bypassing role resolution — an agent's raise that names one is a programming error, so an agent never chooses who is asked (ent#661) and the one path that ends it (`answer` / `cancel` / `bulk_cancel` / `expire`: CAS writer → audit → thin trigger → ending observers); since #3247 `raise_ask(replaces=)` also ends one of the agent's OWN pending asks (`disposed_by='agent'`, reason `replaced`) in the same locked transaction as the create (`_replace_target`, `_refuse_already_pending`, `request_id_of`). See [operating-room.md → Endings / Raising an ask](../feature-flows/operating-room.md#endings-trinity-enterprise611).
- `portal_capabilities.py` - Open-core seam for **per-person Workspace capabilities** that depend on data the core does not hold (whether an email was invited to something a module owns): a provider registry keyed by capability name, asked by the roster as `has(name, email)`. OSS registers nothing → False; **fails closed** (a raising provider, or any answer but True, is False) because a bit that errs toward True advertises routes that would refuse the caller. A seam file — grepped by `enterprise-docs-guard.yml` (ent#661)
- `turn_context.py` - Open-core seam for **lines added to a Workspace turn**: a list-based provider registry (`register_provider` / `collect(TurnContext)`) that both composers call — the 1:1 chat (`portal_chat`, on BOTH the resumed and the cold arm, beside the open-canvas line) and the room wake (`_wake_agent`, ahead of the file manifest). OSS registers one provider — the discussed-ask line (`client_portal/asks/service.py::_register_turn_context`, registered at import; trinity-enterprise#747), which answers only for a chat that is an ask's discussion chat; every other turn gets `""` and composes byte-identically. A private module may register its own. The context is the PLATFORM's: `chat_id` is the resolved chat row or room, and `internal_audience` is the caller's `is_platform` for a chat and `not room_is_user_facing(...)` for a room, so an internal-only line never reaches a turn an outside client can read. Fails open: a provider that raises is skipped. A seam file — its comments describe the mechanism only and are grepped by `enterprise-docs-guard.yml` (#1461 class) (ent#661)
-- `assignment_provider.py` - Open-core seam for **role assignments** — which human fills which business role for an agent, and which of them the agent primarily serves. OSS registers no provider → `resolve_assignment(agent_name, triggered_by)` returns `None` and the execution-context block renders byte-identically to a build without the seam; a registered module answers with a display name, a role id, the stakeholder list, and whether proactive contact is on file. **Deliberately SYNC**: `compose_system_prompt` is a plain `def` called from async handlers, so a provider that blocks on I/O here stalls the worker — providers answer from memory, never over HTTP. The seam owns the failure handling rather than delegating it (the `mfa_gate` position, not `a2a_outbound`'s): `compose_system_prompt` has NO exception handler, so an escape costs all three of its callers the execution-context block and costs one of them the platform prompt entirely. Shape validation sits beside the `try` because `try`/`except` cannot see the defect it catches — a `str` where a list was promised iterates into single characters and renders the WRONG prompt without raising, which is harder to notice than a missing line. `triggered_by` is passed through so a provider can suppress the answer for an audience that must not see staff identities; the seam carries the label and never decides that policy. A seam file — its comments describe the mechanism only and are grepped by `enterprise-docs-guard.yml` (#1461 class) (trinity-enterprise#500) A provider may also answer `people_for(agent_name, role)` (optional; trinity-enterprise#611): `resolve_role_people` returns the people who fill a role for the ask sink's `to` addressing, and `None` on any failure, so the core defaults hold.
+- `assignment_provider.py` - Open-core seam for **role assignments** — which human fills which business role for an agent, and which of them the agent primarily serves. OSS registers no provider → `resolve_assignment(agent_name, triggered_by)` returns `None` and the execution-context block renders byte-identically to a build without the seam; a registered module answers with a display name, a role id, the stakeholder list, and whether proactive contact is on file. **Deliberately SYNC**: `compose_system_prompt` is a plain `def` called from async handlers, so a provider that blocks on I/O here stalls the worker — providers answer from memory, never over HTTP. The seam owns the failure handling rather than delegating it (the `mfa_gate` position, not `a2a_outbound`'s): `compose_system_prompt` has NO exception handler, so an escape costs all three of its callers the execution-context block and costs one of them the platform prompt entirely. Shape validation sits beside the `try` because `try`/`except` cannot see the defect it catches — a `str` where a list was promised iterates into single characters and renders the WRONG prompt without raising, which is harder to notice than a missing line. `triggered_by` is passed through so a provider can suppress the answer for an audience that must not see staff identities; the seam carries the label and never decides that policy. A seam file — its comments describe the mechanism only and are grepped by `enterprise-docs-guard.yml` (#1461 class) (trinity-enterprise#500) A provider may also answer `people_for(agent_name, role)` (optional; trinity-enterprise#611): `resolve_role_people` returns the people who fill a role for the ask sink's `to` addressing, and `None` on any failure, so the core defaults hold. Two trigger-free resolvers (trinity-enterprise#811/#814) read the provider's optional `seat_for` / `served_seat_for` through `getattr` and ALWAYS return a dict, never raise: `resolve_seat(agent)` answers the seat the agent **holds** itself or **serves** through its primary (`{case: holds|serves|none, role_id, reason}`) for the readiness gate (ent#813), the objective join (ent#812) and the role card; `resolve_served_seat(agent, email)` answers the seat a run serves for one person — holds wins, the person's own seat next, the primary's last — for the prompt's `Seat this run serves` line and the seat-decision stamp. Every degraded case reads as no seat with a named reason.
- `role_addressing.py` - The ONE rule that turns an outbound object's `to:` role (`primary | approver | viewer | operator`) into people (trinity-enterprise#606): a provider's `people_for` answer first, else primary → owner, operator → nobody, approver/viewer refused (`RoleRefused`, named codes). Shared by asks (`ask_service._address` — a role several people fill delivers the one ask to each of them, trinity-enterprise#816), reports (`routers/reports._report_audience`) and messages (`routers/messages._recipient`); agent-supplied emails are still honoured and logged as deprecated (`log_email_addressing`).
- `a2a_outbound_service.py` - Outbound orchestration: kill switch → rate bounds (per-agent **and** a fleet Redis key — a per-agent limit bounds one agent, the fleet is the exhaustion path) → resolve → validate → `effect_guard` → call → `agent_activities` row. Ordering is load-bearing: bounds before resolution so a flood can't become a DNS amplifier, and validation before the guard so a refused URL never burns an effect claim (#736)
- `a2a_client.py` - The protocol client, FastAPI-free (raises `A2ACallError`, mapped 1:1 at the router). One resolution → one validated IP → **pinned for both hops**, with the registered hostname carried for `Host` + SNI + cert verification, so DNS rebinding is *closed* rather than accepted as a residual (the template-registry validator can accept it; this request carries a credential). `trust_env=False` — every other control reasons about the target IP and a proxy makes the target irrelevant — with the CA context rebuilt explicitly, since that flag also disables httpx's `SSL_CERT_FILE`/`SSL_CERT_DIR`. Wire-byte ceilings over `aiter_raw()`; any `Content-Encoding` refused, not decoded; a 3xx is a failure on both hops; a wall-clock deadline over cancellable awaits, because httpx's `read` timeout is per-read and a trickling tarpit resets it forever. The card is a **hint**: its `url` must be same-origin (default-port-equivalent — Trinity's own card emits no port) and `securitySchemes` never selects the credential, which is why #736 ships while ent#159 is blocked. Errors ride the body on **HTTP 200**, so the body is parsed for `error` even on 200 (#736). **402/403 on the RPC hop are classified BEFORE the encoding and length guards** (#3185): a CDN-gzipped or oversized "pay me" previously reported `rpc_encoding`/`rpc_too_large` — an outage, for an endpoint working perfectly — so the header is read first and the body only when identity-encoded and under a ceiling 16× tighter than the answer cap, with the outcome surviving from the status alone when it cannot be read. The in-band `x402.payment.status` check runs **before `_parse_task`** on both the send and the poll path, or a priced `input-required` reaches the agent as a prompt it polls forever; the `payment` block handed back is allowlisted, leaf-capped and scrubbed against the token **and its base64 forms and the decoded payload's long leaves**
diff --git a/docs/memory/architecture/database.md b/docs/memory/architecture/database.md
index ed760bd33..2726f1fd5 100644
--- a/docs/memory/architecture/database.md
+++ b/docs/memory/architecture/database.md
@@ -104,7 +104,8 @@ CREATE TABLE agent_sharing (
shared_with_email TEXT NOT NULL,
shared_by_id INTEGER NOT NULL,
created_at TEXT NOT NULL,
- allow_proactive INTEGER DEFAULT 0,
+ allow_proactive INTEGER DEFAULT 0, -- three-state since trinity-enterprise#817: NULL = not asked, 0 = declined, 1 = consented; code writes a new share NULL (the DDL default is unchanged, data-only migration
+ -- SQLite `proactive_consent_not_asked` / Alembic `0098_proactive_consent_not_asked` moved the legacy DEFAULT-0 rows to NULL); only 1 permits proactive contact, before and after
UNIQUE(agent_name, shared_with_email),
FOREIGN KEY (shared_by_id) REFERENCES users(id)
);
diff --git a/docs/memory/feature-flows/workspace-role-card.md b/docs/memory/feature-flows/workspace-role-card.md
index 45789f009..e05f06e67 100644
--- a/docs/memory/feature-flows/workspace-role-card.md
+++ b/docs/memory/feature-flows/workspace-role-card.md
@@ -6,8 +6,9 @@ When a companion has a role (Tandem, ent#497), the Info rail's Agent details sho
**Role** card: the role it fills, the objectives it owns or supports with each metric's
value / target / freshness, the viewer's relationship to it, and its **readiness**.
Framework §8's "organisation UI over the canon" shrunk to one agent — files are truth,
-the card is a projection. This cut ships the Role + Readiness half; the relationship
-line reads from ent#500 when it lands.
+the card is a projection. **The role is the seat on record, not the template
+(ent#811/#814):** the seat this agent serves *for the viewer*; the relationship line is
+the viewer's assignment kind (`kinds_for`).
## Flow
@@ -20,10 +21,16 @@ Info rail ──► PortalAgentRole.vue ──► GET /api/enterprise/client-por
│ (never raises — a refused read is a 200 without objectives)
▼
client_portal/role_card.build_role_card(admit_objectives=…)
- │ docker state ── not running → {unavailable, readiness(stamp)}
+ │ _relationship → seat_decision_service.reader_kind → provider.kinds_for
+ │ (DB only; answered for a stopped agent too)
+ │ docker state ── not running → {unavailable, readiness(stamp), relationship}
│ agent door (get_agent_client):
- │ read_file template.yaml → x-role {role, status, seat}, x-canon.clone_path
- │ read_file /roles/.yaml
+ │ read_file template.yaml → x-canon.clone_path, x-role.status (readiness claim only)
+ │ assignment_provider.resolve_served_seat(agent, viewer)
+ │ → {role_id, source: holds|person|primary|none}
+ │ no seat AND no x-canon AND no x-role → no card
+ │ no seat → role.error = no_seat (objectives still read)
+ │ read_file /roles/.yaml
│ db.get_agent_role_readiness (the owner's stamp)
│ admit_objectives() — only here, after the role file; any earlier
│ return (no role, stopped, role-file error) spends nothing
@@ -35,7 +42,7 @@ Info rail ──► PortalAgentRole.vue ──► GET /api/enterprise/client-por
▼
portal_objective / portal_metric / objectives_error / finding_codes
▼
- PortalRoleCard {role, seat, objectives[metrics], objectives_error,
+ PortalRoleCard {role, seat_source, objectives[metrics], objectives_error,
finding_codes, readiness, walkthrough, relationship,
can_flip_readiness}
@@ -50,9 +57,17 @@ owner ──► Mark ready (ConfirmDialog) ──► POST …/role/readiness {st
- **Never a second store.** Nothing about the role is cached or copied platform-side;
every read goes through the agent's own server, so the card can never disagree with
the file — and a failed read is *named* (`role.error`), never rendered as an empty
- role. No `x-role` → no card at all: the panel is byte-identical for every agent that
- has no role.
-- **Author-controlled input is bounded.** `x-role.role` and `x-canon.clone_path` reach
+ role. No seat, no canon and no `x-role` → no card at all: the panel is byte-identical
+ for every agent that has none of them.
+- **The seat on record, never the template (ent#811/#814, live check 2026-10-06).** The
+ card used to take its role from `x-role.role` and its "Seat ·" line from
+ `x-role.seat` (an email), so a shared companion's card said `sales-lead` while the
+ Access tab said Head of Sales. It now asks `resolve_served_seat(agent, viewer)` — the
+ same answer the agent's prompt gets: the seat it holds, else the viewer's own seat,
+ else its primary's. `seat_source` tells the viewer which, in words, never a person.
+ `x-role` survives only as the template's readiness claim and as a reason to show
+ the card at all (so a legacy companion keeps its readiness control).
+- **Author-controlled input is bounded.** The seat id and `x-canon.clone_path` reach
a file read, so both are validated before any path is built — the role id by `_ID_RE`,
the canon path by the join's own `canon_root` (one validator, not a copy); text fields
are capped. The objective bounds (scan 100 files, 20 objectives, 12 metrics each) are
diff --git a/docs/memory/requirements/core-agent.md b/docs/memory/requirements/core-agent.md
index 8a4bb5b62..760501112 100644
--- a/docs/memory/requirements/core-agent.md
+++ b/docs/memory/requirements/core-agent.md
@@ -3426,7 +3426,7 @@ to localStorage in the clear.
- **Flow**: `docs/memory/feature-flows/workspace-drafts.md`
### 5.36 Workspace — the role card in Agent details: role, objectives with metric freshness, readiness (trinity-enterprise#527)
-- **Status**: ✅ Implemented (2026-09-21) — the Role + Readiness half; the relationship line waits for ent#500. OSS-core (Workspace).
+- **Status**: ✅ Implemented (2026-09-21); the role and the relationship line read the assignment record since 2026-10-07 (ent#811/#814 follow-up, the #3271 / ent#818 live check). OSS-core (Workspace).
- **Requirement ID**: WORKSPACE_ROLE_CARD
- **GitHub Issue**: abilityai/trinity-enterprise#527 (+ #663, the 1.0 gate on who flips readiness; + #676, 2026-09-30, the objectives cut over to the ent#666 join)
- **Description**: When a companion has a role (Tandem, ent#497), the Info rail's
@@ -3437,17 +3437,24 @@ to localStorage in the clear.
- **Files are truth, read through the platform, never a second store.** Everything the
card shows about the role comes from the agent's own container on each read
(`client_portal/role_card.py`, via the agent client — the same door the Files tab
- uses): `template.yaml → x-role: {role, status, seat?}` (written by the
- `create-agent:role-companion` wizard, #511) and `x-canon.clone_path` (default
- `canon`); the role file `/roles/.yaml` and `/objectives/*.yaml`
+ uses): `template.yaml → x-canon.clone_path` (default `canon`) and `x-role.status`
+ (the template's readiness claim, #511); the role file `/roles/.yaml`
+ where `` is **the seat on record** — the seat this agent serves for the
+ viewer (`assignment_provider.resolve_served_seat`: held → the viewer's own →
+ the primary's), never `x-role.role`. `seat_source` (holds / person / primary /
+ none) is rendered as words, never a person. No seat → `role.error = no_seat`
+ with the supported objectives still listed. **Your relationship** is the
+ viewer's assignment kind (`kinds_for` on the provider; primary / approver /
+ collaborator / viewer), "no assignment recorded" only when there is none.
+ The role file `/roles/.yaml` and `/objectives/*.yaml`
(framework §3.4 grammar — `owner: role:` or `supporting_agents` names this
agent). The role file is the card's own read; the objectives and their numbers
are **not** — they come from the one objective ↔ metric join
(`objective_join_service.read_objective_join`, §50 of
`lifecycle-observability.md`), called in process with the template and the agent
client the card already holds (trinity-enterprise#676). Nothing is cached or
- copied platform-side. Every read is fail-soft and **named**: no `x-role` → no
- card at all (the panel is unchanged, AC 5); a role file that cannot be read or
+ copied platform-side. Every read is fail-soft and **named**: no seat, no canon
+ and no `x-role` → no card at all (the panel is unchanged, AC 5); a role file that cannot be read or
parsed → the card says so (`role.error`), never an empty role; a stopped agent
→ "the agent is stopped; the card reads its files when it runs"; objectives
that could not be read → one line saying so (`objectives_error`), never an
@@ -3519,11 +3526,16 @@ to localStorage in the clear.
for the verdict: a held brief is a `skipped` execution whose reason names the fix
("Held: … runs once its owner marks it ready"), a `schedule_execution_skipped` event, and
advanced run times — never a failure, a retry or an alert. **Every ambiguity fails open
- and is logged** (verdict call failed, container not running, Docker or template.yaml
- unreadable within 3 s). The template is read only to learn whether the agent is a
- companion; `x-role.status` is agent-writable and is never trusted (#663). Honest limit: an
- *unstamped* companion can take itself out of scope (drop `x-role`, or stall the read into
- fail-open); an owner's stamp is the one thing it cannot touch.
+ and is logged** (verdict call failed, stamp unreadable). `x-role.status` is
+ agent-writable and is never trusted (#663). **Who is a companion (trinity-enterprise#813,
+ ruling 2026-10-06):** asked only for an unstamped agent, and answered by the seat on
+ record (`assignment_provider.resolve_seat`), not the template — a companion is an agent
+ whose primary is assigned and holds a seat. An agent holding a seat itself (an autonomous
+ player), no primary with a seat, no seat lookup or a lookup error all read as "not a
+ companion", so the brief fires; each decision is logged with its reason. The template is
+ no longer read, so an agent cannot take itself out of scope by editing its own files —
+ the seat is admin-written. A companion template with no assigned primary therefore sends
+ its brief until its primary is assigned.
- **Rollout (ruled 2026-09-24, amended from option 1 at plan review)**: a one-time,
database-only seed on both migration tracks stamps `ready` (`changed_by =
rollout:ent#689`) for every live agent with autonomy on and an enabled, non-deleted
diff --git a/docs/memory/requirements/infrastructure.md b/docs/memory/requirements/infrastructure.md
index e031c6bb5..ff5e587ce 100644
--- a/docs/memory/requirements/infrastructure.md
+++ b/docs/memory/requirements/infrastructure.md
@@ -263,6 +263,9 @@
- **Display name, never an email.** The block reaches anonymous public-link and paid turns, so an address would be third-party PII in front of an outside audience — the answer contract has no email-shaped key at all
- **Audience-scoped.** `triggered_by` is passed to the provider so it can suppress on an outside audience; a Workspace/portal turn is labelled `public`, so the outside set is `{public, paid}` with no third label. Fail-closed against a label nobody has thought of yet
- **Consent is reported, not granted.** An assignment records who fills a role; permission to reach out lives on the separate `agent_sharing.allow_proactive` consent bit, so the line states the consent explicitly rather than leaving a bare name that reads as permission
+ - **Consent has three states** (trinity-enterprise#817, operator ruling 2026-10-06): `agent_sharing.allow_proactive` NULL = not asked, 0 = declined, 1 = consented. A new share is written NULL; the Access toggle writes 0/1; the legacy DEFAULT-0 rows were moved to NULL (data migration on both tracks — the default wrote 0 for everyone). Only 1 allows an unprompted message, before and after. Read back as `proactive_consent` on shares, the Access list and every assignment row
+ - **A shared companion serves the seat of the person each run is for** (trinity-enterprise#814, R26): `assignment_provider.resolve_served_seat(agent, email)` answers from that person's own assignment row, the agent's own seat when it holds one (holds wins), the primary's as the fallback — never raises. The prompt keeps its `Primary human` line and adds `Seat this run serves` only when the run serves a different seat (a chat user's `source_user_email`, or a scheduled brief's Workspace delivery address, which is never rendered); a seat decision (ent#638) is stamped with the served seat, and the agent's own `decided_by_role` is only the fallback. The one-primary rule, the primary display name and the `primary` address are unchanged
+ - **The seats a canon defines** are read by `GET /api/agents/{name}/canon/roles` (`services/canon_roles_service.py`, OSS-core): each `/roles/*.yaml` with id, `title` and `updated`, from the agent's container, on the objectives read budget, every empty answer named. Assignment drift is **checked on read** against it — the row records the file's `updated` stamp when a seat is set; the roster compares (current / changed / missing / unknown with a reason / no_role). No background loop
- Degrades to nothing on: no provider, a provider that raises, and a provider answering a malformed shape — the last needs its own check, because a `str` where a list was promised iterates into single characters and renders the WRONG prompt without raising
- The `trinity_execution_context_enabled` kill-switch covers these lines too (they are inside the block, not beside it)
- **Flow**: `docs/memory/feature-flows/role-assignments.md`, `docs/memory/feature-flows/execution-context-injection.md`
diff --git a/docs/memory/requirements/lifecycle-observability.md b/docs/memory/requirements/lifecycle-observability.md
index 86ca0bed3..24382727c 100644
--- a/docs/memory/requirements/lifecycle-observability.md
+++ b/docs/memory/requirements/lifecycle-observability.md
@@ -1756,10 +1756,15 @@ file-level codes are the deliberate exception**: `objective_invalid` and
carries nothing that says whose concern it is, and silence there is how a file
disappears from a read that claims to show them all.
-An objective reaches an agent two ways: `owner: role:` matching the
-template's `x-role.role` (**owned**), or the agent's own name in
-`supporting_agents` (**supporting**). An agent with no `x-role` can still
-support.
+An objective reaches an agent two ways: `owner: role:` matching a seat the
+agent owns through (**owned**), or the agent's own name in `supporting_agents`
+(**supporting**). Since trinity-enterprise#812 (operator ruling 2026-10-06) the
+seat comes from Trinity's record through `assignment_provider.resolve_seat`, not
+from the template: the seat the agent **holds** itself (that seat only), else
+the seats its assigned people hold — primary first; a shared companion owns the
+union. `x-role` is neither required nor read. An agent with no seat owns nothing
+— a `no_seat` finding says so — and can still support. `supporting_agents` is
+read as before but frozen: nothing new is built on agent names in canon.
### 50.2 The four sources, and which one owns what
@@ -1876,7 +1881,7 @@ it. Findings appear twice — flat in `findings[]` with `objective_id` / `metric
| `objective_id_invalid` | a file's `id:` is not a valid id | the **file name** is used instead and the finding says so — a *missing* `id` falls back silently, an id the author wrote and this read refused does not, because the ent#661 v3 project view keys objectives by id across agents |
| `objective_file_skipped` | a `*.yaml` in `objectives/` whose NAME is not a plain path segment (a space, a non-ASCII character) | rename it; the file is never fetched, and `source.objectives_skipped` counts them so "not there" can be told from "there under a name this read will not open" |
| `objectives_read_timeout` | the fan-out exceeded `OBJECTIVES_READ_BUDGET_SEC` | retry; the agent is answering, just too slowly — `source.objectives_dir: "timeout"`, no objective joined |
-| `role_id_invalid` | `x-role.role` is not a valid id | fix `template.yaml`; no owned objective can match until then |
+| `no_seat` | the agent neither holds a seat nor serves a person who holds one (trinity-enterprise#812; replaces `role_id_invalid`, which read `x-role`) | assign its primary with the seat they hold on the Access tab, or record the seat the agent holds; supporting objectives still show |
| `canon_path_invalid` | `x-canon.clone_path` is not a plain path | fix it; **no file is read with that path** |
Every code in this table above the file-level pair is published **only for the
@@ -1937,7 +1942,7 @@ stopped is an *answer*, not an error.
```
{agent_name, generated_at, stale_rule: "2x cadence",
- role: {id, path} | null, # from x-role; null → objectives can only be supporting
+ role: {id, path, case, seats} | null, # the seat on record (#812): case holds|serves|none
canon_root: "canon" | null,
unavailable: null | agent_stopped | agent_missing | agent_unreachable,
source: {template, objectives_dir, objectives_listed, objectives_scanned,
@@ -2030,10 +2035,10 @@ too (`isinstance(True, int)` is `True`, and `true` is not `1`).
### 50.9 Zero config
-An agent with no `x-role` and no `x-canon` costs one container-state read and
-one `template.yaml` read — **no store query at all** — and answers
-`{objectives: [], message: "no x-role or x-canon in template.yaml — nothing to
-join…"}`. Every empty state carries `message` naming the next action: the
+An agent with no `x-canon` costs one container-state read and one
+`template.yaml` read — **no store query at all** — and answers
+`{objectives: [], message: "no x-canon in template.yaml — nothing to join…"}`
+(trinity-enterprise#812: the canon declaration alone enables the join). Every empty state carries `message` naming the next action: the
`objectives/` directory that was not found, the agent that is stopped, the fact
that no active objective names this agent.
diff --git a/src/backend/client_portal/models.py b/src/backend/client_portal/models.py
index 091b7a2ba..a779e1465 100644
--- a/src/backend/client_portal/models.py
+++ b/src/backend/client_portal/models.py
@@ -688,7 +688,7 @@ class PortalRoleInfo(BaseModel):
stale: bool = False
path: Optional[str] = None
# role_file_not_found | role_file_unreadable | role_file_invalid |
- # role_id_invalid | canon_path_invalid — the card SAYS the file failed.
+ # role_id_invalid | canon_path_invalid | no_seat — the card SAYS why.
error: Optional[str] = None
@@ -704,7 +704,10 @@ class PortalRoleCard(BaseModel):
`role` is None when the agent carries no `x-role` (no card is rendered)."""
agent_name: str
role: Optional[PortalRoleInfo] = None
- seat: Optional[str] = None
+ # ent#814: where the seat on the card came from, for THIS viewer —
+ # holds (the agent's own) | person (the viewer's own) | primary | none.
+ # Never a person: the old `seat` carried x-role's email.
+ seat_source: Optional[Literal["holds", "person", "primary", "none"]] = None
objectives: list[PortalRoleObjective] = Field(default_factory=list)
# ent#676 — why `objectives` is empty when that is NOT simply true:
# objectives_rate_limited | agent_unreachable | objectives_timeout |
@@ -718,7 +721,8 @@ class PortalRoleCard(BaseModel):
finding_codes: list[str] = Field(default_factory=list)
readiness: Optional[PortalRoleReadiness] = None
walkthrough: Optional[PortalRoleWalkthrough] = None
- # ent#500's assignment kind, when it lands; None renders as "no assignment recorded".
+ # The viewer's assignment kind on the agent (`kinds_for`): primary |
+ # approver | collaborator | viewer. None renders as "no assignment recorded".
relationship: Optional[str] = None
can_flip_readiness: bool = False
# ent#689: a live seat-delivery schedule is being held because readiness is
diff --git a/src/backend/client_portal/role_card.py b/src/backend/client_portal/role_card.py
index 9ae4dd611..f68feeb8a 100644
--- a/src/backend/client_portal/role_card.py
+++ b/src/backend/client_portal/role_card.py
@@ -9,10 +9,21 @@
from the agent's own container on each request, through the same agent door
the Files tab uses — never copied platform-side:
-* `template.yaml` → `x-role: {role, status, seat?}` (the wizard writes it,
- #511) and `x-canon.clone_path` (default `canon`);
+* `template.yaml` → `x-canon.clone_path` (default `canon`), and `x-role.status`
+ for the template's own readiness claim (the wizard writes it, #511);
* `/roles/.yaml` (framework §3.4).
+**Which role is the seat on record, not the template (ent#811/#814).** The
+role is the seat this agent serves FOR THIS VIEWER
+(`assignment_provider.resolve_served_seat`): the seat it holds itself, else the
+viewer's own seat on it, else its primary's — the same answer the agent is
+given in its prompt. `x-role.role` / `x-role.seat` are no longer read; a
+template that still says `x-role: sales-lead` while the Access tab says Head
+of Sales showed two truths on one screen. `seat_source` says where the seat
+came from (holds | person | primary | none). No seat → `role.error = no_seat`,
+and supporting objectives still show. The viewer's relationship is the
+provider's `kinds_for` (ent#638's optional method).
+
**The objectives are not this module's to compute (ent#676).** What the agent
is supposed to move, where each number is and whether to believe it is the one
objective ↔ metric join (`services/objective_join_service.read_objective_join`,
@@ -23,7 +34,7 @@
stay on the operator door (the #78 auth-path invariant), and a finding crosses
as its code.
-Every read is fail-soft and NAMED: no `x-role` → no card; an unreadable or
+Every read is fail-soft and NAMED: no seat, no canon and no `x-role` → no card; an unreadable or
unparseable role file → `role.error`, never an empty role; a stopped agent →
`unavailable: agent_stopped`; objectives that could not be read →
`objectives_error`, never an empty list dressed as "this agent has none".
@@ -294,9 +305,8 @@ async def build_role_card(agent_name: str, email: str, *, is_platform: bool,
admit_objectives: Callable[[], bool]) -> dict:
"""Everything the Role card shows, or `{"role": None}` when the agent has no role.
- The relationship line reads from ent#500's assignments when they land;
- until then it is `None`, which the client renders as "no assignment
- recorded" rather than blank.
+ The relationship line is the viewer's assignment kind on this agent
+ (`kinds_for`), or `None` — rendered as "no assignment recorded", never blank.
`admit_objectives` is the router's check against the objective-read budget
(`services/objectives_read_budget`), called here — once, and only when the
@@ -314,6 +324,7 @@ async def build_role_card(agent_name: str, email: str, *, is_platform: bool,
from services import objective_join_service
stamp = _readiness_stamp(agent_name)
+ relationship = _relationship(agent_name, email)
try:
state = await docker_utils.agent_container_state_async(agent_name)
@@ -328,19 +339,22 @@ async def build_role_card(agent_name: str, email: str, *, is_platform: bool,
"role": None,
"unavailable": "agent_stopped" if state else "agent_unreachable",
"readiness": effective_readiness(None, stamp) if stamp else None,
- "relationship": None,
+ "relationship": relationship,
"can_flip_readiness": _is_owner(agent_name, email, is_platform),
}
client = get_agent_client(agent_name)
template, terr = await _read_yaml(client, "template.yaml")
xrole = template.get("x-role") if isinstance(template, dict) else None
- if not isinstance(xrole, dict):
- # No role → no card (AC 5). An unreadable template is the same answer:
- # there is nothing to show and nothing to warn about.
+ xrole = xrole if isinstance(xrole, dict) else {}
+ has_canon = isinstance(template, dict) and isinstance(template.get("x-canon"), dict)
+ served = _served_seat(agent_name, email)
+ if not (served["role_id"] or has_canon or xrole):
+ # No seat, no canon, no role declared → no card (AC 5). An unreadable
+ # template with no seat on record is the same answer: nothing to show.
return {"agent_name": agent_name, "role": None}
- role_id = _safe_id(xrole.get("role"))
+ role_id = _safe_id(served["role_id"])
# The join's validator, not a copy of it — one rule for what may reach a
# file read.
root = objective_join_service.canon_root(template)
@@ -352,7 +366,7 @@ async def build_role_card(agent_name: str, email: str, *, is_platform: bool,
"path": f"{root}/roles/{role_id}.yaml" if (root and role_id) else None,
"error": None,
},
- "seat": _text(xrole.get("seat"), 128),
+ "seat_source": served["source"],
"objectives": [],
"objectives_error": None,
"objectives_partial": False,
@@ -362,24 +376,29 @@ async def build_role_card(agent_name: str, email: str, *, is_platform: bool,
# brief nor see the schedules it comes from (the flip is platform-only too).
"brief_held": is_platform and _brief_held(agent_name, stamp),
"walkthrough": _walkthrough(agent_name, email, is_platform),
- "relationship": None,
+ "relationship": relationship,
"can_flip_readiness": _is_owner(agent_name, email, is_platform),
}
- if not role_id or not root:
- card["role"]["error"] = "role_id_invalid" if not role_id else "canon_path_invalid"
- return card
-
- role, rerr = await _read_yaml(client, card["role"]["path"])
- if rerr:
- card["role"]["error"] = f"role_file_{rerr}"
+ if not root:
+ card["role"]["error"] = "canon_path_invalid"
return card
- card["role"].update({
- "title": _text(role.get("title"), 120) or role_id,
- "mission": _text(role.get("mission")),
- "status": _text(role.get("status"), 32),
- "review_by": _text(role.get("review_by"), 32),
- "stale": is_stale(_text(role.get("review_by"), 32)) if role.get("review_by") else False,
- })
+ if not role_id:
+ # No seat on record (or one that is not a valid id): say so on the role,
+ # and still read the objectives — supporting work shows without a seat
+ # (ent#812), and the join names the same `no_seat`.
+ card["role"]["error"] = "role_id_invalid" if served["role_id"] else "no_seat"
+ else:
+ role, rerr = await _read_yaml(client, card["role"]["path"])
+ if rerr:
+ card["role"]["error"] = f"role_file_{rerr}"
+ return card
+ card["role"].update({
+ "title": _text(role.get("title"), 120) or role_id,
+ "mission": _text(role.get("mission")),
+ "status": _text(role.get("status"), 32),
+ "review_by": _text(role.get("review_by"), 32),
+ "stale": is_stale(_text(role.get("review_by"), 32)) if role.get("review_by") else False,
+ })
if not admit_objectives():
card["objectives_error"] = "objectives_rate_limited"
@@ -404,6 +423,31 @@ async def build_role_card(agent_name: str, email: str, *, is_platform: bool,
return card
+def _served_seat(agent_name: str, email: str) -> dict:
+ """The seat this agent serves for this viewer — the seam's own never-raising
+ answer, with a belt for an import-time failure."""
+ try:
+ from services import assignment_provider
+ return assignment_provider.resolve_served_seat(agent_name, email)
+ except Exception as e: # noqa: BLE001
+ logger.warning("role card: seat read failed for %s: %s", agent_name, e)
+ return {"role_id": None, "source": "none"}
+
+
+def _relationship(agent_name: str, email: str) -> Optional[str]:
+ """The viewer's assignment kind on this agent, or None. The reader gate's
+ own rule (`seat_decision_service.reader_kind`): a missing method, a raise or
+ an unknown kind all read as None."""
+ if not email:
+ return None
+ try:
+ from services import seat_decision_service
+ return seat_decision_service.reader_kind(agent_name, email.strip().lower())
+ except Exception as e: # noqa: BLE001
+ logger.warning("role card: relationship read failed for %s: %s", agent_name, e)
+ return None
+
+
def _brief_held(agent_name: str, stamp: Optional[dict]) -> bool:
"""Whether a proactive brief is being held (ent#689): the agent has a live
seat-delivery schedule and its stamp is not `ready`. Reads the stamp the
diff --git a/src/backend/db/agent_settings/sharing.py b/src/backend/db/agent_settings/sharing.py
index 972c68d38..b87060a33 100644
--- a/src/backend/db/agent_settings/sharing.py
+++ b/src/backend/db/agent_settings/sharing.py
@@ -19,6 +19,18 @@
class SharingMixin:
"""Mixin for agent sharing operations. Requires self._user_ops and ownership methods."""
+ @staticmethod
+ def proactive_consent_state(value) -> str:
+ """`agent_sharing.allow_proactive` as a statement (trinity-enterprise#817).
+
+ NULL = never asked, 0 = declined, 1 = consented. Only `consented` lets
+ an agent send unprompted; the other two are equally "no", and they are
+ told apart only so a person can see whether anyone ever asked.
+ """
+ if value is None:
+ return "not_asked"
+ return "consented" if int(value) == 1 else "declined"
+
@staticmethod
def _row_to_agent_share(row) -> AgentShare:
"""Convert an agent_sharing row to an AgentShare model."""
@@ -29,7 +41,9 @@ def _row_to_agent_share(row) -> AgentShare:
shared_by_id=row["shared_by_id"],
shared_by_email=row["shared_by_email"] or "unknown",
created_at=datetime.fromisoformat(row["created_at"]),
- allow_proactive=bool(row["allow_proactive"]) if "allow_proactive" in row.keys() else False
+ allow_proactive=bool(row["allow_proactive"]) if "allow_proactive" in row.keys() else False,
+ proactive_consent=SharingMixin.proactive_consent_state(
+ row["allow_proactive"] if "allow_proactive" in row.keys() else None),
)
def share_agent(self, agent_name: str, owner_username: str, share_with_email: str) -> Optional[AgentShare]:
@@ -69,6 +83,9 @@ def share_agent(self, agent_name: str, owner_username: str, share_with_email: st
shared_with_email=normalized_email,
shared_by_id=owner["id"],
created_at=now,
+ # ent#817: nobody has been asked yet — NULL, not the
+ # column's legacy DEFAULT 0, which reads as "declined".
+ allow_proactive=None,
)
)
# #446: once the email is on the allow-list, any pending access
@@ -178,6 +195,7 @@ def get_agent_operator_access(self, agent_name: str) -> List[dict]:
"last_active": r["last_login"],
"status": "active" if resolved else "pending",
"allow_proactive": bool(r["allow_proactive"]),
+ "proactive_consent": self.proactive_consent_state(r["allow_proactive"]),
})
return out
diff --git a/src/backend/db/migrations.py b/src/backend/db/migrations.py
index 10d224f4f..c49b251de 100644
--- a/src/backend/db/migrations.py
+++ b/src/backend/db/migrations.py
@@ -5260,6 +5260,22 @@ def _migrate_skill_gate_requests_table(cursor, conn):
"""
+def _migrate_proactive_consent_not_asked(cursor, conn):
+ """trinity-enterprise#817 — proactive consent says "not asked" apart from "no".
+
+ `agent_sharing.allow_proactive` was 0 by DEFAULT for every share, so "nobody
+ asked this person" and "this person declined" were one value. From now on
+ NULL = not asked, 0 = declined, 1 = consented, and a new share is written
+ NULL. The existing 0s are moved to NULL: the default wrote 0 for everyone,
+ so almost none of them was ever an answer (operator ruling 2026-10-06).
+
+ Data only — the column and its DDL are unchanged, so no table rebuild. Who
+ may be messaged is unchanged too: only 1 allows it, before and after.
+ Idempotent. Mirrored by Alembic 0098_proactive_consent_not_asked.
+ """
+ cursor.execute("UPDATE agent_sharing SET allow_proactive = NULL WHERE allow_proactive = 0")
+ conn.commit()
+
def _migrate_supersede_queue_flood_backlog(cursor, conn):
"""#3130: collapse the flood-alert backlog to one pending alert per agent.
@@ -5691,4 +5707,5 @@ def _migrate_agent_skill_gates(cursor, conn):
("portal_messages_execution_id", _migrate_portal_messages_execution_id),
("a2a_internal_scope", _migrate_a2a_internal_scope),
("operator_queue_replace", _migrate_operator_queue_replace),
+ ("proactive_consent_not_asked", _migrate_proactive_consent_not_asked),
]
diff --git a/src/backend/db_models.py b/src/backend/db_models.py
index 6ae79f777..fa1cb21d4 100644
--- a/src/backend/db_models.py
+++ b/src/backend/db_models.py
@@ -58,6 +58,9 @@ class AgentShare(BaseModel):
shared_by_email: str # Email or username of who shared it
created_at: datetime
allow_proactive: bool = False # Can agent send proactive messages to this user
+ # trinity-enterprise#817: consented | declined | not_asked — `allow_proactive`
+ # alone cannot tell "never asked" from "said no".
+ proactive_consent: str = "not_asked"
class AgentOperatorAccess(BaseModel):
@@ -74,6 +77,7 @@ class AgentOperatorAccess(BaseModel):
last_active: Optional[str] = None # ISO-Z last_login; None until first login
status: str # 'active' | 'pending'
allow_proactive: bool = False
+ proactive_consent: str = "not_asked" # ent#817: consented | declined | not_asked
class AgentShareRequest(BaseModel):
diff --git a/src/backend/migrations/versions/0098_proactive_consent_not_asked.py b/src/backend/migrations/versions/0098_proactive_consent_not_asked.py
new file mode 100644
index 000000000..44b747195
--- /dev/null
+++ b/src/backend/migrations/versions/0098_proactive_consent_not_asked.py
@@ -0,0 +1,32 @@
+"""trinity-enterprise#817 — proactive consent says "not asked" apart from "no".
+
+`agent_sharing.allow_proactive` was 0 by default for every share, so "never
+asked" and "declined" were one value. From now on NULL = not asked,
+0 = declined, 1 = consented, and a new share is written NULL. The existing 0s
+move to NULL: the default wrote 0 for everyone, so almost none was ever an
+answer (operator ruling 2026-10-06).
+
+Data only; the column is unchanged. Who may be messaged is unchanged: only 1
+allows it. Idempotent.
+
+Mirrors the SQLite ``proactive_consent_not_asked`` migration.
+
+Revision ID: 0098_proactive_consent_not_asked
+Revises: 0097_operator_queue_replace
+"""
+from alembic import op
+
+
+revision = "0098_proactive_consent_not_asked"
+down_revision = "0097_operator_queue_replace"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.execute("UPDATE agent_sharing SET allow_proactive = NULL WHERE allow_proactive = 0")
+
+
+def downgrade() -> None:
+ # The pre-#817 reading: everything that is not consent is 0.
+ op.execute("UPDATE agent_sharing SET allow_proactive = 0 WHERE allow_proactive IS NULL")
diff --git a/src/backend/models.py b/src/backend/models.py
index f6b46ad73..bc12d7167 100644
--- a/src/backend/models.py
+++ b/src/backend/models.py
@@ -5325,12 +5325,44 @@ class ObjectiveRead(BaseModel):
metrics_truncated: bool = False
+class CanonRoleRead(BaseModel):
+ """One seat the agent's canon defines — `/roles/.yaml`
+ (trinity-enterprise#817). `error` names why the file could not be read."""
+
+ id: str
+ title: Optional[str] = None
+ updated: Optional[str] = None
+ path: Optional[str] = None
+ error: Optional[str] = None
+
+
+class CanonRolesRead(BaseModel):
+ """The seats an agent's canon defines (trinity-enterprise#817). An empty
+ list always carries `unavailable` or `reason` and a `message`, never a blank."""
+
+ agent_name: str
+ canon_root: Optional[str] = None
+ roles: List[CanonRoleRead] = []
+ unavailable: Optional[str] = None
+ reason: Optional[str] = None
+ message: Optional[str] = None
+ truncated: bool = False
+
+
class ObjectiveRoleRead(BaseModel):
- """The role from `x-role`. `null` when the agent has none — it can still
- support an objective by name."""
+ """The seat the agent owns objectives through (trinity-enterprise#812) —
+ from Trinity's record, not from `x-role`. `null` when it has none; it can
+ still support an objective by name.
+
+ `case`: `holds` (the agent holds the seat itself), `serves` (a companion —
+ its assigned people's seats, primary first) or `none`. `seats` is every
+ seat it owns through; `id`/`path` name the first.
+ """
id: Optional[str] = None
path: Optional[str] = None
+ case: Optional[str] = None
+ seats: List[str] = []
class ObjectiveJoinSource(BaseModel):
diff --git a/src/backend/routers/agent_files.py b/src/backend/routers/agent_files.py
index bb9f074b6..8f4322479 100644
--- a/src/backend/routers/agent_files.py
+++ b/src/backend/routers/agent_files.py
@@ -13,6 +13,7 @@
from services.agent_auth import agent_httpx_client
from services import (
agent_skills_listing,
+ canon_roles_service,
metric_access_service,
metric_read_service,
objective_join_service,
@@ -40,6 +41,7 @@
set_file_sharing_status_logic,
)
from models import (
+ CanonRolesRead,
CreateFolderRequest,
FileUpdateRequest,
ObjectiveJoinRead,
@@ -447,6 +449,31 @@ async def get_agent_metrics(
)
+@router.get("/{agent_name}/canon/roles", response_model=CanonRolesRead)
+async def get_agent_canon_roles(
+ agent_name: AuthorizedAgentByName,
+ current_user: User = Depends(get_current_user),
+):
+ """The seats this agent's canon defines — `/roles/*.yaml` with each
+ file's id, `title` and `updated` stamp (trinity-enterprise#817).
+
+ Read from the agent's own container through the same door as its objectives,
+ and drawing on the same per-agent budget (`objectives_read_budget`), because
+ it is the same kind of fan-out against the same container. An agent key reads
+ only its own canon. OSS-core (operator ruling 2026-10-06).
+ """
+ if current_user.agent_name and current_user.agent_name != agent_name:
+ raise HTTPException(
+ status_code=403,
+ detail="Agent-scoped key may only read its own canon",
+ )
+ objectives_read_budget.enforce(
+ agent_name,
+ detail="Canon read rate limit exceeded for this agent.",
+ )
+ return await canon_roles_service.read_canon_roles(agent_name)
+
+
@router.get("/{agent_name}/objectives", response_model=ObjectiveJoinRead)
async def get_agent_objectives(
agent_name: AuthorizedAgentByName,
diff --git a/src/backend/routers/seat_decisions.py b/src/backend/routers/seat_decisions.py
index 48cba2789..4bb1fa711 100644
--- a/src/backend/routers/seat_decisions.py
+++ b/src/backend/routers/seat_decisions.py
@@ -29,7 +29,7 @@
from dependencies import get_current_user, assert_agent_access
from db_models import User
from services import idempotency_service, rate_limiter, schedule_seat_memory, seat_decision_service
-from services.assignment_provider import resolve_assignment
+from services.assignment_provider import resolve_assignment, resolve_served_seat
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/agents", tags=["seat-decisions"])
@@ -102,7 +102,12 @@ async def record_seat_decision(
raise HTTPException(status_code=409, detail="A record with this Idempotency-Key is in flight")
return {**(idem.snapshot or {}), "replayed": True}
- role = body.decided_by_role
+ # ent#814: the decision is stamped with the seat THIS run serves — the
+ # served person's own seat, the agent's own if it holds one, the primary's
+ # as the default. Resolved here, not taken from the agent, whose prompt
+ # used to name the primary's seat on every run; the agent's value is only
+ # the fallback when nobody's seat is on record.
+ role = resolve_served_seat(agent_name, seat_email)["role_id"] or body.decided_by_role
if not role:
assignment = resolve_assignment(agent_name, getattr(execution, "triggered_by", None))
role = (assignment or {}).get("role_id") or None
diff --git a/src/backend/services/assignment_provider.py b/src/backend/services/assignment_provider.py
index a5ce0ed29..fd5d9191b 100644
--- a/src/backend/services/assignment_provider.py
+++ b/src/backend/services/assignment_provider.py
@@ -84,6 +84,23 @@ def assignment_for(
# the value addresses a person, it is never rendered into a prompt.
# def people_for(self, agent_name: str, role: str) -> Optional[dict]: ...
+ # OPTIONAL (trinity-enterprise#811): which seat ``agent_name`` HOLDS itself or
+ # SERVES through its primary, asked with no triggering user — the readiness
+ # gate, the role card and the objective join ask on their own. Shaped as
+ # ``{"case": "holds"|"serves"|"none", "role_id": str|None, "seats": [str]}``;
+ # seat ids only, never a person. Read through ``getattr`` by
+ # :func:`resolve_seat`, which answers "no seat" for every degraded case.
+ # def seat_for(self, agent_name: str) -> Optional[dict]: ...
+
+ # OPTIONAL (trinity-enterprise#814): the seat a run serves when it is
+ # addressed to ``person_email`` — a shared companion serves several people
+ # and each run serves the one it is for. Shaped as
+ # ``{"role_id": str|None, "source": "holds"|"person"|"primary"|"none"}``;
+ # a seat id, never a person. Read through ``getattr`` by
+ # :func:`resolve_served_seat`, which falls back to :func:`resolve_seat`
+ # (the primary's seat) for a provider that predates it.
+ # def served_seat_for(self, agent_name: str, person_email: str) -> Optional[dict]: ...
+
_provider: Optional[AssignmentProvider] = None
@@ -238,3 +255,101 @@ def resolve_assignment(
)
return None
return _validated(answer, agent_name)
+
+
+#: The answer :func:`resolve_seat` gives when nothing better is known.
+SEAT_CASES = ("holds", "serves", "none")
+
+
+def _no_seat(reason: str) -> Dict[str, Any]:
+ return {"case": "none", "role_id": None, "seats": [], "reason": reason}
+
+
+def resolve_seat(agent_name: Optional[str]) -> Dict[str, Any]:
+ """Which seat ``agent_name`` holds or serves (trinity-enterprise#811).
+
+ Always a dict, never ``None`` and never a raise: the callers (the readiness
+ gate above all) must treat ambiguity as "no seat", and a caller that had to
+ tell ``None`` from a dict would be one more place to get that wrong.
+
+ ``{"case", "role_id", "seats", "reason"}``. ``reason`` is ``"provider"`` when
+ a registered provider answered, else why the answer is "no seat":
+ ``no_agent`` / ``no_provider`` / ``unsupported`` (a provider without
+ ``seat_for``) / ``no_answer`` / ``error`` / ``malformed``.
+ """
+ if not agent_name:
+ return _no_seat("no_agent")
+ provider = _provider
+ if provider is None:
+ return _no_seat("no_provider")
+ seat_for = getattr(provider, "seat_for", None)
+ if seat_for is None:
+ return _no_seat("unsupported")
+ try:
+ answer = seat_for(agent_name)
+ except Exception: # noqa: BLE001 — a provider bug must never break a gate
+ logger.warning("[assignment_provider] provider.seat_for failed for %s; "
+ "answering no seat", agent_name, exc_info=True)
+ return _no_seat("error")
+ if answer is None:
+ return _no_seat("no_answer")
+ if not isinstance(answer, Mapping):
+ return _no_seat("malformed")
+ case = answer.get("case")
+ role_id = answer.get("role_id")
+ seats = answer.get("seats")
+ if (case not in SEAT_CASES
+ or (role_id is not None and not isinstance(role_id, str))
+ or (case != "none" and not role_id)
+ or not isinstance(seats, (list, tuple))
+ or not all(isinstance(v, str) for v in seats)):
+ logger.warning("[assignment_provider] provider.seat_for returned a malformed "
+ "answer for %s; answering no seat", agent_name)
+ return _no_seat("malformed")
+ if case == "none":
+ role_id = None
+ return {"case": case, "role_id": role_id, "seats": list(seats), "reason": "provider"}
+
+
+#: Where a served seat came from (trinity-enterprise#814).
+SERVED_SOURCES = ("holds", "person", "primary", "none")
+
+
+def resolve_served_seat(agent_name: Optional[str], person_email: Optional[str]) -> Dict[str, Any]:
+ """The seat a run addressed to ``person_email`` serves (trinity-enterprise#814).
+
+ Always ``{"role_id", "source"}``, never a raise. The person's own
+ assignment row decides; the agent's own seat (``holds``) wins over it, and
+ the primary's seat is the fallback — which is also the whole answer when
+ no person is known or the provider predates ``served_seat_for``.
+ """
+ def _fallback() -> Dict[str, Any]:
+ seat = resolve_seat(agent_name)
+ if seat["case"] == "holds":
+ return {"role_id": seat["role_id"], "source": "holds"}
+ if seat["case"] == "serves":
+ return {"role_id": seat["role_id"], "source": "primary"}
+ return {"role_id": None, "source": "none"}
+
+ email = (person_email or "").strip().lower()
+ provider = _provider
+ fn = getattr(provider, "served_seat_for", None) if provider is not None else None
+ if not agent_name or not email or fn is None:
+ return _fallback()
+ try:
+ answer = fn(agent_name, email)
+ except Exception: # noqa: BLE001 — a provider bug must never break a turn
+ logger.warning("[assignment_provider] provider.served_seat_for failed for %s; "
+ "falling back to the primary's seat", agent_name, exc_info=True)
+ return _fallback()
+ if answer is None:
+ return _fallback()
+ if (not isinstance(answer, Mapping)
+ or answer.get("source") not in SERVED_SOURCES
+ or (answer.get("role_id") is not None and not isinstance(answer.get("role_id"), str))
+ or (answer.get("source") != "none" and not answer.get("role_id"))):
+ logger.warning("[assignment_provider] provider.served_seat_for returned a malformed "
+ "answer for %s; falling back to the primary's seat", agent_name)
+ return _fallback()
+ source = answer["source"]
+ return {"role_id": answer.get("role_id") if source != "none" else None, "source": source}
diff --git a/src/backend/services/canon_roles_service.py b/src/backend/services/canon_roles_service.py
new file mode 100644
index 000000000..135499924
--- /dev/null
+++ b/src/backend/services/canon_roles_service.py
@@ -0,0 +1,127 @@
+"""The seats an agent's canon defines — `/roles/*.yaml` (trinity-enterprise#817).
+
+Lists each role file's id (the file stem), `title` and `updated` stamp, read out
+of the agent's own container through the agent door the objective join and the
+role card already use. **Files are truth; this is a projection**: nothing is
+copied platform-side.
+
+Consumers: the assignments section's seat picker and header (the title and the
+`updated` stamp beside the seat), and the assignment drift check, which compares
+a row's recorded stamp with the file's current one.
+
+OSS-core (operator ruling 2026-10-06), like the objective join beside it: the
+read is edition-agnostic; only the gated assignments section uses it.
+
+Never a blank: every reason the list is empty is named — the agent is stopped,
+the template declares no canon, there is no `roles/` directory, the listing could
+not be read — so "no seats defined" is never confused with "could not look".
+The fan-out draws on the objectives read budget, the container's one bucket.
+"""
+from __future__ import annotations
+
+import asyncio
+import logging
+from typing import Any, Dict, List, Optional
+
+from services import objective_join_service as oj
+
+logger = logging.getLogger(__name__)
+
+#: A canon defines a handful of seats; a hundred is a misconfigured directory.
+MAX_ROLES = 100
+#: The whole read, listing included — well inside a page load.
+ROLES_READ_BUDGET_SEC = 15.0
+_CONCURRENCY = 5
+
+UNAVAILABLE_COPY = {
+ "agent_stopped": "this agent is stopped — its role files live in its container; start it to read them",
+ "agent_missing": "no container for this agent — recreate it to read its role files",
+ "agent_unreachable": "the agent is not answering — its role files are read from its container",
+}
+REASON_COPY = {
+ "no_canon": "no `x-canon` in template.yaml — this agent declares no canon, so it defines no seats",
+ "canon_path_invalid": "`x-canon.clone_path` in template.yaml is not a plain path — no file was read with it",
+ "absent": "the canon has no `roles/` directory",
+ "unreadable": "the canon's `roles/` directory could not be listed",
+ "timeout": "the role files took too long to read — retry",
+ "template_unreadable": "template.yaml could not be read from this agent",
+}
+
+
+def _stamp(value: Any) -> Optional[str]:
+ """`updated:` as text. YAML reads an unquoted date as a date object."""
+ if value is None:
+ return None
+ return oj._text(str(value), 64)
+
+
+def _empty(agent_name: str, *, root=None, unavailable=None, reason=None) -> Dict[str, Any]:
+ message = UNAVAILABLE_COPY.get(unavailable) or REASON_COPY.get(reason)
+ return {"agent_name": agent_name, "canon_root": root, "roles": [],
+ "unavailable": unavailable, "reason": reason, "message": message,
+ "truncated": False}
+
+
+async def read_canon_roles(agent_name: str, *, client=None) -> Dict[str, Any]:
+ """`{agent_name, canon_root, roles: [{id, title, updated, path, error}],
+ unavailable, reason, message, truncated}`. Never raises."""
+ from services import docker_utils
+ from services.agent_client import get_agent_client
+
+ try:
+ state = await docker_utils.agent_container_state_async(agent_name)
+ except Exception as e: # noqa: BLE001
+ logger.warning("canon roles: container state for %s failed: %s", agent_name, e)
+ state = None
+ if state != "running":
+ unavailable = {"stopped": "agent_stopped", "missing": "agent_missing"}.get(state, "agent_unreachable")
+ return _empty(agent_name, unavailable=unavailable)
+
+ client = client or get_agent_client(agent_name)
+ try:
+ return await asyncio.wait_for(_read(agent_name, client), ROLES_READ_BUDGET_SEC)
+ except asyncio.TimeoutError:
+ return _empty(agent_name, reason="timeout")
+ except oj._Unreachable:
+ return _empty(agent_name, unavailable="agent_unreachable")
+ except Exception as e: # noqa: BLE001 — a bad canon is never a 500
+ logger.warning("canon roles: read for %s failed: %s", agent_name, e)
+ return _empty(agent_name, reason="unreadable")
+
+
+async def _read(agent_name: str, client) -> Dict[str, Any]:
+ template, terr = await oj._read_yaml(client, "template.yaml")
+ if terr:
+ return _empty(agent_name, reason="template_unreadable")
+ if not isinstance(template, dict) or "x-canon" not in template:
+ return _empty(agent_name, reason="no_canon")
+ root = oj.canon_root(template)
+ if root is None:
+ return _empty(agent_name, reason="canon_path_invalid")
+
+ names, _skipped, source = await oj._list_objective_files(client, root, "roles")
+ if source != "read":
+ return _empty(agent_name, root=root, reason=source)
+
+ truncated = len(names) > MAX_ROLES
+ names = names[:MAX_ROLES]
+ sem = asyncio.Semaphore(_CONCURRENCY)
+
+ async def one(name: str) -> Optional[Dict[str, Any]]:
+ role_id = oj._safe_id(name.rsplit(".", 1)[0])
+ if not role_id:
+ return None
+ path = f"{root}/roles/{name}"
+ async with sem:
+ doc, err = await oj._read_yaml(client, path)
+ if err:
+ return {"id": role_id, "title": None, "updated": None, "path": path, "error": err}
+ return {"id": role_id, "title": oj._text(doc.get("title"), 120),
+ "updated": _stamp(doc.get("updated")), "path": path, "error": None}
+
+ roles = [r for r in await asyncio.gather(*(one(n) for n in names)) if r]
+ roles.sort(key=lambda r: r["id"])
+ return {"agent_name": agent_name, "canon_root": root, "roles": roles,
+ "unavailable": None, "reason": None if roles else "empty",
+ "message": None if roles else "the canon's `roles/` directory holds no role file",
+ "truncated": truncated}
diff --git a/src/backend/services/objective_join_service.py b/src/backend/services/objective_join_service.py
index 615d1f82e..b26799222 100644
--- a/src/backend/services/objective_join_service.py
+++ b/src/backend/services/objective_join_service.py
@@ -184,15 +184,37 @@ def canon_root(template: Any) -> Optional[str]:
return raw
-def objective_concerns(obj: dict, role_id: Optional[str],
- agent_name: str) -> bool:
- """An objective belongs to this agent when its role owns it or it supports it.
+def owned_seat_ids(role_id: Any) -> Tuple[str, ...]:
+ """The seat ids an agent owns objectives through, as a tuple of valid ids.
+
+ trinity-enterprise#812: an agent owns through the seat it HOLDS, or — a
+ companion — the seats its assigned people hold (a shared companion owns the
+ union). Callers pass one id, a sequence of ids, or None; invalid ids drop.
+ """
+ if role_id is None:
+ return ()
+ values = [role_id] if isinstance(role_id, str) else list(role_id)
+ out: List[str] = []
+ for v in values:
+ sid = _safe_id(v)
+ if sid and sid not in out:
+ out.append(sid)
+ return tuple(out)
+
+
+def objective_concerns(obj: dict, role_id: Any, agent_name: str) -> bool:
+ """An objective belongs to this agent when one of its seats owns it or it
+ supports it.
Framework §3.4: `owner: role:`, `supporting_agents: []`.
- An agent with no `x-role` has no owned objectives — it can still support.
+ `role_id` is one seat id or several (see `owned_seat_ids`). An agent with no
+ seat has no owned objectives — it can still support.
+
+ `supporting_agents` is read as before but FROZEN (trinity-enterprise#812):
+ it puts Trinity agent names into canon, and nothing new is built on it.
"""
owner = _text(obj.get("owner"), 128) or ""
- if role_id and owner == f"role:{role_id}":
+ if any(owner == f"role:{sid}" for sid in owned_seat_ids(role_id)):
return True
return agent_name in (obj.get("supporting_agents") or [])
@@ -470,7 +492,7 @@ def parse_objective(
def select_objectives(
objectives: List[Dict[str, Any]],
*,
- role_id: Optional[str],
+ role_id: Any,
agent_name: str,
) -> Tuple[List[Dict[str, Any]], bool]:
"""(the ones this agent answers for, truncated) — active only, capped last.
@@ -510,7 +532,7 @@ def join_objectives(
latest_by_name: Dict[str, Dict[str, Any]],
*,
agent_name: str,
- role_id: Optional[str],
+ role_id: Any,
served: Optional[Dict[str, Dict[str, Any]]] = None,
) -> Dict[str, Any]:
"""The pure join. Every unit test drives this; nothing here does I/O.
@@ -530,6 +552,7 @@ def join_objectives(
through here and there is no second one to disagree with it.
"""
by_name = {d["name"]: d for d in definitions if d.get("name")}
+ owned_owners = {f"role:{sid}" for sid in owned_seat_ids(role_id)}
findings: List[Dict[str, Any]] = []
rows_out: List[Dict[str, Any]] = []
summary = _new_summary()
@@ -547,7 +570,7 @@ def join_objectives(
else:
seen_ids[obj_id] = obj["path"]
- owned = bool(role_id) and (obj.get("owner") or "") == f"role:{role_id}"
+ owned = (obj.get("owner") or "") in owned_owners
supporting = agent_name in (obj.get("supporting_agents") or [])
metrics_out = []
@@ -860,8 +883,9 @@ async def _read_yaml(client, path: str, *,
async def _list_objective_files(
- client, root: str) -> Tuple[List[str], List[str], str]:
- """(file names, refused names, source code) for `/objectives`.
+ client, root: str, subdir: str = "objectives") -> Tuple[List[str], List[str], str]:
+ """(file names, refused names, source code) for `/` —
+ `objectives` here, `roles` for `canon_roles_service` (trinity-enterprise#817).
The agent-server listing is RECURSIVE with no depth cap, so only top-level
`type: file` entries are taken; nested folders are ignored. A 404 is
@@ -875,7 +899,7 @@ async def _list_objective_files(
fetch".
"""
response = await _agent_get(
- client, f"/api/files?path=/home/developer/{root}/objectives")
+ client, f"/api/files?path=/home/developer/{root}/{subdir}")
if response is None:
return [], [], "unreadable"
status = getattr(response, "status_code", 0)
@@ -1213,30 +1237,43 @@ async def read_objective_join(
agent_name, now, source=source,
message=(
"template.yaml could not be read from this agent "
- f"({terr}) — it is where `x-role` and `x-canon` say which "
- "role this agent fills and where its canon lives"))
+ f"({terr}) — it is where `x-canon` says where this agent's "
+ "canon lives"))
else:
source["template"] = "read"
- xrole = template.get("x-role") if isinstance(template, dict) else None
+ # trinity-enterprise#812 (operator ruling 2026-10-06): the canon
+ # declaration alone enables the join. `x-role` is neither required nor read;
+ # the seat comes from Trinity's record — the one the agent HOLDS, or the
+ # seats its assigned people hold (a companion).
has_canon = isinstance(template, dict) and "x-canon" in template
findings: List[Dict[str, Any]] = []
- if not isinstance(xrole, dict) and not has_canon:
- # Zero config: no role, no canon, nothing to join — and NO store read.
+ if not has_canon:
+ # Zero config: no canon, nothing to join — and NO store read.
return _empty(
agent_name, now, source=source,
- message=("no `x-role` or `x-canon` in template.yaml — nothing to "
- "join. An objective is a canon file that names a metric; "
- "declare the role this agent fills to read them here."))
+ message=("no `x-canon` in template.yaml — nothing to join. An "
+ "objective is a canon file that names a metric; declare "
+ "where this agent's canon lives to read them here."))
- role_id = _safe_id(xrole.get("role")) if isinstance(xrole, dict) else None
- if isinstance(xrole, dict) and xrole.get("role") and not role_id:
+ from services.assignment_provider import resolve_seat
+
+ seat = resolve_seat(agent_name)
+ if seat["case"] == "holds":
+ owned = owned_seat_ids(seat["role_id"])
+ else:
+ owned = owned_seat_ids([seat["role_id"]] + list(seat["seats"])
+ if seat["role_id"] else seat["seats"])
+ role_id = owned[0] if owned else None
+ if not owned:
findings.append(_finding(
- "role_id_invalid",
- f"`x-role.role` in template.yaml is not a valid id, so no owned "
- f"objective can be matched for `{agent_name}` — ids are letters, "
- "digits, dot, dash and underscore.",
+ "no_seat",
+ f"`{agent_name}` neither holds a seat nor serves a person who holds "
+ "one, so it owns no objective — assign its primary (with the seat "
+ "they hold) on the agent's Access tab, or record the seat the "
+ "agent holds itself. Objectives that name it in "
+ "`supporting_agents` still show.",
))
root = canon_root(template)
@@ -1249,17 +1286,19 @@ async def read_objective_join(
))
return _empty(
agent_name, now, source=source, findings=findings,
- role={"id": role_id, "path": None},
+ role={"id": role_id, "path": None, "case": seat["case"], "seats": list(owned)},
message=("`x-canon.clone_path` is not a readable path — fix it in "
"template.yaml"))
role = {
"id": role_id,
"path": f"{root}/roles/{role_id}.yaml" if role_id else None,
+ "case": seat["case"],
+ "seats": list(owned),
}
files = await read_objective_files(
- client, root, agent_name=agent_name, role_id=role_id)
+ client, root, agent_name=agent_name, role_id=owned)
source.update(files["source"])
findings.extend(files["findings"])
@@ -1305,7 +1344,7 @@ async def read_objective_join(
joined = join_objectives(
objectives, definitions, latest,
- agent_name=agent_name, role_id=role_id, served=served)
+ agent_name=agent_name, role_id=owned, served=served)
return {
"agent_name": agent_name,
diff --git a/src/backend/services/platform_prompt_service.py b/src/backend/services/platform_prompt_service.py
index 9566ecc0e..f01c24bf6 100644
--- a/src/backend/services/platform_prompt_service.py
+++ b/src/backend/services/platform_prompt_service.py
@@ -748,6 +748,16 @@ class ExecutionContext:
# are in all (the rest are named as a count). Auto-filled from the DB.
pending_asks: Optional[List[dict]] = None
pending_asks_total: Optional[int] = None
+ # trinity-enterprise#814: the person a scheduled seat run is addressed to
+ # (ent#637 — the Workspace delivery address on the execution row). NEVER
+ # rendered: it only picks which seat `role_id` names. A chat turn uses
+ # `source_user_email` for the same purpose.
+ served_person_email: Optional[str] = None
+ # trinity-enterprise#814: the seat THIS run serves when it is not the
+ # primary's (a shared companion working for someone else, or an agent that
+ # holds a seat itself). Rendered as its own line; the `Primary human` line
+ # and its role stay the primary's.
+ served_role_id: Optional[str] = None
@staticmethod
def derive_mode(triggered_by: Optional[str]) -> str:
@@ -1005,6 +1015,9 @@ def build_execution_context(ctx: ExecutionContext) -> str:
if assignment:
lines.append(f"- **Primary human**: {assignment}")
+ served = _sanitize_field(ctx.served_role_id, max_len=MAX_ROLE_ID_LEN)
+ if served and served != _sanitize_field(ctx.role_id, max_len=MAX_ROLE_ID_LEN):
+ lines.append(f"- **Seat this run serves**: {served}")
stakeholders = _render_stakeholders(ctx)
if stakeholders:
lines.append(f"- **Stakeholders**: {stakeholders}")
@@ -1066,6 +1079,30 @@ def _resolve_assignment(
return resolve_assignment(agent_name, triggered_by) or {}
+def _served_role(ctx: ExecutionContext) -> Optional[str]:
+ """The seat THIS run serves, when it is not the primary's (trinity-enterprise#814).
+
+ A shared companion serves several people; the provider's answer carries the
+ primary's seat. When the run is addressed to a person — the chat user, or
+ the Workspace a brief is delivered to — their own seat is the one served,
+ and the agent's own seat when it holds one. Asked only when the provider
+ already answered, so an audience it suppressed still renders nothing.
+ """
+ person = ctx.served_person_email or ctx.source_user_email
+ if not person:
+ return None
+ try:
+ from services.assignment_provider import resolve_served_seat
+
+ served = resolve_served_seat(ctx.agent_name, person)
+ except Exception as e: # noqa: BLE001 — the line degrades, never the turn
+ logger.debug(f"_served_role({ctx.agent_name}) failed: {e}")
+ return None
+ if served.get("source") in ("holds", "person"):
+ return served.get("role_id")
+ return None
+
+
def _resolve_ended_asks(agent_name: Optional[str]) -> List[dict]:
"""This agent's asks that ended in the last day, newest first. Empty on any
failure — the line is omitted, never the turn (trinity-enterprise#611)."""
@@ -1180,6 +1217,9 @@ def compose_system_prompt(
if ctx.pending_asks is not None
else _resolve_pending_asks(ctx.agent_name)
)
+ served_role = (
+ _served_role(ctx) if assignment and ctx.served_role_id is None else None
+ )
ctx = replace(
ctx,
collaborators=(
@@ -1219,6 +1259,9 @@ def compose_system_prompt(
),
pending_asks=pending,
pending_asks_total=pending_total,
+ served_role_id=(
+ ctx.served_role_id if ctx.served_role_id is not None else served_role
+ ),
)
block = build_execution_context(ctx)
if block:
diff --git a/src/backend/services/role_readiness_gate.py b/src/backend/services/role_readiness_gate.py
index 406ae04a3..3816a1e89 100644
--- a/src/backend/services/role_readiness_gate.py
+++ b/src/backend/services/role_readiness_gate.py
@@ -8,14 +8,20 @@
Two properties are load-bearing:
* **The owner's stamp is the only authority.** `x-role.status` in template.yaml
- is written by the agent itself, so it is never read as a verdict. The template
- is read only to learn whether the agent declares a role at all — a companion.
- Honest limit: an UNSTAMPED agent can therefore take itself out of scope (drop
- `x-role`, or stall the read into fail-open). A stamp it cannot touch: once an
- owner has stamped `calibrating`, nothing the agent writes releases the brief.
+ is written by the agent itself, so it is never read as a verdict. Once an owner
+ has stamped `calibrating`, nothing the agent writes releases the brief.
* **Every ambiguity fails open** (#1638: never mute working behaviour on an
- ambiguous read). Stamp read failed, container not running, Docker unreadable,
- template unreadable within the bound → the brief fires, and the reason is logged.
+ ambiguous read). Stamp read failed, no seat lookup, a lookup error → the brief
+ fires, and the reason is logged.
+
+**Who is a companion (trinity-enterprise#813, ruling 2026-10-06).** Asked only
+for an UNSTAMPED agent: an agent whose primary is assigned and holds a seat —
+`assignment_provider.resolve_seat` answers `serves`. The template is no longer
+read for it (`x-role` stopped being truth), so an agent can no longer take itself
+out of scope by editing its own files: the seat is admin-written. An agent that
+holds a seat ITSELF is an autonomous player, not a companion; no primary, no
+provider or a lookup error all read as "not a companion", so the brief fires as
+it did before.
Grandfathering is not here: a one-time data seed (`role_readiness_rollout_seed`,
both migration tracks) stamped every agent with a live seat brief at deploy.
@@ -33,10 +39,6 @@
#: the role card reads it and says "carried over", never "by ".
ROLLOUT_CHANGED_BY = "rollout:ent#689"
-#: The template read happens on the scheduler's dispatch path; the scheduler's
-#: own call is bounded at 5 s, so this must finish well inside it.
-TEMPLATE_READ_TIMEOUT_SECONDS = 3.0
-
class AgentNotFound(Exception):
"""No ownership row for the agent — the route answers 404 (the scheduler fails open)."""
@@ -57,8 +59,10 @@ def held_reason(agent_name: str) -> str:
)
-def decide(agent_name: str, stamp: Optional[dict], companion: Optional[bool]) -> Verdict:
- """The rule, pure. `companion` is None when it could not be established."""
+def decide(agent_name: str, stamp: Optional[dict], companion: Optional[bool],
+ why: str = "") -> Verdict:
+ """The rule, pure. `companion` is None when it could not be established;
+ `why` is the seat reason behind a "not a companion" (#813)."""
if stamp and stamp.get("status"):
if stamp["status"] == "ready":
return Verdict(True, "stamped ready", "stamp")
@@ -66,7 +70,7 @@ def decide(agent_name: str, stamp: Optional[dict], companion: Optional[bool]) ->
if companion is None:
return Verdict(True, "readiness could not be established — firing (fail open)", "fail_open")
if not companion:
- return Verdict(True, "not a companion (no x-role)", "not_companion")
+ return Verdict(True, f"not a companion ({why or 'no seat'})", "not_companion")
return Verdict(False, held_reason(agent_name), "unstamped_companion")
@@ -122,40 +126,24 @@ def briefs_held_for_list(agents: list, readiness_by_name: dict) -> set:
}
-async def _is_companion(agent_name: str) -> Optional[bool]:
- """Whether template.yaml declares `x-role` — True / False, or None when that
- cannot be read right now (not running, Docker unreadable, slow, unparsable)."""
- from services import docker_utils
- from services.agent_client import get_agent_client
- from utils.safe_yaml import load_template_yaml
+def companion_from_seat(agent_name: str) -> tuple:
+ """(is it a companion, why) from the seat on record (trinity-enterprise#813).
- try:
- state = await docker_utils.agent_container_state_async(agent_name)
- except Exception as e: # noqa: BLE001
- logger.warning("[ent#689] container state for %s unreadable (%s) — fail open", agent_name, e)
- return None
- if state != "running":
- return None
- try:
- res = await asyncio.wait_for(
- get_agent_client(agent_name).read_file("template.yaml", timeout=TEMPLATE_READ_TIMEOUT_SECONDS),
- TEMPLATE_READ_TIMEOUT_SECONDS + 0.5,
- )
- except Exception as e: # noqa: BLE001 — TimeoutError and circuit-open included
- logger.warning("[ent#689] template.yaml for %s unreadable (%r) — fail open", agent_name, e)
- return None
- if not res or not res.get("success"):
- return None
- if res.get("not_found") or res.get("content") is None:
- return False # no template at all: not a companion
- try:
- data = load_template_yaml(res["content"])
- except Exception as e: # noqa: BLE001
- logger.warning("[ent#689] template.yaml for %s unparsable (%s) — fail open", agent_name, e)
- return None
- if not isinstance(data, dict):
- return None
- return isinstance(data.get("x-role"), dict)
+ A companion is an agent whose primary is assigned and holds a seat. Every
+ other answer — the agent holds a seat itself, no primary with a seat, no
+ seat lookup, a lookup error — is "not a companion", stated with its reason.
+ Never raises (`resolve_seat` never does).
+ """
+ from services.assignment_provider import resolve_seat
+
+ seat = resolve_seat(agent_name)
+ if seat["reason"] != "provider":
+ return False, f"seat lookup unavailable: {seat['reason']}"
+ if seat["case"] == "serves":
+ return True, f"its primary holds the seat {seat['role_id']}"
+ if seat["case"] == "holds":
+ return False, f"it holds the seat {seat['role_id']} itself"
+ return False, "no primary with a seat"
async def brief_readiness(agent_name: str) -> Verdict:
@@ -169,8 +157,12 @@ async def brief_readiness(agent_name: str) -> Verdict:
except Exception as e: # noqa: BLE001
logger.warning("[ent#689] readiness stamp for %s unreadable (%s) — fail open", agent_name, e)
return Verdict(True, "readiness could not be established — firing (fail open)", "fail_open")
- companion = None if stamp else await _is_companion(agent_name)
- verdict = decide(agent_name, stamp, companion)
+ companion, why = (None, "") if stamp else companion_from_seat(agent_name)
+ verdict = decide(agent_name, stamp, companion, why)
if verdict.basis == "fail_open":
logger.warning("[ent#689] %s: %s", agent_name, verdict.reason)
+ elif not stamp:
+ # #813: the companion decision, with the reason it took.
+ logger.info("[ent#813] %s: %s → %s", agent_name, why,
+ "held" if not verdict.fire else "fires")
return verdict
diff --git a/src/backend/services/seat_decision_service.py b/src/backend/services/seat_decision_service.py
index 9db30d9b4..525b41afb 100644
--- a/src/backend/services/seat_decision_service.py
+++ b/src/backend/services/seat_decision_service.py
@@ -60,6 +60,11 @@
PROMPT_BLOCK_BYTES = 1500
_SLUG_RE = re.compile(r"\A[a-z0-9][a-z0-9_-]{0,63}\Z")
+#: A canon role id, lowercased — `objective_join_service._ID_RE` (dots allowed:
+#: `/roles/head.of.sales.yaml` is a valid seat). Since ent#814 the seat on
+#: record wins over the agent's word here, so the check must accept every id the
+#: Access tab can put on record, or a dotted seat refuses every decision with 422.
+_ROLE_ID_RE = re.compile(r"\A[a-z0-9][a-z0-9._-]{0,63}\Z")
_DATE_RE = re.compile(r"\A\d{4}-\d{2}-\d{2}\Z")
CANON_PROPOSAL_HINT = (
@@ -192,8 +197,8 @@ def validate_record(payload: dict, *, today: Optional[date] = None) -> dict:
if ask_class and not _SLUG_RE.match(ask_class):
problems["ask_class"] = "ask_class must be a slug (a-z, 0-9, -, _; 64 max)"
role = _clean(payload.get("decided_by_role")) or None
- if role and not _SLUG_RE.match(role.lower()):
- problems["decided_by_role"] = "decided_by_role must be a role id slug"
+ if role and not _ROLE_ID_RE.match(role.lower()):
+ problems["decided_by_role"] = "decided_by_role must be a role id (a-z, 0-9, ., -, _; 64 max)"
raw_cites = payload.get("cites") or []
cites = [c for c in raw_cites if isinstance(c, str) and c.strip()] if isinstance(raw_cites, (list, tuple)) else []
diff --git a/src/backend/services/task_execution_service.py b/src/backend/services/task_execution_service.py
index e0da13324..588c836f2 100644
--- a/src/backend/services/task_execution_service.py
+++ b/src/backend/services/task_execution_service.py
@@ -86,6 +86,21 @@
from db.write_params import ExecutionResult, TaskExecutionFields
+def _served_person_for(execution_id: Optional[str], triggered_by: Optional[str]) -> Optional[str]:
+ """The person a scheduled seat run is addressed to (ent#637), or None —
+ picks the seat the prompt names (trinity-enterprise#814). One row read, and
+ only for a schedule fire; fail-soft."""
+ from services import schedule_seat_memory
+
+ if not execution_id or (triggered_by or "").lower() not in schedule_seat_memory.SCHEDULE_FIRE_TRIGGERS:
+ return None
+ try:
+ return schedule_seat_memory.seat_for_execution(db.get_execution(execution_id))
+ except Exception as e: # noqa: BLE001
+ logger.debug(f"_served_person_for({execution_id}) failed: {e}")
+ return None
+
+
def _resolve_agent_runtime(agent_name: str) -> str:
"""Best-effort resolve an agent's runtime for the platform prompt (#1187).
@@ -2305,6 +2320,7 @@ def _compose_effective_system_prompt(
schedule_cron=(schedule_context or {}).get("cron"),
schedule_next_run=(schedule_context or {}).get("next_run"),
execution_id=execution_id,
+ served_person_email=_served_person_for(execution_id, triggered_by),
)
effective_system_prompt = compose_system_prompt(
execution_context=exec_ctx,
diff --git a/src/frontend/src/components/AccessPanel.vue b/src/frontend/src/components/AccessPanel.vue
index 8f472045d..14f8ee661 100644
--- a/src/frontend/src/components/AccessPanel.vue
+++ b/src/frontend/src/components/AccessPanel.vue
@@ -11,7 +11,13 @@
-
+
+
+
+