Skip to content

DO NOT MERGE — merge train: 2935,2922,2778,2920,2756 - #2939

Closed
vybe wants to merge 44 commits into
devfrom
train/20260921-1644
Closed

vybe wants to merge 44 commits into
devfrom
train/20260921-1644

Conversation

@vybe

@vybe vybe commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Integration surface for #2935, #2922, #2778, #2920, #2756. Never merged — members merge individually once this is green. No closing keywords here on purpose; the members carry those.

Assembled from each member's head with --no-ff, so a red job bisects to a PR rather than a commit. One member-vs-member collision, resolved on the train because dev is not what it conflicts with: #2920 and #2756 both append to docs/memory/learnings.md (kept both; 244 entries on dev -> 248 here).

Why this batch is worth a full-suite run — two things are true only of the combination:

  1. refactor(tests): make the mount harness discoverable and ratchet source-text specs over components (#2918) #2922 x feat(systems): the public half of system teardown — a structured membership read, a license-blind MCP proxy, and a gated remove panel (abilityai/trinity-enterprise#454) #2756 were individually green and jointly red. refactor(tests): make the mount harness discoverable and ratchet source-text specs over components (#2918) #2922 lands a ratchet holding any spec with no baseline entry to zero source-text reads; feat(systems): the public half of system teardown — a structured membership read, a license-blind MCP proxy, and a gated remove panel (abilityai/trinity-enterprise#454) #2756 adds baseButtonVariantBorder.spec.js, exactly that shape. Neither PR can see it alone. Fixed on feat(systems): the public half of system teardown — a structured membership read, a license-blind MCP proxy, and a gated remove panel (abilityai/trinity-enterprise#454) #2756's side with a @source-text-pin (its defect is a cascade tie broken by stylesheet emission order — no mount can observe it). Ratchet now passes 8/8 on this tree.
  2. fix(tests): three reds that keep CI wrong — signal-guard race, process-wide caplog, stale release allowlist #2935 fixes dev's own standing red (test_2814…::test_every_accepted_entry_names_a_real_divergence, 1 failed / 16751 passed at 01162d54) and the test_subprocess_pgroup race that hit feat(systems): the public half of system teardown — a structured membership read, a license-blind MCP proxy, and a gated remove panel (abilityai/trinity-enterprise#454) #2756's regression diff in 1 of 3 seeds while it was queued. Both are in scope here for the first time.

Local pre-flight on this exact tree: frontend 148 files / 3335 passed · check_alembic_heads.py -> 66 revisions, 1 head (0065_agent_skills_delivery_status) · mcp-server 456/456 on #2756's head.

Mechanical fixes pushed to member branches during assembly (each announced on its PR): #2935 six surviving process-wide caplog.records reads + _is_gone now fails closed; #2920 a mounted spec pinning the draft-wipe regression (negative-controlled both ways); #2756 dev merge, the destructive default flipped from a denylist to an allowlist across its three mirrors, the pin above, two false "no mount harness exists" rationales, and a false access.ts comment.

Ejected from this train: #2936 (2 new high-severity CodeQL alerts + an Alembic fork with #2920, both off 0064) and #2728 (103 commits behind, 4 red, forks against dev's own 0059).

AndriiPasternak31 and others added 30 commits September 14, 2026 23:12
…ity-enterprise#454)

Rule #1: requirements before implementation. Records only what lives in this
repo — the edition-agnostic membership primitive, the license-blind MCP proxy
and the Vue gate — and states plainly that the teardown control plane is an
entitlement-gated module in the private submodule (404 on an OSS build). The
paid module's surface and internals stay in the private tree (ent#45).

Also cross-references §33.1: a teardown is N of that soft-delete composed
through the same endpoint, so the 180-day window, name reservation and
schedule freeze are preserved by construction rather than re-implemented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…bilityai/trinity-enterprise#454)

`system_member_names` keeps its signature and becomes a thin wrapper; the body
moves verbatim into `system_membership`, which additionally returns the two
facts the flat name list discarded:

  * `tags_readable` — the body degrades to the raw prefix on a tag-read
    failure, argued there for `restart` (over-capture restarts one container
    too many and logs it; under-capture restarts a subset and reports success).
    A verb that DELETES inverts the cheap half, so it can refuse on unverified
    membership instead of degrading — at its own call site, without forking
    THE ONE predicate into a fourth rule.
  * `evidence` (tag|prefix|both) — derived from the two sets that already
    decided membership, so it cannot disagree with them. Makes the prefix
    fallback's documented residual visible to a caller instead of silent.

`SYSTEM_VIEW_AUTO_DESCRIPTION` replaces the inline f-string: nothing persists a
system<->view link, so that description IS the identifier for the view a deploy
auto-created, and a reader that has to find it again must not restate the
literal.

Behaviour-preserving, not asserted to be: the #2373 suite (33) and the whole
ent#124/125/126 + systems set (325) pass unchanged against the wrapper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rties (Abilityai/trinity-enterprise#454)

#2373's fallback rule was wrong three times and each wrong rule passed the case
test written for its predecessor, so the fallback is pinned as a PROPERTY
through the new entry point: members ⊇ raw prefix match whenever the tags are
unreadable, plus tagged ⊆ members whenever they are readable. Any future
narrowing fails by construction, whatever shape it narrows on.

Also pinned: the wrapper is a projection of `system_membership` (generated, AND
source-anchored — a property cannot see a copied body that happens to agree
today), evidence is keyed by exactly the members and true of each, and the
auto-view description is asserted THROUGH `create_system_view` rather than by
restating the literal.

The named incidents stay as discrete cases: the eleven `vc-due-diligence-dd-*`,
an agent named exactly `acme`, the sibling-prefixed `acme-api-worker`, and the
`acme` vs `acme-extra` floor from the AC.

One counterexample found by Hypothesis and fixed as a TEST bug, not a code one:
`roster=[]` short-circuits before the tag read and so reports
`tags_readable: True` by design — a destructive caller refuses on False, and
refusing a teardown of a memberless system is a 503 where 404 is the truth.

17 passed under the derandomized `ci` profile and under `explore` (5000
examples); both lint guards clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…Abilityai/trinity-enterprise#454)

Invariant #13's third surface. The tool ships in the OSS bundle and knows
nothing about entitlement: it proxies DELETE
/api/enterprise/system-teardown/{name} and reports what the route says.

`dry_run` defaults to TRUE — the opposite of `deploy_system`, deliberately: an
unwanted preview costs a round trip, an unwanted execute costs a deleted fleet.
The default lives in tool code, so it is pinned by a test that an `|| false`
typo would fail.

Refusals degrade rather than throw, and stay DISTINCT: 404 (no such build) /
403 (not licensed, or an agent key on a human-only verb) / 503 (membership
unverified — flagged retryable, and says nothing was removed). A thrown error
reaches the agent as an opaque transport failure it cannot reason about, and
flattening the three is how a human on a user key gets told a licensed feature
"doesn't exist". A `failed` report arriving as a 500 is kept as a RESULT — its
per-member reasons are the only actionable output.

Reads `ApiError.body` rather than regexing `ApiError.message`: the field is
retained verbatim for exactly this purpose (ent#443), and its own docstring
says the regex is what breaks when the message format changes. Found while
writing the test, which constructs a real ApiError rather than a `{status}`
stand-in that would have passed over broken body parsing.

No server.ts change: createSystemTools is already in toolGroups, so the tool
inherits the operatorOnly allow-list.

tsc --noEmit clean; npm test 383 pass / 0 fail (364 before).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…inity-enterprise#454)

Library -> Systems gains the inverse of install: preview -> per-agent checklist
-> acknowledgement -> remove. Gated on `system_teardown` in
`enterprise_features`, so an OSS build's Systems section looks exactly as it
did rather than offering a control whose route 404s.

The preview is a CHECKLIST, not a list, and that is the design rather than
decoration: a member whose `evidence` is `prefix` matched by name with no
deploy tag to confirm it, so it may belong to a sibling system sharing this
one's prefix. The server cannot tell — only the operator can — so the badge
plus the opt-out IS the over-capture escape hatch, and the confirmed list goes
back on execute to be intersected with freshly-resolved membership.

Remove is gated on four conditions, each blocking something the others do not:
the preview is current for the typed name, membership was verified, the
consequence is acknowledged (ent#126's ack contract reused, not a second
pattern), and at least one agent is checked. A disabled button says why.

Honest reporting: tone switches on `status`, never the HTTP code (`partial` is
200, `failed` is 500 with the report as the body). skipped / failed / aborted
render as three different things with per-reason operator copy, because a
refusal is not a breakage and an un-attempted member is neither; `discarded`
never claims recoverability. A timeout is "outcome unknown" and offers no
retry — the server keeps deleting.

`ManifestPreview`'s "there is no un-deploy" now tracks the CAPABILITY: still
exactly true where the module is absent. Retiring it everywhere because one
edition gained the verb would have been the dishonest fix. `DeployResult` gains
a de-emphasised "Remove this system", also entitlement-gated (AC 7).

Design contract: token-only (scanner reports 0 non-gray / 0 hex across all six
systems files), both themes, primitives only, bounded member viewport with a
stated total, no bare loading gate — the in-flight flags reach only BaseButton.

Tests: 21 vitest store specs + 25 source-anchored contract assertions.
Mutation-checked, and it found a real gap: "the preview is bound to its name"
passed with the name comparison replaced by `true`, because `setTeardownName`
nulls the preview and the null-check carried the assertion alone. The
comparison guards a direct-assignment bypass, so the test now uses one.
Frontend suite 2795 passed (2774 before); check:tokens clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…es (Abilityai/trinity-enterprise#454)

`system-manifest.md` gains entry-point rows (the gated route, the license-blind
MCP tool, the UI's new status) and a "Teardown — the public half" section
covering what lives in THIS repo: the membership split and why it is a wrapper
rather than a fork, the one-constant auto-view description, the MCP degradation
contract, the checklist UI, and the four tests. The gated verb's own design
stays in the private tree (#151 doc-follows-the-code).

Feature-flows index row added, per the standing rule that a row goes in even
when the flow doc already exists. (The "newest ~20" cap on that table is
already at 140 rows — pre-existing drift, deliberately not pruned here, since
dropping 120 rows would bury this diff.)

Area files: mcp-server.md `systems.ts (4)` -> `(5)`, backend.md's
`system_service.py` line records the membership split and the constant,
frontend.md records the gated half of the Systems section.

Public docs carry the seam and the gate's name only — no paid-module internals.
Verified by running the enterprise-docs-guard's own grep locally: clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…bilityai/trinity-enterprise#454)

`/sync-feature-flows` mapped every changed file to a flow; two changed files
belong to a second flow each, and those were the gap:

* `mcp-orchestration.md` — the System Tools table and the enumerated tool-count
  comment both said four. `teardown_system` makes five, with its license-blind
  degradation contract and its inverted `dry_run` default recorded where the
  other tools are catalogued.
* `library-page.md` — the `#systems` section now hosts two panels, and the
  second renders nothing without the entitlement. Worth stating there because
  that page's doc is where someone checks why a tab looks different between
  editions.

The index row's Flow column now names all three flows rather than only
`system-manifest.md`.

`system_service.py` also appears in `scheduling.md` and `agent-tags.md`, but
only as the module that writes schedules and tags — nothing this change touched,
so no update.

Index is 552 lines against the skill's 400-line guidance. That is the
pre-existing Recent Updates table at 140 rows against its own documented "~20"
cap; my row is one line of it, and condensing 120 rows would bury this diff.
Flagged, not fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…y-enterprise#454)

Reusing ent#126's acknowledgement contract was right for the prop/emit shape and
wrong for the `data-testid`. Install and remove can both be previewed on the
same Library page, so a shared id puts two elements at one address — and
`e2e/system-install.spec.js` addresses the install ack as
`getByTestId('ack-checkbox')` in three places, which would be strict-mode
ambiguous on an entitled build.

It would NOT have failed CI: the teardown panel is unentitled there and renders
nothing, so the collision is invisible to every check that runs today. That is
exactly why it gets a test rather than a CI run. Found by reading the spec that
shares the surface, while waiting on the e2e job.

The pattern is worth sharing; the address is not. `ManifestPreview` keeps
`ack-checkbox`; the teardown ack is `teardown-ack-checkbox`; the contract test
now asserts BOTH — the shared prop/emit shape and the distinct ids.

Frontend suite 2795 passed, check:tokens clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…bilityai/trinity-enterprise#454)

The per-id fix in the previous commit was the wrong SHAPE, and looking for a
second instance found one immediately: `goto-fleet`, owned by DeployResult.vue
and copied into TeardownResult.vue. Two collisions is the signal to write the
rule instead of fixing instances.

Install and remove are two panels on one Library page, and on an entitled build
both can show a preview AND a result at once — so any id they share is
strict-mode ambiguous for a Playwright `getByTestId(...)`, which is exactly how
`e2e/system-install.spec.js` addresses both `ack-checkbox` and `goto-fleet`.

None of it fails CI today: the teardown panel is unentitled there and renders
nothing, so the collision is invisible to every check that runs. Hence a test
rather than a CI run — `test_no_test_id_is_shared_with_the_install_panel_it_sits_beside`
intersects the two panels' id sets and fails on any overlap, so a third
collision cannot be introduced quietly.

Measured: reintroducing the `goto-fleet` collision turns it red; control clean.

Also drops a now-stale docstring line in TeardownPreview that still claimed the
shared testid.

Frontend suite 2795 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ai/trinity-enterprise#454)

`normalizeError` is shared with deploy, and its unknown-outcome copy read
"Deployment may still be running — re-deploying creates duplicate agents." On a
teardown timeout that is the opposite advice at the worst possible moment: the
in-flight work is removal, and the members removed so far are already gone.

The two sentences that are about WHAT was in flight move into `UNKNOWN_OUTCOME_COPY`,
keyed by verb — everything else in the normalizer is verb-independent, which is why
this stays one function. `teardown-preview` gets its own arm because a dry run writes
nothing, so neither sentence may imply work in flight.

The MCP tool copy is corrected alongside it: the 403 said "an agent-scoped key",
which understates a gate that is now an allowlist refusing every machine credential;
the 503 said "membership could not be verified", which no longer covers the case
where the roster itself was unreadable; and `dry_run` now states that a member with
no container is removed the same way, since that is what frees its name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…not (Abilityai/trinity-enterprise#454)

Three docs claimed the teardown acknowledgement reuses ent#126's
`data-testid="ack-checkbox"`. The code deliberately does the opposite and a guard
test requires it to: both panels render on one Library page, so a shared address
makes every `getByTestId('ack-checkbox')` strict-mode ambiguous. The PATTERN is
what is reused (`:acknowledged` / `update:acknowledged`); the address is not.

The flow and roadmap gain the roster-completeness ruling behind the private fix: every
OSS sibling on this surface resolves its roster from containers, which is cosmetic for
`get_system` / `restart_system` / `export_manifest` and is under-capture-reporting-success
for a verb that claims completion — plus why the #2196 tri-state reader is what makes
the ownership supplement safe, and why an unreadable Docker refuses rather than 404s.

`learnings.md` records C1's class for the next planner: a guard that tests membership
against a list the disqualified entries were just appended to is dead for the case its
own comment describes, and the narrow test beside it passes either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…base string's (Abilityai/trinity-enterprise#454)

BaseButton's shared class string carried `border-transparent` next to the
reserved `border` width, while `secondary` — the one variant drawn BY its
border — set `border-gray-300`. Both are single-class selectors, so
specificity ties and emission order decides: Tailwind emits
`.border-transparent` after `.border-gray-300` (rule 729 vs 698 on the live
dev stylesheet), and the resting keyword won. Every secondary button
rendered borderless in light mode — a white button on a white card with no
outline — across 24 call sites.

It survived review because dark mode won by accident rather than by design:
`dark:border-gray-700` compiles to `.dark .border-gray-700`, one class
heavier, which outranks the tie. The theme most of us work in was correct.

The fix is the one `fieldClasses.js` already documents for FIELD_GHOST_CLASS
one primitive over: the base string keeps the bare `border` (so the focus
ring still costs no layout shift) and every variant names its own colour.

Measured, not reasoned — resting border on the teardown panel's
"Preview removal" control, before → after:
  light: rgba(0,0,0,0)      → rgb(209,213,219)  (gray-300)
  dark:  rgb(55,65,81)      → rgb(55,65,81)     (gray-700, unchanged)

Guard: tests/unit/baseButtonVariantBorder.spec.js pins the base string
colour-free, asserts secondary is outlined in BOTH themes (the bug was
theme-asymmetric, so a light-only pin would let the next edit break dark
while staying green), and fails closed on a fifth variant that names no
border colour. Mutation-checked: restoring `border-transparent` to the base
string turns it red.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ilityai/trinity-enterprise#454)

BaseInput is a three-part stack — label, control, help — so a flex row that
holds one whole aligns against that whole box. `items-end` therefore put the
button's bottom edge on the HELP TEXT's bottom rather than the control's,
leaving "Preview removal" 22px below the field's centre where it read as a
stranded caption instead of the field's action.

Fixed by composing the row the way BaseInput documents and the way
TemplateRegistryPanel already does it for the same control-plus-action shape:
the panel owns the label and the help, and the row holds two control boxes,
so alignment means what it says. `items-stretch` rather than `items-start`
because the two primitives are deliberately different heights (field py-2 on
a 20px line box = 38px; button py-[7px] on 13.5px/1.35 = 34px) and stretching
the shorter one costs no magic number and overrides neither recipe.

Measured on the live dev stack, before → after:
  input  top/bottom  1295/1333  →  1295/1333
  button top/bottom  1319/1353  →  1295/1333
  centre delta           +22px  →       0px

BaseInput carries the second half. Its `:aria-describedby` binding is applied
AFTER `v-bind="controlAttrs"`, so with no `help` prop of its own it did not
leave a caller's `aria-describedby` alone — it ERASED it, and the help
paragraph stopped being announced the moment the panel took ownership of it.
It now hands the attr back instead of winning a merge the caller never knew
it had entered. Strictly additive: error and help still win, in that order,
so no existing caller changes.

There is no mount harness in this project (no @vue/test-utils, vitest runs
environment: 'node'), so the a11y half is browser-verified rather than
unit-pinned — on the live render the control resolves
aria-describedby="v-1-help" to the help paragraph, and label[for] to
"System name". Full unit suite green: 131 files, 2937 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…sits in (Abilityai/trinity-enterprise#454)

Two h4s in this PR's own files were written without a size class and fell
through to the UA default of 1em. On a 16px root that made the DEEPEST
headings on the page the LARGEST — bigger than their four `text-sm` siblings
and bigger than the h3 that contains all of them:

  h3  "Remove a deployed system"        14px
  h4  "Agents to remove" / "Also removed" / "Notes"
                                        14px
  h4  "This removes running agents"     16px   ← TeardownPreview.vue:149
  h4  "Outcome unknown"                 16px   ← SystemTeardownPanel.vue:38

Both are warning callouts whose anatomy is identical to the danger callout at
TeardownPreview.vue:8 — bordered box, icon span, heading, list — and that one
already carries `text-sm`. So this was an inconsistency inside a single file,
not a scale that needed inventing. All five h4s now measure 14px on the live
render, matching the h3 they sit under.

Scope is pinned to the three components this PR ADDS. Six more unsized
headings exist on the install side (ManifestPreview ×2, DeployResult ×3,
SystemInstallPanel ×2) and are left alone: they predate this branch, and
TeardownResult's banner h3 is deliberately byte-identical to DeployResult's,
so changing one half would break a parallel that was built on purpose. They
are reported for their own issue rather than folded in here.

Full unit suite green: 131 files, 2937 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
#	docs/memory/feature-flows.md
#	docs/memory/learnings.md
…lityai/trinity-enterprise#454)

dev's ent#628 policy table landed while this branch was out. Every registered
tool must carry a TOOL_ACCESS_POLICY row, and policyFor checks it AT
REGISTRATION — so merging dev left the MCP server crash-looping on boot
(ToolAccessPolicyError: tool 'teardown_system' has no entry; nine restarts on a
live stack). PR CI never said so: the PR was CONFLICTING, which runs zero checks.

The row is `baselined`, not `none`. The siblings can honestly say "a system
name, not an agent" (deploy_system, restart_system); this one cannot — it takes
the confirmed removal set as `agents` and deletes them. `agents` is absent from
AGENT_TARGET_PARAMS, so a `none` row would have passed the shape check while
saying something false. The owner names the fence that actually holds the line:
the gated route requires role 'creator' AND a human caller.

Proof: src/mcp-server npm test 440/440, including access.test.ts's "createServer
boots: every registered tool has a row"; rebuilt image boots and stays up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ilityai/trinity-enterprise#454)

A fresh teardown preview ticked EVERY member regardless of `evidence`. A
member the server matched by NAME with no deploy tag is one it cannot
vouch for — and arriving ticked makes the escape hatch an opt-OUT on a
verb that deletes.

The 503 refusal does not already cover this. It fires only when the tag
read FAILED (`membership_verified: false`, Remove disabled). A `prefix`
member also appears in the HEALTHY state — tags read fine, that one agent
simply has no tag row — where membership is verified, Remove is ENABLED,
and the unconfirmable agent was pre-ticked beside it. #2373 ranks
under-capture as cheaper than over-capture, which is right for `restart`
and inverts for a verb that deletes; that inversion is why teardown
refuses rather than degrades everywhere else, and the default handed it
back in the one state the refusal misses.

The rule moves to the STORE (`teardownDefaultSelection`) rather than
staying in the panel's watcher, because `vitest.config.js` pins
`environment: 'node'` with no component mounting: a default written into
a `.vue` watcher is a decision no runnable test can execute, which is how
this shipped. The panel now applies it and nothing else.

Found by clicking through a live stack, not by any test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…or an opt-in (Abilityai/trinity-enterprise#454)

The rendered half of the pre-tick fix.

A `prefix` member now arrives UNTICKED, so its own copy had to stop
saying "uncheck it if so" — that sentence describes a box that arrives
ticked, and left in place it would have told the operator to undo a
safeguard that had not been applied.

The short selection is also stated above the list. Without it the
unticked rows read as a miscount against the "N of M selected" counter
beside them, and an operator who re-ticks them to "fix" the count has
re-created the defect by hand.

Guards are source-level because nothing renders this component: vitest
is `environment: 'node'` with zero mounts, and the green `e2e` runs
`system-install.spec.js`, which never touches teardown. They go through
a new `_prose` helper that strips comments AND collapses wrapping — the
first version of one of them matched its own JSDoc line, which is the
trap `_code_only` already existed for, landing a third time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d no retry that cannot help (Abilityai/trinity-enterprise#454)

Two defects in one banner.

"Try the preview again in a moment" is advice for a transient fault.
The condition this renders under is a failed database read — a schema
or permission fault that does not clear on its own — so re-previewing
produces the identical screen forever, which trains the operator to
read a real stop as flakiness. It now says the fault is standing and
offers the one thing that does work: the per-agent path the server's
own refusal detail already names.

The banner also named the TAG read specifically, and it cannot know
that. `membership_verified` is `tags_readable AND roster_complete` —
one boolean over two independent faults — so on the roster arm the
copy was simply false: the tags read perfectly and it is the agent
LIST that is incomplete. The banner now states the consequence, which
IS identical either way, and quotes the server for the cause.

That required routing the warnings. The roster line used to fall
through to "Notes", which put the real fault in a footnote underneath
a banner naming the other one. Both membership lines are now claimed
by the banner while it renders, and `otherWarnings` is defined by
SUBTRACTING what the banner took rather than by a second independent
regex — two filters that must stay complementary are two chances to
drop a line off the screen entirely.

Also repairs an existing assertion that broke on the re-wrap: the
phrase it pinned was split across two lines without a word changing,
so it now goes through `_prose` like the rest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…Abilityai/trinity-enterprise#454)

"The acme tag goes with the agents (2 tagged)" was rendering
`SystemTeardownTag.member_count`, which the service sets to
`len(members)` — every candidate, tagged OR matched by name. So the
word "tagged" overstated the tag the moment any member was `prefix`.

In the refusal state it was worse than imprecise: the banner at the top
of the same screen says the membership could not be confirmed, and this
line then quoted a figure derived from that very read. Two statements
about one read, opposite in confidence, on one screen.

Dropped rather than reworded. The total the operator actually needs is
already stated on the list itself ("N of M selected"), and the
sentence's job is the other clause — that the tag is not a separate
record to go and delete.

The field is not wrong; `member_count` counts members and says so. Only
the noun beside it in the UI was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lityai/trinity-enterprise#454)

The flow doc described the old checklist ("the badge plus the opt-out
is the mechanism") and said nothing about which boxes arrive ticked,
which is the half that was wrong. It now records the opt-in/opt-out
split and where the decision lives, why the refusal banner names no
fault, how warning routing partitions, and that the tag line carries
no count. Tests table and change history updated with it.

The ledger entry is the transferable part: a UI default written in a
`.vue` watcher is a decision no node-environment suite can execute, so
it is reviewed by eye forever — move it to the store and the spec that
already exists starts covering it. Plus the three smaller shapes this
round produced: a boolean over two faults must name neither, a count
rendered with a different noun than the field counts is a lie with a
number on it, and warning routing partitions by subtraction.

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

The preview watcher was not `immediate`, and the store outlives the
component. `Library.vue` mounts the systems section lazily and nothing
resets teardown state on unmount, so leaving the page and coming back
remounts this panel onto a preview that is STILL current — and the
watcher never fires for it. The removal set rendered with every box
empty and "Select at least one agent", which makes this component's own
stated default untrue on the second visit.

Pre-existing (the previous all-members default had the same hole) and
fail-safe in both directions, which is exactly why it survived: nothing
breaks, the panel is just wrong and asks the operator to re-tick a set
it had already reasoned about.

Found by driving the real panel in a browser — the store spec cannot
see it (the rule is correct; the wiring never ran) and neither can a
source regex, until one is told to look for `immediate`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pt-in one (Abilityai/trinity-enterprise#454)

The server's prefix warning ends "Uncheck anything that does not belong
before confirming" — the opt-OUT instruction this branch reversed. It
rendered under "Notes", six lines below the panel's own "tick it only
if it belongs", so one screen gave opposite instructions about the same
row and the stale half read as the authoritative one, because it came
from the server.

Suppressed, which is the rule this component's docstring already
states: a warning that has a dedicated block above is not repeated as
prose. The prefix warning simply had no such block until the
short-selection note gave it one, so it was the only member of that
family still falling through. The excluded, ephemeral and unverified
lines were already suppressed for exactly this reason.

SUPPRESSED rather than MEMBERSHIP_FAULT: it is raised in the HEALTHY
state too, where no banner renders at all.

Visible only in situ — the warning comes from the enterprise module, so
it is absent from every fixture in the frontend tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… before it (Abilityai/trinity-enterprise#454)

`TeardownResult` rendered `result.warnings` unfiltered, so the server's
prefix warning — which ends "Uncheck anything that does not belong
before confirming" — appeared under Notes on the RESULT, beneath a
report of what had already been removed. Advice for a decision already
taken, in the opt-OUT wording the preview stopped using.

Only that line is dropped. Every other warning is a statement of fact
about what happened (agents excluded, members with no container,
ephemeral members with no recovery window) and still reads correctly
after the event.

The preview gained this filter in 581a531; this component did not,
because nothing renders either of them in CI and the preview is the one
a source guard had been written against. Found by tearing a system down
for real on a live stack.

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

Assigning a library skill whose name matched a `.claude/skills/<name>/` the
agent wrote itself overwrote the agent's copy, gitignored and untracked the
directory, and buried an `unmanaged_dir_overwritten` warning in the assignment
response. The library and the agent's repo share one flat namespace on the
agent side, so a name match is not proof of the same skill — the library
silently won.

The inject path now refuses a directory that exists without the platform's
`.trinity-skill.json` marker before a byte is staged: no archive, no restore,
no `.gitignore` line, no untracking, not listed under CLAUDE.md Platform
Skills. `force` (manual Sync) does not override it; a platform-managed dir
keeps upgrading in place; an unreadable probe keeps the pre-existing fail-open
direction. The per-skill status is `conflict` — neither `injected` nor
`failed` (overall `success` stays true so the fleet re-inject does not alarm on
a standing conflict every auto-sync); `skills_conflict` + `conflicts[]` carry
it, and `deliver_assigned` reports `conflict` / `partial` + `conflicts[]`.

The verdict is recorded on the assignment row (`agent_skills.delivery_status`,
SQLite `agent_skills_delivery_status` + Alembic `0065`), carried across the
bulk-replace PUT for retained names, cleared by the next injection where the
name lands, and gone on unassign. The Skills tab reads it off the rows so the
badge, the explanation (which skill, the agent's own copy is what runs) and an
inline "Unassign library skill" action show on a fresh load; `inject()` re-reads
the rows so a resolved conflict clears without a reload. MCP passes the
delivery block through, `sync_agent_skills` names conflicts on its success
branch, and `get_agent_skills` carries `delivery_status` per row.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ce-text specs over components (#2918)

Two PRs ejected from the 2026-09-20 merge train and three from the previous
one landed safety-critical UI logic — a fleet-delete confirmation's
`canRemove`, a modal's Esc/focus-trap contract — in the one tier with no
executable coverage: regexes over the component's source, which an inverted
predicate passes byte-identically. Each justified itself with "this repo's
vitest is node-only, nothing can mount", which has been false since
`vitest.config.js` grew `plugins: [vue()]` + jsdom + @vue/test-utils for a
per-file `// @vitest-environment jsdom` opt-in (precedent:
portalThemeSwitch.spec.js). The capability was never the gap; its
discoverability at the decision point was.

Three things close it:

1. The design-system contract (read before any src/frontend change) gains a
   "Testing a component — the harness mounts" section and a PR self-check
   item naming the opt-in, the copyable precedent, and the bar: source-text-
   only coverage of a predicate that gates a destructive verb, a keyboard
   contract or a store write is not acceptable. design-system.md §9 and
   docs/testing/STRATEGY.md carry the same rule.
2. The methodology names the tell (.claude submodule bump → trinity-dev
   5b09829 + 437674f): /validate-pr §5.4, /review Step 2.5 and the /implement
   checklist state the verdict rule and the escape, and merge-train lists the
   new baseline among the regenerable ratchets.
3. A ratchet, the same shape as raw colours and loading gates:
   scripts/scan-source-text-specs.mjs counts, per spec, non-import `.vue`
   literals under components/ or views/ in a spec that calls readFileSync;
   source-text-baseline.json freezes today's 86 specs / 271 reads;
   tests/unit/sourceTextRatchet.spec.js fails when a spec grows, when a NEW
   spec reads SFC source without a `@source-text-pin: <reason>` marker (the
   reviewable escape for an AST-shaped call-site guard or a parity table), or
   when an entry is stale. The failure message names the harness and the
   precedent — the point-of-decision hint the issue asked for. Negative-
   controlled: a new unpinned spec fails, the same spec pinned passes, and a
   +1 read in a baseline spec fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…; delivery copy and draft fixes (#2914)

Merge-train review on #2920: marker-absence was read as "the agent authored
this" and refused, but the platform itself wrote marker-less directories on
two live paths — the pre-#384 legacy fallback (SKILL.md only) and a restore
that dropped the meta member — so each would have turned its own package
into a permanent, unrepairable conflict on the next sync (AC#6 violated).

Both are closed at the write: `_legacy_fallback` writes the marker beside
SKILL.md (manifest = what was actually written), and `_restore_skill` writes
the marker back directly when the agent's `restored` list lacks it
(`marker_written_directly`). If the marker cannot be written the injection
is reported `failed` (`marker_not_written`; the legacy write is rolled back)
rather than a half-managed directory. Five tests cover the two paths, their
failure arms, and the legacy-marker round trip that closes AC#6.

Also from the review: `deliveryText` grammar ("its own a skill") with a real
plural arm; the `not_delivered` arm now names conflicts beside the failure
it retries; the Skills tab resets its draft only when the assigned SET
changes, so a Sync (which re-reads the rows for the verdict) no longer
wipes unsaved ticks; the conflict copy says "a directory the platform did
not create — usually a skill it authored" rather than asserting authorship.

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

Six bespoke overlays each re-implemented the same markup and each omitted the
same two behaviours: Esc did nothing, and Tab walked out behind the overlay.
Verified still true on `dev` before starting — **zero** `Escape` references
across all six files (the `@keydown` hits there are Enter-to-submit), no shell
primitive, no focus trap anywhere.

`BaseModal` owns exactly four things — overlay, Esc, focus, scroll lock — and
nothing about content, so adopting it is deleting two wrapper divs rather than
rewriting a dialog. It teleports to `<body>`: several of these modals are
declared inside panels that establish a stacking context, where a `z-50`
overlay nested in one renders BEHIND its siblings.

**The decidable half is a separate pure module, and that is the point.** This
repo's vitest runs `environment: 'node'` with no jsdom/happy-dom and no
`@vue/test-utils` — I checked, because a focus trap written entirely inside an
SFC would be a rule no unit test could reach, and source-text assertions would
prove only that it had been TYPED. `utils/focusTrap.js` holds every decision as
a function over plain data (tabbable filtering, the Tab wrap, the dismiss-key
predicate, safe-action selection, backdrop identity) with 25 tests. What that
leaves uncovered is the wiring itself — listener attachment, the focus() calls —
which needs a browser and belongs to e2e. Said plainly rather than implied by a
green suite.

Three decisions worth naming:

* initial focus goes to the SAFE action, and destructiveness is DECLARED
  (`data-destructive`) rather than guessed from label text, which would be
  wrong in every language but English. A dialog that opens with Delete focused
  turns a reflexive Enter into data loss.
* backdrop dismissal compares identity against the overlay node, not a
  rectangle — a rectangle test mis-fires for a select popup or date picker
  rendered at the document root and closes the modal under the user.
* a modified Escape (Ctrl/Cmd/Alt/Shift) does not dismiss; that is a browser or
  OS gesture, not an intent to close.

Migrated in this commit: `SystemViewEditor.vue` and `NavBar.vue`'s build-info
modal. The remaining four files are enumerated in the PR with their exact
overlay bounds; they are 80-230 line tag surgeries and are deliberately left
for a reviewed pass rather than done blind in one go.

One acceptance-criteria item is stale: `views/Agents.vue` was deleted by
ent#260 (the Agents page folded into the Dashboard list view), so its
bulk-tag popover no longer exists.

Related to #1923

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q19uRCksdn4DiRAJ55rfpZ
…y, and a mounted spec for the wiring (#1923)

Merge-train review on #2778, twice: the shell's rules were tested, its
wiring was not, and the docblocks justified that with a claim that was
false on this head — the repo mounts components (per-file jsdom opt-in,
@vue/test-utils, #2918). Two defects sat in exactly that untested wiring.

1. The scroll lock was global state written per instance: `immediate: true`
   ran the else-branch on every mount and `onBeforeUnmount` cleared
   unconditionally, so a nested dialog (the #2780 shape — a ConfirmDialog
   inside another modal's slot) unlocked the page the moment the outer
   opened, and any modal's unmount clobbered another component's lock.
   Now `utils/focusTrap.js::createScrollLock` is a ref count over one
   value (first holder saves and hides, last release restores) and every
   BaseModal shares `bodyScrollLock`, taking and releasing exactly its own
   count.
2. The overlay had no `tabindex="-1"`, so `overlay.focus()` was a no-op and
   Esc did nothing once focus left a control (a click on dialog text lands
   focus on the nearest focusable ancestor, which was <body>).

`tests/unit/baseModal.spec.js` mounts the shell: initial focus on the safe
control, Tab wraps, Esc emits close and focus returns, the overlay is
focusable, a modal with no tabbable child is still dismissable, backdrop
semantics, the lock is taken/restored, two nested modals share one lock,
and unmount releases only what the instance held. Mutation-checked: gutting
@keydown, the focus return, the tabindex or the lock each turns it red.

Also: the false "node-only vitest" claims are rewritten in all three
docblocks; the overlay's p-4 now IS the 16px phone gutter (the two adopted
panels drop their mx-4, which had doubled it to 32); the design-system docs
name BaseModal as the modal shell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
dolho and others added 14 commits September 21, 2026 14:34
…md (#2914)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…a process-wide caplog, and a stale release allowlist

Three failure classes have been redding `backend-unit-test` on dev and the
regression diff on unrelated PRs (#2920, #2924, #2927, the 09-18 train):

1. `test_subprocess_pgroup` — `signal_guard.guarded_killpg` walked the
   group's members, then read each member's cgroup; the harness parent
   exiting in between (that IS the scenario under test) made `_cgroup_of`
   answer None, which read as "outside the session cgroup", and a kill of
   a group that was entirely ours a millisecond earlier was refused.
   Membership is now decided per LIVE member: a pid that vanished (or is a
   zombie) is not a member. A live pid whose cgroup cannot be read stays
   foreign — fail closed, unchanged. Two tests pin both halves; reverting
   the guard turns the race test red.

2. `test_2789…test_retry_budget_is_logged_with_its_cause` asserted over
   EVERY caplog record in the process, so a background task left by an
   earlier test (order-dependent under a random seed) logging an unrelated
   ERROR read as `['ERROR', 'WARNING'] == ['WARNING']`. It now asserts over
   the module's own logger.

3. `test_2814_workflow_trigger_parity` — every ACCEPTED_UNTIL_RELEASE entry
   is declared on `main` since the v0.9.5 cut and the guard has said
   "prune these" on every run since. Pruned, as the guard was designed to
   demand.

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

`mine()` was introduced to stop a background task's unrelated record from
reading as this test's own, and applied at four sites. Six reads of the
process-wide `caplog.records` survived in the same function, after the last
`mine()` call — including `:523`, the exact shape the fix was written for,
and two `assert not caplog.records` that any stray record from any logger
reddens.

Verified by negative control: with an unrelated ERROR emitted inside the
third phase's `caplog.at_level` window, the pre-fix assertions fail at
`assert "30s already spent" in caplog.records[0].message`; with `mine()`
they pass. `caplog.records` now appears once, in `mine()` itself.

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

`_is_gone` returned True for every OSError, so a LIVE pid whose
`/proc/<pid>/status` cannot be read (EACCES under a `hidepid=` mount, a
malformed line) was classified as gone, dropped from `live`, and the
`killpg` proceeded. The docstring two lines above states the opposite
contract: "a LIVE pid whose cgroup we cannot read is treated as foreign
(fail closed)".

Only the vanished-pid case is `gone` — FileNotFoundError. Every other
OSError/IndexError now keeps the pid in `live` so the cgroup check can
refuse the kill. This guard replaces os.kill/os.killpg for the whole unit
suite and exists because a mis-fire SIGKILLed a developer's desktop twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`SkillsPanel.vue` had no spec at all, so the ~60 lines of conflict UI and the
fix for the regression this PR itself introduced were both inert: disabling
the whole conflict block AND reverting the draft-wipe fix left the suite
byte-identically green.

`inject()` re-reads the assignment rows (the conflict verdict rides them), so
the panel sees a new `assigned` array after every Sync even when the set is
unchanged. The shipped defect was a deep watch on that array wiping unsaved
ticks; the fix watches the set identity. Nothing executed it.

Two tests, mounted (the ent#625 / #2918 harness), and negative-controlled in
both directions so neither can be satisfied by the other's cheat:

  revert to `watch(() => store.assigned, resetDraft, {deep:true})`
      -> "keeps an unsaved tick when Sync re-reads the SAME set" FAILS
  delete the watcher outright ("never reset" also passes test 1)
      -> "still follows a real change to the assignment set" FAILS

Full frontend suite: 142 files, 3246 passed.

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

`teardownDefaultSelection` pre-ticked `evidence !== 'prefix'`. `evidence` is
free-form text off the wire and the consumer DELETES, so a member arriving
with the field absent, null, or carrying a future fourth value was pre-ticked
by a denylist that had never heard of it.

It was worse than one predicate: the polarity was hand-mirrored in THREE
places, two of them in the template — the store's default, the "matched by
name only" BaseBadge, and the explanation beneath it. The badge and the note
tested `=== 'prefix'`, which is not the complement of `!== 'prefix'` for an
unrecognised value, so such a member was pre-ticked AND unbadged AND
unexplained: swept into a delete with no signal at all.

All three now read one exported predicate, `isConfirmedMember`, an allowlist
of positive confirmation (`tag` / `both` — what `system_service.py` actually
emits). `teardownUnconfirmedMembers` is its complement by subtraction, so the
pair partitions the roster by construction.

Four cases + a partition property added to the spec that already executes this
rule. Negative-controlled: restoring the denylist turns the three
unrecognised-evidence cases red.

The source guard's assertion moved with it — it pinned the literal
`m.evidence === 'prefix'`, i.e. the implementation spelling, and now pins the
shared predicate plus the absence of a raw comparison.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ming no mount harness exists

Two things, one file.

**The pin.** #2922 lands a ratchet that holds any spec with no baseline entry
to ZERO source-text reads. This spec is new, so it fails that ratchet — the
two PRs are individually green and jointly red, which is invisible to either
one alone. It is a legitimate pin rather than a missing mount: the defect is a
cascade tie broken by stylesheet EMISSION ORDER, and neither jsdom nor
@vue/test-utils evaluates a stylesheet, so a mount would report the class list
it was handed and prove nothing. That is exactly the case the marker exists to
make visible to review.

**The false rationale.** The docblock said vitest "runs `environment: 'node'`
here with no mount harness". `environment: 'node'` is the DEFAULT only; a spec
opts into jsdom per file and mounts, which 22 specs already do. Saying the
harness does not exist is the claim #2918 was filed about, and repeating it in
a new file is how the belief survives. The harness exists and is reachable —
this spec stays at source level because a cascade is invisible to it, which is
a different and true reason.

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

The comment justified the `baselined` row by saying `agents` is absent from
AGENT_TARGET_PARAMS, so a `none` row "would have passed the shape check and
said something false". `agents` is in that set (`access.ts`), and `policyFor`
THROWS on a `none` row whose parameters name an agent — the server refuses to
boot. The row is right; its stated reason was the one thing about it that was
wrong, and it understated why the row matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts:
#	docs/memory/learnings.md
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ui PR touches the frontend UI — triggers Playwright e2e tests

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants