31 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 56 | Unify Rule type and normalize config into flat Ruleset |
Unify Rule type and normalize config into flat Ruleset
Problem Statement
After #55 extracted evaluate(), the codebase has two representations of permission rules:
- The
Rule/Rulesettypes used byevaluate()insrc/rule.ts. - The legacy per-surface types (
ToolPermissions,BashPermissions,SkillPermissions,SpecialPermissions) — allRecord<string, PermissionState>— used by config loading, merging, and compiled pattern caches inPermissionManager.
PermissionManager still maintains separate compiled pattern arrays per surface (compiledBash, compiledMcp, compiledSkills, compiledSpecial) and a separate BashFilter class, even though evaluate() handles all surfaces uniformly.
The compiledToRuleset() bridge in checkPermission() converts between these representations on every call — an adapter that exists only because config loading hasn't caught up with the evaluation model.
Goals
- Add
normalizeConfig()insrc/normalize.tsthat converts the on-disk config shape into a flatRulesetat load time. - Replace
mergePermissions()(per-category object spread) with array concatenation ofRulesetvalues — later scopes' rules appear last and take priority via last-match-wins. - Extract per-surface default policy into
src/defaults.ts, kept separate from theRuleset(see Design Overview for rationale). - Remove
BashFilterclass —evaluate("bash", command, rules)replaces it. - Remove
ToolPermissions,BashPermissions,SkillPermissions,SpecialPermissionstype aliases fromsrc/types.ts. - Remove
GlobalPermissionConfigandAgentPermissionsinterfaces — each config scope becomes aRulesetat runtime. - Preserve all existing external behavior: on-disk config format,
PermissionCheckResultreturn type,checkPermission()/getToolPermission()public API, MCP baseline auto-allow, and two-phase tool filtering.
Non-Goals
- Changing the on-disk config JSON format (preserved exactly).
- Replacing
SessionApprovalCachewith session rules (deferred to #57). - Extracting event handlers (#42) or eliminating module-scope state (#43).
- Pre-compiling regex patterns inside normalized
Ruleobjects —wildcardMatch()per-call is fast enough for current ruleset sizes and this can be optimized in a follow-up. - Changing the JSON schema or example config — no user-facing config changes.
Background
Permission surfaces involved
All: tools (read, write, edit, bash, grep, find, ls, plus extension tools), bash, mcp, skills, special (external_directory).
Relevant modules
| Module | Role |
|---|---|
src/permission-manager.ts |
Config loading, merge, checkPermission(), getToolPermission() |
src/rule.ts |
Rule, Ruleset, evaluate(), getDefaultAction() — introduced in #55 |
src/wildcard-matcher.ts |
wildcardMatch(), compiled pattern infrastructure |
src/bash-filter.ts |
BashFilter class — already unused in checkPermission() post-#55 |
src/config-loader.ts |
UnifiedPermissionConfig, loadUnifiedConfig(), normalizeUnifiedConfig() |
src/types.ts |
Per-surface type aliases, AgentPermissions, GlobalPermissionConfig |
docs/architecture/target-architecture.md |
Target normalizeConfig() shape and module layout |
Current flow (post-#55)
resolvePermissions(agentName)loads four config scopes (global, project, agent frontmatter, project-agent frontmatter) into per-surfaceRecord<string, PermissionState>maps.mergePermissions()shallow-spreads maps per category across scopes.- Compiled pattern arrays are built per surface (
compiledBash,compiledMcp, etc.). checkPermission()converts compiled patterns into temporaryRulesetvalues viacompiledToRuleset(), then callsevaluate().
What changes
Step 4's on-the-fly conversion moves to step 1 — normalizeConfig() produces a Ruleset at load time.
Steps 2–3 collapse into array concatenation.
Step 4 calls evaluate() directly against the merged Ruleset.
Design Overview
Surface naming: tool-name-as-surface
Following the issue proposal and target architecture, tool names and special keys become surfaces:
// tools.read: "allow" → tool name is the surface
{ surface: "read", pattern: "*", action: "allow" }
// bash["git *"]: "ask" → "bash" is the surface, command is the pattern
{ surface: "bash", pattern: "git *", action: "ask" }
// special.external_directory: "ask" → special key is the surface
{ surface: "external_directory", pattern: "*", action: "ask" }
This means tools.bash: "allow" normalizes to { surface: "bash", pattern: "*", action: "allow" } — a bash catch-all.
This naturally preserves the current dual-purpose behavior where tools.bash controls both tool exposure (phase 1) and bash command fallback (phase 2).
Similarly, tools.mcp: "allow" → { surface: "mcp", pattern: "*", action: "allow" }.
normalizeConfig() (src/normalize.ts)
import type { PermissionState } from "./types";
import type { Rule, Ruleset } from "./rule";
interface NormalizableConfig {
tools?: Record<string, PermissionState>;
bash?: Record<string, PermissionState>;
mcp?: Record<string, PermissionState>;
skills?: Record<string, PermissionState>;
special?: Record<string, PermissionState>;
}
/**
* Convert the on-disk config shape into a flat Ruleset.
*
* Ordering within a scope:
* 1. tools entries (tool-name-as-surface, pattern "*")
* 2. bash entries (surface "bash", pattern = command glob)
* 3. mcp entries (surface "mcp", pattern = target glob)
* 4. skills entries (surface "skill", pattern = skill glob)
* 5. special entries (key-as-surface, pattern "*")
*
* defaultPolicy is NOT included — handled separately (see below).
*/
export function normalizeConfig(config: NormalizableConfig): Ruleset {
const rules: Ruleset = [];
for (const [name, action] of Object.entries(config.tools ?? {}))
rules.push({ surface: name, pattern: "*", action });
for (const [pattern, action] of Object.entries(config.bash ?? {}))
rules.push({ surface: "bash", pattern, action });
for (const [pattern, action] of Object.entries(config.mcp ?? {}))
rules.push({ surface: "mcp", pattern, action });
for (const [pattern, action] of Object.entries(config.skills ?? {}))
rules.push({ surface: "skill", pattern, action });
for (const [name, action] of Object.entries(config.special ?? {}))
rules.push({ surface: name, pattern: "*", action });
return rules;
}
Why defaultPolicy stays separate from the Ruleset
defaultPolicy cannot be fully represented as catch-all rules in the Ruleset because:
defaultPolicy.toolswould need to match ALL tool-name surfaces (read, write, edit, bash, grep, find, ls, plus unknown extension tools). No single rule surface pattern can match exactly "all tool names" without also matching bash/mcp/skill surfaces.defaultPolicy.specialhas the same problem — special keys (e.g.,external_directory) become their own surfaces.- MCP baseline auto-allow depends on distinguishing "no rule matched" from "a defaultPolicy catch-all matched."
If
defaultPolicy.mcpwere a catch-all in the ruleset, it would always match and prevent the baseline heuristic from firing.
defaultPolicy is loaded and merged separately as PermissionDefaultPolicy (shallow spread across scopes, same as today).
When evaluate() returns a synthetic default (no rule matched), checkPermission() consults the merged defaultPolicy for the appropriate surface fallback.
Note: defaultPolicy.bash, defaultPolicy.mcp, and defaultPolicy.skills COULD be represented as catch-all rules (they have fixed surface names), but excluding them preserves MCP baseline auto-allow and keeps all defaults in one place.
Once MCP baseline auto-allow is formalized as explicit rules (#57), defaults can optionally move into the ruleset.
src/defaults.ts
import type { PermissionDefaultPolicy, PermissionState } from "./types";
export const DEFAULT_POLICY: PermissionDefaultPolicy = {
tools: "ask",
bash: "ask",
mcp: "ask",
skills: "ask",
special: "ask",
};
/**
* Map a surface name used in evaluate() to the corresponding
* defaultPolicy key. Returns undefined for unknown surfaces.
*/
const SURFACE_TO_DEFAULT_KEY: Record<string, keyof PermissionDefaultPolicy> = {
bash: "bash",
mcp: "mcp",
skill: "skill",
// tool-name surfaces (read, write, edit, etc.) and special-key surfaces
// (external_directory) do not have dedicated keys — they fall back to
// "tools" or "special" respectively via getSurfaceDefault().
};
/**
* Resolve the default action for a surface, consulting merged defaults.
*/
export function getSurfaceDefault(
surface: string,
defaults: PermissionDefaultPolicy,
specialKeys: ReadonlySet<string>,
): PermissionState {
const key = SURFACE_TO_DEFAULT_KEY[surface];
if (key) return defaults[key];
if (specialKeys.has(surface)) return defaults.special;
return defaults.tools;
}
Merge
// Config normalization produces a Ruleset per scope
const globalRules = normalizeConfig(globalConfig);
const projectRules = normalizeConfig(projectConfig);
const agentRules = normalizeConfig(agentFrontmatter);
const projectAgentRules = normalizeConfig(projectAgentFrontmatter);
// Concatenation — later scopes appear last → higher priority via last-match-wins
const mergedRules = [
...globalRules,
...projectRules,
...agentRules,
...projectAgentRules,
];
// Defaults merged separately (shallow spread, same as today)
const mergedDefaults = {
...DEFAULT_POLICY,
...globalDefaults,
...projectDefaults,
...agentDefaults,
...projectAgentDefaults,
};
Simplified ResolvedPermissions
type ResolvedPermissions = {
rules: Ruleset;
defaults: PermissionDefaultPolicy;
configuredMcpServerNames: readonly string[];
// merged.mcp is still needed for MCP baseline auto-allow heuristic
// (checks whether ANY mcp rule has action "allow")
hasAnyMcpAllowRule: boolean;
};
Simplified checkPermission()
Each surface branch reduces to evaluate() + default fallback:
// Tools (read, write, edit, grep, find, ls, extension tools)
const rule = evaluate(normalizedToolName, "*", rules);
const explicit = rules.includes(rule);
return { state: explicit ? rule.action : defaults.tools, source: explicit ? "tool" : "default" };
// Bash
const rule = evaluate("bash", command, rules);
const explicit = rules.includes(rule);
return { state: explicit ? rule.action : defaults.bash, source: explicit ? "bash" : "default" };
// Skills
const rule = evaluate("skill", skillName, rules);
const explicit = rules.includes(rule);
return { state: explicit ? rule.action : defaults.skills, source: explicit ? "skill" : "default" };
// Special (external_directory)
const rule = evaluate(normalizedToolName, "*", rules);
const explicit = rules.includes(rule);
return { state: explicit ? rule.action : defaults.special, source: explicit ? "special" : "default" };
// MCP — multi-name loop preserved, baseline auto-allow preserved
for (const target of mcpTargets) {
const rule = evaluate("mcp", target, rules);
if (rules.includes(rule)) return { state: rule.action, source: "mcp" };
}
// ... baseline auto-allow heuristic ...
return { state: defaults.mcp, source: "default" };
Simplified getToolPermission()
getToolPermission(toolName: string, agentName?: string): PermissionState {
const { rules, defaults } = this.resolvePermissions(agentName);
const rule = evaluate(normalizedToolName, "*", rules);
if (rules.includes(rule)) return rule.action;
if (SPECIAL_PERMISSION_KEYS.has(normalizedToolName)) return defaults.special;
return defaults.tools;
}
Edge cases
- MCP baseline auto-allow: preserved.
hasAnyMcpAllowRuleis derived from the mergedRulesetby checking if any rule withsurface: "mcp"hasaction: "allow". The heuristic fires only when no explicit rule matched AND no catch-all (fromtools.mcp) matched. tools.bashdual-purpose: preserved naturally.tools.bash: "allow"→{ surface: "bash", pattern: "*", action: "allow" }is a bash catch-all. Phase 1 (getToolPermission("bash")) matches it → tool exposed. Phase 2 (evaluate("bash", command, ...)) uses it as fallback for unmatched commands.getBashPermissions()removal: the method returnsmerged.bashwhich no longer exists in the simplified model. It has no callers outsidepermission-manager.tsand can be removed.normalizeRawPermission()(frontmatter parsing): updated to return a shape compatible withnormalizeConfig()input. Its deprecated-key detection and top-level shorthand logic are preserved.- Compiled regex caching: deferred.
evaluate()useswildcardMatch()which compiles a regex per call. This is fast enough for current ruleset sizes (< 1μs per pattern). Pre-compiled rules can be introduced in a follow-up if profiling shows a need. sourcefield inPermissionCheckResult: preserved with the same values."tool"for tool-surface matches,"bash"for bash matches,"mcp"for MCP matches,"skill"for skill matches,"special"for special matches,"default"when no rule matched.
Behavioral difference: getToolPermission() now sees command-level catch-alls
With tool-name-as-surface, getToolPermission("bash") calls evaluate("bash", "*", rules).
If the user has bash: { "*": "allow" } (a command-level catch-all), this matches and returns "allow" — exposing the bash tool.
Previously, getToolPermission("bash") only checked tools.bash and defaultPolicy.bash, ignoring command-level patterns.
The new behavior is more consistent: if every bash command is allowed via a catch-all, the tool should be exposed.
Conversely, if bash: { "*": "deny" }, the tool is hidden — which is better UX than showing a tool that always fails.
Module-Level Changes
src/normalize.ts (new)
- Export
normalizeConfig(config): Ruleset. - Export
NormalizableConfiginterface (subset ofUnifiedPermissionConfigcovering only policy fields). - Pure module — no IO, imports only
./ruleand./types.
src/defaults.ts (new)
- Export
DEFAULT_POLICY: PermissionDefaultPolicy. - Export
getSurfaceDefault(surface, defaults, specialKeys): PermissionState. - Export
mergeDefaults(...partials): PermissionDefaultPolicy. - Move
DEFAULT_POLICYconstant frompermission-manager.ts. - Move
normalizePolicy()/normalizePartialPolicy()here asmergeDefaults().
src/rule.ts (modified)
- Remove
SURFACE_DEFAULTSandgetDefaultAction()— moved tosrc/defaults.tsasgetSurfaceDefault(). evaluate()accepts an optionaldefaultActionparameter (defaults to"ask") instead of callinggetDefaultAction(). This keepsevaluate()pure without depending ondefaults.ts.
src/permission-manager.ts (modified — major)
- Remove
compiledToRuleset()helper. - Remove
compilePermissionPatternsFromSources()helper. - Remove
findCompiledPermissionMatch()/findCompiledPermissionMatchForNames()helpers. - Remove
mergePermissions()— replaced by array concatenation. - Remove
normalizePolicy()/normalizePartialPolicy()— moved todefaults.ts. - Remove
normalizePermissionRecord()—normalizeConfig()handles this. - Remove
BashFilterimport and usage. - Simplify
ResolvedPermissionsto{ rules: Ruleset, defaults: PermissionDefaultPolicy, hasAnyMcpAllowRule: boolean }. - Simplify
resolvePermissions(): callnormalizeConfig()per scope, concatenate, merge defaults. - Simplify
checkPermission(): directevaluate()calls against merged ruleset, no more per-surface compiled pattern intermediary. - Simplify
getToolPermission(): singleevaluate()call + default fallback. - Remove
getBashPermissions()(dead method, no external callers). - Update
normalizeRawPermission()to return a shape compatible withnormalizeConfig()input. - Update config caches to store
Ruleset+ defaults per scope instead ofGlobalPermissionConfig/AgentPermissions.
src/bash-filter.ts (removed)
BashFilterclass is already dead code incheckPermission()(destructured as_bashFiltersince #55).- All functionality replaced by
evaluate("bash", command, rules).
src/types.ts (modified)
- Remove
ToolPermissions,BashPermissions,SkillPermissions,SpecialPermissionstype aliases. - Remove
AgentPermissionsinterface. - Remove
GlobalPermissionConfiginterface. - Keep
PermissionState,BuiltInToolName,PermissionDefaultPolicy,PermissionCheckResult. - Keep
SpecialPermissionName(used for type-level documentation).
src/wildcard-matcher.ts (no change)
wildcardMatch(),compileWildcardPattern(), etc. remain as-is.compileWildcardPatternEntries()andfindCompiledWildcardMatch()may become unused after this change — removal deferred to a cleanup pass.
src/config-loader.ts (no change)
UnifiedPermissionConfigand loading functions unchanged.normalizeConfig()insrc/normalize.tstakes the loaded config as input.
schemas/permissions.schema.json (no change)
- On-disk format unchanged.
config/config.example.json (no change)
- Example config unchanged.
Tests
| File | Change |
|---|---|
tests/normalize.test.ts (new) |
Unit tests for normalizeConfig(): per-surface conversion, ordering, edge cases |
tests/defaults.test.ts (new) |
Unit tests for getSurfaceDefault(), mergeDefaults() |
tests/rule.test.ts (modified) |
Update evaluate() tests for optional defaultAction parameter; remove getDefaultAction tests (moved to defaults.test.ts) |
tests/permission-system.test.ts (modified) |
Update to remove GlobalPermissionConfig/AgentPermissions references; remove BashFilter test; adapt helper functions |
tests/bash-filter.test.ts (removed) |
Covered by normalize.test.ts + rule.test.ts + permission-system.test.ts |
tests/session-start.test.ts (modified) |
Update GlobalPermissionConfig references |
TDD Order
Phase 1: New modules (additive, no existing code changes)
-
test: normalizeConfig converts tools entries to tool-name-as-surface rules Red: test that
normalizeConfig({ tools: { read: "allow", write: "deny" } })produces[{ surface: "read", pattern: "*", action: "allow" }, { surface: "write", pattern: "*", action: "deny" }]. Green: implementnormalizeConfig()tools path insrc/normalize.ts.test: normalizeConfig tools entries -
test: normalizeConfig converts bash entries to surface "bash" rules Red: test
normalizeConfig({ bash: { "git *": "allow", "rm -rf *": "deny" } })produces bash rules. Green: implement bash path.test: normalizeConfig bash entries -
test: normalizeConfig converts mcp, skills, special entries Red: test all remaining surfaces. Verify special keys become their own surface (
external_directory). Green: implement remaining paths.test: normalizeConfig mcp, skills, special entries -
test: normalizeConfig ordering — tools before bash/mcp/skills/special Red: test that with
{ tools: { bash: "allow" }, bash: { "git *": "ask" } }, the tools catch-all appears before the bash-specific rule. Green: already passes from implementation order.test: normalizeConfig rule ordering -
test: normalizeConfig empty/missing sections produce empty ruleset Red: test
normalizeConfig({})returns[]. Green: already passes.test: normalizeConfig empty config -
test: getSurfaceDefault returns correct defaults for each surface category Red: test that
getSurfaceDefault("bash", defaults, specialKeys)returnsdefaults.bash, tool surfaces returndefaults.tools, special surfaces returndefaults.special. Green: implementgetSurfaceDefault()insrc/defaults.ts.test: getSurfaceDefault per-surface dispatch -
test: mergeDefaults shallow-merges partial policies Red: test that
mergeDefaults(globalDefaults, projectDefaults)produces correct merged result. Green: implementmergeDefaults()insrc/defaults.ts.test: mergeDefaults shallow merge -
feat: add normalizeConfig and defaults modules Commit the new modules (
src/normalize.ts,src/defaults.ts) and their tests.feat: add normalizeConfig and defaults modules
Phase 2: Update evaluate() signature
-
test: update evaluate() tests for optional defaultAction parameter Red→Green: update
tests/rule.test.ts—evaluate()now accepts an optionaldefaultActioninstead of callinggetDefaultAction(). MovegetDefaultAction()tests totests/defaults.test.ts.test: evaluate with optional defaultAction parameter -
feat: evaluate() accepts optional defaultAction Change
evaluate()signature to acceptdefaultAction?: PermissionState. When no rule matches, usedefaultAction ?? "ask"instead ofgetDefaultAction(surface). RemovegetDefaultAction()andSURFACE_DEFAULTSfromsrc/rule.ts.feat: evaluate accepts optional defaultAction parameter
Phase 3: Refactor PermissionManager internals
-
refactor: update permission-system.test.ts helpers for new types Update test helper functions that construct
GlobalPermissionConfig/AgentPermissionsto useUnifiedPermissionConfigor inlineRecord<string, PermissionState>. Remove theBashFiltertest frompermission-system.test.ts. All tests should still pass (helpers produce equivalent data).test: update permission-system test helpers for new types -
refactor: resolvePermissions uses normalizeConfig and array concat Replace per-surface compiled pattern arrays with
normalizeConfig()per scope. ReplacemergePermissions()with array concatenation. Replace per-scopeGlobalPermissionConfig/AgentPermissionscaches withRuleset+ defaults. SimplifyResolvedPermissionstype. Run full test suite.refactor: resolvePermissions uses normalizeConfig and array concat -
refactor: checkPermission uses merged Ruleset directly Remove
compiledToRuleset(). Each surface branch callsevaluate()against the merged ruleset. Fallback usesgetSurfaceDefault(). MCP baseline auto-allow logic preserved (useshasAnyMcpAllowRule). Run full test suite.refactor: checkPermission uses merged Ruleset directly -
refactor: getToolPermission uses evaluate Replace the per-surface
if/else ifchain with a singleevaluate()call +getSurfaceDefault()fallback. Run full test suite.refactor: getToolPermission uses evaluate
Phase 4: Remove dead code
-
refactor: remove BashFilter class Delete
src/bash-filter.ts. Deletetests/bash-filter.test.ts. Remove import frompermission-manager.ts. Run full test suite.refactor: remove BashFilter class -
refactor: remove per-surface type aliases Remove
ToolPermissions,BashPermissions,SkillPermissions,SpecialPermissionsfromsrc/types.ts. RemoveAgentPermissions,GlobalPermissionConfigfromsrc/types.ts. Update all remaining imports (tests, other modules). Runpnpm run build(typecheck).refactor: remove per-surface type aliases and wrapper interfaces -
refactor: remove getBashPermissions dead method Remove
getBashPermissions()fromPermissionManager(no callers). Run full test suite.refactor: remove getBashPermissions dead method -
refactor: remove unused compiled-pattern helpers If
compilePermissionPatternsFromSources(),findCompiledPermissionMatch(),findCompiledPermissionMatchForNames()are now unused, remove them. Check whethercompileWildcardPatternEntries(),compileWildcardPatterns(),findCompiledWildcardMatch(),findCompiledWildcardMatchForNames()inwildcard-matcher.tsstill have callers. Remove any that are dead. Run full test suite +pnpm run build.refactor: remove unused compiled-pattern helpers
Phase 5: Docs and verification
-
docs: update target architecture to mark #56 complete Update
docs/architecture/target-architecture.mdrefactoring sequence to mark #56 as done.docs: mark #56 complete in target architecture -
verify: full build and test suite Run
pnpm run buildandnpx vitest run. Confirm no regressions.chore: verify clean build after config normalization refactor
Risks and Mitigations
| Risk | Mitigation |
|---|---|
| Could this silently weaken a permission? | No new "allow" path is introduced. When no rule matches, getSurfaceDefault() consults the merged defaultPolicy which defaults to "ask" (least privilege). The evaluate() fallback is "ask". Each refactor step runs the full test suite. |
Semantic change: getToolPermission() now considers command-level catch-alls |
With tool-name-as-surface, getToolPermission("bash") can match bash: { "*": "allow" }. This is more consistent (don't expose a tool if all commands are denied) and strictly tighter or equivalent. Covered by existing tests + new normalize tests. |
| MCP baseline auto-allow could be bypassed | defaultPolicy is NOT included in the ruleset, so evaluate("mcp", target, rules) returns synthetic default when no explicit/tools.mcp rule matches — baseline heuristic fires as before. Explicit test coverage for this path. |
tools.bash dual-purpose semantic drift |
tools.bash: "allow" normalizes to { surface: "bash", pattern: "*", action: "allow" } — a bash catch-all. This naturally preserves both tool exposure and command fallback. Edge case: tools.bash: "deny" + bash: { "*": "allow" } now exposes the tool (bash catch-all overrides tools entry). Previously, tools.bash: "deny" always hid the tool. This contradictory config is likely a user error, and the new behavior is arguable. Add explicit test. |
| Large test file churn in permission-system.test.ts | Step 11 updates test helpers BEFORE refactoring production code. Changes are mechanical (type alias replacement). Intermediate commits keep the suite green. |
| Performance regression from losing compiled regex cache | wildcardMatch() compiles a regex per call (~1μs). For typical rulesets (< 50 rules), total overhead is < 50μs per permission check. Acceptable. Pre-compiled rules deferred to follow-up if needed. |
normalizeRawPermission() (frontmatter) diverges from normalizeConfig() |
Both share the same input shape (NormalizableConfig). normalizeRawPermission() handles raw YAML parsing + deprecated keys, then passes the validated shape to normalizeConfig(). Tested explicitly. |
Open Questions
- Should
evaluate()acceptdefaultActionas a parameter or keep callinggetDefaultAction()? Plan proposes parameter — cleaner for testing and keepsevaluate()independent ofdefaults.ts. Final decision during implementation. - Should unused
compileWildcardPatternEntries/findCompiledWildcardMatchexports be removed in this PR? They may be used by other modules not yet migrated toevaluate(). Removal is in step 18 but gated on checking callers. - Should
normalizeConfig()accept rawRecord<string, unknown>or the already-validatedRecord<string, PermissionState>sub-objects? Plan uses the validated shape (NormalizableConfig). Raw validation stays inconfig-loader.ts/normalizeRawPermission().