Repository navigation
fix(policy): merge install-apps by package across scopes - #118
Conversation
`Policy.for_device` falls back per FILE - device, then role, then kith. For `install-apps` that has a sharp consequence: the moment a device has a role, `role-<name>.install-apps` REPLACES `kith.install-apps`, so "every device runs the current agent" cannot be expressed at the kith level at all. The self-update line has to be repeated in every role file, forever, and the failure mode when someone forgets is a device that never updates and says nothing. `install-apps` now merges BY PACKAGE NAME across scopes, most specific winning per package: the kith says "everyone runs agent N" and a role pins its own apps. The merge emits one line per package because the agent REFUSES a file that names a package twice (AppInstallPolicy.read) - concatenating the scopes would trade the silent shadowing for a refused file. Comments and blank lines pass through in scope order; absent everywhere stays absent (an empty served file reads as "withdraw every install"). Every other file keeps the per-file fallback. Closes #109.
WalkthroughThis PR changes how the Review effort: moderate (~15 min) Changed files (2)
Shape of the changegraph TD
subgraph "server"
N0["policy.py"]
N1["test_policy.py"]
end
Last walked at |
Grug look hard. Trail clear.fix(policy): merge install-apps by package across scopes Grug Elder - Clear - no markings published - check `success`
|
…case rules - high-complexity: `for_device` grew too tangled. The install-apps scope collection is now `_install_apps_scopes()`, keeping the SHARED_FILES / ROLE_FILES gates the per-file fallback applies. - doc-code-claim-drift: verified against AppInstallPolicy.read - the finding misread the code. `words[0].lower() == "install"` IS case-insensitive (`"INSTALL".lower()` matches), exactly like the agent's `words[0].lowercase() != INSTALL`. No change; documented in `package_of` with the agent line named. - silent-exception-swallow: also verified, and the suggested fix (case-normalized keys) would be wrong. The agent keys its table by the raw package string, so `App.foo` and `app.foo` are two packages to it; normalizing server-side would merge what the agent keeps apart. Keying stays exact-match; pinned by test_install_apps_treats_package_case_exactly_like_the_agent.
…he cap Review on #118: for_device measured 16 cyclomatic after the install-apps extraction, one over the cap of 15. The whole per-file chain (and the one merged file) now lives in _text_for; for_device measures 9. Behavior is unchanged and the suite is green. Claude-Session: https://claude.ai/code/session_01UkWktkaYXZwdwzUkWktkaYXZwdwzUvZQrN9q
Review on #118: the docstring promised lines the device will refuse pass through untouched, but an `install <package>` line is keyed by package whether or not the rest parses, so a shadowed one is dropped. The docstring now says exactly that. A test pins both directions (the winning malformed line reaches the device; a shadowed one does not) and fails if scope precedence is reversed. Claude-Session: https://claude.ai/code/session_01UkWktkaYXZwdwzUvZQrN9q
Refs #109. Part of #122.
Why
Policies.for_devicefalls back per file: device, then role, then kith. That is right for most files, but forinstall-appsa role's file replaces the kith's, so "every device runs the current agent" cannot be said at the kith level. Any device with a role silently never sees it, and the failure is a device that never updates and says nothing. It showed while staging agent 76 for a roled device: the self-update line had to be repeated in every role file, forever.Summary
for_device for name in MANAGED_FILES skip wipe, reboot - device file, else role file, else kith file + _text_for(name) + install-apps: merge device, role, kith by package, most specific wins + every other file: device, else role, else kith (unchanged)AppInstallPolicy.read).installkeyword is case-insensitive, the package is not. Comments and blank lines pass through in scope order, and malformed lines are left for the device to refuse.Evidence
kith.install-appsandrole-x.install-appswas served only the role file.After:
cd server && uv run pytestgives 651 passed, includingtest_install_apps_merges_by_package_with_most_specific_scope_winning,test_install_apps_malformed_lines_are_keyed_by_package_like_any_other,test_install_apps_device_scope_beats_role_and_kith,test_install_apps_treats_package_case_exactly_like_the_agentandtest_other_files_still_replace_rather_than_merge.for_devicecyclomatic complexity measures 9 (14 on main; it was 22 before review, over the cap of 15), after moving the per-file chain into_text_for. Measured with radon.Merge Danger
Door: two-way. Reverting restores per-file replacement.
Blast Radius: fleet install policy. Today the live policy Secret holds
role-zippie.install-appsand one revoked device's file, and nokith.install-apps, so nothing served changes until a kith-level file is written. One consequence to know: a more specific scope can now override a package a broader scope names, but cannot withdraw it.Review
Three Grug Elder threads, answered. Two were misreads, verified against the agent:
"INSTALL".lower()does match the agent's case-insensitive keyword, and the agent keys its table by the raw package string, so normalizing case server-side would create the drift it feared (pinned by a test). The complexity finding was real and is fixed above.Acceptance criteria
install-appsline reaches a device that has a role file, unless the role names the same packageSize: S
Out of scope
A console for editing policy (#36). The directory remains the interface.
https://claude.ai/code/session_01UkWktkaYXZwdwzUvZQrN9q