Skip to content

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

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

dolho wants to merge 27 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 100 revision(s); exactly 1 is required.
  • 0096_seat_ask_class_state  (0096_seat_ask_class_state.py)
  • 0098_proactive_consent_not_asked  (0098_proactive_consent_not_asked.py)

They fork at: 0094_agent_skill_gates  (0094_agent_skill_gates.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 5 unrelated file(s), which do not change this verdict but must be resolved before merge:

docs/memory/requirements/core-agent.md
src/backend/db/migrations.py
src/backend/routers/settings/__init__.py
src/frontend/src/views/Settings.vue
tests/unit/test_1028_settings_package.py

Fix: rechain this PR's revision off 0098_proactive_consent_not_asked (the current dev head), or — if the forked revision may already be applied somewhere — add a merge revision (alembic merge -m "…" 0096_seat_ask_class_state 0098_proactive_consent_not_asked), 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: 3486a25534e14feae114c4f60c0d1dfa801cb6d6 · 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 check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

@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 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>
…s (ent#641)

dev landed 0087_pull_sync and 0088_skill_gate_requests, so the PR's
0087_seat_ask_class_state became a second head. Renamed to
0089_seat_ask_class_state with down_revision 0088_skill_gate_requests;
the SQLite entry now follows pull_sync / skill_gate_requests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dolho

dolho commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Merged dev (745dd37f2). Two conflicts, both unions: the SQLite MIGRATIONS list and _route_census.py AGENT_CALLABLE. Both sides kept, dev's entries first.

Alembic re-chained: 0087_seat_ask_class_state → 0089_seat_ask_class_state, down_revision = "0088_skill_gate_requests". The SQLite seat_ask_class_state_table entry now follows pull_sync / skill_gate_requests.

Still a draft (parked until after the 1.0 cut).

🤖 Generated with Claude Code

@dolho

dolho commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

/review — automated pre-landing review (at 745dd37f2)

Status note: this PR is a draft, parked by product ruling until after the 1.0 cut. This review is for when it is un-parked. It does not ask for any action before then.

Branch: feature/ent641-autonomy-dial → dev (merge-base 482ad0742)
Files Changed: 42 (+3305/−6)
Scope: CLEAN. The diff stays on the dial: service, table on both tracks, portal panel, admin panel, MCP read, prompt block and docs.
Plan Completion (issue acceptance criteria): 4 done / 1 partial / 0 not done / 0 changed / 0 unverifiable

  • DONE: named levels and a rule readable by person, operator and companion (routers/settings/autonomy_dial.py, prompt_lines, MCP get_autonomy). Reversal and negative rating demote visibly (service.py:5599-5610, BLOCK_REVERSAL/BLOCK_RATING). Everything starts on-request under an L1 default ceiling (DEFAULT_LEVEL = "L1"). The per-agent switch is still the hard off (BLOCK_AUTONOMY_OFF in live_verdict).
  • PARTIAL: "promotion happens only when the declared rule holds". The agent can satisfy the per-conversation conjunct alone; see C1.

Alembic / tracks: check_alembic_heads.py passes (90 revisions, 1 head 0089_seat_ask_class_state). check_alembic_parity.py <mb> HEAD passes. git merge-tree against the current dev tip (09f9d0868) is clean.

Prior review items — status at 745dd37f2

Item (source) Status
Alembic fork / conflicts (vybe 09-23, validate-pr 09-23, bot 09-23) ✅ resolved: re-chained 0089 ← 0088, 1 head
Self-graduation inside one execution (validate-pr 09-23 #2) ⚠️ partially resolved. Records from one execution now count as one source, but the source is an id the agent types. See C1
Hold/guard writes before the existence check; unvalidated ask_class (vybe 09-23/09-24) ✅ resolved: _valid_ask_class and _require_class run before the write. Mutation: removing _require_class turns 2 tests red
blocker_text not returned (vybe 09-24) ✅ resolved: autonomy.py page() returns it and PortalAutonomyDial.blocker_text declares it
No loading/error state in PortalAgentAutonomy.vue (vybe, validate-pr) ✅ resolved: viewState handles loading/failed/empty/stale, and the mounted specs pass
Rating → demotion hook never executed (vybe, validate-pr) ✅ resolved: test_a_thumbs_down_through_submit_rating_demotes_the_stored_verdict drives the real submit_rating
Stored verdict never read; held not in the evidence hash (vybe, validate-pr) ❌ open. Left to the author's call (09-28). Still true; see I3
L3 ≡ L2; one thumbs-down blocks every class for 30 days; upgrade adds an "ask first" prompt block (validate-pr) ❌ open. Product calls, not re-raised as findings
PR body says 0080 ← 0079 / 0072 ← 0071 ❌ stale: the code is 0089 ← 0088

Execution coverage (Step 2.5)

changed symbol / test file executed by live consumer verdict
autonomy_dial_service.class_evidence / evaluate_class / evaluate_seat TestTheRule, TestEvidenceIsCountedPerConversation, TestWritesAndGates (real SQLite) seat_decision_service.prompt_block, client_portal/autonomy.py, routers/seat_decisions.py::get_seat_autonomy ✅ executed
live_verdict expiry branch (BLOCK_EVIDENCE_EXPIRED) test_expiry_demotes_with_no_event_at_all, which calls live_verdict directly none on the live path: evaluate_seat drops expired rows first (I3). Reachable only through the I1 defect ⚠️ executed in isolation only
db/evaluations.py::latest_negative_seat_rating — (the fixture fakes it; test_the_query_reads_the_flip… is inspect.getsource) negative_rating_since ❌ source-only. I ran it on in-memory SQLite and it behaves correctly (operator-prefix hit, workspace-only miss)
set_autonomy_dial admin + interactive gate test_the_level_write_is_admin_and_interactive (inspect.getsource) PUT /api/settings/autonomy-dial ❌ source-only over a grant gate (I4)
generic PUT /api/settings/{key} refusal of LEVEL_KEY test_the_generic_settings_catch_all_refuses_the_level_key (read_text) routers/settings/generic.py ❌ source-only (I4)
route order / DDL presence / revision parent read_text / .index runner, include_router order 🛡 guard (also covered by test_1028_settings_package, schema parity)
PortalAgentAutonomy.vue, AutonomyDialPanel.vue mounted specs (@vue/test-utils) Agent details / Settings → Retention ✅ executed
MCP get_autonomy decisions.test.ts server.ts:649 ✅ (not re-run locally)

Fix mutations: disabling the sources conjunct turns 3 tests red. Removing _require_class turns 2 tests red.
Ran: test_ent641_autonomy_dial.py + test_1028 (66 passed); guards 2094/2996/293/cleanup-parity/ent435-sink/models-centralized/schema-parity/alembic (229 passed); vitest on both new specs + raw-color/loading-gate/source-text ratchets (48 passed).

Critical Findings (block merge)

[C1] Auth boundary (grant vs use, Invariant #8): the companion can still graduate a class it invented, because a "source" is an execution_id the agent supplies (Confidence: 8/10)
File: src/backend/services/autonomy_dial_service.py:280-281, src/backend/routers/seat_decisions.py:41-66,79,97
Evidence:

sources.add(("execution", r["source_execution_id"]) if r.get("source_execution_id")
            else ("record", r["id"]))
seat_email, execution = _seat_for(agent_name, body.execution_id)
...
source_execution_id=body.execution_id,

_seat_for checks only that the execution exists, belongs to this agent and resolves to this seat. It does not check that the execution is the one currently running. The rule then counts each distinct source_execution_id as a separate conversation.

Issue: the 09-27 fix closes "three records in one execution" only when the agent cites the same id each time. In a single turn the agent can call record_decision three times, cite three past executions of the same seat, invent an ask_class, and keep one criterion. The class then reports sources == 3. test_three_records_from_three_executions_graduate (test_ent641_autonomy_dial.py:235-241) asserts this outcome is a graduation. The service cannot tell three real conversations from one turn citing three ids.

The agent does not even need old ids. Seated scheduled runs (schedule_seat_memory.seat_for_execution: a cron fire with source_channel='portal') each get a fresh execution id, so three cron fires with no person involved satisfy the conjunct. The learning docs/memory/learnings/2026-09-21-agent-typed-id-proves-a-conversation.md describes this exact class: an agent-typed id proves a conversation at most, and resumed sessions copy ids from their history.

Fix (the owner chooses, as the 09-23 validate-pr said). Any of:

  • (a) count only person-written or person-confirmed records (source_execution_id IS NULL, or an explicit seat confirmation);
  • (b) in record_seat_decision, accept only an execution that is running, so cited history is refused, and also require the sources to span ≥N distinct UTC days. Neither half is enough alone;
  • (c) record the residual as an accepted risk in requirements §5.40 and in the PR body. The body currently says the fix closes this.

Why: at L2+ the companion's prompt tells it "<class>: graduated — you may act without being asked" for a class whose only evidence is its own writes. The grant comes from the principal it constrains, which is the Invariant #8 line this PR cites for release and the level PUT.

Informational Findings (review required)

[I1] Correctness: closed and superseded records stay in the evidence window forever, so the dial can only go down. This is the same class as the reversal ratchet the PR fixed (Confidence: 9/10)
File: src/backend/services/autonomy_dial_service.py:263-276
Evidence:

eff = sd.effective_status(r, today=day)
if eff == "reversed":
    ...
    continue
if eff in ("expired", "routed"):
    continue
window.append(r)

effective_status returns closed and superseded unconditionally (seat_decision_service.py:228-230), so neither is filtered out and neither ever becomes expired.

Probe on real SQLite through the real service:

  • Three records, each superseded with a corrected criterion: count=6, two criteria, ['criterion_not_stable']. The class can never graduate again, because the superseded rows never leave the window. Correcting a criterion is the one thing fix(security): voice WS + stop endpoint missing ownership check (#600) #638's supersede exists for.
  • One closed record whose review_by passed, plus three good records: expires_at is the past date and the result is ['evidence_expired'], permanently.

Suggestion: treat only active (non-expired) rows as evidence. Count reversed as the PR already does, on its own clock. Skip closed, superseded and routed. Add both probes as tests. This should be fixed before the PR leaves draft.

[I2] Product quality: the owner has no UI to release another seat's hold, so a non-owner's hold can only be undone through the API (Confidence: 8/10)
File: src/frontend/src/components/portal/PortalAgentAutonomy.vue:98-108
Evidence: the other_seats list renders {{ c.ask_class }} · {{ c.seat }} · {{ c.unprompted ? 'unprompted' : 'on-request' }} with no buttons, even though the backend marks those rows writable. Meanwhile release is owner-only (autonomy.py act()), and the seat that placed the hold sees "only the agent's owner can release a hold". Suggestion: render hold/release on other_seats rows when can_release is true, passing seat: c.seat. The store's actOnAskClass already forwards it.

[I3] Dead design: the stored verdict is never read, so the expiry conjunct, the "zero-write restore" and the documented stored×live shape do not run on any live path (Confidence: 8/10, still open from 09-23/09-24)
File: src/backend/services/autonomy_dial_service.py:410-412
Evidence: merged = {**stored, **{k: verdict[k] for k in ("state", "blocked_by", "evidence")}, "evidence_expires_at": verdict["expires_at"]}. The verdict is recomputed fresh, and fresh evidence has already dropped expired rows, so BLOCK_EVIDENCE_EXPIRED is reachable only through I1. held is also missing from the hash at :327-329, so a hold leaves the row saying state='graduated', blocked_by=[] (the CAS skips the write). Raising the level "restores exactly what was earned" only in the sense that it recomputes from the evidence as it is now.

Suggestion: choose one design. Either drop the stored×live framing from the docstring, schema comment, workspace.md, the learnings entry and the panel copy ("comes back exactly as it was"), or actually read the stored row. In either case add held to the hash so the row does not contradict the live answer.

[I4] Test gaps: two grant gates and the real rating SQL are pinned by source text only (Confidence: 7/10)
Files: tests/unit/test_ent641_autonomy_dial.py:359-370, 607-620
Evidence: src = inspect.getsource(r.set_autonomy_dial); assert "reject_non_interactive_principal" in src. The fixture also replaces latest_negative_seat_rating with a dict lookup.

Suggestion:

  • Drive PUT /api/settings/autonomy-dial with a TestClient for three principals: an agent key (403), a non-interactive key (403) and an admin JWT (200).
  • Drive PUT /api/settings/autonomy_dial_level:instance (expect 422).
  • Seed an agent_evaluations table in the fixture so the COALESCE and prefix query actually runs. I ran it by hand and it is correct today; the point is that nothing would catch a regression.

[I5] Process: four learnings appended directly to docs/memory/learnings.md (Confidence: 9/10)
File: docs/memory/learnings.md (+15 lines at the tail)
Issue: #2945 requires one fragment per entry under docs/memory/learnings/, because the tail is the conflict hotspot this branch has already merged across several times. The second entry also claims a restore "with no recompute", which contradicts I3. The last added entry runs straight into the next ## 2026-09-23 header with no blank line. Suggestion: move the four entries to fragments and reword the second.

[I6] Concurrency: the first hold/guard on a class is update-then-insert with no IntegrityError handling (Confidence: 6/10)
File: src/backend/db/seat_ask_class_state.py:129-135, 151-161
Evidence: if not conn.execute(update(...)).rowcount: conn.execute(insert(...)). Two concurrent first writes on a triple that has no row yet: one hits UNIQUE(agent_name, seat_email, ask_class) and returns a 500. The same shape exists in the previous_hash is None insert path of upsert_seat_ask_class_state, but that one is fail-open behind evaluate_seat's except. Suggestion: catch IntegrityError and retry the update.

[I7] Invariant #6: AutonomyDialPanel.vue calls api.get/put directly (Confidence: 6/10)
File: src/frontend/src/components/settings/AutonomyDialPanel.vue:128,142
RoomBudgetDefaultsPanel.vue:159,182 does the same, so there is precedent. Noted for consistency only.

Clean Categories

  • SQL safety: all new queries are SQLAlchemy Core with bound parameters. ask_class is slug-validated before any write (_valid_ask_class).
  • Credential exposure: no secrets. The MCP read omits emails (get_seat_autonomy projects ask_class/state/unprompted/blocked_by/evidence_expires_at/guard_metric/held).
  • Auth wiring: the level PUT uses require_admin + reject_non_interactive_principal and is audited. The generic catch-all refuses LEVEL_KEY. The portal routes are roster-gated, with a uniform 404 for another seat's class when the caller is not the owner. test_2094, test_293 and the test_2996 census pass.
  • Enterprise disclosure: the enterprise-docs-guard pattern finds nothing in the added docs/ lines.
  • Dual-track migrations: SQLite seat_ask_class_state_table, Alembic 0089 (has_table-guarded), schema.py and tables.py all agree. The AgentRef CASCADE is registered. Parity, heads and cleanup-parity pass.
  • Route ordering: autonomy_dial.router is included before generic.router. The /seat-autonomy rename avoids the agent_config collision and is pinned by a route-walk test.
  • Frontend states: both panels gate on viewState (no bare loading gates). The raw-color and loading-gate ratchets pass. No v-html.

Summary

  • Critical: 1. C1 (agent-typed execution ids defeat the per-conversation conjunct) must be fixed or explicitly accepted before merge.
  • Informational: 7. I1 (closed/superseded rows ratchet the dial down) is the one to fix with C1.
  • Scope: clean. Alembic: 1 head at this sha.

Suggested learning / deferred debt: "Before windowing a producer's status, enumerate every terminal value it can return. reversed was handled, but closed and superseded have the same no-expiry property (I1)." This extends the PR's own 09-23 learning and should go in a fragment.

🤖 Generated with Claude Code

@dolho dolho added the status-needs-fix PR has an unaddressed review/validation finding; cleared by the author's next push (#2815) label Oct 5, 2026
…ds are evidence; owner releases other seats' holds (ent#641 review)

- Merge dev: SQLite MIGRATIONS keeps dev's three entries first. Alembic
  0089_seat_ask_class_state forked with dev's 0089_supersede_queue_flood_backlog;
  renamed 0093_seat_ask_class_state, down_revision 0091_chat_session_claude_id.
- class_evidence admits only `active` records. closed / superseded keep their
  stored status forever, so a superseded copy counted as its own source and a
  closed record past review_by pinned expires_at in the past (review I1).
- PortalAgentAutonomy: other seats show `held`, and the owner gets Release on
  a writable row, posting with that seat (review I2).
- autonomy.py docstring: release/guard are owner-only; §5.41 storage line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@github-actions github-actions Bot removed the status-needs-fix PR has an unaddressed review/validation finding; cleared by the author's next push (#2815) label Oct 7, 2026
@dolho

dolho commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

This is still a draft, parked until after the 1.0 cut. I merged dev and fixed the review items that don't need a product decision, in f3a7de45f.

  • Alembic: 0089_seat_ask_class_state forked with dev's 0089_supersede_queue_flood_backlog. It's now 0093_seat_ask_class_state, with down_revision = "0091_chat_session_claude_id". 93 revisions, 1 head. fix(workspace): a sent message shows its attachments, and keeps them after a reload (#3265) #3269 and feat(access): who an agent serves, its seat, and what reads it (abilityai/trinity-enterprise#810–#814, #817) #3271 each also add a 0092_* off 0091, so whichever merges after another has to re-chain.
  • I1, closed and superseded records: class_evidence now counts only active records. Two new tests: superseded copies don't add sources, and a closed record whose review_by has passed no longer pins expires_at. Both fail with the old ("expired", "routed") skip.
  • I2, owner release: other-seat rows now show a held badge, and the owner gets Release on writable rows. It posts with that row's seat. Two mounted specs cover it (owner releases; a non-owner sees the badge but no button).
  • Docs: the autonomy.py docstring now says release/guard are owner-only. The §5.41 storage line now reads 0093 ← 0091.

Still open, needs the owner's call: C1 (a "source" is an execution_id the agent itself supplies; options a/b/c in the 10-05 review), I3 (the stored verdict is never read; held isn't in the hash), I4 (two grant gates are tested by source text only), I5 (learnings fragments), I6 (IntegrityError on the first hold), and the product calls (L3 behaves the same as L2; one thumbs-down blocks every class for 30 days).

🤖 Generated with Claude Code

Re-chain seat_ask_class_state: 0093 forked with dev's
0093_platform_alert_responded_heal; it is now 0096_seat_ask_class_state
off 0094_agent_skill_gates (1 head). 0096, not 0095, because #3271 takes
0095_proactive_consent_not_asked off the same parent — whichever of the
two lands second re-chains onto the other.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dolho

dolho commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

Merged dev (3486a2553). Fixes the alembic-head-watch fork: 0093_seat_ask_class_state collided with dev's 0093_platform_alert_responded_heal. It is now 0096_seat_ask_class_state, with down_revision = "0094_agent_skill_gates" (1 head). The pinning test and the §5.41 storage line are updated. ent641: 61 passed. It's 0096 because #3271 takes 0095 off the same parent, so whichever merges second re-chains. This PR is still parked, and the open product calls from 10-07 are unchanged.

🤖 Generated with Claude Code

@dolho

dolho commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

Before un-parking: abilityai/trinity-enterprise#839 (fix in #3355) adds _self_gate to routers/seat_decisions.py. Once that is on dev, GET /{agent_name}/seat-autonomy here must call it first too, or it ships a third handler with the same gap.

vybe added a commit that referenced this pull request Oct 9, 2026
…orable secret value is a named 422 (#3325) (#3417)

Fixes #3325 — independent fix, one of ten small bug fixes in the trinity-pm `chain-easy-1008` run. Base is `dev`. **Draft until the operator merges.**

## What
- `src/backend/services/credential_encryption.py::decrypt`: every malformed envelope shape raises `ValueError`, as the function promises — JSON that is not an object (`[]`, `null`), and a non-string nonce or ciphertext. Importing such a `.credentials.enc` now returns **400** instead of a 500 with a stack trace in the log. The envelope format and every successful decrypt are unchanged.
- `src/backend/services/secret_settings.py::encrypt_secret_setting`: the key is checked first, in its own `try`, and only that check can raise `MissingEncryptionKeyError`. A value that cannot be encoded (for example a lone surrogate) raises the new `SecretSettingValueError(ValueError)`. Its message names the setting, never the value.
- `src/backend/error_handlers.py` + `src/backend/main.py`: one app-level handler maps `SecretSettingValueError` to **422**. `src/backend/routers/settings/credentials.py`: three pass-through lines in the routes whose catch-all would have turned it into a 500 (Anthropic, GitHub PAT, `PUT /slack`).
- Tests: the three strict-xfail markers in `test_ec_credential_crypto_edges.py` are removed; new `tests/unit/test_3325_credential_error_types.py` (registered in `tests/registry.json`) asserts no value in the message or the exception chain, a missing key reported before a bad value, `PUT /api/settings/api-keys/anthropic` → 422, and that `main.py` registers the handler.

## Rulings carried (orchestrator, on the operator's behalf — plan file)
- TD-1: one app-level handler plus the three pass-through lines — as recommended.
- TD-2: a `.credentials.enc` that decrypts correctly but whose contents are not an object is deferred — as recommended; forging one needs the platform key.
- TD-3: 422, matching the existing 422 for refused cleartext writes — as recommended.

## Review + security
`/review` reading pass (claude-fable-5-1, report-only): **MERGEABLE**, no critical findings. The secret cannot leave through the new error: it is raised after the `except` block, so `__context__` is cleared, not merely hidden; the handler returns only the message; nothing logs it; audit rows are written only after a successful write. The only residual carrier is traceback frame locals, which nothing in the backend renders. The handler is keyed on the subclass, so no other `ValueError` is swallowed or relabelled. `/cso --diff`: no findings.

Evidence sweep (claude-opus-5-5): **GREEN**. 23 unit files import the touched modules; the 16 most direct ran shuffled (seed 12345), one file per process, all passed. All 7 tests in the new file ran, 0 skipped. With both service files taken from `dev`, exactly the three formerly-xfail tests fail (8 cases). Seven less direct files are left to CI and named in the review file. `git merge-tree` against `dev` f6bedcd is clean.

## Tests
Targeted counts above. Not run here: the full unit island (CI).

## Before merge
- Startup auto-import (`lifecycle.py`) on a malformed envelope now lands in its `ValueError` retry arm: it retries without a warning per attempt and ends in the same "failed" result, with the existing error log after the last attempt.
- Enterprise callers of `decrypt` get `ValueError` where they got `AttributeError` / `TypeError` on a malformed envelope. Two catch broadly, three propagate as before; no change needed there.
- The new test file has a module-level `importorskip` for `sqlalchemy` and `cryptography`: it skips silently where those are missing. They are present on the engineer and in CI.
- Ten open PRs share `tests/registry.json` or `src/backend/main.py` with this branch (#3412, #3411, #3407, #3304, #3271, #3256, #2984, #2956, #2713, #2709). A test-merge of this head against each adds no conflict beyond what that PR already has with `dev`.

## Handoffs (not in this diff)
- TD-2 above: the decrypted-but-not-an-object case can still 500 on import.
- `PUT /api/settings/slack` saves the client ID before a later secret write fails (partial update, pre-existing).
- The GitHub PAT, Resend and Gemini routes are covered by the app-level handler but not exercised by the new route test.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
vybe added a commit that referenced this pull request Oct 9, 2026
…L is refused, not stored (#3323) (#3420)

Fixes #3323 — independent fix, one of ten small bug fixes in the trinity-pm `chain-easy-1008` run. Base is `dev`. **Draft until the operator merges.**

## What
- `src/backend/utils/url_validation.py::reject_embedded_credentials`: strips its input and treats a leading `//` as already having a host, the same rule `strip_url_credentials` uses. `//<token>@github.com/o/r` is now refused like the `https://` form, including with leading whitespace or mixed case. All three write paths that store a skills-library URL go through this one function (`routers/skills.py` create and update, legacy adoption in `services/skill_service.py`), so the token is no longer saved in plaintext to `skill_sources.url` or the audit details.
- If `urlparse` refuses an input that starts with `//`, the function falls back to the module's existing userinfo regex instead of raising, so this fix adds no new 500.
- Tests: the D3 strict-xfail marker is removed; four more refused shapes and a four-case "must not raise / must not over-reach" test are added.

## Rulings carried (orchestrator, on the operator's behalf — plan file)
- TD-1: the fallback applies only to inputs starting with `//`; the bare `ValueError` for every other malformed input is #3322's and is unchanged (D4 stays a strict xfail) — as recommended.
- TD-2: keep the username/password check; `//@host` carries no secret and is not refused — as recommended.
- TD-3, TD-4: deferred, see Handoffs — as recommended.

## Review + security
`/review` reading pass (claude-fable-5-1, report-only): **MERGEABLE**, zero critical, five informational. The pre-fix and post-fix modules were executed side by side over forty inputs: nothing that was refused before now passes, credential-free inputs are unchanged, the fallback regex is anchored and linear. `/cso --diff`: no findings.

Fixed after review:
- `546f329a` — the `tests/registry.json` descriptions no longer call #3323 a strict xfail.
- `a75a5e04` — the reject stub in `test_ent183_skill_packages.py` mirrors the fixed `//` rule. It is deliberately stricter than production on a malformed bracket (`//[oops/x` raises).

Evidence sweep after the fix (claude-opus-5-5): **GREEN**. 28 unit files import the module; the 14 most direct ran shuffled (seed 12345), one file per process. Three did not come back clean, none because of this branch:
- `test_ent236_skills_lifecycle.py::TestNonRepoDirectoryRecovers::test_real_repo_still_pulls` and `test_skill_service_user_agent.py` (collection error) fail the same way on `dev` f6bedcd in the engineer's environment.
- `test_ent237_skill_sources.py` hit the 240 s per-file limit at about 72 % with no failure printed — left to CI.

`git merge-tree` against `dev` f6bedcd is clean.

## Tests
895 passed across the 11 files that completed cleanly. Not run here: the 14 less direct importer files, `test_ent237_skill_sources.py` to completion, and the full unit island (CI).

## Before merge
- Ten open PRs share `tests/registry.json` with this branch (#3417, #3412, #3411, #3407, #3304, #3271, #3256, #2984, #2956, #2709). A test-merge of this head against each adds no conflict beyond what that PR already has with `dev`.
- One stale docstring remains at `tests/unit/test_ec_url_validation_properties.py:36` (still calls D3 a strict xfail).

## Handoffs (not in this diff)
- TD-3 (P3): shapes that still pass both reject and strip — `/\t/tok@`, `/\n/tok@`, `///tok@`, `https:/tok@` (`urlparse` removes tab and newline after the check). Fixing it needs the same change in `strip_url_credentials` and cases in both test families.
- TD-4: a property test that reject and strip always agree over the shared `test_2052` corpus; it needs exceptions for empty userinfo.
- #3322 (bare `ValueError` / `UnicodeError` → 500) is the sibling issue in the same file and is untouched.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
vybe added a commit that referenced this pull request Oct 9, 2026
…t the database (#3385) (#3421)

Fixes #3385 — one of ten small bug fixes in the trinity-pm `chain-easy-1008` run. **Stacked on #3414** (the fix for #3314): base is `feature/3314-queue-fingerprint-falsy`, because both issues edit `_clamp_ingested_item` and the fingerprint helpers. Merge #3414 first, then retarget this to `dev`. **Draft until the operator merges.**

## What
- `src/backend/services/operator_queue_service.py`: new `OPERATOR_QUEUE_TYPE_MAX = 64` and `_bounded_type`. An agent-written `type` longer than 64 characters is shortened with the existing `…[truncated]` marker at ingest (`_clamp_ingested_item`); the ask is never refused or lost. `_comparable_type` applies the same bound, so a shortened row does not read as a rewrite (#2915) and a row stored before this fix at full length still compares equal.
- `src/backend/db/operator_queue.py::_insert_values`: `_DB_BELT_TYPE_MAX_BYTES = 1024`; above it the insert raises `ValueError`, like the id / title / question / context belts. All three create paths go through it.
- Docs: five lines in `architecture/api-endpoints.md`, `feature-flows/operating-room.md` and `requirements/security.md` that list the clamp and belt fields now name `type`.
- Tests in `tests/unit/test_1632_operator_queue_caps.py`: an oversized type is shortened with the marker; every platform-emitted type and every type `ask_operator` accepts is unchanged; the belt rejects an oversized type and passes 64 four-byte characters; `test_3385_clamped_type_is_not_a_rewrite` (clamped row vs raw entry, legacy full-length row, a real rewrite inside the first 52 characters).

## Rulings carried (orchestrator, on the operator's behalf — plan file)
- T1: a fixed constant, no environment variable — as recommended.
- T2: 64 characters — as recommended. The longest type the platform emits is `workspace_problem_report`, 24.
- T3: the DB belt raises, like its siblings — as recommended.
- Orchestrator: stacked on #3314 instead of branching from `dev`, so the two fixes do not hand the operator a conflict in the same two functions.

## Review + security
`/review` reading pass (claude-fable-5-1, report-only, against the stacked base): **MERGEABLE**, zero critical. Census of every literal `type` the platform and the enterprise submodule pass to the insert paths: longest is 24 characters, none is altered. Every truncated value ends in the marker, which no set member can match, so truncation cannot move a value into or out of a set that decides control flow (`_BUDGETED_ALERT_TYPES` and neighbours). The new `ValueError` is reachable only from the file sync loop, which quarantines it under its existing handler. `/cso --diff`: no findings; the error text carries the constant, never the value.

Fixed after review: `eab91c10` — I1, the five doc lines. Noted, not changed: I2, a non-string `type` over 1 KiB now raises the named `ValueError` where the base failed at the bind — same quarantine outcome.

Evidence sweep after the fix (claude-opus-5-5): **GREEN**. 13 operator-queue files ran shuffled (seed 12345), one file per process: 836 passed, 7 xfailed. With both production files taken from the #3314 branch, the two `TestClampType` cases, the belt test and the clamped-row fingerprint case go red. `git merge-tree` against `dev` f6bedcd is clean; the base branch has not moved (`923b1edc`).

## Tests
Counts above. Not run here: `test_ent815_queue_walk`, `test_ent815_broad_list_agent_scope`, `test_ent751_gate_entries` (2–4 minutes each; they ran green on the #3314 base in that PR's sweep) and the full unit island.

## Before merge
- **This PR's checks are weaker than a dev-based PR's:** a PR whose base is a feature branch runs a reduced CI set and `backend-unit-test` does not run. The first full run happens when it retargets to `dev` after #3414 merges — read that run before merging.
- Accepted trade-off: an agent that rewrites an oversized type only after character 52 is not detected as a change. Titles and questions already behave this way.
- Thirteen open PRs share a file with this branch (mostly `tests/registry.json` and `docs/memory/architecture/api-endpoints.md`; #3256 also `db/operator_queue.py` and the three docs). A test-merge of this head against each of #3420, #3417, #3412, #3411, #3407, #3304, #3271, #3256, #2984, #2956, #2713 and #2709 adds no conflict beyond what that PR already has with `dev`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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