Skip to content

Named tool sets for roles + the self-service ceiling (phases 1-2) - #45

Merged
selic merged 2 commits into
mainfrom
feat/tool-sets
Aug 20, 2026
Merged

Named tool sets for roles + the self-service ceiling (phases 1-2)#45
selic merged 2 commits into
mainfrom
feat/tool-sets

Conversation

@selic

@selic selic commented Aug 20, 2026

Copy link
Copy Markdown
Member

Phases 1 and 2 of #27 + #35. Nothing changes for anyone until a set is assigned — a role
with no granted set keeps using the grants matrix, byte for byte, and the existing policy tests
pass untouched to prove it.

Phase 1 — the resolution layer

Migration v6: tool_sets, tool_set_rules, role_tool_sets (carrying the
granted / self-service mode from #35) and access_requests (inert until #29 — one migration
instead of two).

Selector semantics in the rules table: NULL = "any", '' = the ungrouped bucket. The unique
index COALESCEs both to a sentinel, because a plain UNIQUE treats NULLs as distinct and would
happily store the same selector twice.

domain/toolsets.ts is pure — ruleMatches, ruleSpecificity, resolveCeiling:

  • most specific matching rule wins: tool > category > tier > whole server;
  • ties break by scope (a role's private set over a shared one), then permissiveness, then ids — a
    total order, so "which rule won" is explainable and never depends on row order;
  • hasAnySet is what flips a role to a closed world; without sets the legacy grant/default
    path runs untouched.

PolicyService.explain returns the decision and its inputs, and allows() is now
explain().allowed — an explanation cannot describe a different outcome than the boundary
enforces. allowsFor gains the self-service zone: outside the granted envelope, offered by a set
assigned in self-service mode, live only once the user writes an opt-in row, still capped by that
set's own ceiling. A personal deny still wins; un-assigning the set takes the tool away regardless.

Phase 2 — the admin surface

New sub-router mounted inside the admin router (so it inherits the isAdmin gate): set CRUD,
rules upserted by selector, assignment with a mode, convert-grants, and explain.

Two things it deliberately does not implement twice:

  • every "what does this rule cover" count comes from running the real matcher over the live
    catalog
    ;
  • every "what would this change" preview comes from making the change and rolling it back
    (Repo.dryRun), not from a second resolver that could disagree with the boundary.

So the number in the confirm dialog is the number that happens.

The Tool sets tab shows sets with their rules, a category picker built from the catalog's actual
categories (a free-text box silently matching nothing was exactly how the old group switches
failed), a 0 tools — check the category badge on a rule that covers nothing, granted/self-service
mode chips wherever a set is assigned, and an assignment flow that spells out gains, losses with
examples
, and that self-service goes live only when each user switches it on. Convert grants
states its one real consequence: the role becomes closed-world, so a newly added server is denied
until a rule covers it.

Verification

npm run build clean, npm test 310 passed.

  • domain/toolsets.test.ts — the truth table: selector-shape weights provably distinct,
    order-independence, '' is a category and not a wildcard, closed world vs legacy, and a new tool
    inheriting a covered category without a re-save.
  • domain/toolsets-policy.test.ts — the same through a real Repo + PolicyService, including every
    self-service case (offered but inert, capped by the set's ceiling, personal deny wins,
    un-assignment revokes, self-service alone does not close the world).
  • http/admin-toolsets-api.test.ts — CRUD with 409/400/404 paths, live match counts including the
    typo case, the preview persisting nothing, convert-grants dry-run → write → 409, explain naming
    the winning rule, and the whole router refusing non-admins.
  • Driven against a dev gateway in a browser: tab renders, set creation, rule add with the
    zero-match warning, assignment preview over HTTP, no console errors.

Not in this PR

Phase 2b — rendering the self-service zone on /me (the five per-tool states and the opt-in
toggle) — plus gw_* tools for sets and docs/toolsets.md. Deferred deliberately; the resolution
layer already enforces the zone, so /me is presentation only.

Not deployed.

🤖 Generated with Claude Code

Eugene Samotija and others added 2 commits August 17, 2026 06:10
Phase 1 of #27 + #35: schema, the pure resolver, and the policy wiring. No API
or UI yet, and nothing changes for anyone until a set is assigned.

Migration v6 adds tool_sets, tool_set_rules, role_tool_sets (with the granted /
self-service mode from #35) and access_requests (inert until #29 — one
migration rather than two). Selector semantics in the rules table: NULL means
"any", '' means the ungrouped bucket, and the unique index COALESCEs both to a
sentinel because a plain UNIQUE treats NULLs as distinct and would happily store
the same selector twice.

domain/toolsets.ts is pure: ruleMatches, ruleSpecificity, resolveCeiling. The
most specific matching rule wins (tool > group > tier > server), ties break by
scope (a role's private set over a shared one), then permissiveness, then ids —
a total order, so "which rule won" is explainable and never depends on row
order. `hasAnySet` is what flips a role to a closed world; without sets the
legacy grant/default path runs untouched, which is the migration guarantee and
is asserted by the existing policy tests passing unchanged.

PolicyService.explain returns the decision AND its inputs, and allows() is now
explain().allowed — an explanation cannot describe a different outcome than the
boundary enforces. allowsFor gains the self-service zone: outside the granted
envelope, offered by a set assigned in self-service mode, live only once the
user writes an opt-in row, still capped by that set's own ceiling. A personal
deny still wins, and un-assigning the set takes the tool away regardless.

Tests: the resolution truth table (specificity shapes are provably distinct,
order-independence, '' is a category not a wildcard, closed world vs legacy,
inheritance of new tools into a covered category) and the same through a real
Repo + PolicyService, including every self-service case above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 2 of #27 + #35: the admin surface over the resolution layer.

New sub-router mounted inside the admin router (so it inherits the isAdmin
gate): tool-set CRUD, rules upserted BY SELECTOR, assignment with a mode, the
convert-grants migration helper, and explain.

Two things it deliberately does not do twice: every "what does this rule cover"
count comes from running the real matcher over the LIVE catalog, and every "what
would this change" preview comes from making the change and rolling it back
(Repo.dryRun) rather than from a second resolver that could disagree with the
boundary. So the number in the confirm dialog is the number that happens.

The UI is a new Tool sets tab: sets with their rules, a category picker built
from the catalog's actual categories (a free-text box silently matching nothing
was the failure mode of the old group switches), a "0 tools — check the category"
badge on a rule that covers nothing, granted/self-service mode chips wherever a
set is assigned, and an assignment flow that shows gains, LOSSES with examples,
and a plain-language note that self-service goes live only when each user
switches it on. Convert grants explains its one real consequence: the role
becomes closed-world, so a newly added server is denied until a rule covers it.

Verified against a dev gateway in a browser: tab renders, set creation, rule
add with the 0-match warning, and the assignment preview over HTTP. (Pixel
clicks miss in that pane because of its coordinate scaling, so the buttons were
driven through their own handlers; no console errors either way.)

Tests: CRUD and 409/400/404 paths, live match counts including the typo case,
the preview persisting nothing, self-service assignment not flipping the closed
world, convert-grants dry run then write then 409, explain naming the winning
rule and the closed world, and the whole router refusing non-admins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@selic
selic merged commit aee1ed0 into main Aug 20, 2026
1 check passed
@selic
selic deleted the feat/tool-sets branch August 20, 2026 11:49
selic pushed a commit that referenced this pull request Aug 20, 2026
Minor (#45): named tool sets for roles (#27) with the self-service ceiling
mode (#35) - schema v6, the pure rule resolver (most specific selector wins,
closed world once a granted set is assigned, legacy grants byte-for-byte
untouched otherwise), PolicyService.explain, the admin tool-sets API with
live match counts and dry-run assignment previews, and the Tool sets tab.
Inert until the first set is assigned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant