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