Conversation
…ed (ent#641) Autonomy was one boolean per agent. This adds the two scopes canon tandem-06 §2.3 actually needs: one instance LEVEL above it (L0 Continuity, L1 Companion, L2 Delegated classes, L3 Load-bearing judgment) as the fleet ceiling, and a state per (seat, ask class) below it, earned from #638's decision record. A graduated class means the companion acts and reports; every other class it asks first — and it is told which is which, in the same words the person reads. Promotion is earned, never granted: ≥3 non-expired records, ONE normalized criterion, no reversal in the window, no negative rating for that seat in the last 30 days, and a guard metric that is not capped. There is no promote control anywhere. A person may HOLD a class (a refusal — anyone, for their own seat); only the agent OWNER may RELEASE one (a grant). The verdict is a stored earned-state ANDed with three read-time conjuncts — the instance level, the agent's autonomy_enabled, and now <= evidence_expires_at (the earliest review_by of the window). Materialised instead, each fails silently: records lapse at UTC midnight with no event to hook, and re-evaluating the fleet inside a level change is an unbounded fan-out with no fleet-wide seat query to drive it. As conjuncts a level drop needs zero writes and raising it back restores exactly what each class had earned. Reads persist nothing; writes happen only on a real event and are CAS'd on an evidence hash, so an unchanged verdict writes no row and emits no event. Reversals are counted over the WINDOW, not over all history. effective_status returns `reversed` unconditionally — a reversed record never becomes `expired` — so history-counting would let one reversal block a class forever and make on_request the only reachable steady state. _has_lapsed retires a reversed record at its own review_by like any other. The rating query's three shapes were each a defect first: it matches operator:<email> as well as workspace:<email> (on a single-operator install the seat person IS the platform principal, so their thumbs-down lands under the other prefix and the class could never demote); it orders by COALESCE(updated_at, created_at) (an up→down flip touches only updated_at, and that flip is exactly a demotion); and the window is a fixed 30 days, not "since the oldest surviving record", which would let a blocking rating fall out by attrition — promotion by the clock, which R25 forbids. Every block is named (level_below_L2, agent_autonomy_off, evidence_expired, insufficient_records, criterion_not_settled, reversal_in_window, negative_rating_30d, guard_capped, held_by_person), each with the sentence the panel and the companion both show. The level is a grant: GET/PUT /api/settings/autonomy-dial is require_admin AND interactive-only (an agent's injected key can read the dial, never raise it — the ent#293/#297 line), validated, audited as autonomy_dial_change, and blocklisted on the generic PUT /api/settings/{key} catch-all. Mounted before generic.router (Invariant #4). Surfaces: MCP get_autonomy (seat from execution_id, never a parameter, no email in the answer) → GET /api/agents/{name}/autonomy; the person reads and holds/releases in Agent details (PortalAgentAutonomy.vue); the model reads the same verdict through the seat's existing memory block. Storage: seat_ask_class_state, both tracks (SQLite + Alembic 0072 ← 0071), cleanup CASCADE. The level is one validated system_settings key, not a table. Tests: tests/unit/test_ent641_autonomy_dial.py (44) — the rule as a table, each conjunct demoting without a write, the reversal window, the rating window's three shapes, the CAS, the portal gates, the level's two guards, both tracks, facade parity, the MCP surface. Six mutations each red. Frontend portalAgentAutonomy.spec.js (10, mounted); decisions.test.ts (7). Fixes Abilityai/trinity-enterprise#641 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…(ent#641) The requirement, the flow and the registry entry each spelled five of the nine blocker ids differently from autonomy_dial_service — insufficient_records / criterion_not_settled / negative_rating_30d / guard_capped / held_by_person against the emitted too_few_records / criterion_not_stable / negative_rating_in_window / guard_metric_capped / held_by_operator. A reader greps the doc for the id they saw in the payload; a near-miss reads as a different field. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…l toggle (ent#641)
`GET /api/agents/{name}/autonomy` was already taken — `routers/agent_config`
owns it (the agent-level `autonomy_enabled` toggle) and `main.py` includes
that router first. Both carry `prefix="/api/agents"`, so the seat read
declared the identical path and FastAPI simply served the first one: the
new handler was never reached. Nothing raised, nothing warned, /docs showed
a plausible entry, the unit tests passed (they call the service and read
the router's source text), tsc was clean, and the MCP tool's tests passed
because they assert the outgoing URL rather than what answers it.
Calling it against the dev instance is what found it — the seat read
returned {"autonomy_enabled": true, "total_schedules": 3}, the toggle's
payload, where an ask-class list should have been.
The seat read is a different noun, so it gets one: `/{agent_name}/seat-autonomy`.
The MCP client, both docs and the requirement follow.
Pinned by test_the_seat_read_is_not_shadowed_by_the_agent_level_autonomy_toggle,
which asserts the property rather than the spelling: across `agent_config`
and `seat_decisions`, no two endpoints may share a (path, method), and the
seat read must be what `/seat-autonomy` resolves to. Red on the restored
collision ("GET /api/agents/{agent_name}/autonomy: get_agent_autonomy_status
vs get_seat_autonomy"), green on the fix. Invariant #4 is usually cited for
ordering within one router; the same mechanism eats a duplicate path across
two, where no single diff shows both declarations.
Also: the docs named five of the nine blocker ids differently from the
service (learnings entry added for the route class).
Related to Abilityai/trinity-enterprise#641
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n endpoint (ent#641) The dial shipped with `GET/PUT /api/settings/autonomy-dial` and no UI, so the one thing an admin actually sets — the ceiling on everything the fleet may do unprompted — was reachable by curl alone. The Workspace panel displayed the level as a badge and no surface could change it. AutonomyDialPanel.vue on Settings → Retention, beside the session policy and room budgets for the reason they are there: all three bound what an engagement does on its own. One option per level, with the single fact that changes behaviour said on the row (`unprompted possible` / `always asks first`) rather than inferred from the ordering. The panel argues for itself, because both things an admin fears about a ceiling are false and neither is obvious: raising the level promotes nothing (a seat's classes are still earned from its decision record), and lowering it destroys nothing (what each seat earned is kept and comes back exactly as it was). That is true precisely because the earned half is stored and the level is ANDed at read time — the shape this issue chose — so the panel states it instead of leaving an operator to guess. Load failure, save failure and unentitled are three different states with three different next actions, kept apart (the ent#375 shape one panel over), and `adopt` MERGES because the PUT response carries no `levels` — a replace would blank the option list on the first successful save. Gated on `viewState` (#1927), not a bare `v-if="loading"`: a retry with the dial already on screen keeps the dial and flags it stale. Tests: src/frontend/tests/unit/autonomyDialPanel.spec.js (5, mounted) — the options and their unprompted marks, both halves of the safety claim, the PUT plus the merge that keeps the options, save inert until the choice differs, and load-vs-save failure told apart. Frontend suite 165 files / 3566 passed, both ratchets included. Related to Abilityai/trinity-enterprise#641 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Demo — run against the local dev instanceEvery state below was produced by driving the product's own endpoints on the dev stack (agent 📸 Walkthrough with screenshots (light + dark): https://claude.ai/artifact/9uzS9FDfKoGmo4ou9zMTPh What it shows
One defect the live run found (fixed in 14625eb)The companion's read was declared at Nothing raised, nothing warned, One gap the demo also found (fixed in 090b2a8)The level had an endpoint and no UI, so the one thing an admin sets was reachable by VerificationFour failures in the full backend unit run ( |
…641) `npm run check:tokens` — the design-token gate in frontend-build.yml — rejects an unknown `status-*` family, and AutonomyDialPanel used `status-error-*` for its two failure blocks. There is no such family: the taxonomy is success / warning / danger / info / urgent, exactly as BaseBadge's VARIANT_CLASSES spells it. Nothing renders for an unresolvable token, so both error states would have been unstyled text on the page ground in every theme. Mine, and it should have been caught before the push: check:tokens is one of the four steps the frontend job runs and I ran only two of them. Related to Abilityai/trinity-enterprise#641 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
merge-train note — rides the next train, not today's. Validation came back READY (validate-pr + review, lane B+schema; no criticals; the graduation rule is executed end to end on real SQLite, the portal capability channel is the roster payload, both migration tracks are consistent, raw-color ratchet unchanged). It was held off this train for one reason: Alembic parent. Worth fixing while the branch is open:
For your intent, not a block: |
|
|
/validate-pr — ❌ REQUEST CHANGESBlocking
Warnings
Clean:
Run:
Read only: the new endpoints over live HTTP, the PostgreSQL path beyond CI, and the |
…evision dev landed 0072_agent_capability_grants on the same parent (0071), which forked the Alembic graph into two heads. Renumber this branch's revision to 0073_seat_ask_class_state with down_revision 0072_agent_capability_grants; SQLite list keeps dev's entry first. Resolve learnings.md and migrations.py as a union of both sides. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
merge-train (2026-09-24): not on this train — Alembic fork plus two findings, rides a later train. Held back because Two findings from validation to fix before it rides (the graduation rule itself is executed end to end and is fine):
Minor: |
Renumbers this branch's Alembic revision to 0075 on top of dev's 0074_role_readiness_rollout_seed so the version-line keeps a single head. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The merge-from-dev commit renamed the revision to 0075 but left its down_revision (and the tests/docs naming it) at the 0072 fork point, so the graph still had two heads. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
|
✅ Nightly unit-suite clean when this PR is merged into |
|
Resolve by merging |
…etails, store and docs Conflicts resolved (dev's entries first, then this PR's): - src/backend/db/migrations.py: MIGRATIONS tail keeps dev's four new entries, then seat_ask_class_state_table. - src/frontend/src/components/portal/PortalAgentDetails.vue: keeps dev's decisionsEl wrapper around PortalAgentDecisions, then PortalAgentAutonomy; both imports (PortalSuggestions, PortalAgentAutonomy). - src/frontend/src/stores/clientPortal.js: suggestions state + actions (dev), then autonomy state + actions (this PR). - docs/memory/feature-flows.md: both index rows. - docs/memory/requirements/core-agent.md: both sections; dev's suggestions keeps 5.39, the autonomy dial is renumbered 5.40 (and its pointer in architecture/workspace.md). tests/registry.json auto-merged cleanly (no duplicates, no entry lost). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ot per record (ent#641) The companion writes its own decision records (MCP record_decision stores the seat as decided_by_person and its execution as source_execution_id), so three record() calls inside ONE execution graduated a class the agent had just invented, and the prompt then told it "graduated — you may act without being asked". A class now graduates only when its qualifying records come from at least STABLE_MIN_COUNT distinct sources: each distinct non-null source_execution_id is one source, and every person-written record (no execution) is its own. The count is exposed as evidence.sources (and folded into the evidence hash); short of it the class is blocked with the new named reason too_few_conversations, whose sentence the panel and the prompt both read. Everything else in the rule is unchanged. RULE_VERSION bumped to 2026-09-27. Tests (real SQLite through the real service): one execution x3 does not graduate; three executions do; mixed person + agent records count correctly; the reviewer's invented-class repro stays on-request and the prompt no longer says graduated. Requirement §5.40 states the rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and speaks the service's words (ent#641) - client_portal/autonomy.py: act() and set_guard() now validate ask_class against seat_decision_service._SLUG_RE (422 invalid_ask_class) and check that the seat has decisions in the class (404 class_not_found) BEFORE set_seat_ask_class_hold / set_seat_ask_class_guard. Previously the row was upserted first and the request then 404'd — a persisted write answered "not found", and a roster member could mint rows for any path string on their own seat. The router passes the path value straight through, so the service is the single validation point for both routes. - blocker_text is now returned: page() sets it from autonomy_dial_service.BLOCKER_TEXT and PortalAutonomyDial declares it, so the panel shows the same sentence as the companion's prompt. PortalAgentAutonomy.vue drops its local sentence copy (which disagreed for agent_autonomy_off / guard_metric_capped); a code with no server sentence falls back to the code made readable. - portalAgentAutonomy.spec.js uses the service's exact sentences as its payload instead of an invented map, and pins the server-text and fallback behaviour. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…am_group_context (ent#641) dev's version line now runs 0074 -> 0075_auto_sync_enabled_backfill -> ... -> 0078_workspace_suggestion_feedback, and #2728 adds 0079_telegram_group_context on top. This PR's revision chained off 0074, a second head (the #2068 fork: `upgrade head` would apply zero revisions). Renamed 0075_seat_ask_class_state.py -> 0080_seat_ask_class_state.py, revision 0080_seat_ask_class_state, down_revision 0079_telegram_group_context; docstring, the SQLite migration's pointer, the ent641 test that reads the revision by path, and the architecture/requirements/flow docs updated. The SQLite entry is name-keyed and unchanged. Depends on #2728 landing first: on this branch alone the heads check reports a missing parent until 0079 is on dev. Verified with 0079 copied into a scratch copy of the versions directory: 81 revisions, 1 head (0080_seat_ask_class_state). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…y-dial # Conflicts: # src/backend/db/migrations.py
|
merge-train (2026-09-27): five commits pushed, agreed with the operator.
Locally: 508 passed across the autonomy, seat-decision, migration and Alembic files. Frontend Not done: the missing loading and error state in |
…states (ent#641) The section rendered nothing until data arrived and nothing when a load failed, so a failed fetch read as "no classes" and the panel shifted the page on arrival. It now always mounts: a skeleton until the first load for this agent lands, a retryable InlineError on a failed first load, an empty state only after a load succeeded with zero classes, and a stale banner above the data when a refresh fails. Gated through utils/loadingState.viewState, so autonomyLoaded/autonomyError are read. Light-mode meta text moves off bare gray-400 (2.54:1 on white) to the contract's gray-500 / dark:gray-400 tertiary ink. Four mounted specs, all red against the previous component. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…AY (ent#641) - Drive the real client_portal.service.submit_rating over the real SQLite store: a class graduated by three records is written back on_request naming negative_rating_in_window by the rating call alone. Red with the hook removed. Both reviews noted it was only pinned by source text. - TODAY was date(2026, 9, 23) while record() validates review_by against the real clock, so TODAY+5 went into the past on 2026-09-28 and two TestNotAOneWayRatchet tests failed with decision_prose_only. Anchor TODAY on the real UTC date. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Follow-ups from the 09-23 and 09-24 reviews that were still open after 09-27. Fixed
Not changed (intent, for your call)
Full vitest: 3,701 passed, ratchets included. ent641, ent638 and #1028 backend tests pass under 3 random seeds. Alembic: #3005 ( |
|
Parked as a draft: ent#641 and ent#638 are paused until after the 1.0 cut, by ruling (Mon–Wed plan). The branch is current, with |
…_class_state onto 0080_agent_skill_sets dev landed 0080_agent_skill_sets on 0079_telegram_group_context, the same parent as this branch's 0080_seat_ask_class_state, which made two Alembic heads. Renamed to 0081_seat_ask_class_state with down_revision 0080_agent_skill_sets. The SQLite migrations list keeps both entries (dev's first). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dev gained 0081_portal_messages_unread_idx (#3076). Resolve: - db/migrations.py: dev's portal_messages_unread_index entry, then seat_ask_class_state_table. - Alembic: 0081_seat_ask_class_state -> 0082_seat_ask_class_state, chained off 0081_portal_messages_unread_idx (single head); references updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…revision to 0083 dev gained 0082_agent_sync_state_divergence (#3035), forking the Alembic head. Chain 0083_seat_ask_class_state off it and order the SQLite entry after agent_sync_state_divergence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…de_modules symlink #2996's route census (merged to dev) requires every new route to be classified. GET /seat-autonomy and GET /settings/autonomy-dial are the companion-readable reads the ent#641 docstrings describe, so they join AGENT_CALLABLE; the dial's write stays admin + interactive. src/mcp-server/node_modules was a committed symlink to an absolute local path (test_2080_harness_contract). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#2996's route census (merged to dev) requires every new route to be classified. GET /seat-autonomy and GET /settings/autonomy-dial are the companion-readable reads the ent#641 docstrings describe, so they join AGENT_CALLABLE; the dial's write stays admin + interactive. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dev moved past 0082 (0083 to 0085 landed), so 0083_seat_ask_class_state forked the Alembic graph into two heads, which alembic-head-watch flagged. It is now 0087_seat_ask_class_state on 0085_ent720_email_identity. 0087 rather than 0086 because #3021's pull_sync takes 0086; whichever of the two merges second re-parents onto the other. The SQLite list keeps both sides, with dev's entries first. The merge also brings #3107, the agent-server boot fix whose absence failed journey-smoke. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tatement (#2984) dev gained 0086_metric_points_restatement off 0085, so this revision was a second head (alembic-head-watch). Re-parented. Also resolved: both import lines kept in client_portal/router.py and PortalAgentDetails.vue; dev's §5.40 (Workspace Inbox) kept, the autonomy dial renumbered §5.41; the SQLite list keeps dev's entry first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
dolho
left a comment
There was a problem hiding this comment.
Review — CI fixed; one graduation bug to fix before merge
CI: alembic-head-watch was red: 0087_seat_ask_class_state was re-parented onto 0085, the same parent as dev's new 0086_metric_points_restatement. Merged dev and re-parented onto 0086_metric_points_restatement (0879e00f6); the pinning assertion in test_ent641_autonomy_dial.py follows. Conflicts resolved: both import lines kept in client_portal/router.py and PortalAgentDetails.vue; dev's §5.40 (Workspace Inbox) kept and the dial renumbered §5.41; SQLite list keeps dev's entry first. Locally: 1 head, parity PASS, 335 backend + 4,553 frontend tests pass. The earlier e2e red was a 25-min timeout (≈12 min lost to a slow pip download in the image build) plus one agent-detail-request-dedupe smoke failure on a page this PR doesn't touch; it re-runs on the new head.
⚠️ Merge order with #3021: both now add a revision directly off0086_metric_points_restatement. Whichever lands second needs a one-line re-parent;alembic-head-watchwill flag it.
Overall: the structure is right — live conjuncts at read time, reads persist nothing, writes CAS'd on the evidence hash; the level PUT is require_admin + reject_non_interactive_principal and the /{key} catch-all blocks the key; portal routes reject agent principals; release and guard are owner-only.
High — closed and superseded records count as graduation evidence (verified)
services/autonomy_dial_service.py::class_evidence skips only expired and routed. But seat_decision_service.effective_status returns the stored status for any non-active/routed row — closed, superseded, reversed — so closed and superseded rows enter window, add to count and sources, and never expire (the inline comment says it returns reversed for every non-active row; it doesn't).
- Graduates on one judgment: a person records one decision and supersedes it twice in the Workspace; supersedes write
source_execution_id=None, so each copy is its own source →count=3, sources=3, graduated. This defeats the "≥3 distinct conversations" rule. - Permanent false block: a closed/superseded row with a past
review_bystill setsexpires_at(the earliest), solive_verdictsaysevidence_expiredforever.
Fix: admit onlyeff == "active"into the window (plus the existing lapsed handling forreversed), and add tests for superseded and closed rows — there are none now.
Medium — an owner can't release another seat's hold in the UI
PortalAgentAutonomy.vue (the other_seats <details>, ~L95–106) renders other seats read-only: no held badge, no Release. The backend supports it (act(..., seat=...), the owner check, writable: True), and the spec's main path is "a seat holds → the owner releases" — so a client's hold stays on-request forever unless someone calls the API. Render the badges and Release for other_seats rows where c.writable && page.can_release (the store already passes seat).
Low / nits
_evidence_hashomitsheld, so hold/release writes nothing and the storedstatecan readgraduatedon a held class. Reads recompute, so behaviour is right — includeheldin the hash or document the columns as a cache.db/seat_ask_class_state.py::upsert_seat_ask_class_statestampsdemoted_aton everyon_requestwrite, including a class's first evaluation; stamp only on an actual graduated → on_request transition.- §5.41 storage line still says "Alembic
0080←0079"; it's0087←0086. client_portal/autonomy.pydocstring says release/guard are "Owner/admin only";_is_owneris owner-only, which matches the spec — fix the docstring.- The
/autonomy/classes/{c}/guardendpoint has no UI, socappedis unreachable through the product; fine if deferred, worth a line in the description.
Checked clean: the negative-rating query (workspace: and operator: prefixes, COALESCE, fixed 30 days); rating/record/act/supersede all re-evaluate; the MCP seat-autonomy read takes the seat from the execution and returns no email; frontend states through viewState with a generation guard; table in agent cleanup (CASCADE); PostgreSQL held is an Integer consistently.
🤖 Generated with Claude Code
…est 5 jsdom) dev moved to vitest 5, whose jsdom exposes window.localStorage as a getter-only accessor, so the two specs' plain assignment threw at load and failed the build after the dev merge. Use the defineProperty pattern the other mounted portal specs already use. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
What
Autonomy in Trinity was one boolean per agent — scheduled runs on or off. This adds the two scopes canon
tandem-06-operations.md§2.3 (ruling P12) actually needs.Above the agent: one instance level —
L0Continuity ·L1Companion ·L2Delegated classes ·L3Load-bearing judgment — the ceiling on the whole fleet.Below it: a state per (seat, ask class) —
on_requestorgraduated— earned from that seat's own decision record (#638). A graduated class means the companion acts and reports; every other class it asks first, and it is told which is which in the same words the person reads.Promotion is earned; there is no promote control
A class graduates when its records say so: ≥3 non-expired records · exactly ONE normalized criterion · no reversal in the window, AND no negative rating for that seat in the last 30 days, AND a guard metric that is not
capped. No endpoint anywhere setsgraduated.A person can only move it down and let it back up:
holdreleaseThe shape: stored earned-state × three read-time conjuncts
The row holds only what was earned. The instance level, the agent's
autonomy_enabled, andnow <= evidence_expires_at(the earliestreview_byof the window) are ANDed where the verdict is read.Materialised instead, each fails silently. Records lapse at UTC midnight and fire no event, so a stored verdict outlives its own evidence with nobody watching. Re-evaluating the fleet inside a level change is an unbounded fan-out with no fleet-wide seat query to drive it, on a write path that must stay O(1). As conjuncts, a level drop needs zero writes, and raising it back restores exactly what each class had earned.
Reads persist nothing. Writes happen only on a real event (a decision recorded or acted on, a rating by the seat, a hold/release/guard) and are CAS'd on an evidence hash — an unchanged verdict writes no row and emits no event, so the event stream carries transitions, not heartbeats.
Three things that were defects first
Reversals are counted over the window, not over all history.
effective_statusreturnsreversedunconditionally — a reversed record never becomesexpired. Counted over history, one reversal ever would block its ask class forever andon_requestwould be the only reachable steady state: a dial that can only go down._has_lapsedretires a reversed record at its ownreview_by, like every other row.The rating query matches
operator:as well asworkspace:. On a single-operator install the seat person is the platform principal, so their thumbs-down is filed under the other prefix and the class could never demote.…and it orders by
COALESCE(updated_at, created_at)over a fixed 30 days. Flipping a rating up→down touches onlyupdated_at, and that flip is precisely a demotion. A window derived from the surviving records (rather than a fixed span) would let a blocking rating fall out by attrition — promotion by the clock, which R25 forbids.Every block is named
level_below_l2·agent_autonomy_off·evidence_expired·too_few_records·criterion_not_stable·reversal_in_window·negative_rating_in_window·guard_metric_capped·held_by_operator— each carrying the sentence the panel and the companion both show. A bare "not autonomous yet" teaches nobody what to do next, and the companion needs the reason so it can say why it is asking instead of inventing one.Surfaces
get_autonomy→GET /api/agents/{name}/seat-autonomy. Seat fromexecution_id(the MEM-001 rule), never a parameter; no email in the answer. (/autonomyis the agent-level toggle andagent_configregisters first, so a second route on that path is silently never reached — see the live-run note below.)PortalAgentAutonomy.vuein Agent details:GET …/agents/{name}/autonomy,POST …/autonomy/actions,POST …/autonomy/guard.AutonomyDialPanel.vueon Settings → Retention, overGET/PUT /api/settings/autonomy-dial:require_adminand interactive-only (an agent's injected MCP key can read the dial, never raise it — the ent#293/feat: Telegram group chat support — agents as community managers (TGRAM-GROUP) #297 line), validated against the four levels, audited asautonomy_dial_change, and blocklisted on the genericPUT /api/settings/{key}catch-all — the one door that can address any key. Mounted beforegeneric.router(Invariant fix: add missing logging_config.py to backend Dockerfile #4).prompt_linesrides the seat's existing memory block (fix(security): voice WS + stop endpoint missing ownership check (#600) #638's composer), so no caller can forget it and the model cannot believe it is more autonomous than the panel says.Storage
seat_ask_class_state, both tracks (SQLiteseat_ask_class_state_table+ Alembic0080_seat_ask_class_state←0079_telegram_group_context),AgentRef(..., CASCADE). The level is one validatedsystem_settingskey, not a table.#638's contract is unchanged: it shippedstats().reversalsas this issue's input and deferred the verdict.autonomy_dial_service.class_evidenceis the verdict and reads #638's rows without altering them.Tests
Frontend suite whole (ratchets included): 165 files / 3566 passed. MCP suite: 518 passed,
tsc --noEmitclean.scripts/ci/check_alembic_heads.py: 73 revisions, 1 head.The backend test is written as a table over time, not over a fixture. Six mutations, each red:
autonomy_enabled=falseignoredreleasenot owner-gatedworkspace:onlyFour failures in the full backend unit run (
test_ent435_secret_settings,test_mcp_validatorCGNAT,test_ent582_platform_keys,test_ent14_registry_url_ssrf) reproduce on an unrelated branch — local py3.12 vs the 3.13 image and IPv4-mappedipaddresshandling. None of those files are in this diff.Run against the dev instance
Seeded through the product's own endpoints and walked end to end — screenshots, API traces and the model's own prompt lines in the demo comment. It found two things a green suite could not:
GET /api/agents/{name}/autonomy, a pathrouters/agent_configalready owns andmain.pyregisters first, so FastAPI served the toggle and the new handler was never reached. Nothing raised, nothing warned, the unit tests passed (they call the service and read the router's source text),tscwas clean. Fixed in14625eb(/{agent_name}/seat-autonomy) and pinned by a test asserting the property rather than the spelling: across the two routers no two endpoints may share a(path, method).curlalone.AutonomyDialPanel.vueadded in090b2a8.Docs
requirements/core-agent.md§5.40 ·feature-flows/workspace-autonomy-dial.md(+ index) ·architecture/{workspace,database,api-endpoints,mcp-server}.md· fourlearnings.mdentries (the monotone-input ratchet, earned-vs-ambient verdicts, surface-namespaced identity, the silent cross-router path collision) ·tests/registry.json.Not in scope
Per-agent or per-seat levels (the instance level is the only ceiling in v1); auto-feeding
guard_metricfrom the §49 declared-metric registry (the owner sets it explicitly for now). Both in DEBT_INBOX 2026-09-23.Fixes abilityai/trinity-enterprise#641
🤖 Generated with Claude Code