P3-1: Config, policy overlay and branding #30

Closed
opened 2026-09-10 17:43:06 +00:00 by Cordy · 2 comments
Owner

First task of phase 3. Depends on the phase-2-integration milestone.

Goal

One config system that an end user can edit, an administrator can override, and a build can brand — with a clear, testable precedence order.

Files

  • Modify: internal/config/config.go
  • Create: internal/config/policy.go, internal/config/policy_windows.go, internal/config/policy_darwin.go, internal/config/policy_linux.go, internal/config/branding.go, internal/config/policy_test.go

Precedence — highest wins

1. MDM / GPO policy        (administrator, read-only to the user)
2. Admin config file       (system-wide, e.g. /etc/cairn/config.json)
3. User settings           (per-user config)
4. Build-time branding     (compiled defaults)

Where policy sets a value, the UI must render that field read-only with a visible "managed by your organisation" note. A field that silently ignores the user's edit is worse than one that refuses it.

Where policy comes from, per platform

  • Windows: registry, HKLM\SOFTWARE\Policies\SwissCairn\Cairn. This is what a GPO ADMX template writes to.
  • macOS: a managed preference domain (ch.swisscairn.cairn) written by an MDM configuration profile.
  • Linux: a JSON file at /etc/cairn/policy.json. There is no standard mechanism; a documented path is the honest answer.

Policy-controllable keys — at minimum

server · localPath · autoUpdateEnabled · errorReportingEnabled · concurrency · allowUserToChangeServer

autoUpdateEnabled and errorReportingEnabled are the two an enterprise will actually check for. Both must be switchable off fleet-wide (see docs/design-spec.md §8.1, §8.2).

Branding

Compile-time constants via -ldflags: application name, icon path, accent colour, default server URL. Free and unrestricted — deliberately unlike Nextcloud, which gates branding behind a paid tier. This is a stated competitive position, not an afterthought.

Steps

  • Write failing tests: full precedence chain resolves correctly for every layer; a policy-set field reports Managed == true; a missing policy source is not an error; branding defaults apply when nothing else sets a value.
  • Run; confirm failure.
  • Implement, with build-tagged policy readers.
  • Commit: git commit -s -m "feat(config): policy overlay and build-time branding"

Acceptance criteria

  • Precedence is proven by test at every layer.
  • Each config value exposes whether it is policy-managed, so the UI can grey it out.
  • A machine with no policy configured behaves exactly as before.
First task of phase 3. Depends on the `phase-2-integration` milestone. ## Goal One config system that an end user can edit, an administrator can override, and a build can brand — with a clear, testable precedence order. ## Files - Modify: `internal/config/config.go` - Create: `internal/config/policy.go`, `internal/config/policy_windows.go`, `internal/config/policy_darwin.go`, `internal/config/policy_linux.go`, `internal/config/branding.go`, `internal/config/policy_test.go` ## Precedence — highest wins ``` 1. MDM / GPO policy (administrator, read-only to the user) 2. Admin config file (system-wide, e.g. /etc/cairn/config.json) 3. User settings (per-user config) 4. Build-time branding (compiled defaults) ``` Where policy sets a value, the UI must render that field **read-only with a visible "managed by your organisation" note**. A field that silently ignores the user's edit is worse than one that refuses it. ## Where policy comes from, per platform - **Windows:** registry, `HKLM\SOFTWARE\Policies\SwissCairn\Cairn`. This is what a GPO ADMX template writes to. - **macOS:** a managed preference domain (`ch.swisscairn.cairn`) written by an MDM configuration profile. - **Linux:** a JSON file at `/etc/cairn/policy.json`. There is no standard mechanism; a documented path is the honest answer. ## Policy-controllable keys — at minimum `server` · `localPath` · `autoUpdateEnabled` · `errorReportingEnabled` · `concurrency` · `allowUserToChangeServer` `autoUpdateEnabled` and `errorReportingEnabled` are the two an enterprise will actually check for. Both must be switchable off fleet-wide (see `docs/design-spec.md` §8.1, §8.2). ## Branding Compile-time constants via `-ldflags`: application name, icon path, accent colour, default server URL. **Free and unrestricted** — deliberately unlike Nextcloud, which gates branding behind a paid tier. This is a stated competitive position, not an afterthought. ## Steps - [ ] Write failing tests: full precedence chain resolves correctly for every layer; a policy-set field reports `Managed == true`; a missing policy source is not an error; branding defaults apply when nothing else sets a value. - [ ] Run; confirm failure. - [ ] Implement, with build-tagged policy readers. - [ ] Commit: `git commit -s -m "feat(config): policy overlay and build-time branding"` ## Acceptance criteria - Precedence is proven by test at every layer. - Each config value exposes whether it is policy-managed, so the UI can grey it out. - A machine with no policy configured behaves exactly as before.
Author
Owner

