Named tool sets for roles + the self-service ceiling (phases 1-2) - #45
Merged
Conversation
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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 thegranted/self-servicemode from #35) andaccess_requests(inert until #29 — one migrationinstead of two).
Selector semantics in the rules table:
NULL= "any",''= the ungrouped bucket. The uniqueindex COALESCEs both to a sentinel, because a plain UNIQUE treats NULLs as distinct and would
happily store the same selector twice.
domain/toolsets.tsis pure —ruleMatches,ruleSpecificity,resolveCeiling:total order, so "which rule won" is explainable and never depends on row order;
hasAnySetis what flips a role to a closed world; without sets the legacy grant/defaultpath runs untouched.
PolicyService.explainreturns the decision and its inputs, andallows()is nowexplain().allowed— an explanation cannot describe a different outcome than the boundaryenforces.
allowsForgains the self-service zone: outside the granted envelope, offered by a setassigned 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
isAdmingate): set CRUD,rules upserted by selector, assignment with a mode,
convert-grants, andexplain.Two things it deliberately does not implement twice:
catalog;
(
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 categorybadge on a rule that covers nothing, granted/self-servicemode 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 grantsstates its one real consequence: the role becomes closed-world, so a newly added server is denied
until a rule covers it.
Verification
npm run buildclean,npm test310 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 toolinheriting a covered category without a re-save.
domain/toolsets-policy.test.ts— the same through a real Repo + PolicyService, including everyself-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 thetypo case, the preview persisting nothing, convert-grants dry-run → write → 409, explain naming
the winning rule, and the whole router refusing non-admins.
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-intoggle) — plus
gw_*tools for sets anddocs/toolsets.md. Deferred deliberately; the resolutionlayer already enforces the zone, so
/meis presentation only.Not deployed.
🤖 Generated with Claude Code