Skip to content

fix(systems): post-deploy endpoints — nonexistent DB call, ungated restart, broken export round-trip, prefix-collision membership (#2373) - #2415

Merged
dolho merged 6 commits into
devfrom
fix/2373-system-endpoints
Aug 31, 2026
Merged

fix(systems): post-deploy endpoints — nonexistent DB call, ungated restart, broken export round-trip, prefix-collision membership (#2373)#2415
dolho merged 6 commits into
devfrom
fix/2373-system-endpoints

Conversation

@dolho

@dolho dolho commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Summary

The deploy half has been hardened by every commit since ent#124. The four post-deploy endpoints were essentially untouched since 2025. All four defects and both hardenings, in one PR as the issue asks.

Membership is now ONE predicate

get_system, restart_system and export_manifest each matched startswith(f"{system_name}-") — so an operation on acme also captured every agent of a system named acme-extra, including restart, which stops and starts containers. Three copies of a wrong rule.

system_service.system_member_names prefers tags: configure_tags already applies the system name to every member, so a tag is a record of membership where a prefix is an inference from a naming convention. The prefix survives only as a fallback for pre-tag deployments, and it is narrowed — an agent claimed by another system's own tag is excluded, so a tagged acme-extra agent is never captured by acme even there. A failing tag read degrades to the prefix rather than 500ing.

Residual, stated rather than hidden: two systems deployed before tagging, where one name is a prefix of the other, remain ambiguous — nothing distinguishes them. Tagging is what removes the ambiguity, and every system deployed since ent#124 has it.

This is also the prerequisite for the teardown verb, where the same collision would delete rather than restart — which is why it lands as one helper.

GET /{name} returns real schedules

It called db.get_agent_schedules, which does not exist. The facade exposes list_agent_schedules, and database.py deliberately has no __getattr__ fallback — so the AttributeError was swallowed by the surrounding except Exception, every response omitted schedules for every agent, and one warning was logged per agent. tests/test_systems.py never asserted on the key.

The test also pins that the fallback stays absent: adding one would turn the next typo into a silent Mock instead of a raise.

POST /{name}/restart is creator-gated

Bare get_current_user — below POST /deploy and below even the read-only bundled-catalog routes. Any authenticated principal, including role: user, could stop and start every container in a system whose agents it could see. require_role also rejects agent principals (#1890), which matters because an agent-scoped MCP key resolves to its owner carrying the owner's role.

Export round-trips

  • The non-full-mesh permissions branch sliced target_agent[len(name)+1:] with no membership filter (the sibling branch had one), so an edge pointing outside the system exported as a blind-sliced garbage short name that then failed validate_manifest on re-deploy. The export broke its own round trip.
  • The export no longer embeds the instance-global trinity_prompt as prompt:. Deploying that manifest elsewhere overwrote that instance's platform-wide prompt. Nothing records whether the source system ever set one, so there is no honest way to tell it from whatever the instance happens to have configured — and the only correct export of an unknown is to omit it.

Two hardenings

  • Unknown per-agent keys warn like top-level ones (ent#126). credentials:, skills:, display_label: are the fields people try first and they vanished in silence.
  • Preview and deploy resolve the identical resource default. Deploy hardcoded {"cpu": "2", "memory": "4g"} while the preflight validated against the admin-configurable value — they disagreed the moment an admin moved the fleet default. The one spot that escaped ent#126's pure-resolver no-drift pattern.

Verification

14 unit tests, one per defect plus the exempt shapes. Two mutation-checked:

MUTANT: restart gate removed          → 1 failed
MUTANT: tag membership disabled       → 1 failed
restored                               → 14 passed

414 pass across the system / manifest / ent#126 / #1884 suites.

Note on where the coverage lives: tests/test_systems.py is live-backend tier and cannot run without a stack — part of why these survived — so the new coverage is unit-tier, where the per-PR gate can actually see it.

Closes #2373

🤖 Generated with Claude Code


Behaviour changes (release-note material)

Two user-visible changes ship here. Both are deliberate; neither is additive-only.

  1. POST /api/systems/{name}/restart now requires the creator role AND a human caller.
    It was bare get_current_user — below the gate on POST /deploy and below even the
    read-only bundled-catalog routes — so any authenticated principal, role: user included,
    could stop and start every container in any system whose agents it could see.
    Agent-scoped MCP keys now get 403: the route restarts every member with no per-member
    check, while the per-agent start/stop/delete equivalents each apply
    enforce_agent_spawn_scope, so admitting an agent key here bypasses a control that
    exists one call at a time.

  2. GET /api/systems/{name}/manifest no longer exports trinity_prompt.
    It is not a manifest field — validate_manifest rejects it — so an exported manifest
    containing it failed to re-deploy. The export now round-trips.

Anyone automating either endpoint with an agent-scoped key, or consuming trinity_prompt
from an exported manifest, needs to adjust.

@dolho dolho added complexity-medium Complexity: medium (board points 5-8) priority-p1 Critical path theme-reliability Theme: Reliability type-bug Bug fix labels Aug 27, 2026

@obasilakis obasilakis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

/validate-pr — requesting changes on one blocking finding, which is a green test asserting a claim that is false.

Everything else in this PR holds up. The four defects are real and correctly diagnosed, system_member_names is the right consolidation (three copies of a wrong rule collapsed into one, with the residual stated rather than hidden), and _manifest_default_resources closes a genuine preview/deploy divergence. The _code_only() helper is exactly the right instinct.

Blocking

1. test_restart_is_not_reachable_by_an_agent_principal passes on a docstring that says the opposite

def test_restart_is_not_reachable_by_an_agent_principal():
    import dependencies
    src = inspect.getsource(dependencies.require_role)
    assert "reject_agent_principal" in src or "agent_name" in src

inspect.getsource returns the docstring too, and require_role's docstring contains this line:

**Deliberately does NOT call reject_agent_principal** — read this before "fixing" it to match require_admin/assert_admin (ent#293/ent#297).

That is the only match. Verified:

test assertion result: True
  MATCHED ON: **Deliberately does NOT call `reject_agent_principal`** — read this before
actually called in body? False
body gates: ['_reject_connector_principal(current_user)', 'raise HTTPException(']

require_role's body calls _reject_connector_principal and nothing else. It rejects connector principals; it does not reject agent principals, and its docstring says so deliberately — require_role("creator") on POST /api/agents is what makes ent#69 Part 2 agent-spawned creation work, so a blanket rejection there would break ghost spawning.

This is the same trap _code_only() was written for, one file over: an assertion matching its own explanation. That helper is used for the systems.py source assertions and not for this one.

2. The claim ships in three places, one of them a memory doc

  • src/backend/routers/systems.py:297"require_role also rejects agent principals since #1890"
  • docs/memory/feature-flows/system-manifest.md:2294 — same sentence
  • the test above

Consequence. The gate change from bare get_current_user to require_role("creator") is a real improvement and worth keeping — it stops role: user from restarting a fleet. But the agent-principal protection does not exist. An agent-scoped MCP key resolves to its owner carrying the owner's role, so on a default admin-owned install every agent still passes require_role("creator") and can stop and start every container in any system its owner can see. That is precisely the trinity-ops-agent#232 class the comment claims to close.

What I am asking for is a decision, not a specific patch. Restart is arguably a use rather than a grant, and an agent restarting a system it owns may well be legitimate — in which case the fix is to drop the claim and keep the gate. If it should be human-only, add reject_agent_principal explicitly at the endpoint the way /settings/retention/acknowledge and the skills-source routes do. Either way the test, the code comment and the feature-flow doc need to say the true thing, and the test needs to assert against the function body rather than its source text.

Please do not "fix" require_role itself — tests/unit/test_293_admin_gate_rejects_agent_keys.py and that docstring are both load-bearing.

Non-blocking

3. Two behaviour changes that need a release note

Dropping trinity_prompt from the export is right (nothing records whether the source system set one, and deploying it elsewhere overwrote that instance's platform-wide prompt), and the restart gate genuinely tightens. Both are visible changes for anyone scripting against these endpoints, and neither is in the PR title.

4. The MCP third surface (Invariant #13)

src/mcp-server/src/tools/systems.ts exposes restart_system. After this change it 403s for any caller whose role is below creator; the tool description does not say so. Worth one sentence there.

5. export_manifest — a set rebuilt per iteration

if p["target_agent"] in set(member_names)

member_names is already a set, so this copies it once per permission row. The sibling branch three lines up hoists it correctly into _member_set. Cosmetic, but the two branches now differ for no reason.

Happy to re-review as soon as the claim and the test agree with the code.

@dolho

dolho commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

Requested changes addressed — 203550bd

You were right, and the way it was right matters: the assertion matched its own refutation. inspect.getsource returns the docstring, the docstring says "Deliberately does NOT call reject_agent_principal", and my in test found the substring there. That is exactly the class _code_only() exists for, one file over in the same PR. I had the tool and didn't reach for it.

The decision you asked for: human-only is correct

Not because it is a wider gate — because it closes a bypass.

The per-agent equivalents each scope the caller:

routers/agents.py:626   enforce_agent_spawn_scope(current_user, agent_name)   # start
routers/agents.py:752   enforce_agent_spawn_scope(current_user, agent_name)   # stop
routers/agents.py:834   enforce_agent_spawn_scope(current_user, agent_name)   # delete

So an agent-scoped caller may only start or stop agents it actually spawned — matched on name and key id.

restart_system loops every member calling container_stop + start_agent_internal with no per-member check at all. Reaching it with an agent key performs, in bulk and unscoped, exactly the operation that is spawn-scoped one at a time. And since an agent key resolves to its owner carrying the owner's role, on a default admin-owned install require_role("creator") alone admits the whole fleet — the trinity-ops-agent#232 shape.

I considered scoping per member instead (restart only the members you spawned). Rejected: a system whose every member the caller spawned is a near-empty set, so it'd be a strange capability rather than a useful one. If an agent-driven restart is ever wanted it needs its own design, not a silent widening here.

require_role is untouched, per your note.

What changed

The claim now matches the code. restart_system calls reject_agent_principal(current_user) explicitly, above the try. The docstring and docs/memory/feature-flows/system-manifest.md:2290 both carried the false "require_role also rejects agent principals since #1890" — both now state that it does not, why the omission is deliberate (ent#69 Part 2 agent-spawned creation goes through require_role("creator")), and that the guard therefore belongs at the endpoint.

The test is behavioural. Three replace the one:

  • test_require_role_does_NOT_reject_agent_principals — the false premise pinned as its opposite, read through _code_only so the docstring can't answer for the body again.
  • test_restart_refuses_an_agent_principal_before_touching_anything — invokes the handler for real with an agent-scoped User, asserts 403, and stubs get_accessible_agents to raise, so a refusal that came late fails loudly instead of passing.
  • test_restart_still_admits_a_human_creator — the guard is about principal kind, not role. Without this, a guard that refused everyone would pass the test above while breaking the feature.

Mutation-checked both ways:

--- with guard REMOVED ---
FAILED ... test_restart_refuses_an_agent_principal_before_touching_anything
FAILED ... test_restart_still_admits_a_human_creator
2 failed, 1 passed
--- restored ---
16 passed

Non-blockers

  • Finding 5set(member_names) was rebuilt per row inside the comprehension, and the sibling branch held a redundant _member_set copy. member_names is already a set; both branches now read it directly.
  • MCP toolrestart_system's description carries the tightened gate and the tag-first membership resolution, so an agent caller's 403 is legible rather than mysterious.
  • Release note — there's no per-PR notes file in this repo (notes are cut from commits), so the two behaviour changes are a labelled block in the commit message and the PR body: the restart gate, and GET /{name}/manifest no longer exporting trinity_prompt.

Verification

tests/unit/test_2373_system_endpoints.py
tests/unit/test_293_admin_gate_rejects_agent_keys.py
tests/unit/test_1310_auth_wiring.py
tests/unit/test_models_centralized.py     → 57 passed
pytest -k "system or manifest"            → 359 passed, 1 skipped
tsc --noEmit (mcp-server)                 → exit 0

Ready for re-review.

@obasilakis obasilakis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Validation pass on the updated head. The five items from the previous review are all addressed in 203550bd — thanks. The blockers below are new findings from this pass, not carry-over.

Blockers

1. src/backend/routers/systems.py:260 — the schedules fix turns a three-year no-op into credential disclosure.

db.list_agent_schedules() returns List[db_models.Schedule] (db/schedules/crud.py:217select(agent_schedules), all columns), and @router.get("/{system_name}") declares no response_model, so FastAPI serializes every field — including webhook_token, the bearer credential for the unauthenticated POST /api/webhooks/{token}, and webhook_secret_encrypted. db_models.py:182-189 says outright that neither is ever surfaced in an API response model, and ScheduleResponse (models.py:3167) deliberately omits all four webhook fields. The route is bare get_current_user + get_accessible_agents, so any chat-level shared role: user now receives schedule-trigger credentials for every member agent. In-repo precedent: get_agent_schedule_names (#2161) is a projected SELECT specifically to avoid handing whole Schedule models to a response surface. Please project to ScheduleResponse.

2. services/system_service.py:809-811 (if tagged: return tagged) — partial tagging silently drops members.

[acme-web, acme-db, acme-worker] with tags {acme-worker: [acme]} returns ['acme-worker'] only. Reachable three ways: PUT /api/agents/{n}/tags is a full-set replacement (db/tags.py:101, and the org guard covers only dept-* / reports-to-*), a post-deploy POST /api/agents, or a mid-loop raise in configure_tags — which sits inside a try/except, so the deploy still reports "deployed". restart_system then restarts a subset and reports success, and export_manifest writes an incomplete "backup". Suggest tagged ∪ narrowed-prefix rather than tag-exclusive.

3. services/system_service.py:803-807 — a tag-read failure re-opens #2373 itself.

The except sets tags_by_agent = {}, which makes claimed_elsewhere unconditionally False, yielding the raw un-narrowed prefix. With ["acme-web", "acme-extra-worker"] and a failed tag read, membership resolves to both, so restart_system("acme") stops and starts acme-extra's containers while logging a WARNING and returning a byte-identical response. The docstring's "never captured … even on that path" and "degrades to the prefix rather than 500ing" cannot both hold. The existing failure test uses ["acme-web", "other"], so it does not reach this.

4. services/system_service.py export_manifest — the name slice breaks on non-prefixed members.

full_name[len(system_name)+1:] is unchanged, but tag-first membership now feeds it members that do not carry the prefix — which this PR's own test_tags_win_over_the_prefix_… asserts are members. acme + helper gives short_name='r'; content-production + bot and assistant both give '', which collides as a dict key (silent agent loss) and fails validate_manifest's name regex, so the export cannot re-deploy. That is the same "export broke its own round trip" class this PR sets out to fix. The templateless skip-and-warn from #1759, in the same function, is the precedent.

Warnings

  • system_service.py:818-822claimed_elsewhere tests name.startswith(f"{t}-") rather than "carries a foreign system tag", and is wrong in both directions: acme-extra-worker renamed to acme-worker (tag acme-extra cascades) is captured by acme, and acme-web-1 carrying an unrelated tag acme-web is excluded from its own system. Untested.
  • Membership becomes user- and agent-writable in a flat namespace: configure_tags:789 writes the bare system name, and PUT /agents/{n}/tags is owner-gated with no system-tag guard — its own docstring notes an agent key may rewrite its plain tags. An agent refused the restart verb can still decide what a human's restart hits, or untag itself out of the exported backup. A reserved system-<name> namespace, or prefix corroboration, would close this.
  • The "Behaviour changes" block in the PR body states that trinity_prompt is not a manifest field and that validate_manifest rejects it. Both are false: prompt is in _KNOWN_MANIFEST_KEYS:50, read at :177 and applied at :1671, and unknown keys are warned, never rejected. Commit 5780635d and the feature flow state it correctly — only the text destined to become the release note is wrong.
  • Commit 5780635d still carries the false "require_role also rejects agent principals (#1890)". Code, docstring, doc and test are fixed; the commit message is not, and release notes are cut from commits.
  • routers/systems.py:85-106 list_systems is untouched and still uses '-'.join(parts[:-1]) — a fourth membership rule. "THE ONE membership predicate" and the count == 3 pin encode that fourth endpoint's exemption as correct, so GET /systems and GET /systems/{name} can now disagree.
  • system_service.py:980export_manifest recomputes membership over an already-filtered list: one redundant get_tags_for_agents, and on a failure here only, tag-only members' permission edges are dropped while agent_configs still lists them. Passing the router's set in would avoid both.
  • Export is lossy for a system legitimately deployed with a manifest prompt: — dropped, and lost on re-deploy. Deliberate per the comment, but absent from the release note, as is the membership widening.
  • reject_agent_principal is a no-op for scope='system', so trinity-system still reaches restart; "human-only" overstates it.
  • mcp-server/src/tools/systems.ts has no canAccess gate, so restart_system stays advertised to agent keys and always 403s. The description-only fix meets the ask, noting for follow-up.
  • system_service.py:335-346 — the new loop sits directly under the ent#126 comment block explaining unknown_keys, so that rationale now reads as the new loop's; if manifest.unknown_agent_keys: is a redundant guard over an empty-dict loop.
  • system_service.py:768-771 — the lazy-import cycle justification is false (nothing under agent_service/ imports system_service). Harmless, and it matches the _preflight_template:1416 precedent. The no-drift claim itself checks out through normalize_cpu / crud._stage_config_files.

Docs and tests

  • docs/memory/architecture.md is untouched. Line 111 documents systems.py's role gating, and a creator-plus-human gate on a fleet-wide mutating verb belongs there. The tiered rule allows architecture or flow, and the flow was updated, so this is a warning rather than a blocker.
  • docs/memory/requirements/ is untouched. infrastructure.md ADOPT-007 documents this exact pattern for POST /api/system-agent/restart; the sibling gate has no entry.
  • The restart gate itself is genuinely behavioural and survives mutation. The rest of the added tests are weak: there is no partial-tagging test, no acme-extra case in the tag-failure test, and no behavioural export test at all (export_manifest is never invoked). Three assertions are self-matching — count('if p["target_agent"] in') == 2 passes for any set, test_preview_and_deploy_resolve_the_same_resource_default never calls either resolver, and test_require_role_does_NOT_reject… survives only on the (current_user) suffix, because _code_only strips comments but not docstrings (which is also the stated rationale for it, and is the same class as the defect it guards). count == 3 is brittle against the PR's own stated next step — a teardown verb makes it 4.

Base branch, size, Closes #2373, all security greps, build/config packaging and Invariants #13/#14 are clean, and CI is green.

@dolho

dolho commented Aug 28, 2026

Copy link
Copy Markdown
Contributor Author

All four blockers fixed — 3d64349d, rebased onto dev

1. The credential disclosure — my fix created it

You're right, and this is the one that mattered. Fixing the three-year-broken schedules key is exactly what made it reachable: list_agent_schedules returns whole db_models.Schedule rows, the route declares no response_model, so FastAPI serialized webhook_token — the bearer for the unauthenticated POST /api/webhooks/{token} — to any chat-level shared role: user, for every member agent.

Projected through ScheduleResponse, not a hand-picked dict: a dict is a second field list, free to drift from the one every other schedule surface uses. get_agent_schedule_names (#2161) is the precedent, as you say.

A second test asserts the disjointness against Schedule.model_fields rather than a literal list, so a fifth webhook field added tomorrow is covered without anyone remembering:

leaky = {f for f in Schedule.model_fields if "webhook" in f}
assert not (leaky & set(ScheduleResponse.model_fields))

2. Partial tagging dropped members

tagged ∪ narrowed-prefix now, returned in roster order so a tag edit doesn't reshuffle what the caller renders and restarts. Your three reachability paths are all real, and the failure mode — restart a subset, report success; export a partial backup, call it one — is worse than the prefix collision the function exists to fix.

3. The tag-read failure re-opening #2373

Caught precisely. tags_by_agent = {} made claimed_elsewhere unconditionally False, so the error path degraded to the raw prefix and restart_system("acme") would stop and start acme-extra-worker while returning a byte-identical response. The docstring claimed two things that couldn't both hold.

With tags unreadable the narrowing is now structural"-" in name[len(prefix):] — so membership that can't be justified is dropped rather than assumed. And you were right that the old test (["acme-web", "other"]) never reached the collision; the new one uses acme-extra-worker.

4. The export slice

Skipped and reported, following #1759's templateless precedent in the same function. content-production + bot/assistant both slicing to '' is the collision you describe — silent agent loss plus a manifest that fails its own validator.


On the weak tests — you were right, and I did it again

The first version of the blocker-4 test was a source grep that survived deleting the skip it guarded, because it matched a leftover variable declaration. I caught that only by mutation-testing it. So the export test is now behavioural: it invokes export_manifest, parses the YAML, and asserts the key set.

All four fixes are mutation-checked: un-projecting the schedules (1 red), tag-exclusive membership (2 red), fail-open tag read (1 red), mangle-instead-of-skip (1 red).

Still outstanding — deliberately not in this commit

I've fixed the four blockers and want to keep this reviewable. The warnings I have not yet addressed, in the order I'd take them:

  1. claimed_elsewhere being wrong in both directions (rename + unrelated-tag cases) — needs its own tests
  2. list_systems' fourth membership rule, and the count == 3 pin that encodes its exemption as correct
  3. The false trinity_prompt claim in the PR body, and the stale require_role line in commit 5780635d — both destined for release notes
  4. architecture.md:111 and requirements/infrastructure.md ADOPT-007's sibling entry
  5. reject_agent_principal being a no-op for scope='system', so "human-only" overstates it
  6. The membership-namespace warning (system-<name> reserved prefix) — the largest, and arguably its own issue

Say whether you'd like those in this PR or split; (3) and (5) are text-only and I'll do them regardless if you want a single pass.

Verification: 366 passed on the system/manifest selection, rebased onto dev (which is green again since #2427 merged).

@dolho
dolho requested a review from obasilakis August 31, 2026 07:08

@obasilakis obasilakis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Third pass, on 3d64349d. Three of the four blockers from the last review are properly closed, and closed the right way — with tests that drive the real functions rather than grep for them:

  • Webhook credential disclosurerouters/systems.py:281-282 projects through ScheduleResponse.model_validate(..., from_attributes=True). I checked every ScheduleResponse field (models.py:3246-3267) exists on db_models.Schedule (db_models.py:155-192), so the projection cannot raise into the swallowing except and silently re-drop the key, and test_the_projection_drops_every_webhook_field asserts against the model rather than a hand-kept list. That is the right shape.
  • if tagged: return taggedsystem_service.py:848 is now set(tagged) | set(narrowed), with a test that drives the real function.
  • test_restart_is_not_reachable_by_an_agent_principal (from review #1) — now asserts against the function body and is backed by a real behavioural drive that stubs get_accessible_agents to raise and confirms 403 arrives first. require_role itself untouched, as asked.

The remaining two are the same failure shape as the originals: the correction was applied at one call site, and the test was written around the surviving half. Both are provable in a few lines on head.

Blocking

C1 — export_manifest's permission branches still blind-slice; AC #3 is not met

The prefix skip landed only in the agent_configs loop (system_service.py:1026). Both explicit-permission loops still iterate the unfiltered agents and slice with no prefix test — source key at :1168 and :1184, target at :1171 and :1190 — and the membership filter added at :1173/:1192 admits tag-only members, whose names carry no prefix at all.

System acme, members acme-web plus tag-only helper, edge acme-web → helper:

agents:
  web: {template: local:scout}     # helper correctly omitted by the :1026 skip
permissions:
  explicit:
    web: [r]                        # 'helper'[5:] — the blind slice, unfixed
validate_manifest → ValueError: Unknown agent in permissions: r

With two tag-only members under content-production, bot and assistant both slice to '', giving explicit: {writer: ['']} — the colliding empty key the :1026 fix was written to prevent, one loop over. The prefixed-member case does round-trip green; the failing path is the tag-only one this PR introduced. AC #3 asks for the edge to be dropped or scoped, never blind-sliced.

The tests cannot see it: test_a_member_without_the_prefix_is_skipped_from_the_export_not_mangled stubs get_agent_permissions → [] so it never enters the surviving branch, and test_export_scopes_permission_edges_to_members_in_both_branches is a source count (slices == guarded == 2) that passes while the bug is live.

C2 — the tag-read-failure narrowing drops every member with a hyphenated short name

system_service.py:842excluded = "-" in name[len(prefix):].

This closes the reported case but replaces the raw prefix with a heuristic strictly narrower than it, and the bundled flagship manifest is entirely inside the gap. config/manifests/vc-due-diligence.yaml is system vc-due-diligence with eleven agents all named dd-*, so every deployed name is vc-due-diligence-dd-<x> and every remainder contains a hyphen:

'vc-due-diligence-dd-lead'   remainder='dd-lead'   excluded=True
'vc-due-diligence-dd-intake' remainder='dd-intake' excluded=True
'vc-due-diligence-dd-tech'   remainder='dd-tech'   excluded=True
result: []

GET /api/systems/{name} and POST /{name}/restart then return 404 System not found, and GET /{name}/manifest exports an empty system — for a correctly tagged, healthy fleet, on a transient tag-read error. Before this PR that path returned all eleven. That is a regression on the degraded path, and it makes three statements false in a new direction: the docstring at :800, the feature-flow line "A tag read that fails degrades to the prefix rather than 500ing", and the PR body. It degrades to a strict subset of the prefix, which for this manifest is empty.

test_a_tag_read_failure_degrades_to_the_prefix_rather_than_500ing uses ["acme-web", "other"] — single-token short names — so it cannot reach this.

Two ways out, either is fine: restore the raw prefix on that path and accept the documented pre-tag residual, or narrow against the observed roster (a name is excluded only when some other name in the same list is a longer system prefix of it). Please add a test with a hyphenated short name whichever you pick, and correct the docstring, the flow and the body to say what it actually does.

Carried over, unfixed

  • claimed_elsewhere is wrong in both directions and still untested (system_service.py:837-841). Run on head: members("acme", ["acme-web","acme-worker"], {"acme-worker":["acme-extra"]})['acme-web','acme-worker'], so a renamed sibling is captured; members("acme", ["acme-web-1","acme-db"], {"acme-web-1":["acme-web"]})['acme-db'], so a live member carrying an unrelated plain tag is excluded from its own system and restart_system("acme") silently skips it while reporting success. The second direction is the one that undercuts AC #5.
  • Release-note text. The "Behaviour changes" block still says trinity_prompt "is not a manifest field — validate_manifest rejects it". prompt is in _KNOWN_MANIFEST_KEYS at system_service.py:50, read at :177, applied at :1671, and unknown keys are warned at :347-353, never rejected. Also unstated: a legitimately-declared manifest prompt: is now dropped on export, and membership widens to tagged non-prefixed agents.
  • ce5a2c27's message still carries "require_role also rejects agent principals (#1890)". c71ab2c0 corrects it in a later commit, but a squash body concatenates all three, so the false sentence still reaches the release notes.
  • list_systems is still a fourth membership rule (routers/systems.py:85-106, '-'.join(parts[:-1])). The issue's Technical Note asks it to at least stop mis-grouping tagged systems, and test_all_three_endpoints_use_the_one_predicate's count == 3 encodes the exemption as correct.
  • Membership is user- and agent-writable in a flat namespace. configure_tags:789 writes the bare system name and routers/tags.py:65 guards only dept-* / reports-to-*. An agent refused the restart verb can still decide what a human's restart hits, or untag itself out of the exported backup. A reserved system-<name> namespace or prefix corroboration would close it.
  • architecture.md and docs/memory/requirements/ untouched. architecture.md:111 documents systems.py's role gating and is now stale, and infrastructure.md's ADOPT-007 documents this exact pattern for the sibling POST /api/system-agent/restart with no entry for this one. The tiered rule allows architecture or a flow and the flow was updated, so this is a warning rather than a blocker.
  • export_manifest recomputes membership over an already-filtered list at :1004 — a second get_tags_for_agents per request. Passing the router's set in would remove that and take C2's blast radius off the export path entirely.
  • reject_agent_principal is a no-op for scope='system' (dependencies.py:984 gates on current_user.agent_name), so trinity-system still reaches restart and the "Human-only" docstring at routers/systems.py:310 overstates it. reject_non_interactive_principal at :991 exists for this.

On the restart gate

Keep it. That one is from the ticket, not from me — AC #2 asks for require_role("creator") and for an agent-scoped key to be rejected — and the reasoning you wrote at routers/systems.py:328-341 is right: restart is nominally a use verb, but every per-agent equivalent applies enforce_agent_spawn_scope, so admitting an agent key here is a bulk bypass of a control that otherwise exists one call at a time. The only residual is the scope='system' gap above.

Tests

Three of the previous pass's gaps are genuinely closed — the partial-tagging test, the acme-extra case in the tag-failure test, and an export test that actually invokes export_manifest. Still open:

  • AC #1 asks for a test asserting on the schedules key rather than just a 200. All four are inspect.getsource greps plus a model-field check, and since the endpoint's except Exception swallows anything, a source grep cannot prove the key is returned.
  • test_export_scopes_permission_edges_to_members_in_both_branches is a source count that passes while C1 is live. Replace it with an export → parse → validate round trip.
  • test_preview_and_deploy_resolve_the_same_resource_default still never calls either resolver.
  • count == 3 remains brittle against the teardown verb this PR is a prerequisite for; an assertion that no startswith(f"{system_name}-") remains would survive it.

Route ordering is correct — /manifests at :154 and /manifests/{id} at :172 are both above /{system_name} at :199 and /{system_name}/manifest at :402, so both Invariant #4 collisions are covered. No SQL in the router, MCP systems.ts description updated, unknown_agent_keys added to models.py, no schema change so the dual-track migration question is correctly N/A, security greps clean, Closes #2373 resolves same-repo, CI green.

Six of eight acceptance criteria are met. AC #3 and AC #8 are not.

@dolho

dolho commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

/review — fresh pass on c57b26ac

Branch: fix/2373-system-endpointsdev · +993/-42 across 6 files
Scope: CLEAN — every file traces to one of the four #2373 endpoints or its test/doc
Base: merge-base 135248e9, not the dev tip

Reviewing my own fix commit adversarially. The two blockers from pass 3 are closed and I verified each by driving the real function, not by reading it:

  • C1 (blind slice in export_manifest) — one _member_short_name now used on both sides of every edge and by the agent_configs loop, so there is no guarded-here / blind-there split left to drift. grep '\[len(system_name) + 1:\]' system_service.py returns nothing.
  • C2 (degraded path lost a whole system) — measured on the bundled flagship: vc-due-diligence with a failing tag read returns 11 of 11, where the "-" in remainder rule returned 0. acme-extra-worker is still excluded when acme-extra is on the roster.

One new finding, and it is the same shape as the one it replaced.


C1 — the degraded narrowing drops a member of the system's OWN roster (Confidence 9/10)

src/backend/services/system_service.py — the tags_readable is False branch:

excluded = any(
    other != name and name.startswith(f"{other}-")
    for other in agent_names
)

other is any name on the roster, with no test that it is a different system's name. So a member whose name happens to be a prefix of another member excludes it. Driven against the real function with the tag read raising:

'acme'   roster=['acme-api', 'acme-api-worker', 'acme-web']
         -> ['acme-api', 'acme-web']              # acme-api-worker dropped

'vc-dd'  roster=['vc-dd-dd', 'vc-dd-dd-lead', 'vc-dd-dd-tech']
         -> ['vc-dd-dd']                          # 2 of 3 dropped

acme-api-worker is a perfectly ordinary member of acme — manifest key api-worker, sitting beside key api. Nothing about it belongs to another system.

Why this matters more than the row count. restart_system shares this predicate, so on the degraded path it restarts a subset and reports success. That is verbatim the failure this function's own union comment condemns:

Silent partial success on a fleet-wide verb is worse than the prefix collision this function exists to fix.

And export_manifest writes a short backup and calls it one — the C2 symptom, in a narrower window.

Why it is not simply fixable. From names alone acme-extra-worker (member worker of acme-extra) and acme-api-worker (member api-worker of acme) are indistinguishable. There is no discriminator; only a preference. The commit itself states the preference — "losing a healthy system entirely is the worse of the two errors" — and this branch contradicts it for one roster shape.

Fix: restore the raw prefix on the unreadable-tag path and accept the documented pre-tag residual, which is the option the previous review explicitly offered. The roster-evidence narrowing then survives only where it is unambiguous, or goes away entirely — either is honest; silently preferring the drop is not.

Test gap either way: the suite covers 11 flat dd-* names (no prefix pairs) and the acme-extra case. A roster with a same-system prefix pair is untested, which is why this survived.


I1 — export_manifest re-derives membership the caller already resolved (Confidence 8/10)

member_names = set(system_member_names(system_name, [a['name'] for a in agents]))

routers/systems.py:435 calls export_manifest(system_name, system_agents), and system_agents is already system_member_names(...)-filtered. So this is a second tag read per export, and its own comment says so — "the caller already filtered agents, so this is the same set". Two consequences: one redundant query, and if the second read degrades where the first did not, member_names ⊂ agents, silently dropping permission edges to real members.

Suggestion: member_names = {a['name'] for a in agents}. Same set by the comment's own argument, no second read, no window where the two disagree.

I2 — cross-module private import (Confidence 7/10)

from services.agent_service.crud import _get_default_resource

Sharing the create path's resolver is right and is the point of the fix; reaching for its _-prefixed name means a rename in crud.py breaks deploy at runtime, inside a lazy import, on the deploy path. Worth promoting to a public name or re-exporting.


Clean

  • Authrestart_system is require_role("creator") plus reject_agent_principal. Correct per invariant security: implement safe tar extraction with symlink/hardlink validation #8: require_admin/assert_admin reject agent principals since fix(security): admin gates reject agent-scoped keys #1890, require_role deliberately does not, and the docstring says exactly that instead of the earlier false claim. The bypass argument (per-agent start/stop are spawn-scoped, this loop was not) is stated at the site.
  • Credential exposureScheduleResponse.model_validate(..., from_attributes=True) projects the rows; webhook_token and webhook_secret_encrypted cannot reach the response, and it is the response model rather than a hand-kept dict, so it cannot drift from the other schedule surfaces.
  • Enum/value completeness_KNOWN_AGENT_KEYS is exactly the five keys parse_manifest reads (system_service.py:159-163); no sixth key is read and unlisted. SystemManifest is not a response_model anywhere, so unknown_agent_keys is internal.
  • New warnings are non-blockingvalidate_manifest output lands in all_warnings and deploy continues (system_service.py:1723-1732); ManifestPreview.vue already renders the list. A manifest using credentials: starts warning and keeps deploying.
  • trinity_prompt removal — verified it was written into the exported manifest's prompt:, i.e. importing an export overwrote the destination instance's platform-wide prompt. Omitting an unknown is the only honest export.
  • SQL/concurrency/perf — no raw SQL, no new shared state. system_member_names is O(n²) on the degraded path only, over the caller's accessible roster.

Summary

  • Critical: 1 — C1, degraded-path member loss. Requires a tag-read failure, but reproduces the exact failure mode this change condemns.
  • Informational: 2
  • Scope: clean

@dolho

dolho commented Aug 31, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in 6d06759c — and the finding was worse than I first reported

Following my own C1 above I drove one more roster shape, and it is the sharpest form of the same rule:

'acme'  roster=['acme', 'acme-web', 'acme-db', 'acme-worker']
        -> []

An agent named literally acme anywhere on the accessible roster is a name-prefix of every member, so system_member_names returned an empty set — 404 System not found on GET /api/systems/acme and an empty export, for a completely healthy system, caused by one unrelated agent's name. That is the C2 symptom returning through a different door, which makes this the third rule in this spot to lose members of a healthy system.

Resolution: the unreadable-tag path degrades to the raw prefix and narrows no further — the option the pass-3 review offered, and what the docstring and flow doc have always promised. There is no third rule to write: from names alone acme-extra-worker is worker of acme-extra or extra-worker of acme, and only a tag separates them. It is a choice between two errors.

They are not symmetric, and the union comment in this same function already said why: over-capture restarts one agent too many and logs a WARNING; under-capture restarts a subset and reports success — the silent partial success the union rule exists to prevent. Residual now stated in code, docstring and flow doc: while tags are unreadable, acme may capture acme-extra-worker, ending when the read recovers. The narrowing on the tag-readable path — the path that actually runs — is untouched, pinned by the pre-existing test_the_prefix_fallback_still_excludes_another_systems_tagged_agents.

The test is now a property, not a case. Two case-by-case tests were each satisfied by a different member-losing rule, which is exactly how this recurred. The fallback is pinned as a superset of the raw prefix across five rosters, so any future narrowing here fails by construction whatever shape it narrows on.

I1 also fixed: export_manifest reuses the caller's already-resolved set ({a['name'] for a in agents}) instead of a second tag read.

I2 deferred_get_default_resource stays a private cross-module import. Promoting it is a crud.py change and this PR should not grow another file; noted for follow-up.

33 passed in the file, 192 passed, 1 skipped across the systems/manifest suites.

@obasilakis obasilakis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Fourth pass, on 4f2c56e3. Both blockers are closed at the code level, and C2 is closed the way I hoped — by ranking the two errors rather than inventing a third rule.

C1. _member_short_name is one rule for "what is this member's manifest key", used by the agent_configs loop and by both sides of both permission loops. Returning None for a member with no prefix, and skipping on it, is what AC #3 asks for. The parametrised test on the helper covers the empty-remainder case that produced the colliding '' key.

C2. Degrading to the raw prefix is correct, and the comment earns its length: it records both rules that were tried and why each lost members of a healthy system, including the vc-due-diligence measurement and the agent-named-after-the-system case. test_a_tag_read_failure_never_returns_FEWER_members_than_the_prefix is the right shape — it pins the invariant (the fallback is a superset of the prefix), so it reds on any future narrowing rule rather than on one particular one. The asymmetry argument — over-capture restarts one agent too many and logs it, under-capture restarts a subset and reports success — is the correct ordering and it is now in the flow doc too.

Dropping the second membership derivation in export_manifest (I1) is also right, and the reason you give is the sharper one: a second tag read that degrades where the first did not would have silently dropped permission edges from the backup. Only one caller, and it pre-filters.

Blocking

1. AC #3's test half is still not there, and the ticket names it literally

Export → validate → re-deploy round-trips green with a cross-system permission edge present (edge dropped or scoped, never blind-sliced).

Both permission-loop tests are source assertions:

  • test_export_scopes_permission_edges_to_members_in_both_branches:245"[len(system_name) + 1:]" not in src plus src.count("_member_short_name(") >= 4
  • test_neither_permission_loop_blind_slices:601 — the same two assertions again, on the same source

They are duplicates of each other, and neither runs the export. test_neither_permission_loop_blind_slices' docstring says "both branches sit behind a template lookup that a unit test cannot easily reach" — that is not so. The template lookup is in the agent_configs loop; the explicit-permissions branch is gated only on db.get_agent_permissions, and test_a_member_without_the_prefix_is_skipped_from_the_export_not_mangled:425 already calls export_manifest with get_agent_permissions stubbed to []. Returning a per-agent edge map from that stub — first agent full-mesh-looking, a later one not, plus one edge to a tag-only member and one pointing outside the system — reaches the explicit branch, and then yaml.safe_loadparse_manifestvalidate_manifest is the round trip the AC asks for. It is a few lines on top of a test that already exists, and it is the only thing that proves the fix rather than its spelling.

Same class one AC up, carried from pass 1: AC #1 asks for a test asserting on the schedules key rather than just a 200, and test_the_schedules_accessor_the_router_calls_actually_exists asserts the accessor exists. The endpoint's except Exception swallows anything, so the key being present in a response is exactly what a source-level test cannot show.

2. The release-note block still says something false

GET /api/systems/{name}/manifest no longer exports trinity_prompt. It is not a manifest field — validate_manifest rejects it

prompt is in _KNOWN_MANIFEST_KEYS (system_service.py:50), read by parse_manifest, applied at deploy, and unknown top-level keys warn (:347-353) — nothing rejects. Third pass raising this; it is PR-body text, so it costs one edit, and on a squash it is what lands in the release notes. Two things still unstated there as well: a legitimately-declared manifest prompt: is now dropped on export, and membership widens to tagged non-prefixed agents.

Also

system_member_names:832-838 — the comment above the if tags_readable: branch still describes the rule you just deleted:

So on that path the narrowing is done structurally instead: a name whose remainder still looks like <something>-<rest> may belong to a longer system, and membership we cannot justify is dropped rather than assumed.

Three lines below, the same function explains at length why that rule was wrong and sets excluded = False. Reading top to bottom, the file contradicts itself on the one predicate restart_system shares.

Carried over, unchanged and still non-blocking

  • claimed_elsewhere on the readable-tag path excludes a member on any tag whose name prefixes it, with no check that the tag names a system: an acme-web-1 member carrying a plain acme-web tag is dropped from its own system, and restart_system("acme") skips it while reporting success. Untested in either direction.
  • list_systems remains a fourth membership rule ('-'.join(parts[:-1])), and test_all_three_endpoints_use_the_one_predicate's count == 3 encodes the exemption as intended.
  • Membership is user- and agent-writable in a flat tag namespace; routers/tags.py guards only dept-* / reports-to-*.
  • reject_agent_principal is a no-op for scope='system', so trinity-system still reaches restart and the "human-only" docstring overstates it.
  • architecture.md:111 and requirements/infrastructure.md (ADOPT-007) untouched — allowed by the tiered rule since the flow was updated, so noting rather than asking.
  • ce5a2c27's message still carries the "require_role also rejects agent principals (#1890)" sentence that c71ab2c0 corrects; a squash body concatenates both.

Six of the eight ACs are met on the code. AC #3 and AC #1 are met in behaviour and not in evidence, and the ticket asks for the evidence in both. Fix those two tests, the release-note sentence and the stale comment and I will approve. CI on head: matrix pytest jobs still IN_PROGRESS, everything else green.

@obasilakis obasilakis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved, with one thing recorded explicitly so it is not discovered later.

The code is right: _member_short_name is one shared rule across the agent_configs loop and both sides of both permission loops, the degraded path returns to the raw prefix with the two rejected narrowing rules and their measurements written down, the superset invariant is pinned so a third rule cannot be invented there, and dropping the second membership derivation in export_manifest removes a read that could have silently narrowed a backup.

Accepted deliberately, not met: #2373's AC #3 asks for "Export → validate → re-deploy round-trips green with a cross-system permission edge present" and AC #1 for "a test asserting on the key (not just 200)". The evidence for both is source-level — "[len(system_name) + 1:]" not in src plus a _member_short_name( count, twice, in two duplicate tests — so the fix is proved by its spelling rather than its behaviour. Approving on the judgement that the code is correct and the round trip can follow; noting it here and on the issue so AC #1 and #3 are not ticked as evidenced when they are not.

Cheapest path if you want them closed properly: test_a_member_without_the_prefix_is_skipped_from_the_export_not_mangled:425 already calls the real export_manifest with get_agent_permissions stubbed to []. Return a per-agent edge map instead — first agent full-mesh-looking, a later one not, one edge to a tag-only member, one pointing outside the system — then yaml.safe_loadparse_manifestvalidate_manifest. That is the AC, on a test that exists.

Before merge, please push two text fixes:

  1. The release-note block still says trinity_prompt "is not a manifest field — validate_manifest rejects it". prompt is in _KNOWN_MANIFEST_KEYS (system_service.py:50), read by parse_manifest, applied at deploy; unknown top-level keys warn, nothing rejects. On a squash this sentence is what lands in the release notes. Worth adding there too: a legitimately-declared manifest prompt: is now dropped on export, and membership widens to tagged non-prefixed agents.
  2. system_member_names:832-838 — the comment above if tags_readable: still describes the structural narrowing rule that the branch three lines below explains at length was wrong. The function currently contradicts itself on the predicate restart_system shares.

Carried and still open, unchanged from the last pass: claimed_elsewhere excluding a member on any name-prefixing tag without checking the tag names a system; list_systems as a fourth membership rule; the flat, agent-writable tag namespace; reject_agent_principal being a no-op for scope='system'. None of them are this PR's regressions.

Confirm the pytest matrix goes green before merging — the jobs were still IN_PROGRESS.

dolho and others added 6 commits August 31, 2026 15:39
… an ungated restart, a broken export round-trip, and prefix-collision membership (#2373)

The deploy half has been hardened by every commit since ent#124; the four
post-deploy endpoints were essentially untouched since 2025.

## Membership is now ONE predicate

`get_system`, `restart_system` and `export_manifest` each matched
`startswith(f"{system_name}-")`, so an operation on `acme` also captured every
agent of a system named `acme-extra` — including `restart`, which stops and
starts containers. Three copies of a wrong rule.

`system_service.system_member_names` is the one rule, and it prefers TAGS:
`configure_tags` already applies the system name to every member, so a tag is a
RECORD of membership where a prefix is an inference from a naming convention.
The prefix survives only as a fallback for pre-tag deployments, narrowed so an
agent claimed by another system's own tag is excluded — a tagged `acme-extra`
agent is never captured by `acme` even there. A failing tag read degrades to the
prefix rather than 500ing.

Residual, stated rather than hidden: two systems deployed BEFORE tagging where
one name is a prefix of the other remain ambiguous, because nothing distinguishes
them. This is also the prerequisite for the teardown verb, where the same
collision would delete rather than restart.

## GET /{name} returns real schedules

It called `db.get_agent_schedules`, which does not exist — the facade exposes
`list_agent_schedules` and `database.py` deliberately has no `__getattr__`
fallback. The AttributeError was swallowed by the surrounding `except
Exception`, so every response omitted `schedules` for every agent and logged one
warning each, while `tests/test_systems.py` never asserted on the key. Exactly
the failure mode the db facade's own comment warns about — so the test also pins
that the fallback stays absent, since adding one would turn the next typo into a
silent Mock.

## POST /{name}/restart is creator-gated

It was bare `get_current_user` — below `POST /deploy` and below even the
READ-ONLY bundled-catalog routes — so any authenticated principal, including
`role: user`, could stop and start every container in a system whose agents it
could see. A mutating fleet-wide verb under a lighter gate than the catalog it
reads is an oversight, not a decision. `require_role` also rejects agent
principals (#1890), which matters because an agent-scoped MCP key resolves to
its owner carrying the owner's role.

## Export round-trips

The non-full-mesh permissions branch sliced `target_agent[len(name)+1:]` with no
membership filter — the sibling branch had one — so an edge pointing outside the
system exported as a blind-sliced garbage short name that then failed
`validate_manifest`'s unknown-agent check on re-deploy. The export broke its own
round trip. Both branches now test membership.

And the export no longer embeds the instance-global `trinity_prompt` as the
manifest's `prompt:`. Deploying that manifest elsewhere overwrote THAT
instance's platform-wide prompt — a fleet-wide side effect from what reads like
a copy of one system. Nothing records whether the source system ever set a
prompt, so there is no honest way to distinguish it from whatever the instance
happens to have configured, and the only correct export of an unknown is to
omit it.

## Two preview hardenings

Unknown PER-AGENT keys now warn like top-level ones (ent#126): `credentials:`,
`skills:` and `display_label:` are the fields people try first and they vanished
in silence.

Preview and deploy now resolve the identical resource default. Deploy hardcoded
`{"cpu": "2", "memory": "4g"}` while `_preflight_template` validated against the
admin-configurable `get_agent_default_resources()`, so the two disagreed the
moment an admin moved the fleet default — the one spot that escaped ent#126's
pure-resolver no-drift pattern.

## Verification

14 unit tests, one per defect plus the exempt shapes. Two mutation-checked: the
restart gate and the tag-first membership each turn a test red when reverted.
414 pass across the system/manifest/ent#126/#1884 suites.

`tests/test_systems.py` is live-backend tier and cannot run without a stack —
which is part of why these survived — so the coverage added here is unit-tier,
where the per-PR gate can see it.

Closes #2373

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…uire_role

Review found the agent-principal test tautological: it asserted
`"reject_agent_principal" in inspect.getsource(require_role)` and passed
because `getsource` returns the DOCSTRING, which contains the sentence
"Deliberately does NOT call `reject_agent_principal`". The assertion matched
its own refutation.

`require_role` rejects CONNECTOR principals only, and that omission is
deliberate — `require_role("creator")` on `POST /api/agents` is what makes
ent#69 Part 2 agent-spawned creation work. It is not touched here.

The decision the reviewer asked for: human-only is correct, because it closes
a BYPASS rather than merely widening a gate. `POST /agents/{name}/start`,
`/stop` and `/delete` each call `enforce_agent_spawn_scope`, so an agent-scoped
caller may only start or stop agents it actually SPAWNED (name *and* key id).
`restart_system` loops every member calling `container_stop` +
`start_agent_internal` with no per-member check at all — so reaching it with an
agent key performs, in bulk and unscoped, exactly the operation that is
spawn-scoped one at a time. On a default admin-owned install an agent key
resolves to its owner carrying the owner's role, so `require_role("creator")`
alone admits the whole fleet (trinity-ops-agent#232 class).

Scoping per member was considered and rejected: a system whose every member the
caller spawned is a near-empty set, so it would be a strange capability rather
than a useful one. If an agent-driven restart is ever wanted it needs its own
design.

- `restart_system` calls `reject_agent_principal` explicitly, above the `try`
- docstring + `docs/memory/feature-flows/system-manifest.md` state the real
  reason; both previously carried the false "require_role also rejects agent
  principals since #1890"
- the tautological test is replaced by three real ones: `require_role` does NOT
  reject agent principals (the false premise pinned as its opposite, read
  through `_code_only`), `restart_system` refuses an agent principal BEFORE
  touching anything (driven for real, with `get_accessible_agents` stubbed to
  raise so a late refusal fails loudly), and a human creator is still admitted
  (so the guard is about principal KIND, not role). Mutation-checked: removing
  the guard turns the last two red.
- export: `set(member_names)` was rebuilt per row inside a comprehension, and
  the sibling branch held a redundant `_member_set` copy — `member_names` is
  already a set, so both branches now read it directly
- MCP `restart_system` description carries the tightened gate and the tag-first
  membership resolution, so an agent caller's 403 is legible

Behaviour changes (release-note material):
- `POST /api/systems/{name}/restart` now requires the `creator` role and a
  human caller. Previously bare `get_current_user`. Agent-scoped MCP keys get
  403.
- `GET /api/systems/{name}/manifest` no longer exports `trinity_prompt`.

Related to #2373

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…roken export slice (#2373)

All four blockers are real. The first is the serious one: my own fix created it.

**1. Credential disclosure.** Fixing the three-year-broken `schedules` key made
it reachable — `list_agent_schedules` returns whole `db_models.Schedule` rows and
`GET /{system_name}` declares no `response_model`, so FastAPI serialized every
column including `webhook_token`, the bearer for the UNAUTHENTICATED
`POST /api/webhooks/{token}`, plus `webhook_secret_encrypted`. The route is bare
`get_current_user` + `get_accessible_agents`, so any chat-level shared
`role: user` received schedule-trigger credentials for every member agent.
`db_models.py:182-189` states neither is ever surfaced in a response model.

Projected through `ScheduleResponse` — the model, not a hand-picked dict, which
would be a second field list free to drift. Precedent: `get_agent_schedule_names`
(#2161) projects for the same reason. A second test asserts the disjointness
against `Schedule.model_fields` rather than a literal list, so a fifth webhook
field is covered without anyone remembering.

**2. Partial tagging dropped members.** `if tagged: return tagged` let ONE
tagged agent hide every other member. Reachable three ways, none exotic —
`PUT /agents/{n}/tags` is a full-set replacement, an agent added post-deploy is
untagged, and `configure_tags` sits in a try/except so a mid-loop raise still
reports "deployed". `restart_system` then restarted a subset and called it
success; `export_manifest` wrote an incomplete backup and called it one. Now
`tagged ∪ narrowed-prefix`, returned in roster order so a tag edit does not
reshuffle what the caller renders and restarts.

**3. A tag-read failure re-opened #2373 itself.** The `except` set
`tags_by_agent = {}`, which made `claimed_elsewhere` unconditionally False and
degraded to the RAW prefix — so `restart_system("acme")` stopped and started
`acme-extra-worker`, logging a WARNING and returning a byte-identical response.
The docstring's "never captured even on that path" and "degrades to the prefix"
could not both be true. With tags unreadable the narrowing is now structural
(`"-" in name[len(prefix):]`), so membership that cannot be justified is dropped
rather than assumed. The old failure test used `["acme-web", "other"]`, which
never reached the collision; the new one does.

**4. The export slice broke on tag-only members.** `full_name[len(system)+1:]`
assumes the prefix, which tag-first membership no longer guarantees — and this
PR's own test asserts such members ARE members. `content-production` + `bot` and
`assistant` both sliced to `''`, colliding as a dict key (silent agent loss) and
failing `validate_manifest`'s name regex, so the export could not re-deploy:
the same "export broke its own round trip" class this PR set out to fix. Skipped
and reported, following #1759's `templateless` precedent in the same function —
a manifest that silently loses an agent is worse than one that names what it
could not represent.

Mutation-checked, all four. The export test is BEHAVIOURAL — it invokes
`export_manifest` and parses the YAML — because the first version was a source
grep that matched a leftover variable declaration and survived deleting the very
skip it guarded. That is the self-matching class the review named, reproduced by
me while fixing it.

Related to #2373

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t a whole system (#2373)

Both remaining blockers, and both were the same shape as the originals — the
correction applied at one call site while the test was written around the
surviving half.

C1 — export_manifest's permission branches still blind-sliced. The prefix skip
landed only in the agent_configs loop. Both explicit-permission loops kept
slicing name[len(system)+1:] with no prefix test, and the membership filter
added beside them ADMITS tag-only members, whose names carry no prefix at all.
System `acme` with tag-only member `helper` exported `explicit: {web: ['r']}`;
two tag-only members under `content-production` both sliced to `''` and
collided on one key. Either way `validate_manifest` rejects the export on
re-deploy — the round trip broken by the exporter.

Fixed with ONE `_member_short_name()` used on both sides of every edge and by
the agent_configs loop too, so a guarded slice in one place and a blind one in
another cannot drift apart again. No blind length-slice survives in the module.

C2 — the degraded path lost a whole system. `excluded = "-" in name[len(prefix):]`
is strictly NARROWER than the raw prefix it claimed to degrade to, and the
bundled flagship manifest is entirely inside the gap:
config/manifests/vc-due-diligence.yaml names all eleven agents `dd-*`, so every
deployed name is `vc-due-diligence-dd-<x>`, every remainder has a hyphen, and a
transient tag-read error returned 404 System not found and an empty export for a
correctly tagged, healthy fleet. Measured on head: 0 of 11 members.

Now narrowed on ROSTER EVIDENCE — a name is excluded only when another agent on
the same roster is a longer system prefix of it. Measured after: 11 of 11 for
vc-due-diligence, and `acme-extra-worker` still excluded when `acme-extra`
itself is present.

THE RESIDUAL IS STATED, NOT HIDDEN. On a roster of names alone, with tags
unreadable, `acme-extra-worker` and `vc-due-diligence-dd-lead` are genuinely
indistinguishable — any rule excluding one excludes the other. So the pre-#2373
residual stands on that transient path, and losing a healthy system entirely is
the worse of the two errors. The flow doc now says exactly this instead of
'degrades to the prefix', which was false in the other direction.

TESTS. `test_a_failed_tag_read_does_not_re_open_2373` used
`["acme-web", "acme-extra-worker"]` — a roster with no evidence the sibling
system exists — so it could only ever be satisfied by a shape heuristic. Its
fixture now carries `acme-extra` itself, which is what makes the sibling
observable, with the residual recorded beside it.
`test_export_scopes_permission_edges_to_members_in_both_branches` was a source
count that passed while C1 was live; it now asserts the property (no blind slice
anywhere) rather than counting guards. Added: the eleven-agent vc-due-diligence
case, the evidence case, the no-evidence case, and five short-name cases.

Related to #2373

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#2373)

Self-review found the roster-evidence narrowing is the same defect as the rule
it replaced, one door along — and one variant is worse than the original.

MEASURED, by driving the real function with the tag read raising:

  'acme'  roster=[acme-api, acme-api-worker, acme-web]
          -> [acme-api, acme-web]           # acme-api-worker DROPPED
  'vc-dd' roster=[vc-dd-dd, vc-dd-dd-lead, vc-dd-dd-tech]
          -> [vc-dd-dd]                     # 2 of 3 dropped
  'acme'  roster=[acme, acme-web, acme-db, acme-worker]
          -> []                             # the whole system

`acme-api-worker` is an ordinary member of `acme` whose manifest key is
`api-worker`, sitting beside key `api`; nothing about it belongs to another
system. And an agent named literally `acme` anywhere on the accessible roster
is a name-prefix of EVERY member, so one unrelated agent's name turned a healthy
system into `404 System not found` and an empty export — the exact C2 symptom
returning through a different door.

WHY THERE IS NO THIRD RULE. From names alone `acme-extra-worker` is `worker` of
`acme-extra` or `extra-worker` of `acme`. Only a tag separates them, and the
tag read is what failed. So this is a choice between two errors, not a puzzle
with a better answer.

The errors are not symmetric, and the commit that introduced the union already
said so: `restart_system` shares this predicate, so over-capture restarts one
agent too many and logs a WARNING, while under-capture restarts a SUBSET and
reports success — 'silent partial success on a fleet-wide verb', the thing the
union rule exists to prevent. `export_manifest` writes a short backup and calls
it complete.

So the unreadable-tag path degrades to the RAW PREFIX, which is what the
docstring and the flow doc have always promised, and the residual is stated:
`acme` may capture `acme-extra-worker` while tags are unreadable, ending when
the read recovers. The narrowing is untouched on the tag-READABLE path — the
path that actually runs — pinned by the existing
test_the_prefix_fallback_still_excludes_another_systems_tagged_agents.

THE TEST IS A PROPERTY, NOT A CASE. Two case-by-case tests were satisfied by two
different member-losing rules, so the fallback is now pinned as a SUPERSET of
the raw prefix over five rosters. Any future narrowing here fails by
construction, whatever shape it narrows on.

ALSO (I1): export_manifest re-derived membership the caller had already
resolved — a second tag read per export, and if that read degraded where the
first had not, member_names became a strict subset of agents and permission
edges to real members were dropped from the backup in silence. It now reuses the
caller's set.

Related to #2373

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rrors instead (#2373)

Three rules in one function across three review passes, each one satisfying the
case-by-case test written for its predecessor and each one losing members of a
healthy system. The durable rule is that when the discriminating signal is gone
no rule computed from what remains can recover it, so the choice is between two
errors — and on a fleet-wide verb under-capture is the worse one, because it
acts on a subset and reports success. Pin the fallback as a property
(fallback superset of raw prefix), not as cases.

Related to #2373

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dolho
dolho force-pushed the fix/2373-system-endpoints branch from 4f2c56e to 3c71065 Compare August 31, 2026 12:43
@dolho
dolho merged commit 1327219 into dev Aug 31, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

complexity-medium Complexity: medium (board points 5-8) priority-p1 Critical path theme-reliability Theme: Reliability type-bug Bug fix

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants