Skip to content

feat(workspace): the autonomy dial — what a companion may do unprompted (ent#641) - #2984

Draft
dolho wants to merge 24 commits into
devfrom
feature/ent641-autonomy-dial
Draft

dolho wants to merge 24 commits into
devfrom
feature/ent641-autonomy-dial

Conversation

@dolho

@dolho dolho commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

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 — L0 Continuity · L1 Companion · L2 Delegated classes · L3 Load-bearing judgment — the ceiling on the whole fleet.

Below it: a state per (seat, ask class) — on_request or graduated — 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 sets graduated.

A person can only move it down and let it back up:

Action Who Why
hold any reader, on their own seat (owner: any seat) holding is a refusal
release the agent owner only releasing is a grant
set the level admin and interactive principal raising the ceiling is what lets anything run unprompted

The shape: stored earned-state × three read-time conjuncts

The row holds only what was earned. The instance level, the agent's autonomy_enabled, and now <= evidence_expires_at (the earliest review_by of 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_status returns reversed unconditionally — a reversed record never becomes expired. Counted over history, one reversal ever would block its ask class forever and on_request would be the only reachable steady state: a dial that can only go down. _has_lapsed retires a reversed record at its own review_by, like every other row.

The rating query matches operator: as well as workspace:. 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 only updated_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

  • Companion — MCP get_autonomy → GET /api/agents/{name}/seat-autonomy. Seat from execution_id (the MEM-001 rule), never a parameter; no email in the answer. (/autonomy is the agent-level toggle and agent_config registers first, so a second route on that path is silently never reached — see the live-run note below.)
  • Person — PortalAgentAutonomy.vue in Agent details: GET …/agents/{name}/autonomy, POST …/autonomy/actions, POST …/autonomy/guard.
  • Admin — AutonomyDialPanel.vue on Settings → Retention, over GET/PUT /api/settings/autonomy-dial: require_admin and 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 as autonomy_dial_change, and blocklisted on the generic PUT /api/settings/{key} catch-all — the one door that can address any key. Mounted before generic.router (Invariant fix: add missing logging_config.py to backend Dockerfile #4).
  • The model — prompt_lines rides 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 (SQLite seat_ask_class_state_table + Alembic 0080_seat_ask_class_state ← 0079_telegram_group_context), AgentRef(..., CASCADE). The level is one validated system_settings key, not a table.

#638's contract is unchanged: it shipped stats().reversals as this issue's input and deferred the verdict. autonomy_dial_service.class_evidence is the verdict and reads #638's rows without altering them.

Tests

tests/unit/test_ent641_autonomy_dial.py ......................................... 45 passed
src/frontend/tests/unit/portalAgentAutonomy.spec.js (mounted) ..................... 10 passed
src/frontend/tests/unit/autonomyDialPanel.spec.js   (mounted) ..................... 5 passed
src/mcp-server/src/tools/decisions.test.ts ......................................... 7 passed

Frontend suite whole (ratchets included): 165 files / 3566 passed. MCP suite: 518 passed, tsc --noEmit clean. 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:

Mutation Fails
level not treated as a ceiling 1
autonomy_enabled=false ignored 1
expiry never demotes 1
reversals counted over all history 2
release not owner-gated 1
ratings matched on workspace: only 1

Four failures in the full backend unit run (test_ent435_secret_settings, test_mcp_validator CGNAT, test_ent582_platform_keys, test_ent14_registry_url_ssrf) reproduce on an unrelated branch — local py3.12 vs the 3.13 image and IPv4-mapped ipaddress handling. 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:

  1. The companion's read was dead. It was declared at GET /api/agents/{name}/autonomy, a path routers/agent_config already owns and main.py registers 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), tsc was clean. Fixed in 14625eb (/{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).
  2. The level had no control. Endpoint only, so the one thing an admin sets was reachable by curl alone. AutonomyDialPanel.vue added in 090b2a8.

Docs

requirements/core-agent.md §5.40 · feature-flows/workspace-autonomy-dial.md (+ index) · architecture/{workspace,database,api-endpoints,mcp-server}.md · four learnings.md entries (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_metric from 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

…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>
@dolho
dolho requested a review from vybe September 23, 2026 09:45
dolho and others added 3 commits September 23, 2026 12:47
…(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>
@dolho

dolho commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

Demo — run against the local dev instance

Every state below was produced by driving the product's own endpoints on the dev stack (agent sidekick, one seat, seven ask classes). No fixtures.

📸 Walkthrough with screenshots (light + dark): https://claude.ai/artifact/9uzS9FDfKoGmo4ou9zMTPh

What it shows

One graduated class vendor-renewal — 3 decisions, one criterion, no reversal, no thumbs-down in 30 days. No button granted it.
Six blocked classes, six different reasons guard_metric_capped · criterion_not_stable · reversal_in_window · held_by_operator · too_few_records — each with the sentence the panel and the companion both read.
The ceiling Settings → Retention → Autonomy level, L0–L3, with unprompted possible / always asks first on the row.
A level drop costs zero writes L2 → L1 → L2: every class picks up level_below_l2, vendor-renewal reads earned — blocked by the dial, and seat_ask_class_state.updated_at never moves.
A thumbs-down demotes live One rating on a real message → graduated → on_request with negative_rating_in_window, inside the same request.
What the model is told prompt_lines on the seat's memory block — the same verdict, in the same words.

One defect the live run found (fixed in 14625eb)

The companion's read was declared at GET /api/agents/{name}/autonomy — a path routers/agent_config already owns for the agent-level autonomy toggle, registered first in main.py. FastAPI serves the first match, so the new handler was never reached:

GET /api/agents/sidekick/autonomy
  → {"autonomy_enabled": true, "total_schedules": 3}     ← the toggle, not the seat

Nothing raised, nothing warned, /docs listed a plausible entry, the unit tests passed (they call the service and read the router's source text) and tsc was clean. Only calling the URL found it. Renamed to /{agent_name}/seat-autonomy and pinned by a test that asserts the property, not the spelling — across the two routers no two endpoints may share a (path, method). Red on the restored collision, green on the fix.

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 curl alone. AutonomyDialPanel.vue now sits on Settings → Retention. It also states the two things an admin otherwise fears — raising the level promotes nothing, lowering it destroys nothing — which is true precisely because the earned half is stored and the level is ANDed at read time.

Verification

tests/unit/test_ent641_autonomy_dial.py .............. 45 passed  (6 mutations each red)
frontend (vitest, ratchets included) ........ 165 files / 3566 passed
mcp-server .................................. 518 passed · tsc --noEmit clean
scripts/ci/check_alembic_heads.py ........... 73 revisions, 1 head

Four failures in the full backend unit run (test_ent435_secret_settings, test_mcp_validator CGNAT, test_ent582_platform_keys, test_ent14_registry_url_ssrf) reproduce on an unrelated branch — local py3.12 vs the 3.13 image and IPv4-mapped ipaddress handling. None of those files are in this diff.

…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>
@vybe

vybe commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

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. 0072_seat_ask_class_state chains on 0071_seat_decisions, and so do #2990 (landing today) and #2989. Two revisions off one parent is the #2068 two-heads fork: upgrade head applies zero revisions. Once #2990 is on dev, re-parent this revision onto 0072_agent_capability_grants (rename the file to 0073_seat_ask_class_state.py, set down_revision = "0072_agent_capability_grants"), merge dev, and python3 scripts/ci/check_alembic_heads.py src/backend/migrations/versions should report one head. The SQLite side is name-keyed and needs only the routine keep-both merge.

Worth fixing while the branch is open:

  • src/backend/client_portal/autonomy.py:108-113 — act() writes the hold (set_seat_ask_class_hold upserts a row for any ask_class string) before _one() checks the class exists, then 404s. A persisted write answered "not found", and a roster member can mint rows for arbitrary path strings on their own seat. Move the existence check ahead of the write.
  • PortalAgentAutonomy.vue has no loading or load-failure state; autonomyError / autonomyLoaded in the store are assigned and never read. The sibling PortalAgentDecisions.vue renders skeleton + InlineError retryable.

For your intent, not a block: autonomy_dial_service.py:391 merges the fresh recompute over the stored row before live_verdict, so state, evidence, promoted_at, demoted_at, held_by, held_at are write-only — the docs describe a stored×live design the code does not rely on. The thumbs-down → demotion path (latest_negative_seat_rating, submit_rating's hook) is asserted by source text only and never executed. Cross-tracker close: ent#641 needs status-in-dev by hand after merge.

@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

⚠️ Alembic head fork if this PR is merged into dev.

dev has advanced since this PR's checks last ran. GitHub recomputes the merge ref when the base moves but does not re-trigger workflows, so a green schema-parity here can describe a base that no longer exists (#2533).

scripts/ci/check_alembic_heads.py against dev + this PR, merged in memory
alembic-heads: FAIL — src/backend/migrations/versions resolves to 2 heads across 89 revision(s); exactly 1 is required.
  • 0087_pull_sync  (0087_pull_sync.py)
  • 0087_seat_ask_class_state  (0087_seat_ask_class_state.py)

They fork at: 0086_metric_points_restatement  (0086_metric_points_restatement.py)

`alembic upgrade head` is singular and resolves its target BEFORE applying anything,
so this graph applies ZERO revisions — every revision since the fork stops arriving,
not only the one that forked. Fix by chaining the newer revision off the real head,
or — if the forked revision may already be applied somewhere — by adding a merge
revision (`alembic merge -m "…" <head-a> <head-b>`), whose tuple `down_revision`
converges the line from any starting state. See Architectural Invariant #3.
alembic-heads: src/backend/enterprise/backend/migrations/versions — version directory absent (submodule not initialised) — skipped.

Evaluated on the version line only — this PR also conflicts with dev in 2 unrelated file(s), which do not change this verdict but must be resolved before merge:

src/backend/db/migrations.py
tests/unit/_route_census.py

Fix: rechain this PR's revision off 0087_pull_sync (the current dev head), or — if the forked revision may already be applied somewhere — add a merge revision (alembic merge -m "…" 0087_pull_sync 0087_seat_ask_class_state), whose tuple down_revision converges the line from any starting state. See Architectural Invariant #3.

Push the fix and schema-parity re-checks it against the merge ref immediately; this comment clears on the next push to dev touching src/backend/migrations/versions/**.

Advisory — this check does not block merge. · head_sha: 691ead6cd93663b7e46f8f32da345a2446032b2d · run

@AndriiPasternak31

AndriiPasternak31 commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

/validate-pr — ❌ REQUEST CHANGES

Blocking

  1. Alembic fork plus conflicts. migrations/versions/0072_seat_ask_class_state.py has down_revision = "0071_seat_decisions", but dev already has 0072_agent_capability_grants (feat(skills): only designated agents may change an agent's skills — their own included (ent#596) #2990) on the same parent, so there are 2 heads (alembic-head-watch is red). It also conflicts in db/migrations.py and docs/memory/learnings.md. Re-parent and renumber; tests/unit/test_ent641_autonomy_dial.py:525 reads the revision by path and will need updating.
  2. A companion can graduate itself (Invariant security: implement safe tar extraction with symlink/hardlink validation #8, grant vs use). The graduation rule (services/autonomy_dial_service.py:237-312) counts every active decision record, but the agent writes those itself: MCP record_decision → routers/seat_decisions.py:93-98 stores decided_by_person=seat_email. The agent also chooses the ask class, criterion and review_by. Agent-written and person-written rows differ only by source_execution_id, which the rule never reads.
    • Proven: at L2, three record() calls inside one execution made an invented class unprompted: True, and the prompt then says "graduated — you may act without being asked".
    • What limits it: raising the level is admin-only and interactive, and graduation only changes the prompt.
    • Fix options: count only person-written/confirmed records (source_execution_id IS NULL), require records spread across executions or days, or record an explicit accepted risk in requirements §5.38. This needs the owner's call.
    • Related (not caused by this PR): the per-agent "hard off" switch looks agent-flippable. See bug(auth): agent-scoped keys can toggle their own agent's autonomy — PUT /api/agents/{name}/autonomy lacks reject_agent_principal #2996.

Warnings

  • A hold writes before it checks that the class exists (client_portal/autonomy.py:107-112). A hold on a nonexistent class returns 404 but leaves a held=True row behind.
  • PortalAgentAutonomy.vue has no loading or error state (autonomyError/autonomyLoaded are never read), and the section causes a layout shift when it loads. Both break the design contract.
  • New frontend files use raw gray-* classes. The ratchet allows gray, but the contract says semantic tokens only.
  • The stored verdict is never read (it is always recomputed), and held is not in the evidence hash.
  • L3 behaves identically to L2.
  • One thumbs-down blocks every class for 30 days.
  • The rating-triggered demotion hook in submit_rating is never executed by any test.
  • Existing seats with decision records get a new "ask first" prompt block on upgrade.

Clean:

  • OSS-core ungated, consistent with the ent#356 Workspace ruling; the enterprise-docs-guard pattern finds nothing
  • both migration tracks; the MCP tool and # mcp: headers
  • route ordering, and the generic settings PUT refuses the level key
  • fails closed on upgrade (L1 default, per-agent switch off)

Run:

  • backend ent641/ent638/1028: 100 passed across seeds 12345/99999/7
  • frontend: new specs plus ratchets (43 passed), full vitest (3566 passed), build OK
  • MCP: npm test 518/518, tsc clean

Read only: the new endpoints over live HTTP, the PostgreSQL path beyond CI, and the submit_rating hook.

…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>
@vybe

vybe commented Sep 24, 2026

Copy link
Copy Markdown
Contributor

merge-train (2026-09-24): not on this train — Alembic fork plus two findings, rides a later train.

Held back because 0073_seat_ask_class_state parents on 0072_agent_capability_grants, shared with #2989 and #3003 (on this train), and because it conflicts with #3000 in PortalAgentDetails.vue and stores/clientPortal.js, so it follows #3000. Re-parent onto whichever revision is head when you rebase.

Two findings from validation to fix before it rides (the graduation rule itself is executed end to end and is fine):

  1. src/backend/client_portal/autonomy.py:73-81 / client_portal/models.py::PortalAutonomyDial — blocker_text is never returned and is not in the response model, so PortalAgentAutonomy.vue:125 always falls through to its local fallback, which differs from BLOCKER_TEXT for agent_autonomy_off / guard_metric_capped. The spec at portalAgentAutonomy.spec.js:76 injects a field the backend never sends.
  2. client_portal/router.py (portal_agent_autonomy_act / _guard) — ask_class is unvalidated on the path, and autonomy.py:107 writes a hold row before _one() 404s for a class with no decisions, so a roster member can mint unbounded seat_ask_class_state rows for their own seat and get a 404 after the write. Validate against _SLUG_RE and check existence before writing.

Minor: autonomyLoaded / autonomyError in stores/clientPortal.js are assigned and never read, so a failed load looks like "no classes". PR body still says 0072 ← 0071; the code is right.

dolho and others added 2 commits September 25, 2026 10:19
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>
@github-actions

github-actions Bot commented Sep 26, 2026 •

Copy link
Copy Markdown

✅ Nightly unit-suite clean when this PR is merged into dev, all 3 seeds (head_sha: 691ead6cd93663b7e46f8f32da345a2446032b2d).

@github-actions

Copy link
Copy Markdown

⚠️ Live-instance suite skipped — merge conflict against dev.

Resolve by merging dev locally and pushing the result; the next nightly re-tests.

trinity-ability and others added 5 commits September 27, 2026 16:42
…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
@vybe

vybe commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

merge-train (2026-09-27): five commits pushed, agreed with the operator.

  1. 1a5e64dd2 merges dev: keep-both in migrations.py, PortalAgentDetails.vue (dev's decisionsEl wrapper, then the autonomy panel), stores/clientPortal.js, feature-flows.md and requirements/core-agent.md. feat(workspace): suggestions — what you can do with this agent, and what is waiting (abilityai/trinity-enterprise#465) #3000 took §5.39 there first, so the autonomy dial is now §5.40, and its pointer in architecture/workspace.md is updated.
  2. 8acd125a7 fixes self-graduation: evidence is counted per conversation, not per record. Each distinct source_execution_id is one source, and each person-written record (the Workspace path, no execution) is its own source. A class needs STABLE_MIN_COUNT sources as well as records. Short of that it is blocked with the new named reason too_few_conversations ("the decisions for this kind of ask come from fewer than three separate conversations"), which the panel and the prompt both read. evidence.sources is exposed and hashed; RULE_VERSION is 2026-09-27; §5.40 states the rule. Four tests on real SQLite through the real service, including the reviewer's invented-class repro, all red before the fix.
  3. 111649f3c: a hold or guard on a class with no decisions now returns 404 and writes no row. A malformed class gets 422 invalid_ask_class before any write (validated in the service, which both routes pass through). blocker_text is now returned by PortalAutonomyDial and rendered by PortalAgentAutonomy.vue, whose local copy of the sentences, the one that drifted, is gone. The spec now uses the service's real sentences.
  4. 77f775e71 re-parents the revision to 0080_seat_ask_class_state ← 0079_telegram_group_context, with every literal reference updated.
  5. f47cf94d4 merges dev again after feat(telegram): a tagged group turn knows the group's recent conversation (abilityai/trinity-enterprise#600) #2728 landed (migrations.py keep-both). check_alembic_heads.py: 81 revisions, one head.

Locally: 508 passed across the autonomy, seat-decision, migration and Alembic files. Frontend npm run test:unit: 3,698 passed, both ratchets included.

Not done: the missing loading and error state in PortalAgentAutonomy.vue (autonomyLoaded / autonomyError are still set and never read), and the write-through of the fresh recompute in autonomy_dial_service.py:391.

dolho and others added 2 commits September 28, 2026 10:32
…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>
@dolho

dolho commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

Follow-ups from the 09-23 and 09-24 reviews that were still open after 09-27.

Fixed

  • b221c1763: PortalAgentAutonomy.vue now has loading, failed, empty and stale states. They go through utils/loadingState.viewState, so autonomyLoaded and autonomyError are finally read.
    • The section is always mounted: a skeleton until the first load, a retryable InlineError if that load fails, an empty state only after a load succeeds with no classes, and a stale banner over the data when a refresh fails. Nothing appears or disappears on arrival, so there's no layout shift.
    • Light-mode meta text is now gray-500 dark:gray-400. Bare gray-400 is 2.54:1 on white.
    • 4 new mounted specs. All 4 fail against the old component.
  • 4552aaca8: the thumbs-down → demotion hook now runs in a test. The test drives the real client_portal.service.submit_rating over the real SQLite store and checks that a class graduated by three records is written back on_request with negative_rating_in_window. It fails when the hook is removed.
  • Also in 4552aaca8: two TestNotAOneWayRatchet tests started failing on 2026-09-28. The test's TODAY was fixed at 2026-09-23, but record() checks review_by against the real clock, so TODAY + 5 became a past date. TODAY now follows the real UTC date.

Not changed (intent, for your call)

  • The fresh recompute is still merged over the stored row (autonomy_dial_service.py:391), so the stored verdict is never read. held is still not in the evidence hash.
  • L3 still behaves exactly like L2. One thumbs-down still blocks every class for 30 days. Existing seats with decision records still get the new "ask first" prompt block on upgrade.
  • Raw gray-* stays. The contract says "everything else is gray", there's no semantic neutral token to use instead, and the new files have zero non-gray palette classes.

Full vitest: 3,701 passed, ratchets included. ent641, ent638 and #1028 backend tests pass under 3 random seeds.

Alembic: #3005 (0080_agent_skill_sets) and #3021 (0080_pull_sync) also sit on 0079_telegram_group_context. Whichever of the three merges later has to re-chain onto the live head.

@dolho
dolho marked this pull request as draft September 28, 2026 10:37
@dolho

dolho commented Sep 28, 2026

Copy link
Copy Markdown
Contributor Author

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 dev merged, CI green, and today's review follow-ups in. Mark it ready for review again after the cut.

@AndriiPasternak31

Copy link
Copy Markdown
Contributor

Heads-up for when this is unparked: #3005 landed 0080_agent_skill_sets on dev, and #3035 now takes 0081_agent_sync_state_divergence on top of it. 0080_seat_ask_class_state still sits on 0079, so re-chain it onto whatever head is live at the time (scripts/ci/check_alembic_heads.py will tell you).

…_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>
dolho and others added 4 commits September 29, 2026 16:54
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>
dolho and others added 2 commits October 1, 2026 10:19
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>
…3102 guard)

dev's #3102 guard forbids reading request.url.path, because request.url is
rebuilt from the client-controlled Host header. The dial's audit entry now
records request.scope['path'], the path the router matched.

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 dolho left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 off 0086_metric_points_restatement. Whichever lands second needs a one-line re-parent; alembic-head-watch will 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_by still sets expires_at (the earliest), so live_verdict says evidence_expired forever.
    Fix: admit only eff == "active" into the window (plus the existing lapsed handling for reversed), 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_hash omits held, so hold/release writes nothing and the stored state can read graduated on a held class. Reads recompute, so behaviour is right — include held in the hash or document the columns as a cache.
  • db/seat_ask_class_state.py::upsert_seat_ask_class_state stamps demoted_at on every on_request write, 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's 0087 ← 0086.
  • client_portal/autonomy.py docstring says release/guard are "Owner/admin only"; _is_owner is owner-only, which matches the spec — fix the docstring.
  • The /autonomy/classes/{c}/guard endpoint has no UI, so capped is 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>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants