Skip to content

fix(policy): merge install-apps by package across scopes - #118

Merged
quadseven merged 4 commits into
mainfrom
fix/install-apps-merge-109
Oct 10, 2026
Merged

quadseven merged 4 commits into
mainfrom
fix/install-apps-merge-109

Conversation

@quadseven

@quadseven quadseven commented Oct 2, 2026 •

Copy link
Copy Markdown
Owner

Refs #109. Part of #122.

Why

Policies.for_device falls back per file: device, then role, then kith. That is right for most files, but for install-apps a 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)
  • The merge emits one line per package. Concatenating scope files was rejected: the agent refuses a file that names a package twice (AppInstallPolicy.read).
  • Lines are keyed as the agent reads them: the install keyword 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.
  • A file absent from every scope stays absent. A merged-from-nothing empty file would read as "withdraw every install".

Evidence

  • Before: a roled device given kith.install-apps and role-x.install-apps was served only the role file.
    After: cd server && uv run pytest gives 651 passed, including test_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_agent and test_other_files_still_replace_rather_than_merge.
  • for_device cyclomatic 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-apps and one revoked device's file, and no kith.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

  • A kith-level install-apps line reaches a device that has a role file, unless the role names the same package
  • Per package, the most specific scope wins (kith, then role, then device)
  • The served file never names a package twice, and every other managed file keeps the per-file fallback

Size: S

Out of scope

A console for editing policy (#36). The directory remains the interface.

https://claude.ai/code/session_01UkWktkaYXZwdwzUvZQrN9q

`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.
@grug-tribe

grug-tribe Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Walkthrough

This PR changes how the install-apps policy file is served to devices: instead of the usual per-file fallback (where the most specific scope's file wholesale replaces the broader one), install-apps now merges across kith, role, and device scopes by package name, with the most specific scope winning per package. This lets a fleet-wide baseline like 'every device runs agent N' coexist with role-specific pins without forcing every role file to repeat the baseline line; it also avoids the sharp failure mode where a single role file silently replaced the entire kith install list. The change extracts per-file resolution into a helper, adds the merge logic, and keeps the original replace/fallback behavior for all other files. The agent remains the validator: the server does not second-guess install-line grammar, keys packages exactly as the agent does (raw string, case-insensitive keyword), and refuses to emit a package's line twice.

Review effort: moderate (~15 min)

Changed files (2)
File Summary Changes
server/muster/policy.py Adds package-aware merge for install-apps across scopes (kith → role → device), most-specific wins per package; refactors per-file resolution into _text_for and +127/-16
server/tests/test_policy.py Adds tests covering install-apps merge semantics: per-package most-specific wins, comment/blank passthrough, malformed-line handling, case sensitivity of packag +201/-0
Shape of the change
graph TD
  subgraph "server"
    N0["policy.py"]
    N1["test_policy.py"]
  end
Loading

Last walked at 6d194cfedfe4.

@grug-tribe grug-tribe Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grug Elder

Grug Elder gaze upon your PR · 3 finding(s)

Comment thread server/muster/policy.py
Comment thread server/muster/policy.py
Comment thread server/muster/policy.py Outdated
@grug-tribe

grug-tribe Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

Grug look hard. Trail clear.

fix(policy): merge install-apps by package across scopes

Grug Elder - Clear - no markings published - check `success`
  • Fast look only - deep look may add more.
  • Looked at: effde5fc..6d194cfe

…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

@grug-tribe grug-tribe Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grug Elder

Grug Elder gaze upon your PR · 1 finding(s)

Comment thread server/muster/policy.py
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
@quadseven
quadseven merged commit 6a5c805 into main Oct 10, 2026
9 of 10 checks passed
@quadseven
quadseven deleted the fix/install-apps-merge-109 branch October 10, 2026 17:50
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