From 21a3073375febcaac36da517307e2076a07ca789 Mon Sep 17 00:00:00 2001
From: Oleksii Dolhov
Date: Tue, 6 Oct 2026 14:42:53 +0300
Subject: [PATCH 01/14] feat(access): manage who an agent serves from its
Access tab (abilityai/trinity-enterprise#810)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Assignments (primary, approver, collaborator, viewer) had no UI: setting
an agent's primary human or its approvers took an API call. Agent Detail
› Access now has a "People this agent serves" section above the
operator list, over the existing ent#500 endpoints.
An instance admin sees the primary apart from the stakeholders, with
the seat it serves, each row's proactive-brief consent and role-check
state; adds a person picked by name, changes a kind in one action on the
row, removes a row, and replaces the primary as one flow that demotes
the old primary first and puts it back if the new one cannot be written.
Every refused write shows the backend's own reason. Non-admins see the
same section read-only. The section is absent without the assignments
module.
Co-Authored-By: Claude Opus 5.5
---
src/frontend/src/components/AccessPanel.vue | 5 +
.../components/AgentAssignmentsSection.vue | 331 ++++++++++++++++++
src/frontend/src/stores/assignments.js | 116 ++++++
src/frontend/src/utils/assignments.js | 120 +++++++
.../agentAssignmentsSection.mount.spec.js | 245 +++++++++++++
5 files changed, 817 insertions(+)
create mode 100644 src/frontend/src/components/AgentAssignmentsSection.vue
create mode 100644 src/frontend/src/stores/assignments.js
create mode 100644 src/frontend/src/utils/assignments.js
create mode 100644 src/frontend/tests/unit/agentAssignmentsSection.mount.spec.js
diff --git a/src/frontend/src/components/AccessPanel.vue b/src/frontend/src/components/AccessPanel.vue
index 2183364f50..1954372cbb 100644
--- a/src/frontend/src/components/AccessPanel.vue
+++ b/src/frontend/src/components/AccessPanel.vue
@@ -10,6 +10,10 @@
+
+
+
@@ -181,7 +228,7 @@ import { useEnterpriseStore } from '../stores/enterprise'
import { viewState } from '../utils/loadingState'
import {
KIND_LABELS, STAKEHOLDER_KINDS, primaryOf, stakeholdersOf, seatLine,
- consentLabel, driftNote, writeError,
+ consentLabel, driftNote, writeError, withRole,
} from '../utils/assignments'
const FEATURE_ID = 'assignments'
@@ -230,7 +277,12 @@ const addForm = ref({ userId: '', kind: 'approver', roleId: '' })
const savingAdd = ref(false)
const addError = ref('')
-watch(addKinds, (kinds) => { if (!kinds.includes(addForm.value.kind)) addForm.value.kind = kinds[0] }, { immediate: true })
+// With no primary yet, the next person added is most likely the one the agent
+// coaches — the empty state says so — so the form starts there.
+watch(addKinds, (kinds) => {
+ if (kinds[0] === 'primary') addForm.value.kind = 'primary'
+ else if (!kinds.includes(addForm.value.kind)) addForm.value.kind = kinds[0]
+}, { immediate: true })
// Start the role from the agent's seat; the admin can type any other id.
watch(seat, (s) => { if (!addForm.value.roleId && s.form === 'serves') addForm.value.roleId = s.roleId }, { immediate: true })
@@ -238,11 +290,10 @@ async function addPerson() {
addError.value = ''
savingAdd.value = true
try {
- await assignments.add(props.agentName, {
+ await assignments.add(props.agentName, withRole({
user_id: Number(addForm.value.userId),
- role_id: addForm.value.roleId.trim(),
kind: addForm.value.kind,
- })
+ }, addForm.value.roleId))
addForm.value = { ...addForm.value, userId: '' }
} catch (err) {
addError.value = writeError(err, "Couldn't add that person.")
@@ -285,6 +336,69 @@ async function removeRow(row) {
}
}
+// ---- a row's seat (ent#811) ---------------------------------------------------
+const roleEdit = ref({ id: null, value: '' })
+
+function startRoleEdit(row) {
+ rowError.value = { id: null, message: '' }
+ roleEdit.value = { id: row.id, value: row.role_id || seat.value.roleId || '' }
+}
+
+async function writeRole(row, roleId) {
+ busyId.value = row.id
+ busyVerb.value = 'role'
+ rowError.value = { id: null, message: '' }
+ try {
+ await assignments.setRole(props.agentName, row.id, roleId)
+ roleEdit.value = { id: null, value: '' }
+ } catch (err) {
+ rowError.value = { id: row.id, message: writeError(err, "Couldn't change the seat.") }
+ } finally {
+ busyId.value = null
+ busyVerb.value = ''
+ }
+}
+
+function saveRole(row) { return writeRole(row, roleEdit.value.value.trim()) }
+function clearRole(row) { return writeRole(row, null) }
+
+// ---- the seat the agent itself holds (ent#811, R50a) -------------------------
+const editingHolder = ref(false)
+const holderForm = ref('')
+const savingHolder = ref(false)
+const holderError = ref('')
+
+function startHolder() {
+ holderError.value = ''
+ holderForm.value = roster.value.held_seat || ''
+ editingHolder.value = true
+}
+
+async function saveHolder() {
+ holderError.value = ''
+ savingHolder.value = true
+ try {
+ await assignments.setSeatHolder(props.agentName, holderForm.value.trim())
+ editingHolder.value = false
+ } catch (err) {
+ holderError.value = writeError(err, "Couldn't record the seat this agent holds.")
+ } finally {
+ savingHolder.value = false
+ }
+}
+
+async function clearHolder() {
+ holderError.value = ''
+ savingHolder.value = true
+ try {
+ await assignments.clearSeatHolder(props.agentName)
+ } catch (err) {
+ holderError.value = writeError(err, "Couldn't clear the seat this agent holds.")
+ } finally {
+ savingHolder.value = false
+ }
+}
+
// ---- replace the primary ---------------------------------------------------
const replacing = ref(false)
const savingReplace = ref(false)
diff --git a/src/frontend/src/stores/assignments.js b/src/frontend/src/stores/assignments.js
index e1337f4b69..8a8b35deea 100644
--- a/src/frontend/src/stores/assignments.js
+++ b/src/frontend/src/stores/assignments.js
@@ -83,6 +83,23 @@ export const useAssignmentsStore = defineStore('assignments', () => {
await load(agent)
}
+ /** ent#811: set a row's seat, or clear it with `null` (an explicit null clears). */
+ async function setRole(agent, id, roleId) {
+ await run(agent, { op: 'update', id, body: { role_id: roleId || null } })
+ await load(agent)
+ }
+
+ /** ent#811 (R50a): record the agent itself as the holder of a seat. */
+ async function setSeatHolder(agent, roleId) {
+ await api.put(`${base(agent)}/seat`, { role_id: roleId })
+ await load(agent)
+ }
+
+ async function clearSeatHolder(agent) {
+ await api.delete(`${base(agent)}/seat`)
+ await load(agent)
+ }
+
/**
* Replace the primary as one flow (see `replacePrimarySteps`). If the new
* primary cannot be written, step one is undone so the agent keeps the
@@ -112,5 +129,6 @@ export const useAssignmentsStore = defineStore('assignments', () => {
return {
byAgent, users, usersLoaded, usersError,
entry, peek, load, loadUsers, add, setKind, remove, replacePrimary,
+ setRole, setSeatHolder, clearSeatHolder,
}
})
diff --git a/src/frontend/src/utils/assignments.js b/src/frontend/src/utils/assignments.js
index 8f23881c8b..478780d326 100644
--- a/src/frontend/src/utils/assignments.js
+++ b/src/frontend/src/utils/assignments.js
@@ -35,13 +35,17 @@ export function stakeholdersOf(assignments) {
}
/**
- * The section header's seat line. This cut knows one form: the agent serves
- * the seat its primary holds (the role id on the primary's row). "Holds the
- * seat" needs seat-holder storage that does not exist yet (ent#810 follow-up).
+ * The section header's seat line, in the two forms ent#810 names: the agent
+ * HOLDS a seat itself (R50a, `held_seat` from the backend — an autonomous
+ * player), or it SERVES the seat its primary holds (the role on the primary's
+ * row — a companion). Holds wins when both apply (operator ruling 2026-10-06).
*
- * @returns {{ form: 'serves', roleId: string, person: string } | { form: 'none' }}
+ * @returns {{ form: 'holds', roleId: string }
+ * | { form: 'serves', roleId: string, person: string }
+ * | { form: 'none' }}
*/
-export function seatLine({ assignments = null, primary = null } = {}) {
+export function seatLine({ assignments = null, primary = null, held_seat: heldSeat = null } = {}) {
+ if (heldSeat) return { form: 'holds', roleId: heldSeat }
const row = primaryOf(assignments)
if (row && row.role_id) return { form: 'serves', roleId: row.role_id, person: row.display_name }
if (!assignments && primary) return { form: 'none', person: primary }
@@ -68,7 +72,8 @@ export function seatLine({ assignments = null, primary = null } = {}) {
*/
export function replacePrimarySteps(assignments, { newUserId, oldBecomes, roleId }) {
const old = primaryOf(assignments)
- const create = { op: 'create', body: { user_id: newUserId, role_id: roleId, kind: 'primary' } }
+ // ent#811: the seat is optional — sent only when there is one.
+ const create = { op: 'create', body: withRole({ user_id: newUserId, kind: 'primary' }, roleId) }
if (!old) return { steps: [create], rollback: [] }
if (old.user_id === newUserId) return { steps: [], rollback: [] }
@@ -82,10 +87,16 @@ export function replacePrimarySteps(assignments, { newUserId, oldBecomes, roleId
}
return {
steps: [{ op: 'delete', id: old.id }, create],
- rollback: [{ op: 'create', body: { user_id: old.user_id, role_id: old.role_id, kind: 'primary' } }],
+ rollback: [{ op: 'create', body: withRole({ user_id: old.user_id, kind: 'primary' }, old.role_id) }],
}
}
+/** A create body with `role_id` only when there is a seat to name (ent#811). */
+export function withRole(body, roleId) {
+ const role = typeof roleId === 'string' ? roleId.trim() : roleId
+ return role ? { ...body, role_id: role } : { ...body }
+}
+
/**
* Proactive-brief consent as the row can state it. The backend returns one
* boolean today, so "not asked" and "declined" are one state; the label says
diff --git a/src/frontend/tests/unit/agentAssignmentsSection.mount.spec.js b/src/frontend/tests/unit/agentAssignmentsSection.mount.spec.js
index 21dff0733d..e362dfcd9e 100644
--- a/src/frontend/tests/unit/agentAssignmentsSection.mount.spec.js
+++ b/src/frontend/tests/unit/agentAssignmentsSection.mount.spec.js
@@ -243,3 +243,87 @@ describe('helpers', () => {
expect(writeError({ response: { data: { detail: [{ msg: 'field required' }] } } }, 'x')).toBe('field required')
})
})
+
+describe('seats (trinity-enterprise#811)', () => {
+ const SEAT = `/api/enterprise/assignments/agents/${AGENT}/seat`
+
+ it('names the seat the agent holds itself, and holds wins over serves', async () => {
+ roster = { ...roster, held_seat: 'brain' }
+ const w = render()
+ await flushPromises()
+ expect(q(w, 'assignments-seat').text()).toBe('Holds the seat: brain')
+ })
+
+ it('records the agent as the holder of a seat', async () => {
+ api.put = vi.fn(async () => ({ data: {} }))
+ const w = render()
+ await flushPromises()
+ await q(w, 'holder-edit').trigger('click')
+ await q(w, 'holder-role').setValue('orchestrator')
+ await q(w, 'holder-form').trigger('submit')
+ await flushPromises()
+ expect(api.put).toHaveBeenCalledWith(SEAT, { role_id: 'orchestrator' })
+ })
+
+ it('names the agent that already holds the seat when refused', async () => {
+ api.put = vi.fn(async () => { throw conflict('The seat orchestrator is already held by the agent brain-bot.') })
+ const w = render()
+ await flushPromises()
+ await q(w, 'holder-edit').trigger('click')
+ await q(w, 'holder-role').setValue('orchestrator')
+ await q(w, 'holder-form').trigger('submit')
+ await flushPromises()
+ expect(q(w, 'holder-error').text()).toContain('already held by the agent brain-bot')
+ })
+
+ it('clears the seat the agent holds', async () => {
+ roster = { ...roster, held_seat: 'brain' }
+ const w = render()
+ await flushPromises()
+ await q(w, 'holder-clear').trigger('click')
+ await flushPromises()
+ expect(api.delete).toHaveBeenCalledWith(SEAT)
+ })
+
+ it('adds a person with kind only — the seat is optional', async () => {
+ roster = { agent_name: AGENT, assignments: [], mine: null, primary: null }
+ const w = render()
+ await flushPromises()
+ await q(w, 'add-user').setValue('3')
+ expect(q(w, 'add-submit').attributes('disabled')).toBeUndefined()
+ await q(w, 'assignment-add-form').trigger('submit')
+ await flushPromises()
+ const [, body] = api.post.mock.calls[0]
+ expect(body).toEqual({ user_id: 3, kind: 'primary' })
+ })
+
+ it("sets and clears a row's seat (an explicit null clears)", async () => {
+ const w = render()
+ await flushPromises()
+ const bob = () => w.find('[data-testid="assignment-row"]')
+ await bob().find('[data-testid="assignment-edit-role"]').trigger('click')
+ await bob().find('[data-testid="assignment-role-input"]').setValue('head-of-sales')
+ await bob().find('[data-testid="assignment-role-form"]').trigger('submit')
+ await flushPromises()
+ expect(api.patch).toHaveBeenLastCalledWith(`/api/enterprise/assignments/agents/${AGENT}/a2`, { role_id: 'head-of-sales' })
+
+ await bob().find('[data-testid="assignment-edit-role"]').trigger('click')
+ await bob().find('[data-testid="assignment-role-clear"]').trigger('click')
+ await flushPromises()
+ expect(api.patch).toHaveBeenLastCalledWith(`/api/enterprise/assignments/agents/${AGENT}/a2`, { role_id: null })
+ })
+
+ it('hides every seat control from a non-admin', async () => {
+ roster = { ...roster, held_seat: 'brain' }
+ const w = render({ admin: false })
+ await flushPromises()
+ expect(q(w, 'assignments-seat').text()).toBe('Holds the seat: brain')
+ expect(q(w, 'assignments-holder').exists()).toBe(false)
+ expect(q(w, 'assignment-edit-role').exists()).toBe(false)
+ })
+
+ it('replaces the primary without naming a seat when none is given', () => {
+ const { steps } = replacePrimarySteps([], { newUserId: 3, oldBecomes: 'viewer', roleId: ' ' })
+ expect(steps).toEqual([{ op: 'create', body: { user_id: 3, kind: 'primary' } }])
+ })
+})
diff --git a/tests/unit/test_ent811_resolve_seat.py b/tests/unit/test_ent811_resolve_seat.py
new file mode 100644
index 0000000000..c6ae7dc089
--- /dev/null
+++ b/tests/unit/test_ent811_resolve_seat.py
@@ -0,0 +1,102 @@
+"""trinity-enterprise#811 — the seam's trigger-free seat read.
+
+`assignment_provider.resolve_seat(agent)` answers which seat an agent 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). It is
+always a dict and never raises: every degraded case — no provider, a provider
+without `seat_for`, no answer, a raise, a malformed shape — reads as "no seat",
+which is the failure direction the readiness gate needs (ent#813).
+"""
+import pytest
+
+pytestmark = pytest.mark.unit
+
+
+@pytest.fixture
+def seam():
+ from services import assignment_provider as ap
+ ap.clear_provider()
+ yield ap
+ ap.clear_provider()
+
+
+class _Provider:
+ def __init__(self, answer=None, raises=False):
+ self.answer, self.raises, self.asked = answer, raises, []
+
+ def assignment_for(self, agent_name, triggered_by):
+ return None
+
+ def seat_for(self, agent_name):
+ self.asked.append(agent_name)
+ if self.raises:
+ raise RuntimeError("boom")
+ return self.answer
+
+
+def _none(reason):
+ return {"case": "none", "role_id": None, "seats": [], "reason": reason}
+
+
+def test_no_provider_is_no_seat(seam):
+ assert seam.resolve_seat("scout") == _none("no_provider")
+
+
+def test_no_agent_is_no_seat(seam):
+ seam.register_provider(_Provider({"case": "holds", "role_id": "x", "seats": []}))
+ assert seam.resolve_seat("") == _none("no_agent")
+ assert seam.resolve_seat(None) == _none("no_agent")
+
+
+def test_a_provider_without_seat_for_is_no_seat(seam):
+ class Old:
+ def assignment_for(self, agent_name, triggered_by):
+ return None
+ seam.register_provider(Old())
+ assert seam.resolve_seat("scout") == _none("unsupported")
+
+
+@pytest.mark.parametrize("answer,expected", [
+ ({"case": "holds", "role_id": "orchestrator", "seats": []},
+ {"case": "holds", "role_id": "orchestrator", "seats": [], "reason": "provider"}),
+ ({"case": "serves", "role_id": "cfo", "seats": ["cfo", "head-of-ops"]},
+ {"case": "serves", "role_id": "cfo", "seats": ["cfo", "head-of-ops"], "reason": "provider"}),
+ ({"case": "none", "role_id": None, "seats": ["viewer-seat"]},
+ {"case": "none", "role_id": None, "seats": ["viewer-seat"], "reason": "provider"}),
+], ids=["holds", "serves", "none-with-people"])
+def test_a_provider_answer_passes_through(seam, answer, expected):
+ p = _Provider(answer)
+ seam.register_provider(p)
+ assert seam.resolve_seat("scout") == expected
+ assert p.asked == ["scout"], "asked with the agent alone — no trigger"
+
+
+def test_a_raise_is_no_seat(seam):
+ seam.register_provider(_Provider(raises=True))
+ assert seam.resolve_seat("scout") == _none("error")
+
+
+def test_no_answer_is_no_seat(seam):
+ seam.register_provider(_Provider(None))
+ assert seam.resolve_seat("scout") == _none("no_answer")
+
+
+@pytest.mark.parametrize("answer", [
+ "holds",
+ {"case": "owns", "role_id": "x", "seats": []},
+ {"case": "holds", "role_id": None, "seats": []},
+ {"case": "serves", "role_id": "", "seats": []},
+ {"case": "holds", "role_id": 7, "seats": []},
+ {"case": "holds", "role_id": "x", "seats": "x"},
+ {"case": "holds", "role_id": "x", "seats": ["x", 3]},
+ {"case": "holds", "role_id": "x"},
+], ids=["str", "unknown-case", "holds-without-seat", "serves-empty-seat", "int-role",
+ "seats-str", "seats-mixed", "seats-missing"])
+def test_a_malformed_answer_is_no_seat(seam, answer):
+ seam.register_provider(_Provider(answer))
+ assert seam.resolve_seat("scout") == _none("malformed")
+
+
+def test_case_none_never_carries_a_seat(seam):
+ seam.register_provider(_Provider({"case": "none", "role_id": "stray", "seats": []}))
+ assert seam.resolve_seat("scout")["role_id"] is None
From 621c2107f97cd872f5339a73d6b771a030140d84 Mon Sep 17 00:00:00 2001
From: Oleksii Dolhov
Date: Tue, 6 Oct 2026 15:48:41 +0300
Subject: [PATCH 03/14] feat(objectives): owned objectives come from the seat
on record, not x-role (abilityai/trinity-enterprise#812)
Under the 2026-10-06 ruling (R50 + R50a) the seat is recorded in
Trinity, held by a person or by an agent; x-role.role in template.yaml
stops being truth. The objective join was the one surface that relied
on it.
- "Owned" resolves through assignment_provider.resolve_seat: the seat
the agent holds (that seat only), else the seats its assigned people
hold, primary first; a shared companion owns the union.
- The canon declaration alone enables the join; x-role is neither
required nor read.
- An agent with no seat owns nothing and a no_seat finding says why and
what to do (it replaces role_id_invalid); supporting objectives still
show. The role card renders the sentence.
- supporting_agents is read as before, documented as frozen.
- The response's role carries case and seats.
Existing suites that declared their seat through x-role now record it
through a registered provider, which is what an assigned primary does.
Co-Authored-By: Claude Opus 5.5
---
.../requirements/lifecycle-observability.md | 25 +--
src/backend/models.py | 12 +-
.../services/objective_join_service.py | 88 +++++++---
.../src/components/portal/PortalAgentRole.vue | 2 +
tests/registry.json | 21 +++
tests/unit/test_ent527_role_card.py | 19 ++
tests/unit/test_ent666_objective_join.py | 40 ++++-
tests/unit/test_ent666_objectives_route.py | 4 +-
tests/unit/test_ent676_role_card_join.py | 18 ++
tests/unit/test_ent727_cross_agent_metrics.py | 20 +++
tests/unit/test_ent812_objective_join_seat.py | 164 ++++++++++++++++++
11 files changed, 372 insertions(+), 41 deletions(-)
create mode 100644 tests/unit/test_ent812_objective_join_seat.py
diff --git a/docs/memory/requirements/lifecycle-observability.md b/docs/memory/requirements/lifecycle-observability.md
index 29c84b8d24..9025d51176 100644
--- a/docs/memory/requirements/lifecycle-observability.md
+++ b/docs/memory/requirements/lifecycle-observability.md
@@ -1659,10 +1659,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
@@ -1776,7 +1781,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
@@ -1837,7 +1842,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,
@@ -1928,10 +1933,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/models.py b/src/backend/models.py
index 1cbb5b8660..cee08cd099 100644
--- a/src/backend/models.py
+++ b/src/backend/models.py
@@ -5102,11 +5102,19 @@ class ObjectiveRead(BaseModel):
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/services/objective_join_service.py b/src/backend/services/objective_join_service.py
index f2d509f3da..0459b373ee 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 [])
@@ -415,7 +437,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.
@@ -442,7 +464,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.
@@ -462,6 +484,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 = {
@@ -483,7 +506,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 = []
@@ -1121,30 +1144,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)
@@ -1157,17 +1193,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"])
@@ -1213,7 +1251,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/frontend/src/components/portal/PortalAgentRole.vue b/src/frontend/src/components/portal/PortalAgentRole.vue
index 7974a36094..a3bafad89a 100644
--- a/src/frontend/src/components/portal/PortalAgentRole.vue
+++ b/src/frontend/src/components/portal/PortalAgentRole.vue
@@ -174,6 +174,8 @@ function roleErrorText(code) {
role_file_unreadable: 'The role file could not be read from the agent. Try again in a moment.',
role_file_invalid: 'The role file is not valid YAML (or not a role) — fix the file; nothing is edited here.',
role_id_invalid: 'The role id in template.yaml is not a valid id.',
+ // trinity-enterprise#812: owned objectives come from the seat on record.
+ no_seat: 'This agent neither holds a seat nor serves someone who holds one, so it owns no objective yet.',
canon_path_invalid: 'The canon path in template.yaml is not a valid path.',
}[code] || 'The role could not be loaded.'
}
diff --git a/tests/registry.json b/tests/registry.json
index 326e202681..64a9469a5c 100644
--- a/tests/registry.json
+++ b/tests/registry.json
@@ -1,5 +1,26 @@
{
"test_files": [
+ {
+ "file": "unit/test_ent812_objective_join_seat.py",
+ "feature": "trinity-enterprise#812",
+ "added": "2026-10-06",
+ "categories": [
+ "backend",
+ "objectives",
+ "assignments"
+ ],
+ "description": "The objective join owns objectives through the seat on record (assignment_provider.resolve_seat), not x-role: a held seat owns only itself, a companion owns the union of its people's seats (primary first), x-canon alone enables the join, no seat owns nothing with a no_seat finding while supporting work still shows."
+ },
+ {
+ "file": "unit/test_ent811_resolve_seat.py",
+ "feature": "trinity-enterprise#811",
+ "added": "2026-10-06",
+ "categories": [
+ "backend",
+ "assignments"
+ ],
+ "description": "assignment_provider.resolve_seat answers holds/serves/none with no triggering user; every degraded case (no agent, no provider, no seat_for, a raise, no answer, a malformed shape) reads as no seat with a named reason, never a raise."
+ },
{
"file": "unit/test_3243_atomic_asks.py",
"feature": "#3243",
diff --git a/tests/unit/test_ent527_role_card.py b/tests/unit/test_ent527_role_card.py
index aba83f8ac6..c1e098e977 100644
--- a/tests/unit/test_ent527_role_card.py
+++ b/tests/unit/test_ent527_role_card.py
@@ -387,3 +387,22 @@ def test_the_migration_graph_still_has_exactly_one_head():
out = subprocess.run(["python3", str(REPO / "scripts/ci/check_alembic_heads.py"),
str(REPO / "src/backend/migrations/versions")], capture_output=True, text=True)
assert out.returncode == 0, out.stdout + out.stderr
+
+
+@pytest.fixture(autouse=True)
+def _seat_on_record():
+ """trinity-enterprise#812: the join reads the seat from Trinity's record,
+ not from `x-role`; record the seat this suite's template declares, as a
+ companion's."""
+ from services import assignment_provider as ap
+
+ class _Seats:
+ def assignment_for(self, agent_name, triggered_by):
+ return None
+
+ def seat_for(self, agent_name):
+ return {"case": "serves", "role_id": "sales-lead", "seats": ["sales-lead"]}
+
+ ap.register_provider(_Seats())
+ yield
+ ap.clear_provider()
diff --git a/tests/unit/test_ent666_objective_join.py b/tests/unit/test_ent666_objective_join.py
index a99cbdf8dc..7ffb2271fb 100644
--- a/tests/unit/test_ent666_objective_join.py
+++ b/tests/unit/test_ent666_objective_join.py
@@ -593,7 +593,9 @@ async def test_the_whole_read_end_to_end(store, running):
result = await svc.read_objective_join(AGENT, now=NOW, client=client)
assert result["unavailable"] is None
- assert result["role"] == {"id": ROLE, "path": "canon/roles/revenue-lead.yaml"}
+ # trinity-enterprise#812: the seat comes from Trinity's record.
+ assert result["role"] == {"id": ROLE, "path": "canon/roles/revenue-lead.yaml",
+ "case": "serves", "seats": [ROLE]}
assert result["canon_root"] == "canon"
assert result["stale_rule"] == "2x cadence"
assert result["source"]["template"] == "read"
@@ -770,17 +772,29 @@ async def test_a_traversing_clone_path_reads_no_file_with_it(store, running):
@pytest.mark.asyncio
async def test_an_invalid_role_id_is_a_finding_and_still_reads_supported_work(
store, running):
+ """trinity-enterprise#812: the seat comes from Trinity's record, so an
+ invalid id there owns nothing — named `no_seat` — and `x-role` is not read."""
+ from services import assignment_provider as ap
+
+ class _Bad:
+ def assignment_for(self, agent_name, triggered_by):
+ return None
+
+ def seat_for(self, agent_name):
+ return {"case": "serves", "role_id": "not a valid id", "seats": ["not a valid id"]}
+
+ ap.register_provider(_Bad())
supported = OBJECTIVE_YAML.replace(
"owner: role:revenue-lead",
f"owner: role:someone-else\nsupporting_agents: [{AGENT}]")
client = _FakeClient(files={
- "template.yaml": "x-role:\n role: 'not a valid id'\n"
+ "template.yaml": "x-role:\n role: revenue-lead\n"
"x-canon:\n clone_path: canon\n",
"canon/objectives/q4-close-rate.yaml": supported,
})
result = await svc.read_objective_join(AGENT, now=NOW, client=client)
- assert "role_id_invalid" in [f["code"] for f in result["findings"]]
+ assert "no_seat" in [f["code"] for f in result["findings"]]
assert result["objectives"][0]["supporting"] is True
assert result["objectives"][0]["owned"] is False
@@ -1039,3 +1053,23 @@ def _calls(name):
passed = {kw.arg for kw in joins[0].keywords}
assert {"template", "client"} <= passed, (
"the join is handed the template and client the card already holds")
+
+
+@pytest.fixture(autouse=True)
+def _seat_on_record():
+ """trinity-enterprise#812: the join reads the seat from Trinity's record,
+ not from `x-role`. Record the seat these tests' templates used to declare,
+ as a companion whose primary holds it; the `x-role` left in a template is
+ now simply not read."""
+ from services import assignment_provider as ap
+
+ class _Seats:
+ def assignment_for(self, agent_name, triggered_by):
+ return None
+
+ def seat_for(self, agent_name):
+ return {"case": "serves", "role_id": "revenue-lead", "seats": ["revenue-lead"]}
+
+ ap.register_provider(_Seats())
+ yield
+ ap.clear_provider()
diff --git a/tests/unit/test_ent666_objectives_route.py b/tests/unit/test_ent666_objectives_route.py
index fc8ecf753c..2fba8bbd8f 100644
--- a/tests/unit/test_ent666_objectives_route.py
+++ b/tests/unit/test_ent666_objectives_route.py
@@ -89,7 +89,9 @@ def _body(**overrides):
"generated_at": "2026-09-22T12:00:00.000000Z",
"stale_rule": svc.STALE_RULE,
"role": {"id": "revenue-lead",
- "path": "canon/roles/revenue-lead.yaml"},
+ "path": "canon/roles/revenue-lead.yaml",
+ # trinity-enterprise#812: where the seat came from.
+ "case": "serves", "seats": ["revenue-lead"]},
"canon_root": "canon",
"unavailable": None,
"source": {"template": "read", "objectives_dir": "read",
diff --git a/tests/unit/test_ent676_role_card_join.py b/tests/unit/test_ent676_role_card_join.py
index 03eee1e7f7..7e6111a7de 100644
--- a/tests/unit/test_ent676_role_card_join.py
+++ b/tests/unit/test_ent676_role_card_join.py
@@ -882,3 +882,21 @@ def test_the_card_module_does_not_import_the_metrics_stack_at_module_top():
if "objective_join" in name or "metric_read" in name
or "metric_points" in name]
assert heavy == []
+
+
+@pytest.fixture(autouse=True)
+def _seat_on_record():
+ """trinity-enterprise#812: the join reads the seat from Trinity's record,
+ not from `x-role`; record the seat these templates declare, as a companion's."""
+ from services import assignment_provider as ap
+
+ class _Seats:
+ def assignment_for(self, agent_name, triggered_by):
+ return None
+
+ def seat_for(self, agent_name):
+ return {"case": "serves", "role_id": ROLE, "seats": [ROLE]}
+
+ ap.register_provider(_Seats())
+ yield
+ ap.clear_provider()
diff --git a/tests/unit/test_ent727_cross_agent_metrics.py b/tests/unit/test_ent727_cross_agent_metrics.py
index adc54a125f..b23eaa8dfe 100644
--- a/tests/unit/test_ent727_cross_agent_metrics.py
+++ b/tests/unit/test_ent727_cross_agent_metrics.py
@@ -598,3 +598,23 @@ async def _audit(reader, target, route, **kw):
assert can_view(SERVER) is True and can_view(OTHER) is False
assert access_calls[-1] == ("operator", OTHER)
assert audits == [(READER, SERVER, "objectives")]
+
+
+@pytest.fixture(autouse=True)
+def _seat_on_record():
+ """trinity-enterprise#812: the join reads the seat from Trinity's record,
+ not from `x-role`. Record the seat these tests' templates used to declare,
+ as a companion whose primary holds it; the `x-role` left in a template is
+ now simply not read."""
+ from services import assignment_provider as ap
+
+ class _Seats:
+ def assignment_for(self, agent_name, triggered_by):
+ return None
+
+ def seat_for(self, agent_name):
+ return {"case": "serves", "role_id": "revenue-lead", "seats": ["revenue-lead"]}
+
+ ap.register_provider(_Seats())
+ yield
+ ap.clear_provider()
diff --git a/tests/unit/test_ent812_objective_join_seat.py b/tests/unit/test_ent812_objective_join_seat.py
new file mode 100644
index 0000000000..0ce1bc0f15
--- /dev/null
+++ b/tests/unit/test_ent812_objective_join_seat.py
@@ -0,0 +1,164 @@
+"""trinity-enterprise#812 — owned objectives come from the seat on record.
+
+Operator ruling 2026-10-06 (R50 + R50a): a seat is held by a person or by an
+agent, recorded in Trinity; `x-role.role` in template.yaml stops being truth.
+The objective join therefore resolves "owned" through `assignment_provider.
+resolve_seat`:
+
+* the seat the agent HOLDS (an autonomous player) — that seat only;
+* otherwise the seats its assigned people hold (a companion), primary first —
+ a shared companion owns the union;
+* neither → it owns nothing, and a `no_seat` finding says why; work that names
+ it in `supporting_agents` (read as before, now frozen) still shows.
+
+The canon declaration alone enables the join: `x-role` is neither required nor
+read. Driven through the real `read_objective_join` with the ent#666 suite's
+fakes (the agent door and the store).
+"""
+import pytest
+
+from services import objective_join_service as svc
+from unit.test_ent666_objective_join import ( # noqa: F401 — fixtures by name
+ AGENT, NOW, OBJECTIVE_YAML, _FakeClient, running, store,
+)
+
+pytestmark = pytest.mark.unit
+
+CANON_ONLY = "name: sales-companion\nx-canon:\n clone_path: canon\n"
+
+
+def _objective(obj_id, owner, supporting=None):
+ text = OBJECTIVE_YAML.replace("id: q4-close-rate", f"id: {obj_id}")
+ text = text.replace("owner: role:revenue-lead", f"owner: role:{owner}")
+ if supporting:
+ text += f"supporting_agents: [{supporting}]\n"
+ return text
+
+
+@pytest.fixture
+def seat():
+ """Register a provider answering `seat_for` with what the test sets."""
+ from services import assignment_provider as ap
+
+ answer = {"value": None}
+
+ class _Seats:
+ def assignment_for(self, agent_name, triggered_by):
+ return None
+
+ def seat_for(self, agent_name):
+ return answer["value"]
+
+ ap.register_provider(_Seats())
+ yield answer
+ ap.clear_provider()
+
+
+def _files(*objectives, template=CANON_ONLY):
+ files = {"template.yaml": template}
+ for i, text in enumerate(objectives):
+ files[f"canon/objectives/o{i}.yaml"] = text
+ return files
+
+
+def _owned(result):
+ return sorted(o["id"] for o in result["objectives"] if o["owned"])
+
+
+@pytest.mark.asyncio
+async def test_an_agent_that_holds_a_seat_owns_through_that_seat(store, running, seat):
+ seat["value"] = {"case": "holds", "role_id": "orchestrator", "seats": ["cfo"]}
+ client = _FakeClient(files=_files(_objective("run-the-fleet", "orchestrator"),
+ _objective("cut-costs", "cfo")))
+ result = await svc.read_objective_join(AGENT, now=NOW, client=client)
+
+ assert _owned(result) == ["run-the-fleet"], "holds wins: its people's seats are not its own"
+ assert result["role"] == {"id": "orchestrator", "path": "canon/roles/orchestrator.yaml",
+ "case": "holds", "seats": ["orchestrator"]}
+
+
+@pytest.mark.asyncio
+async def test_a_companion_owns_the_union_of_its_peoples_seats(store, running, seat):
+ seat["value"] = {"case": "serves", "role_id": "revenue-lead",
+ "seats": ["revenue-lead", "cfo"]}
+ client = _FakeClient(files=_files(_objective("close-rate", "revenue-lead"),
+ _objective("cut-costs", "cfo"),
+ _objective("brand", "marketing-lead")))
+ result = await svc.read_objective_join(AGENT, now=NOW, client=client)
+
+ assert _owned(result) == ["close-rate", "cut-costs"]
+ assert result["role"]["seats"] == ["revenue-lead", "cfo"], "primary first"
+ assert result["role"]["id"] == "revenue-lead"
+
+
+@pytest.mark.asyncio
+async def test_x_role_is_neither_required_nor_read(store, running, seat):
+ seat["value"] = {"case": "serves", "role_id": "revenue-lead", "seats": ["revenue-lead"]}
+ lying = "x-role:\n role: marketing-lead\nx-canon:\n clone_path: canon\n"
+ client = _FakeClient(files=_files(_objective("close-rate", "revenue-lead"),
+ _objective("brand", "marketing-lead"), template=lying))
+ result = await svc.read_objective_join(AGENT, now=NOW, client=client)
+ assert _owned(result) == ["close-rate"], "the template's x-role is not read"
+
+ client = _FakeClient(files=_files(_objective("close-rate", "revenue-lead")))
+ assert _owned(await svc.read_objective_join(AGENT, now=NOW, client=client)) == ["close-rate"]
+
+
+@pytest.mark.asyncio
+async def test_the_canon_declaration_alone_enables_the_join(store, running, seat):
+ seat["value"] = {"case": "serves", "role_id": "revenue-lead", "seats": ["revenue-lead"]}
+ client = _FakeClient(files=_files(_objective("close-rate", "revenue-lead"),
+ template="x-role:\n role: revenue-lead\n"))
+ result = await svc.read_objective_join(AGENT, now=NOW, client=client)
+
+ assert result["objectives"] == []
+ assert "no `x-canon`" in result["message"]
+ assert result["source"]["objectives_dir"] == "skipped", "no canon, no file read"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("answer", [
+ {"case": "none", "role_id": None, "seats": []},
+ None,
+], ids=["provider-says-none", "provider-answers-nothing"])
+async def test_no_seat_owns_nothing_and_says_why(store, running, seat, answer):
+ seat["value"] = answer
+ client = _FakeClient(files=_files(_objective("close-rate", "revenue-lead"),
+ _objective("helping", "cfo", supporting=AGENT)))
+ result = await svc.read_objective_join(AGENT, now=NOW, client=client)
+
+ assert _owned(result) == []
+ assert [o["id"] for o in result["objectives"]] == ["helping"], "supporting work still shows"
+ assert result["objectives"][0]["supporting"] is True
+ codes = [f["code"] for f in result["findings"]]
+ assert "no_seat" in codes
+ assert result["role"]["case"] == "none" and result["role"]["seats"] == []
+
+
+@pytest.mark.asyncio
+async def test_with_no_provider_at_all_the_answer_is_no_seat(store, running):
+ from services import assignment_provider as ap
+
+ ap.clear_provider()
+ client = _FakeClient(files=_files(_objective("close-rate", "revenue-lead")))
+ result = await svc.read_objective_join(AGENT, now=NOW, client=client)
+
+ assert result["objectives"] == []
+ assert "no_seat" in [f["code"] for f in result["findings"]]
+ assert "Access tab" in next(f["message"] for f in result["findings"] if f["code"] == "no_seat")
+
+
+@pytest.mark.parametrize("given,expected", [
+ (None, ()),
+ ("cfo", ("cfo",)),
+ (["cfo", "cfo", "head-of-ops"], ("cfo", "head-of-ops")),
+ (["cfo", "not valid", None, ""], ("cfo",)),
+])
+def test_owned_seat_ids_normalises_one_or_many(given, expected):
+ assert svc.owned_seat_ids(given) == expected
+
+
+def test_supporting_agents_is_still_read_by_the_pure_join():
+ obj = {"owner": "role:someone", "supporting_agents": [AGENT], "status": "active"}
+ assert svc.objective_concerns(obj, (), AGENT) is True
+ assert svc.objective_concerns(obj, ("cfo",), "other-agent") is False
From 02a6158e0ef194bfca8d233b2a3829e8867eb520 Mon Sep 17 00:00:00 2001
From: Oleksii Dolhov
Date: Tue, 6 Oct 2026 16:52:31 +0300
Subject: [PATCH 04/14] feat(readiness): a companion is an agent whose primary
holds a seat, not one whose template has x-role
(abilityai/trinity-enterprise#813)
The readiness gate (ent#689) held an unstamped agent's proactive brief
when its template declared x-role. Under the 2026-10-06 ruling the seat
is recorded in Trinity, so "is this a companion" is now answered by
assignment_provider.resolve_seat: a primary that holds a seat (serves).
- An agent holding a seat itself, no primary with a seat, no seat
lookup or a lookup error all read as "not a companion", so the brief
fires as before (fails open, #1638).
- The owner's stamp still decides first and the seat is not looked up
for a stamped agent; the rollout seed and held-schedule behaviour of
ent#689 are unchanged.
- The template is no longer read: one container read less per brief,
and an agent can no longer take itself out of scope by editing its own
files.
- Each decision is logged with its reason.
Co-Authored-By: Claude Opus 5.5
---
docs/memory/requirements/core-agent.md | 15 ++-
src/backend/services/role_readiness_gate.py | 90 ++++++++--------
tests/unit/test_ent689_readiness_gate.py | 109 +++++++++++++-------
3 files changed, 123 insertions(+), 91 deletions(-)
diff --git a/docs/memory/requirements/core-agent.md b/docs/memory/requirements/core-agent.md
index 51bcb3cb4e..74b8829adc 100644
--- a/docs/memory/requirements/core-agent.md
+++ b/docs/memory/requirements/core-agent.md
@@ -3375,11 +3375,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/src/backend/services/role_readiness_gate.py b/src/backend/services/role_readiness_gate.py
index 406ae04a31..3816a1e897 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/tests/unit/test_ent689_readiness_gate.py b/tests/unit/test_ent689_readiness_gate.py
index 52f864874e..89a1a07486 100644
--- a/tests/unit/test_ent689_readiness_gate.py
+++ b/tests/unit/test_ent689_readiness_gate.py
@@ -6,10 +6,10 @@
* **The unstamped companion — the case that exists on every install today** —
is held, and an unstamped NON-companion is not (the gate must not mute every
- scheduled delivery). The template is read only to learn whether the agent
- declares `x-role`; its `status` never decides anything (#663).
-* **Every ambiguity fires** (#1638): stamp unreadable, container not running,
- Docker unreadable, template slow/unparsable.
+ scheduled delivery). Since trinity-enterprise#813 a companion is an agent whose
+ primary holds a seat (the seat on record); the template is not read at all.
+* **Every ambiguity fires** (#1638): stamp unreadable, no seat lookup, a lookup
+ error.
* **The rollout seed** stamps exactly the agents whose seat brief fires today —
never over an existing stamp — on BOTH migration tracks, idempotently.
* **The card** calls a rollout stamp "carried over", never a person's act, and
@@ -61,13 +61,18 @@ def test_unknown_companionship_fails_open(self):
class TestBriefReadiness:
+ """trinity-enterprise#813: who is a companion comes from the seat on record
+ (`assignment_provider.resolve_seat`), not from the template — a companion is
+ an agent whose primary holds a seat. The template is never read."""
+
@pytest.fixture()
def wired(self, monkeypatch):
import database
- from services import docker_utils
from services import agent_client
+ from services import assignment_provider as ap
gate = _gate()
- state = {"stamp": None, "container": "running", "file": {"success": True, "content": "x-role:\n role: sales-lead\n status: ready\n"}, "raise": None}
+ state = {"stamp": None, "raise": None, "asked": 0,
+ "seat": {"case": "serves", "role_id": "sales-lead", "seats": ["sales-lead"]}}
def stamp(agent):
if state["raise"] == "stamp":
@@ -76,59 +81,89 @@ def stamp(agent):
monkeypatch.setattr(database.db, "get_agent_role_readiness", stamp)
monkeypatch.setattr(database.db, "get_agent_owner", lambda name: {"owner_id": 1})
- async def container(agent):
- if state["raise"] == "docker":
- raise RuntimeError("docker unreadable")
- return state["container"]
- monkeypatch.setattr(docker_utils, "agent_container_state_async", container)
+ def no_template_read(name):
+ raise AssertionError("the gate must not read the agent's files (#813)")
+ monkeypatch.setattr(agent_client, "get_agent_client", no_template_read)
- class Client:
- async def read_file(self, path, timeout=30.0):
- assert timeout <= gate.TEMPLATE_READ_TIMEOUT_SECONDS
- if state["raise"] == "slow":
- await asyncio.sleep(10)
- return state["file"]
- monkeypatch.setattr(agent_client, "get_agent_client", lambda name: Client())
- monkeypatch.setattr(gate, "TEMPLATE_READ_TIMEOUT_SECONDS", 0.05)
- return gate, state
+ class Seats:
+ def assignment_for(self, agent_name, triggered_by):
+ return None
+
+ def seat_for(self, agent_name):
+ state["asked"] += 1
+ if state["raise"] == "seat":
+ raise RuntimeError("provider down")
+ return state["seat"]
+ ap.register_provider(Seats())
+ yield gate, state
+ ap.clear_provider()
@pytest.mark.asyncio
- async def test_an_unstamped_companion_is_held_even_when_its_file_says_ready(self, wired):
+ async def test_an_unstamped_companion_is_held(self, wired):
gate, state = wired
v = await gate.brief_readiness(AGENT)
assert not v.fire and v.basis == "unstamped_companion"
@pytest.mark.asyncio
- async def test_the_stamp_decides_without_reading_the_container(self, wired):
+ async def test_a_stamped_calibrating_companion_with_a_primary_is_still_held(self, wired):
+ """The ent#689 behaviour this change must keep (#813 AC)."""
+ gate, state = wired
+ state["stamp"] = {"status": "calibrating"}
+ v = await gate.brief_readiness(AGENT)
+ assert not v.fire and v.basis == "stamp" and "marks it ready" in v.reason
+
+ @pytest.mark.asyncio
+ async def test_the_stamp_decides_without_asking_for_the_seat(self, wired):
gate, state = wired
state["stamp"] = {"status": "ready"}
- state["raise"] = "docker" # would fail open if it were consulted
v = await gate.brief_readiness(AGENT)
assert v.fire and v.basis == "stamp"
+ assert state["asked"] == 0, "a stamped agent never asks for its seat"
@pytest.mark.asyncio
- async def test_no_x_role_and_no_template_are_not_companions(self, wired):
+ @pytest.mark.parametrize("seat,why", [
+ ({"case": "holds", "role_id": "orchestrator", "seats": []}, "holds the seat orchestrator itself"),
+ ({"case": "none", "role_id": None, "seats": []}, "no primary with a seat"),
+ ], ids=["holds-a-seat-itself", "no-primary-with-a-seat"])
+ async def test_an_agent_that_is_not_a_companion_fires_and_says_why(self, wired, seat, why):
gate, state = wired
- state["file"] = {"success": True, "content": "name: plain-agent\n"}
- assert (await gate.brief_readiness(AGENT)).basis == "not_companion"
- state["file"] = {"success": True, "not_found": True, "content": None}
- assert (await gate.brief_readiness(AGENT)).basis == "not_companion"
+ state["seat"] = seat
+ v = await gate.brief_readiness(AGENT)
+ assert v.fire and v.basis == "not_companion" and why in v.reason
@pytest.mark.asyncio
- @pytest.mark.parametrize("case", ["stamp", "docker", "stopped", "slow", "unreadable", "unparsable"])
- async def test_every_ambiguity_fires(self, wired, case):
+ @pytest.mark.parametrize("case", ["no_provider", "seat_error", "no_answer", "malformed"])
+ async def test_an_unknown_seat_reads_as_not_a_companion_and_fires(self, wired, case):
+ """#813 AC: no provider, no primary, or a resolver error → not a companion."""
+ from services import assignment_provider as ap
gate, state = wired
- if case in ("stamp", "docker", "slow"):
- state["raise"] = case
- elif case == "stopped":
- state["container"] = "exited"
- elif case == "unreadable":
- state["file"] = {"success": False}
+ if case == "no_provider":
+ ap.clear_provider()
+ elif case == "seat_error":
+ state["raise"] = "seat"
+ elif case == "no_answer":
+ state["seat"] = None
else:
- state["file"] = {"success": True, "content": "x-role: [unclosed"}
+ state["seat"] = {"case": "serves"}
+ v = await gate.brief_readiness(AGENT)
+ assert v.fire and v.basis == "not_companion" and "seat lookup unavailable" in v.reason
+
+ @pytest.mark.asyncio
+ async def test_an_unreadable_stamp_still_fails_open(self, wired):
+ gate, state = wired
+ state["raise"] = "stamp"
v = await gate.brief_readiness(AGENT)
assert v.fire and v.basis == "fail_open"
+ @pytest.mark.asyncio
+ async def test_the_decision_is_logged_with_its_reason(self, wired, caplog):
+ import logging
+ gate, state = wired
+ with caplog.at_level(logging.INFO, logger="services.role_readiness_gate"):
+ await gate.brief_readiness(AGENT)
+ assert any("[ent#813]" in r.message and "its primary holds the seat sales-lead" in r.message
+ and "held" in r.message for r in caplog.records)
+
# ---------------------------------------------------------------------------
# The endpoint
From aa8993e1512d2ba1111dbe678cb8b90784cdf001 Mon Sep 17 00:00:00 2001
From: Oleksii Dolhov
Date: Tue, 6 Oct 2026 17:31:19 +0300
Subject: [PATCH 05/14] feat(access): canon seats, three-state consent, and
drift on read (abilityai/trinity-enterprise#817)
The public half of ent#817, completing ent#810's remaining criteria.
- Canon seats (OSS-core, operator ruling 2026-10-06): GET
/api/agents/{name}/canon/roles lists /roles/*.yaml with id,
title and updated through the agent door the objective join uses, on
the same per-agent read budget, self-gated for agent keys. Every empty
answer is named (stopped, no canon, no roles/, unreadable, timeout).
- Proactive consent in three states: agent_sharing.allow_proactive NULL
= not asked, 0 = declined, 1 = consented. New shares are written NULL;
a data migration on both tracks (SQLite + Alembic 0090) moves the
legacy DEFAULT-0 rows to NULL. Who may be messaged is unchanged: only
1 allows it. Shares and the Access list carry proactive_consent.
- The assignments section: seat fields suggest the canon's seats (a
typed id still works), the header and rows show the role's title and
the file's updated stamp, drift reads current / changed / missing /
unknown with its reason, and consent reads in three states.
Co-Authored-By: Claude Opus 5.5
---
docs/memory/requirements/infrastructure.md | 2 +
src/backend/db/agent_settings/sharing.py | 20 ++-
src/backend/db/migrations.py | 17 +++
src/backend/db_models.py | 4 +
.../0090_proactive_consent_not_asked.py | 32 ++++
src/backend/models.py | 24 +++
src/backend/routers/agent_files.py | 27 ++++
src/backend/services/canon_roles_service.py | 127 ++++++++++++++++
.../services/objective_join_service.py | 7 +-
.../components/AgentAssignmentsSection.vue | 43 ++++--
src/frontend/src/stores/assignments.js | 18 ++-
src/frontend/src/utils/assignments.js | 41 ++++-
.../agentAssignmentsSection.mount.spec.js | 74 ++++++++-
tests/registry.json | 23 +++
tests/unit/_route_census.py | 4 +
tests/unit/test_ent817_canon_roles.py | 143 ++++++++++++++++++
tests/unit/test_ent817_proactive_consent.py | 124 +++++++++++++++
17 files changed, 707 insertions(+), 23 deletions(-)
create mode 100644 src/backend/migrations/versions/0090_proactive_consent_not_asked.py
create mode 100644 src/backend/services/canon_roles_service.py
create mode 100644 tests/unit/test_ent817_canon_roles.py
create mode 100644 tests/unit/test_ent817_proactive_consent.py
diff --git a/docs/memory/requirements/infrastructure.md b/docs/memory/requirements/infrastructure.md
index e031c6bb58..287bf508bd 100644
--- a/docs/memory/requirements/infrastructure.md
+++ b/docs/memory/requirements/infrastructure.md
@@ -263,6 +263,8 @@
- **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
+ - **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/src/backend/db/agent_settings/sharing.py b/src/backend/db/agent_settings/sharing.py
index 972c68d38b..b87060a33c 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 4cad464811..491fa2f09b 100644
--- a/src/backend/db/migrations.py
+++ b/src/backend/db/migrations.py
@@ -5154,6 +5154,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 0090_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.
@@ -5337,4 +5353,5 @@ def _migrate_supersede_queue_flood_backlog(cursor, conn):
("pull_sync", _migrate_pull_sync),
("skill_gate_requests_table", _migrate_skill_gate_requests_table),
("supersede_queue_flood_backlog", _migrate_supersede_queue_flood_backlog),
+ ("proactive_consent_not_asked", _migrate_proactive_consent_not_asked),
]
diff --git a/src/backend/db_models.py b/src/backend/db_models.py
index fc814160e0..a9052c7524 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/0090_proactive_consent_not_asked.py b/src/backend/migrations/versions/0090_proactive_consent_not_asked.py
new file mode 100644
index 0000000000..5767c12829
--- /dev/null
+++ b/src/backend/migrations/versions/0090_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: 0090_proactive_consent_not_asked
+Revises: 0089_supersede_queue_flood_backlog
+"""
+from alembic import op
+
+
+revision = "0090_proactive_consent_not_asked"
+down_revision = "0089_supersede_queue_flood_backlog"
+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 cee08cd099..a934b31c23 100644
--- a/src/backend/models.py
+++ b/src/backend/models.py
@@ -5101,6 +5101,30 @@ 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 seat the agent owns objectives through (trinity-enterprise#812) —
from Trinity's record, not from `x-role`. `null` when it has none; it can
diff --git a/src/backend/routers/agent_files.py b/src/backend/routers/agent_files.py
index 27253144f6..7bf3e9a85d 100644
--- a/src/backend/routers/agent_files.py
+++ b/src/backend/routers/agent_files.py
@@ -12,6 +12,7 @@
from dependencies import get_current_user, AuthorizedAgentByName, reject_agent_principal, assert_agent_owner, is_interactive_principal
from services.agent_auth import agent_httpx_client
from services import (
+ canon_roles_service,
metric_access_service,
metric_read_service,
objective_join_service,
@@ -39,6 +40,7 @@
set_file_sharing_status_logic,
)
from models import (
+ CanonRolesRead,
CreateFolderRequest,
FileUpdateRequest,
ObjectiveJoinRead,
@@ -458,6 +460,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/services/canon_roles_service.py b/src/backend/services/canon_roles_service.py
new file mode 100644
index 0000000000..1354999246
--- /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 0459b373ee..d2d4593061 100644
--- a/src/backend/services/objective_join_service.py
+++ b/src/backend/services/objective_join_service.py
@@ -787,8 +787,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
@@ -802,7 +803,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)
diff --git a/src/frontend/src/components/AgentAssignmentsSection.vue b/src/frontend/src/components/AgentAssignmentsSection.vue
index 51a23af4a4..2ad302a292 100644
--- a/src/frontend/src/components/AgentAssignmentsSection.vue
+++ b/src/frontend/src/components/AgentAssignmentsSection.vue
@@ -5,14 +5,22 @@
People this agent serves
+
+
- Holds the seat: {{ seat.roleId }}
+ Holds the seat: {{ seatName(seat.roleId) }}
- Serves the seat of its primary: {{ seat.roleId }} ({{ seat.person }})
+ Serves the seat of its primary: {{ seatName(seat.roleId) }} ({{ seat.person }})
No seat yet
+
+ · updated {{ seatUpdated(canon, seat.roleId) }}
+
+ No seats to suggest: {{ canonNote }}. A typed role id still works.
+
Only an instance admin can change who this agent serves.
@@ -228,7 +240,7 @@ import { useEnterpriseStore } from '../stores/enterprise'
import { viewState } from '../utils/loadingState'
import {
KIND_LABELS, STAKEHOLDER_KINDS, primaryOf, stakeholdersOf, seatLine,
- consentLabel, driftNote, writeError, withRole,
+ consentLabel, driftNote, writeError, withRole, seatTitle, seatUpdated,
} from '../utils/assignments'
const FEATURE_ID = 'assignments'
@@ -258,6 +270,17 @@ const view = computed(() => viewState({
count: 1,
}))
+// ent#817: the canon's seats — titles for the header and rows, and suggestions.
+const canon = computed(() => assignments.canonRoles[props.agentName] || null)
+const canonList = computed(() => (canon.value?.roles || []).filter((r) => !r.error))
+const roleListId = computed(() => `canon-roles-${props.agentName}`)
+const canonNote = computed(() => (canon.value && !canonList.value.length ? canon.value.message : ''))
+const seatTitleClass = computed(() => 'font-medium text-gray-900 dark:text-gray-100')
+function seatName(roleId) {
+ const title = seatTitle(canon.value, roleId)
+ return title ? `${title} (${roleId})` : roleId
+}
+
const addKinds = computed(() => (primary.value ? STAKEHOLDER_KINDS : ['primary', ...STAKEHOLDER_KINDS]))
function kindLabel(k) { return KIND_LABELS[k] || k }
@@ -436,6 +459,8 @@ function reload() { return assignments.load(props.agentName) }
async function start() {
await enterprise.loadFeatureFlags()
if (!entitled.value) return
+ // The canon read is a container read; it never blocks the roster.
+ assignments.loadCanonRoles(props.agentName)
await reload()
if (isAdmin.value && !assignments.usersLoaded) await assignments.loadUsers()
}
diff --git a/src/frontend/src/stores/assignments.js b/src/frontend/src/stores/assignments.js
index 8a8b35deea..848b53fa07 100644
--- a/src/frontend/src/stores/assignments.js
+++ b/src/frontend/src/stores/assignments.js
@@ -22,6 +22,9 @@ export const useAssignmentsStore = defineStore('assignments', () => {
const users = ref([])
const usersLoaded = ref(false)
const usersError = ref(null)
+ // ent#817: the seats each agent's canon defines — agentName -> the
+ // CanonRolesRead answer. Fail-soft: a failed read is a named reason.
+ const canonRoles = ref({})
function entry(agent) {
if (!byAgent.value[agent]) {
@@ -83,6 +86,19 @@ export const useAssignmentsStore = defineStore('assignments', () => {
await load(agent)
}
+ /** ent#817: the seats the agent's canon defines (id, title, updated). Never throws. */
+ async function loadCanonRoles(agent) {
+ try {
+ const { data } = await api.get(`/api/agents/${encodeURIComponent(agent)}/canon/roles`)
+ canonRoles.value = { ...canonRoles.value, [agent]: data }
+ } catch (err) {
+ canonRoles.value = {
+ ...canonRoles.value,
+ [agent]: { roles: [], unavailable: null, reason: 'unreadable', message: writeError(err, "Couldn't read this agent's role files.") },
+ }
+ }
+ }
+
/** ent#811: set a row's seat, or clear it with `null` (an explicit null clears). */
async function setRole(agent, id, roleId) {
await run(agent, { op: 'update', id, body: { role_id: roleId || null } })
@@ -127,7 +143,7 @@ export const useAssignmentsStore = defineStore('assignments', () => {
}
return {
- byAgent, users, usersLoaded, usersError,
+ byAgent, users, usersLoaded, usersError, canonRoles, loadCanonRoles,
entry, peek, load, loadUsers, add, setKind, remove, replacePrimary,
setRole, setSeatHolder, clearSeatHolder,
}
diff --git a/src/frontend/src/utils/assignments.js b/src/frontend/src/utils/assignments.js
index 478780d326..256c4fdf34 100644
--- a/src/frontend/src/utils/assignments.js
+++ b/src/frontend/src/utils/assignments.js
@@ -98,12 +98,38 @@ export function withRole(body, roleId) {
}
/**
- * Proactive-brief consent as the row can state it. The backend returns one
- * boolean today, so "not asked" and "declined" are one state; the label says
- * only what is known.
+ * Proactive-brief consent in three states (ent#817): consented, declined, or
+ * never asked. Falls back to the boolean for a backend that predates the state.
*/
export function consentLabel(row) {
- return row?.proactive_consent ? 'Proactive briefs on' : 'Proactive briefs off'
+ const state = row?.proactive_consent_state || (row?.proactive_consent ? 'consented' : null)
+ if (state === 'consented') return 'Proactive briefs: consented'
+ if (state === 'declined') return 'Proactive briefs: declined'
+ return 'Proactive briefs: not asked'
+}
+
+const DRIFT_REASONS = {
+ not_recorded: 'set before the check existed — set the seat again to start tracking it',
+ no_updated_stamp: 'the role file has no `updated` stamp to compare',
+ no_canon: 'this agent declares no canon',
+ no_roles_dir: "the canon has no roles/ directory",
+ agent_stopped: 'the agent is stopped',
+ agent_missing: 'the agent has no container',
+ agent_unreachable: 'the agent is not answering',
+ rate_limited: 'too many reads just now — reload shortly',
+ role_file_invalid: "the role file doesn't parse",
+ role_file_unreadable: "the role file couldn't be read",
+ canon_read_unavailable: 'this install cannot read canon files',
+}
+
+/** The title the canon gives a seat id, or null. */
+export function seatTitle(canon, roleId) {
+ return (canon?.roles || []).find((r) => r.id === roleId)?.title || null
+}
+
+/** The canon's `updated` stamp for a seat id, or null. */
+export function seatUpdated(canon, roleId) {
+ return (canon?.roles || []).find((r) => r.id === roleId)?.updated || null
}
/**
@@ -115,11 +141,16 @@ export function consentLabel(row) {
*/
export function driftNote(row) {
const state = row?.drift_state || 'unknown'
+ if (state === 'no_role') return null
if (state === 'changed') {
return { tone: 'warning', text: 'The role file changed since this was assigned — review it' }
}
+ if (state === 'missing') {
+ return { tone: 'warning', text: 'No role file for this seat in the canon' }
+ }
if (state === 'current') return { tone: 'ok', text: 'Role file unchanged' }
- return { tone: 'muted', text: 'Role file not checked yet' }
+ const why = DRIFT_REASONS[row?.drift_reason]
+ return { tone: 'muted', text: why ? `Role file unknown: ${why}` : 'Role file not checked yet' }
}
/** The backend's own words for a failed write, never a generic line first. */
diff --git a/src/frontend/tests/unit/agentAssignmentsSection.mount.spec.js b/src/frontend/tests/unit/agentAssignmentsSection.mount.spec.js
index e362dfcd9e..b2a783df7b 100644
--- a/src/frontend/tests/unit/agentAssignmentsSection.mount.spec.js
+++ b/src/frontend/tests/unit/agentAssignmentsSection.mount.spec.js
@@ -19,8 +19,8 @@ import { useEnterpriseStore } from '@/stores/enterprise'
import { replacePrimarySteps, seatLine, driftNote, writeError } from '@/utils/assignments'
const AGENT = 'scout'
-const ANN = { id: 'a1', user_id: 1, display_name: 'Ann Lee', role_id: 'cfo', kind: 'primary', drift_state: 'unknown', proactive_consent: true }
-const BOB = { id: 'a2', user_id: 2, display_name: 'Bob Ray', role_id: 'cfo', kind: 'approver', drift_state: 'changed', proactive_consent: false }
+const ANN = { id: 'a1', user_id: 1, display_name: 'Ann Lee', role_id: 'cfo', kind: 'primary', drift_state: 'unknown', proactive_consent: true, proactive_consent_state: 'consented' }
+const BOB = { id: 'a2', user_id: 2, display_name: 'Bob Ray', role_id: 'cfo', kind: 'approver', drift_state: 'changed', proactive_consent: false, proactive_consent_state: 'not_asked' }
const USERS = [
{ id: 1, username: 'ann', name: 'Ann Lee' },
{ id: 2, username: 'bob', name: 'Bob Ray' },
@@ -29,9 +29,11 @@ const USERS = [
]
let roster
+let canonRoles
function serve() {
api.get.mockImplementation(async (url) => {
if (url === '/api/users') return { data: USERS }
+ if (url === `/api/agents/${AGENT}/canon/roles`) return { data: canonRoles }
return { data: roster }
})
}
@@ -54,6 +56,7 @@ beforeEach(() => {
setActivePinia(createPinia())
for (const fn of Object.values(api)) fn.mockReset()
roster = { agent_name: AGENT, assignments: [ANN, BOB], mine: null, primary: 'Ann Lee' }
+ canonRoles = { agent_name: AGENT, roles: [], unavailable: null, reason: 'no_canon', message: 'this agent declares no canon' }
serve()
api.post.mockResolvedValue({ data: {} })
api.patch.mockResolvedValue({ data: {} })
@@ -84,9 +87,9 @@ describe('what an admin sees', () => {
it('shows consent and a changed role file as a warning on its row', async () => {
const w = render()
await flushPromises()
- expect(q(w, 'assignment-primary').find('[data-testid="assignment-consent"]').text()).toBe('Proactive briefs on')
+ expect(q(w, 'assignment-primary').find('[data-testid="assignment-consent"]').text()).toBe('Proactive briefs: consented')
const bob = w.find('[data-testid="assignment-row"]')
- expect(bob.find('[data-testid="assignment-consent"]').text()).toBe('Proactive briefs off')
+ expect(bob.find('[data-testid="assignment-consent"]').text()).toBe('Proactive briefs: not asked')
expect(bob.find('[data-testid="assignment-drift"]').text()).toContain('role file changed')
})
@@ -327,3 +330,66 @@ describe('seats (trinity-enterprise#811)', () => {
expect(steps).toEqual([{ op: 'create', body: { user_id: 3, kind: 'primary' } }])
})
})
+
+
+describe('canon seats, drift and consent (trinity-enterprise#817)', () => {
+ const CANON = {
+ agent_name: AGENT, canon_root: 'canon', unavailable: null, reason: null, message: null,
+ roles: [
+ { id: 'cfo', title: 'Chief Financial Officer', updated: '2026-09-30', path: 'canon/roles/cfo.yaml', error: null },
+ { id: 'head-of-sales', title: 'Head of Sales', updated: null, path: 'canon/roles/head-of-sales.yaml', error: null },
+ ],
+ }
+
+ it("names the seat by its canon title, with the file's updated stamp", async () => {
+ canonRoles = CANON
+ const w = render()
+ await flushPromises()
+ expect(q(w, 'assignments-seat').text().replace(/\s+/g, ' '))
+ .toBe('Serves the seat of its primary: Chief Financial Officer (cfo) (Ann Lee) · updated 2026-09-30')
+ expect(q(w, 'assignment-primary').find('[data-testid="assignment-seat"]').text()).toBe('Chief Financial Officer (cfo)')
+ })
+
+ it('offers the canon seats on every seat field, and a typed id still works', async () => {
+ canonRoles = CANON
+ const w = render()
+ await flushPromises()
+ const list = w.find(`datalist#canon-roles-${AGENT}`)
+ expect(list.findAll('option').map((o) => o.attributes('value'))).toEqual(['cfo', 'head-of-sales'])
+ expect(q(w, 'add-role').attributes('list')).toBe(`canon-roles-${AGENT}`)
+ await q(w, 'add-user').setValue('3')
+ await q(w, 'add-role').setValue('not-in-canon')
+ await q(w, 'assignment-add-form').trigger('submit')
+ await flushPromises()
+ expect(api.post.mock.calls[0][1].role_id).toBe('not-in-canon')
+ })
+
+ it('tells an admin why there are no seats to suggest', async () => {
+ const w = render()
+ await flushPromises()
+ expect(q(w, 'assignments-canon-note').text()).toContain('this agent declares no canon')
+ })
+
+ it('reads consent in three states', async () => {
+ roster = { ...roster, assignments: [
+ ANN, { ...BOB, proactive_consent_state: 'declined' },
+ ] }
+ const w = render()
+ await flushPromises()
+ expect(w.find('[data-testid="assignment-row"] [data-testid="assignment-consent"]').text()).toBe('Proactive briefs: declined')
+ })
+
+ it('warns on a missing role file, names why drift is unknown, and says nothing without a seat', async () => {
+ roster = { ...roster, assignments: [
+ { ...ANN, drift_state: 'missing', drift_reason: 'role_file_not_found' },
+ { ...BOB, drift_state: 'unknown', drift_reason: 'not_recorded' },
+ { ...BOB, id: 'a3', user_id: 3, display_name: 'Cat Moe', role_id: null, kind: 'viewer', drift_state: 'no_role' },
+ ] }
+ const w = render()
+ await flushPromises()
+ expect(q(w, 'assignment-primary').find('[data-testid="assignment-drift"]').text()).toBe('No role file for this seat in the canon')
+ const [bob, cat] = w.findAll('[data-testid="assignment-row"]')
+ expect(bob.find('[data-testid="assignment-drift"]').text()).toContain('set the seat again to start tracking')
+ expect(cat.find('[data-testid="assignment-drift"]').exists()).toBe(false)
+ })
+})
diff --git a/tests/registry.json b/tests/registry.json
index 64a9469a5c..f0351ffdd6 100644
--- a/tests/registry.json
+++ b/tests/registry.json
@@ -1,5 +1,28 @@
{
"test_files": [
+ {
+ "file": "unit/test_ent817_proactive_consent.py",
+ "feature": "trinity-enterprise#817",
+ "added": "2026-10-06",
+ "categories": [
+ "backend",
+ "sharing",
+ "consent",
+ "migrations"
+ ],
+ "description": "Proactive consent in three states on real SQLite: a new share is NULL (not asked), the toggle writes declined/consented and both read paths say which, only consented may be messaged, and the data migration moves legacy 0s to NULL idempotently on both tracks."
+ },
+ {
+ "file": "unit/test_ent817_canon_roles.py",
+ "feature": "trinity-enterprise#817",
+ "added": "2026-10-06",
+ "categories": [
+ "backend",
+ "canon",
+ "api"
+ ],
+ "description": "canon_roles_service lists /roles/*.yaml with id, title and updated through the agent door; names every empty answer (no canon, traversing path, no roles dir, empty, stopped, unreachable, timeout), names an unparsable file, caps and says so; the route self-gates agent keys and draws on the objectives budget."
+ },
{
"file": "unit/test_ent812_objective_join_seat.py",
"feature": "trinity-enterprise#812",
diff --git a/tests/unit/_route_census.py b/tests/unit/_route_census.py
index bf7bbc1f7b..2a076b8738 100644
--- a/tests/unit/_route_census.py
+++ b/tests/unit/_route_census.py
@@ -506,6 +506,10 @@ def _resolve_endpoint(
# Reviewed uses an agent key may make. key -> ("METHOD /full/path", reason)
AGENT_CALLABLE: Dict[str, Tuple[str, str]] = {
# The agent's own runtime surface (#2996 §3.4 regression set).
+ "routers/agent_files.py::get_agent_canon_roles": (
+ "GET /api/agents/{agent_name}/canon/roles",
+ "a READ of the agent's own canon seats (trinity-enterprise#817); an agent key is "
+ "self-gated to its own canon, the same rule and budget as its objectives"),
"routers/agents.py::agent_heartbeat": (
"POST /api/agents/{agent_name}/heartbeat",
"liveness; authenticates the agent's own agent-scoped key outside get_current_user (#307)"),
diff --git a/tests/unit/test_ent817_canon_roles.py b/tests/unit/test_ent817_canon_roles.py
new file mode 100644
index 0000000000..59d069f4ab
--- /dev/null
+++ b/tests/unit/test_ent817_canon_roles.py
@@ -0,0 +1,143 @@
+"""trinity-enterprise#817 — the seats an agent's canon defines.
+
+`canon_roles_service.read_canon_roles(agent)` lists `/roles/*.yaml` with
+each file's id (the stem), `title` and `updated` stamp, read from the agent's
+own container through the objective join's agent-door helpers. It never raises,
+and an empty list always says why: stopped, no canon, no `roles/`, unreadable,
+too slow. Driven with the ent#666 suite's fake agent door.
+"""
+import pytest
+
+from services import canon_roles_service as svc
+from unit.test_ent666_objective_join import _FakeClient, running # noqa: F401 — fixture
+
+pytestmark = pytest.mark.unit
+
+AGENT = "sales-companion"
+CANON = "name: sales-companion\nx-canon:\n clone_path: canon\n"
+
+
+def _client(files, listing):
+ return _FakeClient(files=files, listing=listing)
+
+
+@pytest.mark.asyncio
+async def test_it_lists_each_role_with_its_title_and_updated_stamp(running):
+ client = _client({
+ "template.yaml": CANON,
+ "canon/roles/head-of-sales.yaml": "title: Head of Sales\nupdated: 2026-09-30\nmission: Grow\n",
+ "canon/roles/cfo.yaml": "title: CFO\n",
+ }, ["head-of-sales.yaml", "cfo.yaml", "README.md"])
+ out = await svc.read_canon_roles(AGENT, client=client)
+
+ assert out["unavailable"] is None and out["reason"] is None and out["canon_root"] == "canon"
+ assert out["roles"] == [
+ {"id": "cfo", "title": "CFO", "updated": None, "path": "canon/roles/cfo.yaml", "error": None},
+ {"id": "head-of-sales", "title": "Head of Sales", "updated": "2026-09-30",
+ "path": "canon/roles/head-of-sales.yaml", "error": None},
+ ]
+ assert any("roles" in c for c in client.calls if c.startswith("/api/files?"))
+
+
+@pytest.mark.asyncio
+async def test_a_file_that_will_not_parse_is_named_not_dropped(running):
+ client = _client({"template.yaml": CANON, "canon/roles/broken.yaml": "title: [unclosed"},
+ ["broken.yaml"])
+ out = await svc.read_canon_roles(AGENT, client=client)
+ assert out["roles"] == [{"id": "broken", "title": None, "updated": None,
+ "path": "canon/roles/broken.yaml", "error": "invalid"}]
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("template,reason", [
+ ("name: plain-agent\n", "no_canon"),
+ ("x-canon:\n clone_path: ../../etc\n", "canon_path_invalid"),
+], ids=["no-canon", "traversing-path"])
+async def test_no_usable_canon_is_named(running, template, reason):
+ client = _client({"template.yaml": template}, [])
+ out = await svc.read_canon_roles(AGENT, client=client)
+ assert out["roles"] == [] and out["reason"] == reason and out["message"]
+ assert not any(c.startswith("/api/files?") for c in client.calls), "no listing without a canon"
+
+
+@pytest.mark.asyncio
+async def test_a_missing_roles_directory_is_named(running):
+ client = _FakeClient(files={"template.yaml": CANON}, list_status=404)
+ out = await svc.read_canon_roles(AGENT, client=client)
+ assert out["roles"] == [] and out["reason"] == "absent" and "roles/" in out["message"]
+
+
+@pytest.mark.asyncio
+async def test_an_empty_roles_directory_says_so(running):
+ out = await svc.read_canon_roles(AGENT, client=_client({"template.yaml": CANON}, []))
+ assert out["roles"] == [] and out["reason"] == "empty"
+
+
+@pytest.mark.asyncio
+@pytest.mark.parametrize("state,unavailable", [
+ ("stopped", "agent_stopped"), ("missing", "agent_missing"), (None, "agent_unreachable")])
+async def test_an_agent_that_is_not_running_is_named(monkeypatch, state, unavailable):
+ from services import docker_utils
+
+ async def _state(name):
+ return state
+ monkeypatch.setattr(docker_utils, "agent_container_state_async", _state)
+ out = await svc.read_canon_roles(AGENT, client=_client({}, []))
+ assert out["unavailable"] == unavailable and out["roles"] == [] and out["message"]
+
+
+@pytest.mark.asyncio
+async def test_an_agent_that_stops_answering_is_named(running):
+ client = _FakeClient(files={"template.yaml": CANON}, listing=["a.yaml"], fail_after=1)
+ out = await svc.read_canon_roles(AGENT, client=client)
+ assert out["unavailable"] == "agent_unreachable"
+
+
+@pytest.mark.asyncio
+async def test_too_many_files_are_capped_and_said(running, monkeypatch):
+ monkeypatch.setattr(svc, "MAX_ROLES", 2)
+ files = {"template.yaml": CANON, **{f"canon/roles/r{i}.yaml": f"title: R{i}\n" for i in range(3)}}
+ out = await svc.read_canon_roles(AGENT, client=_client(files, [f"r{i}.yaml" for i in range(3)]))
+ assert len(out["roles"]) == 2 and out["truncated"] is True
+
+
+@pytest.mark.asyncio
+async def test_a_slow_read_is_named_not_waited_on(running, monkeypatch):
+ import asyncio
+
+ class Slow(_FakeClient):
+ async def get(self, path, timeout=None, **kw):
+ await asyncio.sleep(5)
+ monkeypatch.setattr(svc, "ROLES_READ_BUDGET_SEC", 0.05)
+ out = await svc.read_canon_roles(AGENT, client=Slow())
+ assert out["reason"] == "timeout" and out["roles"] == []
+
+
+@pytest.mark.asyncio
+async def test_an_agent_key_reads_only_its_own_canon(monkeypatch):
+ from fastapi import HTTPException
+ from models import User
+ from routers import agent_files
+ monkeypatch.setattr(agent_files.objectives_read_budget, "enforce", lambda *a, **k: None)
+ with pytest.raises(HTTPException) as e:
+ await agent_files.get_agent_canon_roles(
+ agent_name="other-agent",
+ current_user=User(id=1, username="admin", role="admin",
+ agent_name=AGENT, mcp_scope="agent"))
+ assert e.value.status_code == 403
+
+
+@pytest.mark.asyncio
+async def test_the_read_draws_on_the_objectives_budget(monkeypatch):
+ from models import User
+ from routers import agent_files
+ spent = []
+ monkeypatch.setattr(agent_files.objectives_read_budget, "enforce",
+ lambda agent, **k: spent.append(agent))
+
+ async def roles(agent):
+ return {"agent_name": agent, "roles": []}
+ monkeypatch.setattr(agent_files.canon_roles_service, "read_canon_roles", roles)
+ out = await agent_files.get_agent_canon_roles(
+ agent_name=AGENT, current_user=User(id=1, username="admin", role="admin"))
+ assert spent == [AGENT] and out["agent_name"] == AGENT
diff --git a/tests/unit/test_ent817_proactive_consent.py b/tests/unit/test_ent817_proactive_consent.py
new file mode 100644
index 0000000000..5b39397baf
--- /dev/null
+++ b/tests/unit/test_ent817_proactive_consent.py
@@ -0,0 +1,124 @@
+"""trinity-enterprise#817 — proactive consent: consented / declined / not asked.
+
+`agent_sharing.allow_proactive` defaulted to 0 for every share, so "nobody asked
+this person" and "this person declined" were the same stored value. Now:
+NULL = not asked, 0 = declined, 1 = consented (operator ruling 2026-10-06).
+
+Pinned against the unit island's real SQLite:
+
+* a new share is written NULL — "not asked", never the legacy DEFAULT 0;
+* the Access toggle writes 0 / 1, read back as declined / consented;
+* both read paths (the share list and the Access-tab operator list) say which;
+* only `consented` lets an agent send unprompted — unchanged;
+* the data migration moves existing 0s to NULL, leaves 1s, and is idempotent.
+"""
+import uuid
+
+import pytest
+
+pytestmark = pytest.mark.unit
+
+
+def _agent():
+ return f"agent-817-{uuid.uuid4().hex[:8]}"
+
+
+@pytest.fixture
+def owner():
+ """An admin who can share any agent, plus the agent's ownership row."""
+ from database import db
+ from db.engine import get_engine
+ from db.tables import users
+ from sqlalchemy import insert
+ from utils.helpers import utc_now_iso
+
+ name = f"owner-817-{uuid.uuid4().hex[:6]}"
+ with get_engine().begin() as conn:
+ conn.execute(insert(users).values(
+ username=name, role="admin", email=f"{name}@example.com",
+ created_at=utc_now_iso(), updated_at=utc_now_iso()))
+ agent = _agent()
+ db.register_agent_owner(agent, name)
+ return name, agent
+
+
+def _stored(agent, email):
+ from db.engine import get_engine
+ from sqlalchemy import text
+ with get_engine().connect() as conn:
+ return conn.execute(text(
+ "SELECT allow_proactive FROM agent_sharing WHERE agent_name=:a AND shared_with_email=:e"),
+ {"a": agent, "e": email}).scalar()
+
+
+@pytest.mark.parametrize("value,state", [(None, "not_asked"), (0, "declined"), (1, "consented")])
+def test_the_stored_value_reads_as_one_of_three_states(value, state):
+ from db.agent_settings.sharing import SharingMixin
+ assert SharingMixin.proactive_consent_state(value) == state
+
+
+def test_a_new_share_is_not_asked_rather_than_declined(owner):
+ from database import db
+ name, agent = owner
+ share = db.share_agent(agent, name, "Person@Example.com")
+ assert share is not None
+ assert _stored(agent, "person@example.com") is None
+ assert share.proactive_consent == "not_asked" and share.allow_proactive is False
+
+
+def test_the_toggle_records_an_answer_and_both_reads_say_which(owner):
+ from database import db
+ name, agent = owner
+ db.share_agent(agent, name, "yes@example.com")
+ db.share_agent(agent, name, "no@example.com")
+ db.share_agent(agent, name, "unasked@example.com")
+ assert db.set_allow_proactive(agent, "yes@example.com", True, name)
+ assert db.set_allow_proactive(agent, "no@example.com", False, name)
+
+ states = {r["email"]: r["proactive_consent"] for r in db.get_agent_operator_access(agent)}
+ assert states == {"yes@example.com": "consented", "no@example.com": "declined",
+ "unasked@example.com": "not_asked"}
+ shares = {s.shared_with_email: s.proactive_consent for s in db.get_agent_shares(agent)}
+ assert shares == states
+
+
+def test_only_consent_lets_an_agent_send(owner):
+ from database import db
+ name, agent = owner
+ for email in ("yes@example.com", "no@example.com", "unasked@example.com"):
+ db.share_agent(agent, name, email)
+ db.set_allow_proactive(agent, "yes@example.com", True, name)
+ db.set_allow_proactive(agent, "no@example.com", False, name)
+ assert db.get_proactive_enabled_shares(agent) == ["yes@example.com"]
+
+
+def test_the_migration_moves_legacy_zeros_to_not_asked_and_is_idempotent(owner):
+ from database import db
+ from db.connection import get_db_connection
+ from db.engine import get_engine
+ from db.migrations import _migrate_proactive_consent_not_asked
+ from sqlalchemy import text
+ name, agent = owner
+ db.share_agent(agent, name, "legacy@example.com")
+ db.share_agent(agent, name, "yes@example.com")
+ with get_engine().begin() as conn: # what every pre-#817 share looked like
+ conn.execute(text("UPDATE agent_sharing SET allow_proactive = 0 "
+ "WHERE agent_name = :a AND shared_with_email = 'legacy@example.com'"),
+ {"a": agent})
+ db.set_allow_proactive(agent, "yes@example.com", True, name)
+
+ for _ in range(2):
+ with get_db_connection() as conn:
+ _migrate_proactive_consent_not_asked(conn.cursor(), conn)
+ assert _stored(agent, "legacy@example.com") is None
+ assert _stored(agent, "yes@example.com") == 1
+
+
+def test_the_migration_is_registered_on_both_tracks():
+ from pathlib import Path
+ from db import migrations
+ names = [n for n, _ in migrations.MIGRATIONS]
+ assert "proactive_consent_not_asked" in names
+ rev = (Path(migrations.__file__).resolve().parents[1]
+ / "migrations" / "versions" / "0090_proactive_consent_not_asked.py").read_text()
+ assert "allow_proactive = NULL WHERE allow_proactive = 0" in rev
From c8407612dc8a5101ef826fede2ef20f341363877 Mon Sep 17 00:00:00 2001
From: Oleksii Dolhov
Date: Tue, 6 Oct 2026 18:10:48 +0300
Subject: [PATCH 06/14] feat(access): a shared companion serves the seat of the
person each run is for (abilityai/trinity-enterprise#814)
- assignment_provider.resolve_served_seat(agent, email): the person's
own seat, the agent's own when it holds one (holds wins), the
primary's as the fallback; never raises. A provider that predates
the OPTIONAL served_seat_for falls back to seat_for.
- Seat decisions (ent#638) are stamped with the served seat; the
agent's decided_by_role is only the fallback when nobody's seat is on
record (its prompt used to name the primary's seat on every run).
- The prompt keeps its Primary human line and adds "Seat this run
serves" only when the run serves a different seat: a chat user, or
the Workspace a scheduled brief is delivered to (read off the
execution row, never rendered). Suppressed audiences render nothing.
- The add form no longer pre-fills the primary's seat for every new
person.
The one-primary rule, the primary display name and the primary address
are unchanged.
Co-Authored-By: Claude Opus 5.5
---
docs/memory/requirements/infrastructure.md | 1 +
src/backend/routers/seat_decisions.py | 9 +-
src/backend/services/assignment_provider.py | 53 ++++
.../services/platform_prompt_service.py | 43 +++
.../services/task_execution_service.py | 16 ++
.../components/AgentAssignmentsSection.vue | 4 +-
.../agentAssignmentsSection.mount.spec.js | 5 +-
tests/registry.json | 12 +
tests/unit/test_ent814_served_seat.py | 257 ++++++++++++++++++
9 files changed, 394 insertions(+), 6 deletions(-)
create mode 100644 tests/unit/test_ent814_served_seat.py
diff --git a/docs/memory/requirements/infrastructure.md b/docs/memory/requirements/infrastructure.md
index 287bf508bd..ff5e587ce8 100644
--- a/docs/memory/requirements/infrastructure.md
+++ b/docs/memory/requirements/infrastructure.md
@@ -264,6 +264,7 @@
- **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)
diff --git a/src/backend/routers/seat_decisions.py b/src/backend/routers/seat_decisions.py
index d417ff5539..da84acf070 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"])
@@ -85,7 +85,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 3a3adba75e..fd5d9191bf 100644
--- a/src/backend/services/assignment_provider.py
+++ b/src/backend/services/assignment_provider.py
@@ -92,6 +92,15 @@ def assignment_for(
# :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
@@ -300,3 +309,47 @@ def resolve_seat(agent_name: Optional[str]) -> Dict[str, Any]:
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/platform_prompt_service.py b/src/backend/services/platform_prompt_service.py
index e47454c1eb..ed6b38587a 100644
--- a/src/backend/services/platform_prompt_service.py
+++ b/src/backend/services/platform_prompt_service.py
@@ -725,6 +725,16 @@ class ExecutionContext:
# from the DB; stateless, so a wake that was lost (a crash, a stopped agent)
# still reaches the agent on its next turn.
ended_asks: Optional[List[dict]] = 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:
@@ -926,6 +936,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}")
@@ -983,6 +996,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)."""
@@ -1067,6 +1104,9 @@ def compose_system_prompt(
if needs_assignment
else {}
)
+ served_role = (
+ _served_role(ctx) if assignment and ctx.served_role_id is None else None
+ )
ctx = replace(
ctx,
collaborators=(
@@ -1104,6 +1144,9 @@ def compose_system_prompt(
if ctx.ended_asks is not None
else _resolve_ended_asks(ctx.agent_name)
),
+ 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/task_execution_service.py b/src/backend/services/task_execution_service.py
index a94c1c3e87..fdb15b899a 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).
@@ -2259,6 +2274,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/AgentAssignmentsSection.vue b/src/frontend/src/components/AgentAssignmentsSection.vue
index 2ad302a292..f35945b85e 100644
--- a/src/frontend/src/components/AgentAssignmentsSection.vue
+++ b/src/frontend/src/components/AgentAssignmentsSection.vue
@@ -306,8 +306,8 @@ watch(addKinds, (kinds) => {
if (kinds[0] === 'primary') addForm.value.kind = 'primary'
else if (!kinds.includes(addForm.value.kind)) addForm.value.kind = kinds[0]
}, { immediate: true })
-// Start the role from the agent's seat; the admin can type any other id.
-watch(seat, (s) => { if (!addForm.value.roleId && s.form === 'serves') addForm.value.roleId = s.roleId }, { immediate: true })
+// No prefill (ent#814): each person holds their own seat, and a run for them
+// serves it — starting from the primary's would stamp everyone with that role.
async function addPerson() {
addError.value = ''
diff --git a/src/frontend/tests/unit/agentAssignmentsSection.mount.spec.js b/src/frontend/tests/unit/agentAssignmentsSection.mount.spec.js
index b2a783df7b..0ff10fe1f6 100644
--- a/src/frontend/tests/unit/agentAssignmentsSection.mount.spec.js
+++ b/src/frontend/tests/unit/agentAssignmentsSection.mount.spec.js
@@ -104,14 +104,15 @@ describe('what an admin sees', () => {
})
describe('writes', () => {
- it('adds a person picked by name, with the seat filled in, and reloads', async () => {
+ it('adds a person picked by name, with their own seat, and reloads', async () => {
const w = render()
await flushPromises()
const options = q(w, 'add-user').findAll('option').map((o) => o.text())
expect(options).toContain('Cat Moe')
expect(options).not.toContain('Dan Off') // suspended
- expect(q(w, 'add-role').element.value).toBe('cfo') // the agent's seat
+ expect(q(w, 'add-role').element.value).toBe('') // ent#814: never the primary's seat by default
await q(w, 'add-user').setValue('3')
+ await q(w, 'add-role').setValue('cfo')
await q(w, 'add-kind').setValue('viewer')
await q(w, 'assignment-add-form').trigger('submit')
await flushPromises()
diff --git a/tests/registry.json b/tests/registry.json
index f0351ffdd6..f2b6bca77d 100644
--- a/tests/registry.json
+++ b/tests/registry.json
@@ -1,5 +1,17 @@
{
"test_files": [
+ {
+ "file": "unit/test_ent814_served_seat.py",
+ "feature": "trinity-enterprise#814",
+ "added": "2026-10-06",
+ "categories": [
+ "backend",
+ "assignments",
+ "prompt",
+ "seat-decisions"
+ ],
+ "description": "A shared companion serves the seat of the person each run is for: resolve_served_seat answers the person's own seat, holds wins, the primary's as fallback for every degraded provider answer; the prompt keeps the Primary human line and adds 'Seat this run serves' only when different (chat user or a brief's Workspace address, never rendered); a seat decision is stamped with the served seat over the agent's claim."
+ },
{
"file": "unit/test_ent817_proactive_consent.py",
"feature": "trinity-enterprise#817",
diff --git a/tests/unit/test_ent814_served_seat.py b/tests/unit/test_ent814_served_seat.py
new file mode 100644
index 0000000000..6dc12491df
--- /dev/null
+++ b/tests/unit/test_ent814_served_seat.py
@@ -0,0 +1,257 @@
+"""trinity-enterprise#814 — a shared companion serves the seat of the person each run is for.
+
+R26 lets one companion serve several people; the one-primary rule stays. So the
+seat is resolved per run, from the served person's own assignment row:
+
+* ``assignment_provider.resolve_served_seat(agent, email)`` — the person's own
+ seat; the agent's own seat when it holds one (holds wins, ruling 2026-10-06);
+ the primary's as the fallback, which is also the whole answer for a provider
+ that predates ``served_seat_for``. Never raises.
+* A seat decision (ent#638) is stamped with that seat, not the primary's.
+* The prompt keeps its `Primary human` line exactly as it was and adds a
+ `Seat this run serves` line only when the run serves a different seat — for a
+ chat user or for the Workspace a scheduled brief is delivered to.
+"""
+from __future__ import annotations
+
+import asyncio
+from types import SimpleNamespace
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from services import assignment_provider as ap
+from services import platform_prompt_service as pps
+from services.platform_prompt_service import ExecutionContext, compose_system_prompt
+
+pytestmark = pytest.mark.unit
+
+AGENT = "shared-companion"
+ANN, BOB, CAT = "ann@example.com", "bob@example.com", "cat@example.com"
+
+
+@pytest.fixture(autouse=True)
+def _clean_provider():
+ ap.clear_provider()
+ yield
+ ap.clear_provider()
+
+
+class _Roster:
+ """Ann is the primary (cfo); Bob holds head-of-sales; Cat has a row and no seat."""
+
+ def __init__(self, *, held=None, served=True, raises=False, answer=None):
+ self.held, self.served, self.raises, self.answer = held, served, raises, answer
+ self.asked = []
+ if not served:
+ self.served_seat_for = None
+
+ def assignment_for(self, agent_name, triggered_by):
+ if triggered_by in ("public", "paid"):
+ return None
+ return {"primary_user_display": "Ann", "role_id": "cfo", "proactive_consent": False}
+
+ def seat_for(self, agent_name):
+ if self.held:
+ return {"case": "holds", "role_id": self.held, "seats": ["cfo", "head-of-sales"]}
+ return {"case": "serves", "role_id": "cfo", "seats": ["cfo", "head-of-sales"]}
+
+ def served_seat_for(self, agent_name, person_email): # noqa: F811 — nulled when served=False
+ self.asked.append(person_email)
+ if self.raises:
+ raise RuntimeError("boom")
+ if self.answer is not None:
+ return self.answer
+ if self.held:
+ return {"role_id": self.held, "source": "holds"}
+ own = {BOB: "head-of-sales"}.get(person_email)
+ return {"role_id": own, "source": "person"} if own else {"role_id": "cfo", "source": "primary"}
+
+
+def _register(**kw):
+ roster = _Roster(**kw)
+ ap.register_provider(roster)
+ return roster
+
+
+# ---------------------------------------------------------------------------
+# The seam
+# ---------------------------------------------------------------------------
+
+def test_the_served_persons_own_seat_wins_over_the_primarys():
+ roster = _register()
+ assert ap.resolve_served_seat(AGENT, " Bob@Example.com ") == {"role_id": "head-of-sales", "source": "person"}
+ assert roster.asked == [BOB], "the email reaches the provider case-folded"
+
+
+def test_a_person_with_no_seat_of_their_own_gets_the_primarys():
+ _register()
+ assert ap.resolve_served_seat(AGENT, CAT) == {"role_id": "cfo", "source": "primary"}
+
+
+def test_an_agent_that_holds_a_seat_serves_it_whoever_the_run_is_for():
+ _register(held="orchestrator")
+ assert ap.resolve_served_seat(AGENT, BOB) == {"role_id": "orchestrator", "source": "holds"}
+
+
+@pytest.mark.parametrize("kw", [
+ {"served": False}, {"raises": True}, {"answer": {"role_id": 7, "source": "person"}},
+ {"answer": {"role_id": None, "source": "person"}}, {"answer": "cfo"},
+ {"answer": {"role_id": "x", "source": "someone"}},
+], ids=["predates-method", "raises", "non-str-role", "person-without-role", "not-a-dict", "unknown-source"])
+def test_every_degraded_answer_falls_back_to_the_primarys_seat(kw):
+ _register(**kw)
+ assert ap.resolve_served_seat(AGENT, BOB) == {"role_id": "cfo", "source": "primary"}
+
+
+def test_no_person_or_no_provider_is_the_trigger_free_answer():
+ assert ap.resolve_served_seat(AGENT, BOB) == {"role_id": None, "source": "none"}
+ roster = _register()
+ assert ap.resolve_served_seat(AGENT, None) == {"role_id": "cfo", "source": "primary"}
+ assert ap.resolve_served_seat(AGENT, "") == {"role_id": "cfo", "source": "primary"}
+ assert roster.asked == [], "no person, no per-person read"
+
+
+# ---------------------------------------------------------------------------
+# The prompt
+# ---------------------------------------------------------------------------
+
+@pytest.fixture
+def quiet_db():
+ fake = MagicMock()
+ fake.get_setting_value = MagicMock(return_value=None)
+ fake.get_permitted_agents = MagicMock(return_value=[])
+ fake.list_recent_operator_queue_endings = MagicMock(return_value=[])
+ with patch.object(pps, "db", fake):
+ yield fake
+
+
+def _block(**ctx):
+ return compose_system_prompt(ExecutionContext(agent_name=AGENT, **ctx))
+
+
+def test_a_chat_with_bob_names_bobs_seat_and_leaves_the_primary_line_alone(quiet_db):
+ _register()
+ out = _block(triggered_by="chat", source_user_email=BOB)
+ assert "- **Primary human**: Ann (role: cfo)" in out
+ assert "- **Seat this run serves**: head-of-sales" in out
+
+
+def test_a_brief_delivered_to_bobs_workspace_names_bobs_seat_without_his_email(quiet_db):
+ _register()
+ out = _block(triggered_by="schedule", served_person_email=BOB)
+ assert "- **Seat this run serves**: head-of-sales" in out
+ assert BOB not in out, "the delivery address picks the seat; it is never rendered"
+
+
+@pytest.mark.parametrize("ctx", [
+ {"triggered_by": "chat", "source_user_email": ANN},
+ {"triggered_by": "chat", "source_user_email": CAT},
+ {"triggered_by": "schedule"},
+], ids=["the-primary", "no-seat-of-their-own", "no-person"])
+def test_no_second_line_when_the_run_serves_the_primarys_seat(quiet_db, ctx):
+ _register()
+ out = _block(**ctx)
+ assert "- **Primary human**: Ann (role: cfo)" in out
+ assert "Seat this run serves" not in out
+
+
+def test_a_suppressed_audience_still_renders_nothing(quiet_db):
+ roster = _register()
+ out = _block(triggered_by="public", source_user_email=BOB)
+ assert "Primary human" not in out and "Seat this run serves" not in out
+ assert roster.asked == [], "no assignment answer, no per-person read"
+
+
+def test_an_agent_that_holds_a_seat_says_so(quiet_db):
+ _register(held="orchestrator")
+ out = _block(triggered_by="chat", source_user_email=BOB)
+ assert "- **Seat this run serves**: orchestrator" in out
+
+
+def test_a_raising_provider_still_yields_the_block(quiet_db):
+ _register(raises=True)
+ out = _block(triggered_by="chat", source_user_email=BOB)
+ assert "- **Primary human**: Ann (role: cfo)" in out
+ assert "Seat this run serves" not in out
+
+
+def test_a_scheduled_seat_run_reads_its_delivery_address_off_the_row(monkeypatch):
+ from services import task_execution_service as tes
+
+ row = SimpleNamespace(triggered_by="schedule", schedule_id="sch-1", source_channel="portal",
+ source_channel_client="Bob@Example.com")
+ reads = []
+ monkeypatch.setattr(tes.db, "get_execution", lambda eid: reads.append(eid) or row)
+ assert tes._served_person_for("e1", "schedule") == BOB
+ assert tes._served_person_for("e1", "chat") is None, "a chat turn names its user itself"
+ assert tes._served_person_for(None, "schedule") is None
+ assert reads == ["e1"], "one row read, and only for a schedule fire"
+
+
+# ---------------------------------------------------------------------------
+# Seat decisions (ent#638)
+# ---------------------------------------------------------------------------
+
+@pytest.fixture
+def wired(monkeypatch):
+ from routers import seat_decisions as r
+ from services import idempotency_service as idem
+ from services import seat_decision_service as sds
+
+ store = {}
+ monkeypatch.setattr(r.db, "get_execution", lambda eid: store.get("execution"))
+ monkeypatch.setattr(r, "assert_agent_access", lambda *a, **k: None)
+ monkeypatch.setattr(r.rate_limiter, "enforce", lambda *a, **k: None)
+ recorded = []
+ monkeypatch.setattr(sds, "record", lambda db, **kw: recorded.append(kw) or {
+ "id": "d1", "seat_email": kw["seat_email"], "decided_by_person": kw["decided_by_person"],
+ "outcome": "approved", "decided": "x", "alternatives": ["y"], "criterion": "c", "reversal": "r",
+ "decided_at": "2026-10-06T00:00:00Z", "review_by": "2027-01-01", "status": "active", "cites": [],
+ "decided_by_role": kw["payload"].get("decided_by_role"),
+ })
+ monkeypatch.setattr(idem, "begin", lambda scope, key: SimpleNamespace(enabled=False, replay=False, in_flight=False))
+ monkeypatch.setattr(idem, "complete", lambda *a, **k: None)
+ monkeypatch.setattr(idem, "fail", lambda *a, **k: None)
+ return r, store, recorded
+
+
+def _record(r, store, email, **body):
+ from models import RecordDecisionRequest
+
+ store["execution"] = SimpleNamespace(
+ id="e1", agent_name=AGENT, triggered_by="schedule", source_user_email=None,
+ source_channel="portal", source_channel_chat_id="s", source_channel_client=email,
+ schedule_id="sch-1")
+ req = RecordDecisionRequest(
+ execution_id="e1", outcome="approved", decided="Renew the Acme contract",
+ alternatives=["let it lapse"], criterion="margin above 20%", reversal="margin drops",
+ review_by="2027-01-01", **body)
+ return asyncio.run(r.record_seat_decision(AGENT, req, current_user=SimpleNamespace(id=1)))
+
+
+def test_a_decision_for_bob_is_stamped_with_bobs_seat_not_the_primarys(wired):
+ r, store, recorded = wired
+ _register()
+ out = _record(r, store, BOB)
+ assert recorded[0]["seat_email"] == BOB
+ assert recorded[0]["payload"]["decided_by_role"] == "head-of-sales"
+ assert out["decision"]["decided_by"]["role"] == "head-of-sales"
+
+
+def test_the_agents_word_does_not_override_the_seat_on_record(wired):
+ r, store, recorded = wired
+ _register()
+ _record(r, store, BOB, decided_by_role="cfo")
+ assert recorded[0]["payload"]["decided_by_role"] == "head-of-sales"
+
+
+def test_a_person_without_a_seat_gets_the_primarys_and_no_record_at_all_keeps_the_agents(wired):
+ r, store, recorded = wired
+ _register()
+ _record(r, store, CAT)
+ assert recorded[-1]["payload"]["decided_by_role"] == "cfo"
+
+ ap.clear_provider()
+ _record(r, store, CAT, decided_by_role="named-by-agent")
+ assert recorded[-1]["payload"]["decided_by_role"] == "named-by-agent"
From c3ec316e44c301e27a5b573e21b8978a83806557 Mon Sep 17 00:00:00 2001
From: Oleksii Dolhov
Date: Tue, 6 Oct 2026 18:43:03 +0300
Subject: [PATCH 07/14] fix(access): what a live walkthrough of the assignments
section turned up (abilityai/trinity-enterprise#810, #812, #814)
Found by driving the section end to end on a local instance (seeded
people, canon, three agents) with layout-shift capture on every step.
- Replacing the primary with someone already on the agent promotes
their existing row instead of adding a second one. Before, the person
was listed twice (primary and approver) and, under ent#814, held two
seats to choose between. The old primary still steps down first.
- The add form clears the seat after a successful add. Before, the seat
typed for one person was silently given to the next one added.
- First load keeps the loaded section's shape (seat bar, a two-person
list card, the add form) and no longer flashes "No seat yet" for an
agent that has one. On a slow network the operator list below moved
133px on arrival; it now moves 5-24px for agents with up to two
people.
- A failed replacement says where the agent stands, from the re-read
roster ("Leo Park is still the primary."), not only the error.
- On a phone each row stacks its details above its controls; the kind
select no longer covered the person's name.
- The Workspace role card renders the no_seat finding. ent#812 had
mapped it as a role error, which the backend never sends, so the
sentence could not appear.
Co-Authored-By: Claude Opus 5.5
---
.../components/AgentAssignmentsSection.vue | 38 +++++++++++++++----
.../src/components/portal/PortalAgentRole.vue | 14 +++++--
src/frontend/src/utils/assignments.js | 11 ++++--
.../agentAssignmentsSection.mount.spec.js | 31 +++++++++++++++
.../tests/unit/portalAgentRole.spec.js | 10 +++++
5 files changed, 90 insertions(+), 14 deletions(-)
diff --git a/src/frontend/src/components/AgentAssignmentsSection.vue b/src/frontend/src/components/AgentAssignmentsSection.vue
index f35945b85e..f1365d920a 100644
--- a/src/frontend/src/components/AgentAssignmentsSection.vue
+++ b/src/frontend/src/components/AgentAssignmentsSection.vue
@@ -11,7 +11,9 @@
- Your relationship · {{ card.relationship || 'no assignment recorded' }}
+ Your relationship · {{ relationshipText }}
@@ -173,6 +174,24 @@ function flip() {
return store.flipAgentReadiness(props.agentName, next)
}
+// ent#814: the seat on the card is the one this agent serves for YOU.
+const SEAT_SOURCE_TEXT = {
+ holds: 'The agent holds this seat itself',
+ person: 'Your seat on this agent',
+ primary: "Its primary's seat",
+}
+const seatSourceText = computed(() => SEAT_SOURCE_TEXT[card.value?.seat_source] || '')
+
+// ent#638 kinds — the viewer's assignment on this agent.
+const RELATIONSHIP_TEXT = {
+ primary: 'primary — it works for you',
+ approver: 'approver',
+ collaborator: 'collaborator',
+ viewer: 'viewer',
+}
+const relationshipText = computed(() =>
+ RELATIONSHIP_TEXT[card.value?.relationship] || 'no assignment recorded')
+
// trinity-enterprise#812: the join's `no_seat` finding, carried on the card.
const noSeat = computed(() => (card.value?.finding_codes || []).includes('no_seat'))
diff --git a/src/frontend/tests/unit/portalAgentRole.spec.js b/src/frontend/tests/unit/portalAgentRole.spec.js
index 4fe1f16f8a..208adc1558 100644
--- a/src/frontend/tests/unit/portalAgentRole.spec.js
+++ b/src/frontend/tests/unit/portalAgentRole.spec.js
@@ -71,7 +71,7 @@ function card(over = {}) {
agent_name: AGENT,
role: { id: 'sales-lead', title: 'Sales Lead', mission: 'Close ICP-fit pipeline.', status: 'active',
review_by: '2099-12-01', stale: false, path: 'canon/roles/sales-lead.yaml', error: null },
- seat: 'gary@example.com',
+ seat_source: 'person',
objectives: [{
id: 'q4-close-rate', statement: 'Raise close rate this quarter.', horizon: 'quarter', status: 'active', owned: true,
metrics: [
@@ -267,6 +267,37 @@ describe('PortalAgentRole (mounted)', () => {
expect(w.get('[data-testid="portal-role-error"]').text()).toContain("not in the agent's canon yet")
})
+ it('says where the seat comes from, for this viewer — never a person (ent#814)', async () => {
+ const mine = await mountWith(card({ seat_source: 'person' }))
+ expect(mine.get('[data-testid="portal-role-seat-source"]').text()).toBe('Your seat on this agent')
+ const held = await mountWith(card({ seat_source: 'holds' }))
+ expect(held.get('[data-testid="portal-role-seat-source"]').text()).toBe('The agent holds this seat itself')
+ const primary = await mountWith(card({ seat_source: 'primary' }))
+ expect(primary.get('[data-testid="portal-role-seat-source"]').text()).toBe("Its primary's seat")
+ const none = await mountWith(card({ seat_source: 'none' }))
+ expect(none.find('[data-testid="portal-role-seat-source"]').exists()).toBe(false)
+ })
+
+ it("the viewer's relationship comes from their assignment, and is stated when there is none", async () => {
+ const primary = await mountWith(card({ relationship: 'primary' }))
+ expect(primary.get('[data-testid="portal-role-relationship"]').text()).toContain('primary — it works for you')
+ const approver = await mountWith(card({ relationship: 'approver' }))
+ expect(approver.get('[data-testid="portal-role-relationship"]').text()).toContain('approver')
+ const unknown = await mountWith(card({ relationship: 'owner-of-everything' }))
+ expect(unknown.get('[data-testid="portal-role-relationship"]').text()).toContain('no assignment recorded')
+ })
+
+ it('no seat on record is said once, on the role, and the supported work still shows', async () => {
+ const sentence = 'This agent neither holds a seat nor serves someone who holds one, so it owns no objective yet.'
+ const w = await mountWith(card({
+ seat_source: 'none', finding_codes: ['no_seat'],
+ role: { id: null, title: null, error: 'no_seat', path: null },
+ }))
+ expect(w.get('[data-testid="portal-role-error"]').text()).toBe(sentence)
+ expect(w.find('[data-testid="portal-role-no-seat"]').exists()).toBe(false)
+ expect(w.findAll('[data-testid="portal-role-objective"]').length).toBe(1)
+ })
+
it('a stopped agent says the files live in the container', async () => {
const w = await mountWith(card({ role: null, unavailable: 'agent_stopped', objectives: [] }))
expect(w.get('[data-testid="portal-role-unavailable"]').text()).toContain('stopped')
diff --git a/tests/unit/test_ent527_role_card.py b/tests/unit/test_ent527_role_card.py
index c1e098e977..e16e3bd664 100644
--- a/tests/unit/test_ent527_role_card.py
+++ b/tests/unit/test_ent527_role_card.py
@@ -176,16 +176,48 @@ def _full_client():
# --- no role → no card ----------------------------------------------------------
-def test_an_agent_with_no_x_role_has_no_card(monkeypatch):
+def _seats(seat_for=None, served_seat_for=None, kinds_for=None):
+ """Register an assignment provider for one test (the autouse fixture's
+ teardown clears it)."""
+ from services import assignment_provider as ap
+
+ class _P:
+ def assignment_for(self, agent_name, triggered_by):
+ return None
+
+ p = _P()
+ if seat_for:
+ p.seat_for = seat_for
+ if served_seat_for:
+ p.served_seat_for = served_seat_for
+ if kinds_for:
+ p.kinds_for = kinds_for
+ ap.register_provider(p)
+
+
+def test_no_seat_no_canon_and_no_x_role_is_no_card(monkeypatch):
+ from services import assignment_provider as ap
+ ap.clear_provider()
rc = _wire(monkeypatch, FakeClient({"template.yaml": "name: plain\n"}))
assert _card(rc) == {"agent_name": AGENT, "role": None}
-def test_an_unreadable_template_is_also_no_card(monkeypatch):
+def test_an_unreadable_template_with_no_seat_is_also_no_card(monkeypatch):
+ from services import assignment_provider as ap
+ ap.clear_provider()
rc = _wire(monkeypatch, FakeClient({}, broken={"template.yaml"}))
assert _card(rc)["role"] is None
+def test_a_seat_on_record_shows_the_card_even_without_x_role(monkeypatch):
+ """ent#811: the seat is the record. A template that never declared
+ `x-role` still gets its card when the agent serves a seat."""
+ rc = _wire(monkeypatch, FakeClient({"template.yaml": "name: plain\n",
+ "canon/roles/sales-lead.yaml": ROLE}))
+ card = _card(rc)
+ assert card["role"]["id"] == "sales-lead" and card["role"]["title"] == "Sales Lead"
+
+
# --- the role, and a role file that fails to load --------------------------------
def test_the_role_reads_from_the_canon_file_through_the_agent(monkeypatch):
@@ -198,7 +230,64 @@ def test_the_role_reads_from_the_canon_file_through_the_agent(monkeypatch):
assert role["status"] == "active"
assert role["path"] == "canon/roles/sales-lead.yaml"
assert role["error"] is None
- assert card["seat"] == "gary@example.com"
+ # ent#814: where the seat came from, never x-role's email.
+ assert card["seat_source"] == "primary"
+ assert "seat" not in card
+
+
+def test_the_template_x_role_is_not_read_the_seat_on_record_is(monkeypatch):
+ """The live check's defect: the card said `sales-lead` from the template
+ while the Access tab said Head of Sales."""
+ lying = TEMPLATE.replace("role: sales-lead", "role: sales-lead-old")
+ _seats(seat_for=lambda a: {"case": "serves", "role_id": "head-of-sales", "seats": ["head-of-sales"]})
+ rc = _wire(monkeypatch, FakeClient({"template.yaml": lying,
+ "canon/roles/head-of-sales.yaml": ROLE.replace(
+ "id: sales-lead", "id: head-of-sales").replace(
+ "title: Sales Lead", "title: Head of Sales")}))
+ role = _card(rc)["role"]
+ assert (role["id"], role["title"]) == ("head-of-sales", "Head of Sales")
+
+
+def test_each_viewer_sees_the_seat_the_agent_serves_for_them(monkeypatch):
+ """ent#814: a shared companion serves Maya in the CFO seat and Leo in his."""
+ seats = {EMAIL: {"role_id": "cfo", "source": "person"}}
+ _seats(seat_for=lambda a: {"case": "serves", "role_id": "sales-lead", "seats": ["sales-lead", "cfo"]},
+ served_seat_for=lambda a, e: seats.get(e))
+ rc = _wire(monkeypatch, FakeClient({"template.yaml": TEMPLATE,
+ "canon/roles/cfo.yaml": ROLE.replace("id: sales-lead", "id: cfo")
+ .replace("title: Sales Lead", "title: CFO")}))
+ card = _card(rc)
+ assert (card["role"]["id"], card["role"]["title"], card["seat_source"]) == ("cfo", "CFO", "person")
+
+
+def test_no_seat_on_record_is_named_on_the_role_and_supported_work_still_reads(monkeypatch):
+ """ent#812: a canon agent nobody's seat is on owns nothing, says why, and
+ still shows what it supports."""
+ from services import assignment_provider as ap
+ ap.clear_provider()
+ rc = _wire(monkeypatch, _full_client())
+ card = _card(rc)
+ assert card["role"]["error"] == "no_seat" and card["role"]["id"] is None
+ assert card["seat_source"] == "none"
+ assert [o["id"] for o in card["objectives"]] == ["q4-icp-demand"]
+ assert card["objectives"][0]["owned"] is False
+
+
+def test_the_relationship_is_the_viewers_assignment_kind(monkeypatch):
+ """The live check: Priya, the agent's primary, read "no assignment recorded"
+ because the line was never wired. It is the provider's `kinds_for`."""
+ _seats(seat_for=lambda a: {"case": "serves", "role_id": "sales-lead", "seats": ["sales-lead"]},
+ kinds_for=lambda a, e: {"kind": "primary", "role_id": "sales-lead"} if e == EMAIL else None)
+ rc = _wire(monkeypatch, _full_client())
+ assert _card(rc)["relationship"] == "primary"
+ # A stopped agent still knows who you are to it — the record is not in the container.
+ rc = _wire(monkeypatch, _full_client(), state="exited")
+ assert _card(rc)["relationship"] == "primary"
+
+
+def test_no_assignment_is_none_never_a_guess(monkeypatch):
+ rc = _wire(monkeypatch, _full_client())
+ assert _card(rc)["relationship"] is None
@pytest.mark.parametrize("files,expected", [
@@ -230,8 +319,10 @@ def test_a_traversal_shaped_canon_path_or_role_id_never_reaches_a_read(monkeypat
shared = FakeClient({"template.yaml": TEMPLATE.replace("clone_path: canon", "clone_path: shared/canon"),
"shared/canon/roles/sales-lead.yaml": ROLE})
assert _card(_wire(monkeypatch, shared))["role"]["path"] == "shared/canon/roles/sales-lead.yaml"
- bad = TEMPLATE.replace("role: sales-lead", "role: ../../secrets")
- rc2 = _wire(monkeypatch, FakeClient({"template.yaml": bad}))
+ # The seat id now comes from the record; a traversal-shaped one is refused
+ # by the same id rule before any read.
+ _seats(seat_for=lambda a: {"case": "serves", "role_id": "../../secrets", "seats": []})
+ rc2 = _wire(monkeypatch, FakeClient({"template.yaml": TEMPLATE}))
assert _card(rc2)["role"]["error"] == "role_id_invalid"
From 96673aae6ef1c87128af891dea1d32ec2d05d458 Mon Sep 17 00:00:00 2001
From: Oleksii Dolhov
Date: Thu, 8 Oct 2026 10:40:42 +0300
Subject: [PATCH 10/14] test: classify ent#814's prompt-context fields for
#3274's guard; run the ent#817 migration on the engine's connection
- test_3274's free-text guard (from dev) requires every Optional[str]
ExecutionContext field to be scanned or named platform-controlled:
served_role_id is a sanitized seat id, served_person_email is never
rendered.
- The ent#817 migration test wrote through get_engine() but migrated
through get_db_connection(); under seed 12345 the two pointed at
different files ('no such table: agent_sharing'). It now migrates on the
engine's own DBAPI connection.
Co-Authored-By: Claude Opus 5.5
---
tests/unit/test_3274_gate_context.py | 2 ++
tests/unit/test_ent817_proactive_consent.py | 11 ++++++++---
2 files changed, 10 insertions(+), 3 deletions(-)
diff --git a/tests/unit/test_3274_gate_context.py b/tests/unit/test_3274_gate_context.py
index b76cad2e04..0815e96bae 100644
--- a/tests/unit/test_3274_gate_context.py
+++ b/tests/unit/test_3274_gate_context.py
@@ -150,6 +150,8 @@ def test_every_free_text_field_the_prompt_renders_is_scanned_or_named_platform_c
"execution_id": "a platform id",
"primary_user_display": "assignment provider display name (ent#500)",
"role_id": "an assignment id",
+ "served_role_id": "a seat id from the assignment provider, sanitized like role_id (ent#814)",
+ "served_person_email": "never rendered: it only picks the seat served_role_id names (ent#814)",
}
strings = {f.name for f in dataclasses.fields(ExecutionContext)
if typing.get_type_hints(ExecutionContext)[f.name] == typing.Optional[str]}
diff --git a/tests/unit/test_ent817_proactive_consent.py b/tests/unit/test_ent817_proactive_consent.py
index 960ac36f2c..be0ca58a7e 100644
--- a/tests/unit/test_ent817_proactive_consent.py
+++ b/tests/unit/test_ent817_proactive_consent.py
@@ -94,7 +94,6 @@ def test_only_consent_lets_an_agent_send(owner):
def test_the_migration_moves_legacy_zeros_to_not_asked_and_is_idempotent(owner):
from database import db
- from db.connection import get_db_connection
from db.engine import get_engine
from db.migrations import _migrate_proactive_consent_not_asked
from sqlalchemy import text
@@ -107,9 +106,15 @@ def test_the_migration_moves_legacy_zeros_to_not_asked_and_is_idempotent(owner):
{"a": agent})
db.set_allow_proactive(agent, "yes@example.com", True, name)
+ # The engine's own DBAPI connection, not `get_db_connection()`: the rows
+ # above went through the engine, and under some test orders the two point
+ # at different files ("no such table: agent_sharing", seed 12345).
for _ in range(2):
- with get_db_connection() as conn:
- _migrate_proactive_consent_not_asked(conn.cursor(), conn)
+ raw = get_engine().raw_connection()
+ try:
+ _migrate_proactive_consent_not_asked(raw.cursor(), raw)
+ finally:
+ raw.close()
assert _stored(agent, "legacy@example.com") is None
assert _stored(agent, "yes@example.com") == 1
From 8eda88239d74f67cfa5682f2893723c0831b04f6 Mon Sep 17 00:00:00 2001
From: Eugene Vyborov <1073874+vybe@users.noreply.github.com>
Date: Sun, 11 Oct 2026 16:18:24 +0100
Subject: [PATCH 11/14] test(ent814): the seat-decision user stub carries
agent_name, as the real User does
dev's ent#839 self-gate reads current_user.agent_name before any lookup; a bare
SimpleNamespace(id=1) raised AttributeError after the merge.
Co-Authored-By: Claude Fable 5.1
---
tests/unit/test_ent814_served_seat.py | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/unit/test_ent814_served_seat.py b/tests/unit/test_ent814_served_seat.py
index 6dc12491df..244df119b3 100644
--- a/tests/unit/test_ent814_served_seat.py
+++ b/tests/unit/test_ent814_served_seat.py
@@ -227,7 +227,7 @@ def _record(r, store, email, **body):
execution_id="e1", outcome="approved", decided="Renew the Acme contract",
alternatives=["let it lapse"], criterion="margin above 20%", reversal="margin drops",
review_by="2027-01-01", **body)
- return asyncio.run(r.record_seat_decision(AGENT, req, current_user=SimpleNamespace(id=1)))
+ return asyncio.run(r.record_seat_decision(AGENT, req, current_user=SimpleNamespace(id=1, agent_name=None)))
def test_a_decision_for_bob_is_stamped_with_bobs_seat_not_the_primarys(wired):
From dce477a95f9c50cbbcfc736882c0a6becf61b842 Mon Sep 17 00:00:00 2001
From: Eugene Vyborov <1073874+vybe@users.noreply.github.com>
Date: Sun, 11 Oct 2026 16:20:17 +0100
Subject: [PATCH 12/14] docs(architecture): catalog the canon roles read, the
seat resolvers and the three-state consent (trinity-enterprise#811/#814/#817)
api-endpoints.md gains GET /api/agents/{name}/canon/roles; backend.md gains
canon_roles_service and the resolve_seat / resolve_served_seat contract on the
assignment seam; database.md marks allow_proactive as NULL/0/1 with both
migration names.
Co-Authored-By: Claude Fable 5.1
---
docs/memory/architecture/api-endpoints.md | 1 +
docs/memory/architecture/backend.md | 3 ++-
docs/memory/architecture/database.md | 3 ++-
3 files changed, 5 insertions(+), 2 deletions(-)
diff --git a/docs/memory/architecture/api-endpoints.md b/docs/memory/architecture/api-endpoints.md
index bc8391aa26..a8784691b5 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 48d0fc4394..b710381335 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 ed760bd339..2726f1fd50 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)
);
From eeccbb80cc7adfb78f7f7b85c4432e020d019319 Mon Sep 17 00:00:00 2001
From: Eugene Vyborov <1073874+vybe@users.noreply.github.com>
Date: Sun, 11 Oct 2026 16:25:20 +0100
Subject: [PATCH 13/14] fix(seat-decisions): the seat on record validates as a
canon role id, dots included (trinity-enterprise#814)
Since ent#814 the served seat wins over the agent's decided_by_role, but the
record's check was the ask_class slug (no dots) while canon role ids follow
objective_join_service._ID_RE (roles/head.of.sales.yaml is a valid seat). A
dotted seat on record refused every decision with 422 and the agent could not
work around it. decided_by_role now takes the lowercased canon id shape;
ask_class keeps the strict slug. Regression tests in test_ent814_served_seat.py.
Co-Authored-By: Claude Fable 5.1
---
src/backend/services/seat_decision_service.py | 9 +++--
tests/unit/test_ent814_served_seat.py | 33 +++++++++++++++++++
2 files changed, 40 insertions(+), 2 deletions(-)
diff --git a/src/backend/services/seat_decision_service.py b/src/backend/services/seat_decision_service.py
index 9db30d9b46..525b41afbf 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/tests/unit/test_ent814_served_seat.py b/tests/unit/test_ent814_served_seat.py
index 244df119b3..e95c3f38b8 100644
--- a/tests/unit/test_ent814_served_seat.py
+++ b/tests/unit/test_ent814_served_seat.py
@@ -255,3 +255,36 @@ def test_a_person_without_a_seat_gets_the_primarys_and_no_record_at_all_keeps_th
ap.clear_provider()
_record(r, store, CAT, decided_by_role="named-by-agent")
assert recorded[-1]["payload"]["decided_by_role"] == "named-by-agent"
+
+
+# --- the seat on record must pass the record's own validation -----------------
+# Since ent#814 the seat on record wins over the agent's word, so every id the
+# Access tab can put on record has to validate here. Canon role ids follow
+# `objective_join_service._ID_RE`, which allows dots (`roles/head.of.sales.yaml`);
+# the decision slug did not, so a dotted seat refused every decision with 422.
+
+def _validation_payload(**over):
+ body = dict(outcome="approved", decided="Renew the Acme contract",
+ alternatives=["let it lapse"], criterion="margin above 20%",
+ reversal="margin drops", review_by="2027-01-01")
+ body.update(over)
+ return body
+
+
+def test_a_dotted_seat_on_record_validates_as_a_role_id():
+ from services import seat_decision_service as sds
+ out = sds.validate_record(_validation_payload(decided_by_role="Head.Of.Sales"))
+ assert out["decided_by_role"] == "head.of.sales"
+
+
+@pytest.mark.parametrize("bad", ["../etc", "a b", ".leading", "x" * 65])
+def test_a_role_id_that_is_not_a_canon_id_is_still_refused(bad):
+ from services import seat_decision_service as sds
+ with pytest.raises(sds.DecisionRefused):
+ sds.validate_record(_validation_payload(decided_by_role=bad))
+
+
+def test_ask_class_keeps_the_strict_slug():
+ from services import seat_decision_service as sds
+ with pytest.raises(sds.DecisionRefused):
+ sds.validate_record(_validation_payload(ask_class="vendor.renewal"))
From e6271b15a2088fe052d48909c2e2591094ce1c1d Mon Sep 17 00:00:00 2001
From: Eugene Vyborov <1073874+vybe@users.noreply.github.com>
Date: Sun, 11 Oct 2026 16:48:27 +0100
Subject: [PATCH 14/14] test(ent731): the guard-metrics whole-read test records
its seat like its ent#666 siblings
dev's ent#731 suite reuses the ent#666 fakes but not the autouse _seat_on_record
fixture ent#812 added there, so after the merge read_objective_join owned nothing
(x-role is no longer read) and the test indexed an empty list. Importing the
fixture registers it for this module.
Co-Authored-By: Claude Fable 5.1
---
tests/unit/test_ent731_guard_metrics.py | 1 +
1 file changed, 1 insertion(+)
diff --git a/tests/unit/test_ent731_guard_metrics.py b/tests/unit/test_ent731_guard_metrics.py
index ac2552ee40..079beadcc3 100644
--- a/tests/unit/test_ent731_guard_metrics.py
+++ b/tests/unit/test_ent731_guard_metrics.py
@@ -23,6 +23,7 @@
sys.path.insert(0, str(Path(__file__).resolve().parent))
from test_ent666_objective_join import ( # noqa: E402
AGENT, NOW, ROLE, TEMPLATE, _FakeClient, _definition, _join, _latest,
+ _seat_on_record, # autouse: the seat on record (ent#812) — x-role is not read
running, store, svc,
)