Amendment — 2026-09-14: phase-3 pre-flight rulings for #30

These rulings come from the phase-3 pre-flight survey. The owner approved posting them and may veto any of them. They bind this issue.

  • Key set (P3-R2). Implement the union of the keys the other issues need. Spell them identically in policy.json, the admin config.json and the macOS managed plist, and (through #38) as the ADMX value names:
    server, localPath, autoUpdateEnabled, errorReportingEnabled, concurrency, allowUserToChangeServer, autostartEnabled, locale.

  • Managed contract (P3-R3). The resolved config reports a Source for each key, in the order Branding < User < Admin < Policy. Managed(key) is true exactly when that source is Policy. Keys are exported constants. #33, #34, #35, #36 and #41, and #51's read-only form variant, consume only this contract.

  • Admin config paths (P3-R4, proposed; confirm them in this issue).

    • Linux: /etc/cairn/config.json
    • Windows: %ProgramData%\SwissCairn\Cairn\config.json (the #38 MSI writes this file)
    • macOS: /Library/Application Support/SwissCairn/Cairn/config.json

    A machine with none of these files behaves exactly as it does today, and cairnsync's precedence is unchanged.

  • macOS policy without cgo (P3-R5). Read /Library/Managed Preferences/ch.swisscairn.cairn.plist and /Library/Managed Preferences/<user>/ch.swisscairn.cairn.plist with a pure-Go plist decoder. The decoder must build with CGO_ENABLED=0. Do not use defaults read, which cannot tell a forced value from a user-set one.

  • Dependencies (P3-R22). go.mod stays at go 1.25 with no toolchain line, and go mod tidy -diff stays clean.

  • Closing (D4). Two checks cannot run here yet: Windows registry reads at runtime, and a real MDM or GPO round trip. The policy code is cross-built and vetted for GOOS=windows. The closing comment records both runtime checks as an open checklist, to be run once the Windows runner (#19) and a test domain (see #38) exist.

Posted by Claude on behalf of @Cordy: phase-3 pre-flight, owner-approved process; the owner may veto any point.

## Amendment — 2026-09-14: phase-3 pre-flight rulings for #30 These rulings come from the phase-3 pre-flight survey. The owner approved posting them and may veto any of them. They bind this issue. - **Key set (P3-R2).** Implement the union of the keys the other issues need. Spell them identically in `policy.json`, the admin `config.json` and the macOS managed plist, and (through #38) as the ADMX value names: `server`, `localPath`, `autoUpdateEnabled`, `errorReportingEnabled`, `concurrency`, `allowUserToChangeServer`, `autostartEnabled`, `locale`. - **Managed contract (P3-R3).** The resolved config reports a `Source` for each key, in the order Branding < User < Admin < Policy. `Managed(key)` is true exactly when that source is Policy. Keys are exported constants. #33, #34, #35, #36 and #41, and #51's read-only form variant, consume only this contract. - **Admin config paths (P3-R4, proposed; confirm them in this issue).** - Linux: `/etc/cairn/config.json` - Windows: `%ProgramData%\SwissCairn\Cairn\config.json` (the #38 MSI writes this file) - macOS: `/Library/Application Support/SwissCairn/Cairn/config.json` A machine with none of these files behaves exactly as it does today, and cairnsync's precedence is unchanged. - **macOS policy without cgo (P3-R5).** Read `/Library/Managed Preferences/ch.swisscairn.cairn.plist` and `/Library/Managed Preferences/<user>/ch.swisscairn.cairn.plist` with a pure-Go plist decoder. The decoder must build with `CGO_ENABLED=0`. Do not use `defaults read`, which cannot tell a forced value from a user-set one. - **Dependencies (P3-R22).** go.mod stays at `go 1.25` with no toolchain line, and `go mod tidy -diff` stays clean. - **Closing (D4).** Two checks cannot run here yet: Windows registry reads at runtime, and a real MDM or GPO round trip. The policy code is cross-built and vetted for `GOOS=windows`. The closing comment records both runtime checks as an open checklist, to be run once the Windows runner (#19) and a test domain (see #38) exist. _Posted by Claude on behalf of @Cordy: phase-3 pre-flight, owner-approved process; the owner may veto any point._
Author
Owner

Done

  • bf03c8f feat(config): policy overlay and build-time branding
  • c39f189 feat(config): Locked marks keys the admin config or policy sets
  • 67b5ac2 fix(config): accept only an absolute localPath, and expand REG_EXPAND_SZ

What was built

  • Four-layer resolver (Branding < User < Admin < Policy) with a Source/Managed/Locked contract per key, built on eight exported Key constants spelled once (P3-R2, P3-R3).
  • Build-tagged policy readers: Linux /etc/cairn/policy.json, Windows HKLM\SOFTWARE\Policies\SwissCairn\Cairn, macOS device + per-user managed preferences via a pure-Go, cgo-free plist decoder (P3-R5).
  • Admin config paths confirmed per OS (P3-R4); cairnsync's own precedence untouched.
  • Free -ldflags branding (name, icon, accent colour, default server).
  • localPath is now checked absolute from every layer, and Windows REG_EXPAND_SZ values are expanded (fix round 1, F2).

Tests

  • internal/config: full suite green, incl. TestResolvePrecedenceAtEveryLayer (8 keys × 8 layer subsets), TestAPolicySetFieldReportsManaged, TestAMissingPolicySourceIsNotAnError, TestBrandingDefaultsApplyWhenNothingElseSetsAValue, TestAnAdminOrPolicySetFieldIsLocked, TestTheKeysAreTheEightOfTheRuling, plus JSON/plist/registry fixture tests.
  • Mutation-tested (8 mutations in round 1, 1 in the F1 fix) — every mutant caught.
  • CI on 67b5ac2, both green on arm64:
    • ci.yml run #37go vet/go test ./..., coverage total: 90.5%.
    • interop.yml run #38 — full integration suite against live cairnd, all PASS.

Acceptance criteria

  • Precedence proven by test at every layer — met: the three tests above.
  • Each value exposes whether it's policy-managed — met via Source/Managed; Locked added (fix round 1) so UI editability gates on Locked, not Managed alone (P3-R3 amendment).
  • A machine with no policy behaves exactly as before — met: TestAMissingPolicySourceIsNotAnError, TestLoadSettingsOnAMachineWithNoPolicy.
  • P3-R2/R4/R5/R22 — met as specified; go mod tidy -diff clean, go.mod stays go 1.25.

Rulings

  • P3-R2: the eight-key union, one spelling everywhere.
  • P3-R3: Source/Managed contract, amended this round to add Locked — consumers (#33–#36, #41, #51) gate editability on Locked, never Managed alone.
  • P3-R4: admin config paths confirmed as proposed.
  • P3-R5: macOS policy reader stays cgo-free, pure-Go plist decoder.
  • P3-R22: dependencies stay on go 1.25, go mod tidy -diff clean.
  • D4: closing recorded with an open checklist (below) since the Windows runner and a test domain don't exist yet.

D4 checklist (unverified here, tracked forward)

  • Windows registry runtime round trip — first Windows host (#19/#38/CV1).
  • macOS MDM profile round trip, incl. per-user override and folder permissions — #39 or an owner-installed test profile (CV2).
  • GPO/ADMX round trip, incl. REG_DWORD encoding — #38's test domain (CV3).

Deferred
F3–F7 from the first review (BSD build regression, JSON null, user-layer strictness, plist panic recovery, branded-server checks) remain open, plus two new minor findings from this round (Windows registry tests compile-checked but not run; REG_EXPAND_SZ expansion applies by value type, not by key). None blocks this issue; all are tracked for the final review.

Implemented and reviewed by Claude (subagent-driven), landed on main after review and green CI.

**Done** - [bf03c8f](http://192.168.10.245/Cordy/cairn-desktop/commit/bf03c8fa9bbbf31e0eea2d7bf09648ab19a7218d) feat(config): policy overlay and build-time branding - [c39f189](http://192.168.10.245/Cordy/cairn-desktop/commit/c39f189bb8d1e68a631e200638daa07261cff470) feat(config): Locked marks keys the admin config or policy sets - [67b5ac2](http://192.168.10.245/Cordy/cairn-desktop/commit/67b5ac279e0de07010d694599de9f03f9690d78d) fix(config): accept only an absolute localPath, and expand REG_EXPAND_SZ **What was built** - Four-layer resolver (`Branding < User < Admin < Policy`) with a `Source`/`Managed`/`Locked` contract per key, built on eight exported `Key` constants spelled once (P3-R2, P3-R3). - Build-tagged policy readers: Linux `/etc/cairn/policy.json`, Windows `HKLM\SOFTWARE\Policies\SwissCairn\Cairn`, macOS device + per-user managed preferences via a pure-Go, cgo-free plist decoder (P3-R5). - Admin config paths confirmed per OS (P3-R4); cairnsync's own precedence untouched. - Free `-ldflags` branding (name, icon, accent colour, default server). - `localPath` is now checked absolute from every layer, and Windows `REG_EXPAND_SZ` values are expanded (fix round 1, F2). **Tests** - `internal/config`: full suite green, incl. `TestResolvePrecedenceAtEveryLayer` (8 keys × 8 layer subsets), `TestAPolicySetFieldReportsManaged`, `TestAMissingPolicySourceIsNotAnError`, `TestBrandingDefaultsApplyWhenNothingElseSetsAValue`, `TestAnAdminOrPolicySetFieldIsLocked`, `TestTheKeysAreTheEightOfTheRuling`, plus JSON/plist/registry fixture tests. - Mutation-tested (8 mutations in round 1, 1 in the F1 fix) — every mutant caught. - CI on `67b5ac2`, both green on **arm64**: - `ci.yml` run [#37](http://192.168.10.245/Cordy/cairn-desktop/actions/runs/37) — `go vet`/`go test ./...`, coverage `total: 90.5%`. - `interop.yml` run [#38](http://192.168.10.245/Cordy/cairn-desktop/actions/runs/38) — full integration suite against live `cairnd`, all PASS. **Acceptance criteria** - *Precedence proven by test at every layer* — met: the three tests above. - *Each value exposes whether it's policy-managed* — met via `Source`/`Managed`; `Locked` added (fix round 1) so UI editability gates on `Locked`, not `Managed` alone (P3-R3 amendment). - *A machine with no policy behaves exactly as before* — met: `TestAMissingPolicySourceIsNotAnError`, `TestLoadSettingsOnAMachineWithNoPolicy`. - P3-R2/R4/R5/R22 — met as specified; `go mod tidy -diff` clean, go.mod stays `go 1.25`. **Rulings** - P3-R2: the eight-key union, one spelling everywhere. - P3-R3: `Source`/`Managed` contract, amended this round to add `Locked` — consumers (#33–#36, #41, #51) gate editability on `Locked`, never `Managed` alone. - P3-R4: admin config paths confirmed as proposed. - P3-R5: macOS policy reader stays cgo-free, pure-Go plist decoder. - P3-R22: dependencies stay on go 1.25, `go mod tidy -diff` clean. - D4: closing recorded with an open checklist (below) since the Windows runner and a test domain don't exist yet. **D4 checklist (unverified here, tracked forward)** - [ ] Windows registry runtime round trip — first Windows host (#19/#38/CV1). - [ ] macOS MDM profile round trip, incl. per-user override and folder permissions — #39 or an owner-installed test profile (CV2). - [ ] GPO/ADMX round trip, incl. REG_DWORD encoding — #38's test domain (CV3). **Deferred** F3–F7 from the first review (BSD build regression, JSON `null`, user-layer strictness, plist panic recovery, branded-server checks) remain open, plus two new minor findings from this round (Windows registry tests compile-checked but not run; `REG_EXPAND_SZ` expansion applies by value type, not by key). None blocks this issue; all are tracked for the final review. _Implemented and reviewed by Claude (subagent-driven), landed on main after review and green CI._
Cordy closed this issue 2026-09-14 04:09:43 +00:00
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/cairn-desktop#30
No description provided.