Cosmix Specification Suite v0.4.2
‹ 0912 ›cosmix.dev ↗
10Substrate
Chapter 10

Cosmix Daemon Identity — UID/GID Registry, Filesystem Layout, and Install-Time Verification

version 1.4.4status stabledated 2026-08-08layer Substrate

Every Cosmix daemon has a stable, registry-pinned POSIX identity. The registry is canonical and append-only; the daemon-identity and shared-credential streams never reuse a retired number, while the citizen-identity stream (v1.2.0) permits scoped, gated reuse under automated mesh-wide purge-verification plus a quarantine window. Identity is verified at install time and fails closed on conflict. The filesystem layout under that identity is uniform: one parent tree, one leaf per daemon, with substrate-shared state held separately from daemon-owned state.

This specification governs the POSIX-level identity of Cosmix daemons — which UIDs and GIDs they run under, where their state and configuration live on disk, how those identities are provisioned at install time, and how they are verified to be correct. It is the foundation on which the substrate's hardening and isolation guarantees rest.

The constitution (Ch 00) governs autonomy. SPEC 07 (self-aware) governs observability. This SPEC governs identity: who the daemon is, where it lives, and how it is created. Without stable identity the constitution cannot trace audit trails to a principal, and the self-aware layer cannot reason about which paths a daemon has authority over.

The keywords MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL in this document are to be interpreted as described in RFC 2119.


1. Introduction

1.1 Purpose

This SPEC defines:

  1. The numeric range Cosmix daemons occupy in the system UID/GID space.
  2. The initial registry of daemon names to UID/GID assignments.
  3. The rules by which the registry grows, changes, and tombstones entries.
  4. The filesystem layout under which daemons own state.
  5. The sysusers.d fragment that materializes the registry.
  6. The systemd unit directives every daemon's service file MUST carry.
  7. The install-time preflight that verifies identity before service start.
  8. The conformance levels and CI lint shape that prevent drift.

1.2 Scope

This SPEC applies to every long-running Cosmix daemon that registers as a service on the node-local ABP broker (cosmix-noded) or runs under systemd as a system unit. It does not apply to:

  • Session-scoped processes (§7) — compositors, launchers, and other components bound to a logged-in human seat.
  • Build-time identities — the UID under which cargo build runs is intentionally unspecified; see §1.3.
  • Per-tenant vhost identities — NetServa NS 3.0 vhost users at UID ≥ 1000 are governed by NetServa, not by this SPEC.

Every daemon in scope is a full ABP citizen by default: it SHALL connect to the node-local broker, register its service name, and expose its control surface over ABP verbs — there is no "edge daemon, no ABP" exemption, and no custom IPC/control-file/signal-based control surface may substitute for the ABP surface. (Promoted 2026-07-23 from the all-daemons-amp-citizens decision; rationale in _decisions/2026-04-27-core-and-citizen-crate-pattern.md.)

1.3 Non-goals

  • A globally reserved UID range. Cosmix does not own any portion of the POSIX system UID space by fiat. The 500–599 window is a preferred allocation on Cosmix-managed hosts; conflicts with non-Cosmix reservations are detected and resolved at install time, not assumed away.
  • A standard build identity. A public clone of the Cosmix repository builds under whatever UID the operator chooses. This SPEC governs deployed identity, not development identity.
  • Universal hardening. Per-daemon systemd.exec(5) hardening (ProtectSystem=, RestrictAddressFamilies=, SystemCallFilter=) is REQUIRED but daemon-specific; this SPEC mandates the directives every unit MUST carry, not the full hardening profile.

1.4 Terminology

TermDefinition
registryThe canonical mapping of daemon names to UID/GID values, defined in §2 and Appendix A.
canonical sourceThe Markdown table in this SPEC and Appendix A. The sysusers.d fragment is generated from this source (§4).
daemon leafA directory of the form /etc/cosmix/<d>/, /var/lib/cosmix/<d>/, etc., owned by cosmix-<d>.
substrate-shared stateDirectories under /var/lib/cosmix/ that are not daemon-leaves, owned by root:root, and accessed by daemons through explicit ABP contracts (§3.4).
tombstoneA registry entry whose UID/GID is permanently retired from allocation but kept in the spec for audit (§2.4). Applies to daemon-identity and shared-credential entries only; the citizen-identity stream uses the retire→quarantine→reclaim lifecycle (§2.5) instead of tombstoning.
citizen-identity entryA registry entry (v1.2.0) with the same shape as a daemon-identity entry (POSIX cosmix-<d> user + same-numbered group, GID==UID, ABP service name = name minus cosmix-) but allocated in the dedicated citizen band and governed by the scoped, gated reuse rule (§2.3 R7/R8, §2.5) rather than the strict daemon no-reuse rule. It is the SPEC-10 identity of a registered serve-mode Mix citizen (SPEC 18 §2).
citizen bandThe inclusive UID/GID window 600–699, the citizen-identity stream's preferred fixed-ID window (§2.1). Disjoint from the 500–599 daemon-identity/shared-credential window.
quarantine windowThe fixed interval (30 days, §2.3 R8) that MUST elapse after a citizen-identity entry is retired: before its UID is eligible to re-enter the free pool, in addition to (not instead of) purge-verification passing.
purge-verificationThe automated, mesh-wide check (§2.3 R8) that a retired citizen UID owns no file, no enabled/started unit, and no leftover state-directory leaf on any node that ever projected it, and that the cosmix-<d> projection has been removed everywhere. It is R2.a's conjunctive check set, generalized and automated because the citizen class has a uniform machine-checkable footprint.
preflightThe install-time procedure (§6) that verifies every registry entry is either free or already correctly assigned, before any service is started.
fail closedAn install-time check that, on any deviation, halts installation rather than auto-correcting. The operator MUST resolve the conflict manually.
session-scoped processA Cosmix process that runs under the logged-in human user via a user systemd unit, not as a system daemon (§7).

2. UID/GID Registry

2.1 The preferred allocation window

Cosmix daemons are allocated UIDs and GIDs in the inclusive range 500–599 on Cosmix-managed hosts. This range is the preferred fixed-ID window, not a globally reserved range.

The window was chosen empirically over the period 2026-05-08 across the mesh node distributions (Debian 13, Arch Linux). Appendix B records the survey of known distro UID reservations in 100–999 and the rationale for selecting the 500-anchor; see Appendix C for the worked comparison against alternative ranges.

UIDs in 500–599 that are not yet allocated to a Cosmix daemon are available for future Cosmix allocation, subject to install-time preflight (§6). They are not reserved against external packages. An operator who installs an unrelated package that pins a UID in 500–599 SHALL resolve the conflict at install time (§6.1); the substrate does not silently work around it.

The citizen band is the inclusive range 600–699 (100 slots), introduced in v1.2.0 as the citizen-identity stream's preferred fixed-ID window. It is disjoint from the 500–599 daemon-identity / shared-credential window: no number is ever shared across the two windows, which makes the daemon and citizen streams trivially separable in getent passwd | grep cosmix- output (a UID ≥ 600 is a citizen; a UID 500–599 is a daemon or a shared-credential group). The citizen band carries the same posture as 500–599 — preferred, not globally reserved: it is not owned by fiat, conflicts are detected and resolved at install-time preflight (§6) and fail closed, and an operator who installs an unrelated package pinning a UID in 600–699 SHALL resolve the conflict at install time. The 600–699 window was selected because Appendix C already records it as surveyed clean ("600 | 600–699 | No known reservations | Clean"); a confirmatory re-survey on 2026-05-16 (Appendix B.4) found no new reservations. This satisfies the R5 obligation that a new window rest on a fresh empirical survey rather than on the 500-anchor survey alone. Allocation within the band is governed by R7/R8 (§2.3), not by R1.

2.2 Initial registry

The initial allocation, as of this SPEC's publication, is given in Appendix A. As a summary:

UID/GIDPOSIX nameABP serviceRole
500cosmix-nodednodedPer-node ABP broker / mesh peer
501cosmix-maildmaildMail daemon
502cosmix-webdwebdWeb daemon
503cosmix-indexdindexdKnowledge / vector index daemon
504cosmix-agentdagentdAgent runtime
505cosmix-mcpmcpMCP bridge
506cosmix-dnsddnsdAuthoritative WG-mesh DNS daemon (R2.a-reclaimed cloudd slot)
507cosmix-croncronScheduler
508cosmix-prometheusprometheusObservability tier: Prometheus time-series store (v1.4.0)
509cosmix-grafanagrafanaObservability tier: Grafana dashboards (v1.4.0)
510 (GID-only)cosmix-tlsShared-credential group: TLS keypair read access (§3.3)
516 (GID-only)cosmix-meshShared-credential group: signed-inventory read access (§3.3) (v1.4.2)
511cosmix-lokilokiObservability tier: Loki log store (v1.4.0)
512cosmix-alloyalloyObservability tier: Grafana Alloy log shipper, per-node (v1.4.0)
513cosmix-pveexportpveexportObservability tier: proxmox-exporter (Starttoaster, Go), PVE API scraper (v1.4.0)
514cosmix-nodeexportnodeexportObservability tier: node_exporter (Prometheus, Go), per-host OS metrics (v1.4.1)
515cosmix-wgdwgdWireGuard mesh control plane (SPEC-13 D0) (v1.4.2)
517cosmix-interactdinteractReserved interaction namespace/props identity; desktop sink runs in-session (§7.3) (v1.4.3, explicit R6 override)
518cosmix-nspawndnspawndnspawn host executor: generation-fenced CT lifecycle (nspawn cluster-lite C1) (v1.4.4)
600cosmix-statecachestatecacheCitizen-identity: SPEC-18 reference citizen (§2.5)

cosmix-noded registers under the ABP service name noded, matching the binary name. The historical alias hub was removed in the 2026-05-09 cosmix-noded rename — the substrate no longer has a centralised "hub" role; every mesh node runs its own cosmix-noded and peers as equals.

The registry has three entry classes:

  • Daemon-identity entries (the cosmix-<d> rows in 500–599): pair a POSIX user with a same-numbered group. GID MUST equal UID for every daemon-identity entry. This is enforced by the CI lint defined in §8.2 and by systemd-sysusers when only a u line is given. Governed by the strict no-reuse rules R1–R6 (§2.3).
  • Shared-credential group entries (the cosmix-tls row, and any future rows of the same shape): a POSIX group with no associated user. Shared-credential GIDs are allocated within the same 500–599 window as a separate append-only stream from the daemon-identity stream, starting at GID 510 and continuing upward. The two streams SHALL NOT collide on a single number, but each advances independently — the daemon-identity stream's "next free UID" pointer is not bumped when a shared-credential entry is added, and vice versa. The GID==UID rule does not apply because there is no user. The 500–509 range is reserved for the daemon-identity stream so the two streams stay visually separable in getent group | grep cosmix- output (entries up to 509 are always daemons; entries from 510 may be either, distinguished by whether a same-numbered user exists). These entries exist to mediate credential read access shared by two or more daemons (TLS material, secret bundles) without coupling daemon identities to one another. They are not ABP services and have no ABP service name (the amp column is ).
  • Citizen-identity entries (the cosmix-<d> rows in the 600–699 citizen band, v1.2.0): pair a POSIX user with a same-numbered group, exactly the same shape as a daemon-identity entry — GID MUST equal UID, R6's two-name split applies (POSIX cosmix-<d> + prefix-stripped ABP service name), and R3's tier-neutral numbering applies. They differ from daemon-identity entries in two respects only: (1) they are allocated in the disjoint citizen band (§2.1), forming a third separate append-only stream whose "next free UID" pointer advances independently of the daemon-identity and shared-credential streams and never collides with either; and (2) their UID may be reclaimed under the scoped, gated reuse rule R7/R8 (§2.3) and the retire→quarantine→reclaim lifecycle (§2.5), instead of the strict daemon no-reuse/tombstone rule (R2, §2.4). A citizen-identity entry is the SPEC-10 identity of a registered serve-mode Mix citizen (SPEC 18 §2); the unregistered transient Mix-citizen form (SPEC 18 §2) consumes no registry slot and is out of scope for this registry. The reuse exception is confined to this class: it SHALL NOT be read as relaxing R2/R2.a for daemon-identity entries or the no-reuse posture of the shared-credential stream.

Shared-credential groups SHALL NOT be used to grant write access across daemon identities; per §3.4 substrate-shared writable state is mediated by the §3.4 mechanisms (ABP contract or supplemental write group), not by general-purpose shared groups in this section.

2.3 Allocation rules

The following rules govern the registry over time. They are normative. R1–R6 govern the daemon-identity stream (and, where stated, shared-credential entries). R7–R8 govern the citizen-identity stream (§2.2, v1.2.0). R3 (tier-neutral numbering) and R6 (the two-name split) additionally bind citizen-identity entries by §2.2's "same shape" clause; R1/R2/R2.a/R4/R5 do not apply to the citizen stream, which has its own allocation and reuse rules (R7/R8). No rule below relaxes any pre-v1.2.0 constraint on the daemon-identity or shared-credential streams.

R1. New daemons SHALL be allocated the next free UID in the 500–599 daemon-identity window in append-only order. Existing allocations SHALL NOT be reordered, renumbered, or compacted. The sole exception is an R2.a-reclaimed UID, which is allowed to sit numerically below the current append-only frontier (see R2.a below); such a UID re-enters the free pool and is preferred over the next sequential UID for the next eligible daemon allocation. R1 does not govern the citizen band (600–699); see R7.

R2. UIDs SHALL NOT be reused, with one exception. When a daemon is retired (renamed, merged into another daemon, or removed entirely), its UID becomes a tombstone (§2.4) and SHALL NOT be assigned to any future daemon.

Exception (R2.a — never-functional reclamation). A UID that was allocated to a daemon which was retired without that daemon ever becoming functional on any mesh node MAY be reclaimed: the allocation is fully removed from the registry (no tombstone) and re-enters the free pool, where per R1 it is preferred over the next sequential UID for the next eligible daemon allocation (i.e. allowed out of append-order).

The substantive precondition is the absence of on-disk state owned by the retired UID — that is the ambiguity R2 exists to prevent. A passwd/group projection alone (the sysusers fragment having created the user on one or more nodes) does NOT block reclamation, but it SHALL be cleaned up before the new fragment promotes (see remediation below). The conditions are conjunctive — all of the following SHALL hold for the operator to invoke R2.a:

  • No file on any mesh node is owned by the retired UID or its group. This SHALL be verified by sudo find / -uid <N> -o -gid <N> on every node that ever ran systemd-sysusers against a fragment containing the retired entry. Empty output on every node is required.
  • No systemd unit, ABP-registered service, on-disk daemon-state directory, or unit-managed StateDirectory=/RuntimeDirectory= ever ran in production under the retired UID. (Allocations that were never wired up to a unit, or whose unit was never enabled or started, satisfy this trivially.)
  • Where a node has projected the retired cosmix-<d> user from a prior sysusers fragment, the operator SHALL userdel cosmix-<d> (which also removes the same-numbered group via USERGROUPS_ENAB, per Debian/Ubuntu default; groupdel cosmix-<d> is the explicit fallback) on every such node BEFORE promoting the new fragment. This is the remediation step that closes the gap between the registry and the live getent passwd|group state, restoring the invariant before file ownership has a chance to drift.
  • The reclamation commit message records the R2.a invocation, names the retired daemon and its UID, and (where remediation was needed) lists the nodes on which userdel was run, so the audit trail survives even though the registry row does not.

The justification for R2.a is that the no-reuse invariant exists to prevent file-ownership ambiguity after a chown-style sweep — ambiguity that cannot arise when no host has files owned by the retired UID. A sysusers projection that created the passwd entry but never had any file, unit, or service materialise under it is not the failure mode R2 guards against; the projection is removable with userdel and leaves no residue. UIDs of daemons that did become functional in production — that owned files, ran units, or registered ABP services — SHALL continue to follow the strict tombstone rule.

R3. Tier or category (substrate, application, UI plumbing, etc.) SHALL NOT influence the numeric allocation. Tier MAY appear as metadata in the Markdown registry but SHALL NOT be encoded in the numeric ordering. Architecture taxonomy is mutable; UID numbers are not.

R4. When two daemons merge, the merged daemon SHOULD inherit the UID of whichever predecessor owns more on-disk state, to avoid a filesystem-wide chown sweep. The other predecessor's UID becomes a tombstone.

R5. When the next free daemon-identity UID would exceed 599, the SPEC SHALL be amended (per Constitution Article VII) to either (a) extend the window or (b) move to a new window after a fresh empirical survey of upstream UID reservations. The operator SHALL NOT silently allocate beyond 599. (v1.2.0 invoked R5(b)'s "fresh empirical survey" discipline to open the separate 600–699 citizen window — §2.1, Appendix B.4 — even though the daemon stream was not exhausted; the citizen band's own exhaustion is handled by R7, not R5.)

R6. Each daemon-identity entry has two distinct names. (Shared-credential group entries per §2.2 have only the POSIX/systemd group name and no ABP service name, because they are credential boundaries rather than process identities.)

  • The POSIX/systemd name SHALL begin with the prefix cosmix- and SHALL match the regex ^cosmix-[a-z][a-z0-9-]{1,30}$. This name is used for User=, Group=, the systemd unit file (cosmix-<d>.service), the sysusers.d entry, and on-disk paths (/etc/cosmix/<d>/, /var/lib/cosmix/<d>/). The portion after the cosmix- prefix is the registry's <d> token.
  • The ABP service name is the same <d> token without the cosmix- prefix (e.g. noded, maild, webd, indexd). It is the name the daemon registers with under its node-local cosmix-noded broker. The ABP service name SHALL match ^[a-z][a-z0-9-]{1,30}$ and SHALL be unique across the registry. Because a registered serve-mode Mix citizen registers on the same node-local broker as a daemon, ABP-name uniqueness is enforced across the union of non-tombstoned daemon-identity entries and non-retired/non-reclaimed citizen-identity entries: a citizen ABP name SHALL NOT collide with a daemon ABP name or with another live citizen ABP name (CI lint L14, §8.2).

Existing ABP service names that diverge from this derivation (historical aliases, multi-service daemons) SHALL be recorded explicitly in the registry's amp: field per entry; absence of an amp: field means the ABP service name is the registry name minus cosmix-.

R7. (Citizen allocation — citizen-identity stream only.) New citizens SHALL be allocated a UID/GID in the 600–699 citizen band. The allocation order is the lowest free UID in the band, where the free pool is the union of (a) UIDs in 600–699 never allocated to any citizen and (b) UIDs whose only remaining registry rows are reclaimed: rows (R8). Append-only ordering does not bind citizen allocation — a reclaimed UID re-enters the pool and may be re-allocated below the current frontier. The safety mechanism is the R8 reuse gate, not numeric monotonicity. Within never-reclaimed citizen rows the canonical source SHALL remain append-only: a row that has not been reclaimed SHALL NOT be reordered, renumbered, or removed (it is retired: or live, never silently deleted). The citizen stream's "next free UID" pointer advances independently of the daemon-identity and shared-credential pointers and SHALL NOT collide with either window (the bands are disjoint by §2.1, so the non-collision is structural; the CI lint asserts it as defence-in-depth).

R8. (Citizen reuse gate — citizen-identity stream only.) A retired citizen UID N re-enters the free pool (R7) if and only if BOTH of the following hold; the conditions are conjunctive and neither alone is sufficient:

  • (a) Mesh-wide purge-verification passes. On every mesh node that ever projected the entry (ran systemd-sysusers against a fragment containing cosmix-<d>), all of the following SHALL hold: sudo find / -uid N -o -gid N produces empty output; no enabled or started systemd unit references the retired identity; the per-name StateDirectory=/RuntimeDirectory=/LogsDirectory= leaves (/var/lib/cosmix/<d>/, /run/cosmix/<d>/, /var/log/cosmix/<d>/) are absent; the citizen's /usr/local/lib/cosmix/<d>.mix script is absent; and the cosmix-<d> projection has been userdel'd (group via USERGROUPS_ENAB, groupdel fallback). This is R2.a's conjunctive check set, automated and generalized to functional citizens.
  • (b) The 30-day quarantine window has elapsed. At least 30 days SHALL have passed since the entry's retired: date before its UID is eligible, even if (a) already passes. The window covers eventually-consistent mesh state and nodes that were offline during the verification sweep.

When BOTH hold, the operator SHALL annotate the retired row with a reclaimed: <date> attribute and a verifier: <record> token (the per-node purge-verification run record), leaving the row in the canonical source for audit (it materializes as a comment only — §4.3, §2.5). The reclamation change message SHALL record the R8 invocation, the citizen name and UID, the retired: and reclaimed: dates, and the per-node verifier run records. R8 SHALL NOT auto-correct, auto-allocate, or bypass either gate; a tooling-unavailable or inconclusive purge-verification is a non-pass and the UID stays in quarantine.

Justification for R7/R8 (why scoped reuse is sound for citizens but not daemons). The no-reuse invariant (R2) exists to prevent file-ownership ambiguity after a chown-style sweep. For the daemon-identity class that ambiguity is unbounded — daemons have heterogeneous, spec-specific on-disk footprints — so daemons stay strictly no-reuse with only the narrow, manual R2.a escape for never-functional allocations. The citizen-identity class is different in kind: every registered serve-mode Mix citizen has the same machine-enumerable footprint (exactly one /usr/local/lib/cosmix/<d>.mix, exactly one per-name StateDirectory=cosmix/<d> leaf, exactly one cosmix-<d>.service unit — SPEC 18 §2, §9). "No file, unit, or state owned by N on any node" is therefore an automatable, exhaustive predicate for citizens in a way it is not for daemons, which is precisely why R8 may generalize and automate R2.a for functional citizens whereas R2.a must stay manual and never-functional-only for daemons. The 30-day quarantine adds defence-in-depth against eventually-consistent and offline-node state that a single sweep could miss. The pair (automated exhaustive purge-verify) ∧ (quarantine) reconstructs exactly the safety property R2 protects — which is why bounded scoped reuse is sound for the citizen band specifically and is not extended to the daemon-identity or shared-credential streams. This also keeps the 506/cloudd daemon-stream question orthogonal to the citizen band: the SPEC-18 reference citizen is a citizen-band entry and never touches the 500–599 stream. The 506/cloudd question is itself resolved by the v1.3.0 amendment — cosmix-dnsd consumes the R2.a-reclaimed 506 slot as the next eligible daemon allocation (R1's "preferred over the next sequential UID" rule for an R2.a-reclaimed UID; Appendix A, Appendix D 1.3.0). No daemon-stream gap remains; the append-only frontier is unchanged at 508 because the R2.a slot sits below the frontier (R1).

2.4 Tombstones

Tombstoning applies to daemon-identity and shared-credential entries only. Citizen-identity entries are never tombstoned; their retirement is governed by the retire→quarantine→reclaim lifecycle in §2.5 (the citizen-class analogue of a tombstone is a permanently-retired:, never-reclaimed: row).

A tombstoned entry remains in the registry with a tombstoned: <date> attribute and a reason: field. Tombstones are visible in the generated sysusers.d fragment as comments only — they SHALL NOT materialize as system users on installed hosts.

The CI lint (§8.2) SHALL reject any change that:

  • removes a tombstone entry from the canonical source,
  • assigns a tombstoned UID to a new daemon, or
  • silently re-uses a tombstoned name without an explicit successor: pointer.

2.5 Citizen retirement, quarantine, and reclamation

A citizen-identity entry (§2.2) moves through up to three lifecycle states. The state is carried as an attribute on the canonical Appendix A row and determines how the entry projects into the generated sysusers.d fragment (§4.3):

StateRegistry attributessysusers projectionUID reusable?
live (default)none (no retired:/reclaimed:)u cosmix-<d> <uid> … (materializes)No — it is in use
retiredretired: <date>comment # quarantine: … (does not materialize)No — in quarantine (R8)
reclaimedretired: <date> + reclaimed: <date> + verifier: <record>comment # reclaimed: … (does not materialize)Yes — back in the free pool (R7)

Transitions:

  1. live → retired. The operator decommissions the citizen (removes its unit, stops/disables it, deletes its /usr/local/lib/cosmix/<d>.mix). The Appendix A row gains retired: <date>; the row is kept (never deleted — R7) and stops materializing as a u line. The 30-day quarantine clock (R8(b)) starts at <date>.
  2. retired → reclaimed. Only when both R8 gates pass (automated mesh-wide purge-verification and the 30-day window). The operator adds reclaimed: <date> and verifier: <record>; the UID re-enters the citizen free pool (R7). The row remains in Appendix A as an audit record (comment-only projection).
  3. (re-)allocation. A reclaimed UID may be assigned to a new citizen by appending a new live row with the same UID and a different name. The prior reclaimed: row(s) for that UID are retained for audit. The CI lint (§8.2 L5) enforces that at most one non-reclaimed row (live or retired) exists per citizen UID at any time, and that every reclaimed: row carries both a reclaimed: date and a verifier: token.

A row that is retired: but has never satisfied R8 stays in quarantine indefinitely; it is the citizen-class equivalent of a tombstone and SHALL NOT be reclaimed until both gates pass. There is no "never-functional" citizen short-cut analogous to R2.a — the uniform-footprint purge-verification (R8(a)) already subsumes the never-functional case (a never-started citizen trivially passes the find/unit/state checks), and the quarantine window still applies.


3. Filesystem Layout

3.1 Directory hierarchy

Every Cosmix daemon's on-disk presence SHALL conform to the following hierarchy. The structure mirrors across all five trees: each daemon owns a leaf named <d>/ corresponding to its registry name minus the cosmix- prefix.

PathPurposeCreated byOwner / mode
/etc/cosmix/Configuration rootCosmix packageroot:root 0755
/etc/cosmix/<d>/Per-daemon configurationCosmix packageroot:root 0755
/etc/cosmix/<d>/config.tomlDaemon configuration fileCosmix packagesee §3.3
/var/lib/cosmix/State rootsystemd-sysusers / packageroot:root 0755
/var/lib/cosmix/<d>/Per-daemon writable stateStateDirectory=cosmix/<d>cosmix-<d>:cosmix-<d> 0750
/var/lib/cosmix/<shared>/Substrate-shared state (§3.4)Cosmix packageroot:root 0755
/run/cosmix/<d>/Per-daemon ephemeral runtimeRuntimeDirectory=cosmix/<d>cosmix-<d>:cosmix-<d> 0750
/var/cache/cosmix/<d>/Per-daemon cache (OPTIONAL)CacheDirectory=cosmix/<d>cosmix-<d>:cosmix-<d> 0750
/usr/share/cosmix/<d>/Per-daemon read-only dataCosmix packageroot:root 0755
/usr/share/cosmix/spec/Substrate-wide read-only data (specs, schemas)Cosmix packageroot:root 0755
/var/log/cosmix/<d>/Per-daemon file logs (CONDITIONAL — see §3.5)LogsDirectory=cosmix/<d>cosmix-<d>:cosmix-<d> 0750

Unit files SHALL use the nested form (StateDirectory=cosmix/<d>), not the flat form (StateDirectory=cosmix-<d>). The flat form is forbidden because it produces sibling directories that defeat the unified-parent property.

Unit files SHALL NOT mix nested-form (StateDirectory=cosmix/<d>) with parent-form (StateDirectory=cosmix) across the daemon set. The parent cosmix/ is owned by root:root; if any unit declares StateDirectory=cosmix, ownership of the parent collapses to that daemon and other daemons lose traversal guarantees.

3.2 Ownership matrix

The default ownership and mode for each directory class:

ClassOwnerGroupModeDaemon access
Tree roots (/etc/cosmix/, /var/lib/cosmix/, /run/cosmix/, /usr/share/cosmix/)rootroot0755Traverse only
Daemon leaf (state, runtime, cache, logs)cosmix-<d>cosmix-<d>0750Read/write
Daemon leaf (config, share)rootroot or cosmix-<d>0755 or 0750 (see §3.3)Read only
Substrate-shared state (§3.4)rootroot0755Traverse + explicit ABP-contract write only

3.3 Configuration ownership

Daemons SHALL NOT own their own configuration files. Configuration is owned by the package or the operator, not by the daemon process. The ownership rules are:

  • Non-secret configuration: root:root 0644 for the file, root:root 0755 for the containing directory. This is the case satisfied by ConfigurationDirectory=cosmix/<d> alone, because systemd.exec(5) documents that ConfigurationDirectory= does not chown the directory to the daemon's User=/Group= (unlike StateDirectory=). The 0755 mode allows world-traverse to the config file, which the daemon reads as a regular user.
  • Configuration containing secrets (API tokens, TLS keys, database passwords, ABP credentials): root:cosmix-<d> 0640 for the file, root:cosmix-<d> 0750 for the containing directory. Because ConfigurationDirectory= does not set group ownership, the secret-config directory SHALL be created by the package installer or by a tmpfiles.d fragment with the correct root:cosmix-<d> 0750 ownership before the unit starts, and the unit SHALL declare ConfigurationDirectoryMode=0750. The registry user (§2) SHALL exist before this directory is materialised: the installer (or boot sequence) SHALL run systemd-sysusers before systemd-tmpfiles --create invokes the Cosmix tmpfiles fragment, so that the cosmix-<d> group exists when the tmpfiles fragment chowns the directory. This is an installer/boot ordering requirement, not a property of the tmpfiles fragment itself.
  • TLS keypairs read by more than one daemon (e.g. a host certificate consumed by both cosmix-maild and cosmix-webd): root:cosmix-tls 0640 for the private key. The cosmix-tls shared-credential group (§2.2) SHALL be the access mechanism; per-host POSIX ACLs (setfacl u:cosmix-<d>:r …) SHALL NOT be used for this case. Each consuming daemon's cosmix-<d> user SHALL be added to cosmix-tls via the canonical sysusers fragment so that the membership is reproducible and auditable rather than a per-host operator action. The certificate (public material) MAY remain root:root 0644. This pattern keeps the daemons mutually isolated — neither daemon's primary group grants the other any access — while avoiding both the per-host ACL workaround and the acl package dependency on hosts (e.g. Debian 13) that do not ship setfacl by default.
  • Daemon-writable runtime state derived from config (e.g. a generated cache of resolved DNS names) SHALL be written under /var/lib/cosmix/<d>/ or /var/cache/cosmix/<d>/, never back into /etc/cosmix/<d>/.

A daemon process SHALL NOT have write access to /etc/cosmix/<d>/. This is enforced by ReadOnlyPaths=/etc/cosmix (or the equivalent implicit guarantee from ProtectSystem=strict plus a ReadWritePaths= allow-list that excludes /etc/cosmix).

3.4 Substrate-shared state

Some state is owned by the substrate as a whole, not by any individual daemon. Examples (each governed by its own SPEC or doc):

  • /var/lib/cosmix/registry/ — service-name registry (planned)
  • /var/lib/cosmix/topology/ — mesh topology snapshot (planned)
  • /var/lib/cosmix/spec/ — SPEC delivery cache (per SPEC 07)

These directories are owned by root:root 0755. Daemons SHALL access them only through one of:

  1. Read-only traversal, with no writes;
  2. An explicit ABP contract that delegates writes to a designated substrate service (e.g. cosmix-noded may write to /var/lib/cosmix/registry/ because the registry is part of its contract); or
  3. A supplemental group that grants the specific daemon write access, with the supplemental group declared in this SPEC's amendment record.

Daemons SHALL NOT use file ownership of substrate-shared directories to coordinate. Cross-daemon coordination is ABP, not filesystem.

3.5 Logging

/var/log/cosmix/<d>/ SHALL exist only when a daemon emits log files directly. Daemons that log via journald (the default) SHALL NOT declare LogsDirectory=. The /var/log/cosmix/ parent SHALL NOT be created unless at least one daemon requires it, to avoid empty-tree litter.

3.6 Backup, restore, and image-transfer boundary

The 500–599 window is preferred, not globally reserved (§2.1). Numeric ownership of files in /var/lib/cosmix/ is therefore only meaningful on a host that has passed §6.2 verification. The following SHALL hold:

  • Backup, restore, snapshot, replication, container-image, and rsync-style transfer tooling SHALL NOT interpret numeric UID/GID 500–599 ownership as "Cosmix-owned" on a target host before that host has passed §6.2 verification.
  • A restore that places /var/lib/cosmix/ content onto a host failing §6.2 SHALL be reported as a fail-closed condition; the restore tool SHALL NOT chown the tree to satisfy the registry.
  • Bind-mounting /var/lib/cosmix/<d>/ from a host into a container is permitted only when the container's registry projection (sysusers fragment baked into the container image) matches the host's. Mismatched projections SHALL be treated as a §6.2 failure inside the container. When the container uses user-namespace remapping (e.g. unprivileged Incus, rootless Podman), the comparison SHALL be made against the effective UIDs/GIDs visible inside the container's mount namespace — i.e. the remapped IDs, not the host-side IDs. A registry user whose remapped ID falls outside the 500–599 window inside the container is a fail-closed condition.

This boundary protects against a class of cross-host confused-deputy attacks where files restored from a backup of a Cosmix host end up owned by an unrelated UID 500–599 on the destination host.


4. sysusers.d Derivation

4.1 Canonical source

The Markdown table in Appendix A is the single canonical source for the registry. The /usr/lib/sysusers.d/cosmix.conf fragment is a generated artifact.

When the canonical Markdown and the generated fragment disagree, the canonical Markdown is authoritative. CI (§8.2) SHALL reject any commit in which they disagree.

4.2 Generated artifact

The generated fragment SHALL be installed at /usr/lib/sysusers.d/cosmix.conf. It SHALL be machine-applied via systemd-sysusers at install time (§6) and at every package update.

4.3 Syntax

Each registry entry SHALL produce a sysusers.d line of the form:

u <name> <uid> "<gecos>" /nonexistent /usr/sbin/nologin
  • The u directive (lowercase u, no !) creates a system user. Cosmix does not use the u! form (which marks the user as system-only and prevents login by some tools' heuristics) because nologin shell already prevents interactive use, and u! adds a non-uniformity that complicates automation.
  • The home directory SHALL be /nonexistent. Daemons own state under /var/lib/cosmix/<d>/, not under a traditional home.
  • The shell SHALL be /usr/sbin/nologin. (/sbin/nologin is also acceptable on systems that use the older path; the generator SHALL pick the path that exists on the target host.)
  • The GECOS field SHALL be the descriptive role string from the registry, double-quoted.
  • The matching group SHALL be created implicitly by systemd-sysusers when only a u line is given. No separate g line is required; one MAY be added if a future daemon needs supplemental group membership beyond its primary group.

Tombstoned entries SHALL appear in the generated fragment as comments of the form:

# tombstone: <name> <uid> retired <date> — <reason>

Citizen-identity entries project per their §2.5 lifecycle state:

  • A live citizen row produces a normal u line, identical in shape to a daemon-identity u line (no :gid suffix; same /nonexistent home and nologin shell):

    u <name> <uid> "<gecos>" /nonexistent /usr/sbin/nologin
    
  • A retired (in-quarantine) citizen row SHALL NOT materialize a u line; it appears as a comment of the form:

    # quarantine: <name> <uid> retired <date> — eligible <date+30d> (SPEC-10 R8)
    
  • A reclaimed citizen row SHALL NOT materialize a u line; it appears as a comment of the form:

    # reclaimed: <name> <uid> retired <date> reclaimed <date> verifier <record>
    

Citizen u lines are emitted in their own sub-block, after the daemon-identity u lines and the shared-credential g/m lines, so the three streams stay visually separable in the fragment (§9.1).


5. systemd Unit Requirements

5.1 Required directives

5.1.1 System-service daemons

Every system-service Cosmix daemon's cosmix-<d>.service unit file SHALL include the following directives in [Service]:

DirectiveValuePurpose
User=cosmix-<d>Match registry
Group=cosmix-<d>Match registry
StateDirectory=cosmix/<d>Materialize /var/lib/cosmix/<d>/
RuntimeDirectory=cosmix/<d>Materialize /run/cosmix/<d>/
ConfigurationDirectory=cosmix/<d>Materialize /etc/cosmix/<d>/ (read-only at runtime)
ProtectSystem=strictRead-only /usr, /boot, /etc
ProtectHome=trueNo /home, /root, /run/user access
PrivateTmp=truePer-daemon /tmp namespace
NoNewPrivileges=trueBlock setuid/setgid escalation

5.1.2 Registered systemd-user daemons

A registry entry MAY be classified by an amendment as a registered systemd-user daemon when its live service must inherit resources belonging to the logged-in session and cannot truthfully run under its reserved POSIX row. Version 1.4.3 classifies only cosmix-interactd this way (§7.3). This is a closed class: placing a unit under the user manager does not implicitly earn the exception.

A registered systemd-user daemon's unit SHALL:

  • be installed for the user manager and run as the logged-in user;
  • omit User=, Group=, DynamicUser=, StateDirectory=, RuntimeDirectory=, and ConfigurationDirectory=;
  • declare After=graphical-session.target and PartOf=graphical-session.target when it consumes the session D-Bus;
  • use an absolute /opt/cosmix/bin/cosmix-<d> ExecStart= and declare Restart=on-failure; and
  • rely on the daemon's broker reconnect loop instead of declaring After=/Requires=cosmix-noded.service, because system and user managers do not share a dependency graph.

Session-bus access under /run/user/<uid>/bus is explicitly permitted for this class. The reserved SPEC-10 POSIX row remains sysusers/preflight and future namespace-ownership material; it is not asserted as the live process UID/GID.

5.2 Hardening expectations

This subsection defines the Level 2 mandatory hardening set for system-service daemons. At conformance Level 2 (§8.1), every such daemon's [Service] section SHALL include each of the following directives, in the canonical hardening order given by the row order of the table below, with the exact value shown — except for the four directives where a named alternative is permitted:

  • RestrictAddressFamilies= MAY be a strict subset of AF_UNIX AF_INET AF_INET6 (i.e. the daemon MAY drop families it does not need; it MUST NOT add others).
  • SystemCallFilter= MAY add daemon-specific deny filters (SystemCallFilter=~@<group>), but the base allow filter SHALL remain @system-service.
  • CapabilityBoundingSet= MAY be either empty or a non-empty CAP_* allow-list. A non-empty value SHALL list only CAP_* tokens (no minus operators, no ~ complement form).
  • AmbientCapabilities= MAY be either empty or a non-empty CAP_* allow-list. A non-empty value SHALL be a subset of CapabilityBoundingSet= (per systemd.exec(5) semantics) and SHALL list only CAP_* tokens.

Any other deviation is permitted only via an in-line deviation comment placed in canonical order at the position the directive would otherwise occupy. The comment SHALL match the exact form

# §5.2 deviation: <Directive>= — <reason>

where <Directive>= is the directive name with a single trailing = (e.g. MemoryDenyWriteExecute=). Documented deviations are permitted at Level 2; silent omissions and value drift are not.

Boolean normalisation. Boolean values in this table use the canonical true / false form. Implementations and the CI lint SHALL treat the systemd-equivalent spellings (yes/on/1, no/off/0, per systemd.syntax(5)) as identical for §5.2 conformance purposes. The §9.2 example uses yes/no to match common upstream systemd unit conventions.

DirectiveLevel 2 value
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 (or a strict subset)
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
SystemCallFilter=@system-service plus daemon-specific add/deny lists
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectKernelLogs=true
ProtectControlGroups=true
ProtectProc=invisible
ProcSubset=pid
CapabilityBoundingSet=empty, or an explicit CAP_* allow-list
AmbientCapabilities=empty, or an explicit CAP_* allow-list
UMask=0027

CI lint L13 (§8.2) is the enforcement contract for this table.

A daemon's unit file SHOULD declare ReadWritePaths= only when the state directory machinery (StateDirectory=, RuntimeDirectory=) is insufficient. Adding a path here is a privilege escalation relative to the default; it SHALL carry a comment explaining the need.

5.3 Forbidden directives

The following directives SHALL NOT appear in any Cosmix daemon's service unit:

  • DynamicUser= — incompatible with the fixed registry. Conflicts with the append-only and never-reused invariants and produces UIDs in 61184–65519 that are not under registry control.
  • User=root — Cosmix daemons do not run as root. A daemon that legitimately requires root capability (e.g. binding port < 1024) SHALL use AmbientCapabilities= and the relevant CAP_* token rather than running as root.
  • User= matching a UID outside the registry — every Cosmix daemon SHALL run as its registered identity.

5.4 Unit ordering and dependencies

System-service cosmix-<d>.service units SHALL declare:

  • After=network-online.target (for daemons requiring network)
  • Wants=network-online.target (matching pair)
  • After=cosmix-noded.service for any daemon that registers as an ABP service (registration requires the local broker be up first), with one exception (next bullet)
  • Requires=cosmix-noded.service is OPTIONAL; daemons MAY survive broker bounces and reconnect via ABP's reconnection contract (SPEC 01 §10).

A registered serve-mode Mix citizen is a SPEC-10 daemon for the purposes of this section: its cosmix-<d>.service unit carries the §5.1 required directives and the §5.2 hardening set identically to a Rust daemon, differing only in ExecStart=/opt/cosmix/bin/mix --serve /usr/local/lib/cosmix/<d>.mix (the interpreter is the long-running process; MemoryDenyWriteExecute= remains true because Mix is a tree-walking interpreter with no JIT). Such a unit registers as an ABP service and therefore declares After=cosmix-noded.service under the rule above, and Requires=cosmix-noded.service is OPTIONAL on the same terms (the SPEC 18 §3.3 supervised reconnect contract is the in-process counterpart). See SPEC 18 §2 (identity delegated here, not reinvented) and SPEC 18 §9 (the Phase 1 unit template).

Broker-provider exception. The unit that provides the node-local ABP broker (currently cosmix-noded.service, registry UID 500, ABP service noded) SHALL NOT declare After=cosmix-noded.service or Requires=cosmix-noded.service against itself. CI lint L15 (§8.2) is the enforcement contract: it requires After=cosmix-noded.service on every other ABP-registering unit and excludes the single broker-provider unit. If a future amendment moves the broker-provider role to a different unit, the exception SHALL move with it; only one unit holds this exception at a time.

Registered systemd-user daemons are exempt from the noded ordering rule and MUST NOT name the system-manager cosmix-noded.service in After= or Requires=. Their mandatory reconnect loop is the cross-manager startup-race control; cosmix-interactd is the v1.4.3 instance (§5.1.2, §7.3).

5.5 Unit installation, PATH, and the per-crate deploy slice

(Promoted 2026-07-23 from the cosmix-deployment-layout decision; full rationale in git history.)

  • Units are linked, not copied. The canonical unit file lives at /opt/cosmix/systemd/cosmix-<d>.service; it is activated via systemctl link (a symlink under /etc/systemd/system/), never by copying the file. The reinstallable tree stays the single source of truth; an image-rebuild or upgrade replaces /opt/cosmix/ wholesale and the links follow.
  • PATH is provided by a one-line /etc/profile.d/cosmix.sh adding /opt/cosmix/bin — no per-user shell config, no parallel installs.
  • Users and directories are declared via /etc/sysusers.d/cosmix.conf (generated per §4) and /etc/tmpfiles.d/cosmix.conf; both are applied idempotently at boot, never by ad-hoc useradd/mkdir in scripts.
  • Each daemon crate carries its own deploy/ slice (unit file, config template — *.conf.mix since the fleet-wide conf.mix migration — and libexec scripts), mirroring the core-and-citizen pattern into deployment; only genuinely cross-cutting artifacts (meta-units, node-role units, per-host drop-ins) live outside the crate.

6. Install-Time and Startup Verification

The substrate distinguishes three verification phases. Each phase runs at a different point in the lifecycle, against a different set of expected states, and on a different failure surface. An implementation SHALL implement all three.

PhaseWhenExpected state of registry entriesOn absenceOn wrong UID/GIDABP availability
Install preflight (§6.1)Before systemd-sysusers materializes entries; before any unit is enabledMAY be absentOK to createFail closedNot assumed
Post-sysusers verification (§6.2)After systemd-sysusers runs; before any cosmix-*.service is startedMUST existFail closedFail closedNot assumed
Startup verification (§6.3)At cosmix-noded startup, before any ABP socket is boundMUST existFail closedFail closedInitial run: log locally only (ABP is not up yet by definition). Subsequent reload-time runs: MAY emit on the preflight topic.

6.1 Install preflight

Before systemd-sysusers materializes new entries, the installer SHALL perform the following preflight, in order, with the rules that apply to each entry's class:

Daemon-identity entries (§2.2):

For each daemon-identity entry (name, uid):
    let user_by_uid    = getent passwd <uid>
    let user_by_name   = getent passwd <name>
    let group_by_gid   = getent group <uid>     # GID == UID per §2.2
    let group_by_name  = getent group <name>

    if (user_by_uid empty and user_by_name empty
        and group_by_gid empty and group_by_name empty):
        # UID/GID and name both free — sysusers may safely create
        OK to create

    elif (user_by_uid.name == <name> and user_by_uid.uid == <uid>
          and user_by_name.uid == <uid>
          and group_by_gid.name == <name> and group_by_gid.gid == <uid>
          and group_by_name.gid == <uid>):
        # Already correctly assigned — no-op
        OK to skip

    else:
        # Conflict — fail closed
        ABORT with diagnostic: which UID/GID is taken by which existing
        user/group, and which existing user/group holds the cosmix-*
        name with a different ID. Operator MUST resolve before
        installation proceeds.

Shared-credential group entries (§2.2):

For each shared-credential group entry (name, gid):
    let user_by_name   = getent passwd <name>     # MUST be empty
    let group_by_gid   = getent group  <gid>
    let group_by_name  = getent group  <name>

    if (user_by_name empty
        and group_by_gid empty and group_by_name empty):
        # GID and name both free — sysusers may safely create
        OK to create

    elif (user_by_name empty
          and group_by_gid.name == <name> and group_by_gid.gid == <gid>
          and group_by_name.gid == <gid>):
        # Already correctly assigned — no-op
        OK to skip

    else:
        # Conflict — fail closed (includes the case where a
        # same-named user has appeared, which §2.2 forbids).
        ABORT with diagnostic: which GID is taken by which existing
        group, which existing group holds the cosmix-* name with a
        different GID, or that a user with the same name exists.
        Operator MUST resolve before installation proceeds.

Citizen-identity entries (§2.2, §2.5):

For each citizen-identity entry (name, uid, state):
    if state == live:
        # Identical to the daemon-identity preflight: a live citizen
        # has the same passwd+same-numbered-group shape (GID == UID).
        apply the Daemon-identity preflight block above, verbatim,
        to (name, uid).

    else:   # state in { retired, reclaimed }
        # A retired/reclaimed row does NOT materialize (§4.3). Its
        # NAME MUST NOT resolve to a live user or group. The check is
        # by-NAME ONLY — never by UID: if the UID has been
        # R7-re-allocated it is held by a *different* live citizen
        # name, preflighted above on its own live row; a by-UID test
        # would false-positive that legitimate re-allocation, so the
        # by-name rule needs no R7 exception clause.
        let user_by_name  = getent passwd <name>
        let group_by_name = getent group  <name>

        if user_by_name empty and group_by_name empty:
            # The retired/reclaimed NAME is unbound — OK. A
            # R7-re-allocated UID held under a *different* live name
            # is verified by that citizen's own live row, never here.
            OK to skip
        else:
            # A retired/reclaimed citizen NAME is unexpectedly
            # live (stale projection, drift) — fail closed
            ABORT with diagnostic: retired/reclaimed citizen <name>
            (<uid>) name resolves to a live user/group; operator MUST
            run the §2.5 / R8 remediation (userdel on every
            projecting node) before installation proceeds.

The R8 reuse gate (purge-verification + quarantine) is an operator amendment-time procedure performed when a row transitions retired→reclaimed in the canonical source; it is not an install-time check. Install preflight only enforces the projection consequence: a retired/reclaimed row fails closed if it is unexpectedly live on the host.

Membership lines (m <user> <group>) are not preflighted on their own: they take effect only when systemd-sysusers runs Phase 3, at which point the §6.2 post-sysusers verification re-asserts every declared membership.

The install preflight SHALL NOT auto-correct, auto-allocate, or rename to bypass a conflict.

6.2 Post-sysusers verification

After systemd-sysusers has been invoked, and before any cosmix-*.service unit is enabled or started, the installer SHALL re-query each registry entry and SHALL fail closed under the rules that apply to the entry's class:

  • Daemon-identity entries (§2.2) SHALL resolve to a user and same-numbered group with uid == gid == registered UID and name == registered name.
  • Shared-credential group entries (§2.2) SHALL resolve to a group with gid == registered GID and name == registered name, and SHALL have no associated user with the same name. In addition, every membership pair declared in the canonical sysusers fragment for that group (each m <user> <group> line) SHALL be present in getent group <group>'s membership list.
  • Citizen-identity entries (§2.2, §2.5): a live citizen row SHALL resolve to a user and same-numbered group with uid == gid == registered UID and name == registered name (the daemon-identity rule, applied unchanged). A retired or reclaimed citizen row is comment-only (§4.3) and its name SHALL NOT resolve to a live user or group. The check is by name only — never by UID: if the retired/reclaimed UID has been R7-re-allocated it is held by a different live citizen name, which is verified independently by that citizen's own live row above; a by-UID test would false-positive that legitimate re-allocation, so the by-name rule needs no R7 exception clause.

Any of the following constitutes a fail-closed condition:

  • A registry user or group is absent after sysusers should have created it (sysusers fragment drift, install ordering bug, or a lock contention).
  • A registry name resolves to a UID/GID outside the registry.
  • A registry UID/GID resolves to a name other than the registered name.
  • A shared-credential group has unexpectedly acquired a same-named user, or has lost a declared membership.
  • A tombstoned UID/GID is currently held by a live user or group.
  • A retired: or reclaimed: citizen name resolves to a live user or group (by-name check; the §6.1 retired/reclaimed-state check, re-asserted post-sysusers). A UID R7-re-allocated to a different live name is not a violation — that name is verified by its own live row, never by this audit row.
  • A citizen UID has more than one non-reclaimed (live or retired:) registry row, or a reclaimed: row lacks its reclaimed: date or verifier: token (§2.5; CI lint L5 catches this in-source, this re-asserts it against the projected state).
  • The sysusers.d fragment on disk does not match the canonical source.

Failures here SHALL halt installation with a structured diagnostic and SHALL NOT be silently coerced.

6.3 Startup verification

At cosmix-noded startup, before any ABP socket is bound, the daemon SHALL run §6.2's verification pass against its own registry copy. The same fail-closed rules apply: a single mismatch prevents the ABP broker socket from binding.

Because this phase runs before ABP is up, results SHALL be written to the daemon's local log channel (systemd journal, with the structured fields cosmix.spec=10, cosmix.spec.version=1.0.0, cosmix.preflight=ok|fail, and on failure a cosmix.preflight.errors= field listing affected entries). Once ABP is up, subsequent verification runs (e.g. on broker reload) MAY additionally emit structured events on the substrate's preflight topic; absence of that topic SHALL NOT be treated as a failure.

6.4 Idempotency

All three phases are idempotent. Re-running any of them on a correctly provisioned host SHALL be a no-op and SHALL exit zero. This is the property the CI lint depends on (§8.2).

6.5 Fail-closed semantics

"Fail closed" in this SPEC means: the verifier exits non-zero, no cosmix-*.service is started by the installer or broker, and (where applicable) the ABP socket is not bound. There SHALL be no fallback path that allows partial start-up under a registry mismatch.

6.6 Read-only-root and immutable-image hosts

On hosts with read-only root filesystems (e.g. systemd-portabled images, OSTree-style deployments), the registry SHALL be applied at image build time, not at first boot. The sysusers.d fragment is baked into the image; first-boot verification is the §6.2 pass against the baked state, and runtime verification is §6.3.

Live mutation of the registry on a read-only-root host is a SPEC violation: the only path is to rebuild the image with an amended SPEC.


7. Session-Scoped Exclusions

7.1 Test for inclusion

A Cosmix process belongs in the daemon registry (§2) if and only if it runs as a system service with persistent identity across user logins. A process is session-scoped and excluded from the registry if any of the following hold:

  • The process binds to a logged-in human seat (Wayland session, console, audio group, video group).
  • The process consumes per-user XDG state under $XDG_*_HOME.
  • The process uses the user D-Bus bus rather than the system bus.
  • The process is launched by a systemd --user unit, not a system unit.
  • The process holds capabilities or file descriptors that are valid only within an active session (e.g. a logind session ID).

If any one of the above holds, the process SHALL run as the logged-in human user. It SHALL NOT have a registry entry, a cosmix-* system UID, or a /var/lib/cosmix/<d>/ daemon leaf.

7.2 Listed exclusions

The following Cosmix components are session-scoped and excluded from the registry as of this SPEC's publication:

ComponentReason
cosmix-disp-skiaDisplay backend bound to the logged-in Wayland session/seat

Future Cosmix components SHALL apply the test in §7.1 to decide inclusion. (cosmix-menu was previously listed here; the crate was removed when its assumptions — XDG-tray launcher for a desktop full of Dioxus apps — no longer matched the post-pivot Cosmix surface.)

7.3 cosmix-interactd reserved identity and session runtime

cosmix-interactd is the narrow exception to §7.1's “no registry entry” rule. UID/GID 517 reserves a stable POSIX identity for the interaction namespace and any future system-owned persisted props projection, but the current notify.v1 implementation is memory-backed and its freedesktop sink requires the logged-in user's session D-Bus. The shipped process therefore MUST run from cosmix-interactd.service as a systemd --user service under the logged-in user and conform to §5.1.2; it MUST NOT claim that its live process is running as cosmix-interactd UID/GID 517.

This reservation does not grant the session process access to a /var/lib/cosmix/interactd/ daemon leaf and does not weaken the general session-scoped exclusion. A later system-owned persistence helper MAY use the reserved identity only through a separately specified trust boundary; until then the row is namespace/ownership reservation and preflight material only.


8. Conformance

8.1 Conformance levels

A Cosmix installation conforms to this SPEC at one of three levels:

Level 0 (Pre-conformance). Daemons run under arbitrary identities, without registry verification. Permitted only in a dev box during substrate bootstrap. Not permitted on any mesh node.

Level 1 (Registered). Every system-service daemon's running UID/GID matches its registry entry; every registered systemd-user daemon satisfies §5.1.2 and is not claimed to run as its reserved row. The sysusers.d fragment on disk matches the canonical source. The applicable §5.1 directives are present. Required for every mesh node.

Level 2 (Hardened). Level 1 plus the §5.2 hardening directives for system-service daemons; registered systemd-user daemons remain governed by §5.1.2. Install-time preflight (§6) is executed and logged and CI lint (§8.2) is green at the time of last package update. Required for any internet-exposed mesh node.

8.2 CI lint shape

A CI lint SHALL be runnable in the source tree and SHALL verify all of the following invariants. The lint is part of the substrate's self-observation surface (per the Three Design Criteria) and is itself agent-operable.

IDInvariant
L1Canonical Markdown registry parses without syntax errors into up to three ordered blocks, each opened by its own header row: a daemon-identity block (header begins uid) of (name, uid, gid, amp, gecos) tuples (the amp field is either an explicit ABP service name or the placeholder - meaning "default-derive: name minus cosmix- prefix"); an OPTIONAL shared-credential-group block (header begins gid) of (name, gid, purpose) tuples (no UID, no ABP service); and an OPTIONAL citizen-identity block (header begins cid) of (name, uid, gid, amp, gecos, state) tuples where state is one of live (no retired:/reclaimed:), retired: (with a date), or reclaimed: (with both a retired: and a reclaimed: date and a verifier: token). The lint MUST distinguish the three blocks and apply the per-class rules below.
L2UID == GID for every non-tombstoned daemon-identity entry and for every citizen-identity entry (citizen entries have the daemon-identity shape, §2.2). Enforced via the projected u <name> <uid> form carrying no :gid suffix. (Shared-credential group entries have no UID; this rule does not apply to them.)
L3Allocation is monotonic within each block: daemon UIDs appear in append-only order; shared-credential GIDs appear in append-only order; non-reclaimed: citizen rows appear in append-only order. Tombstones (daemon/shared) and retired:/reclaimed: citizen rows appear in their original positions; a reclaimed: citizen UID is exempt from monotonicity because R7 permits below-frontier re-allocation.
L4Every daemon UID and every shared-credential GID is in the 500–599 window; every citizen-identity UID is in the 600–699 citizen band (§2.1). (Or in a window declared by a future amendment.)
L5No tombstoned UID or name appears as a live daemon/shared entry. For the citizen-identity block: at most one non-reclaimed: row (live or retired:) exists per citizen UID; every reclaimed: row carries both a reclaimed: date and a non-empty verifier: token; no retired: or reclaimed: citizen row projects a u line (§4.3); a re-allocated UID's new live row has a different name from every prior reclaimed: row for that UID.
L6systemd-sysusers --dry-run <build-tree>/cosmix.conf succeeds against the freshly generated fragment in the build tree (not the installed /usr/lib/sysusers.d/ copy, which may be older or absent in CI).
L7The generated sysusers.d fragment matches the canonical source (regenerate, diff, fail on diff).
L8Every system-service cosmix-*.service unit carries §5.1.1's directives and its User= / Group= / StateDirectory= / RuntimeDirectory= references a canonical registry name. Every registered systemd-user unit explicitly listed by §5.1.2 instead carries that subsection's directives and omits User= / Group= / DynamicUser= / StateDirectory= / RuntimeDirectory= / ConfigurationDirectory=. A filename alone does not select the user-unit branch.
L9No cosmix-*.service unit declares DynamicUser= or User=root.
L10No daemon-writable path under /etc/cosmix/ appears in any unit's ReadWritePaths=.
L11No two units declare conflicting StateDirectory= parents (i.e. one declaring cosmix while another declares cosmix/<d>).
L12The session-scoped exclusion list (§7.2) is consistent: every name listed there has no registry entry; every component with a registry entry is not in the exclusion list.
L13Every system-service cosmix-*.service unit at Level 2 declares each §5.2 directive with the value listed in the §5.2 table, compared after systemd boolean normalisation (per systemd.syntax(5): true/yes/on/1 are equivalent for a boolean true; false/no/off/0 for false), in canonical hardening order (the row order in the §5.2 table). Named alternatives are accepted only for the four directives listed under §5.2 (RestrictAddressFamilies= strict subset; SystemCallFilter= base allow plus daemon-specific deny filters; CapabilityBoundingSet= and AmbientCapabilities= empty or CAP_* allow-list). Each missing directive is replaced by an in-line comment of the form # §5.2 deviation: <Directive>= — <reason> placed in the same canonical order, where <Directive>= is the directive name with trailing =. Registered systemd-user units are checked under L8's §5.1.2 branch, not this system-service hardening table.
L14The R6 split is enforced: every daemon-identity entry's and every non-retired:/non-reclaimed: citizen-identity entry's resolved ABP service name (the explicit amp: value, or the default derivation when amp: is -) matches the regex ^[a-z][a-z0-9-]{1,30}$, does not start with cosmix-, and is unique across the union of non-tombstoned daemon-identity entries and live citizen-identity entries (a citizen ABP name SHALL NOT collide with a daemon ABP name or another live citizen ABP name — §2.3 R6). (Shared-credential group entries have no ABP service name; this rule does not apply to them.)
L15Every system-service cosmix-*.service unit other than the broker-provider unit that registers as an ABP service declares After=cosmix-noded.service. The broker-provider unit (currently cosmix-noded.service per §5.4) does not order against itself. Registered systemd-user daemons are explicitly exempt from this cross-manager ordering and MUST omit After=/Requires=cosmix-noded.service; the lint verifies that omission and their explicit §5.1.2 classification.
L16The shared-credential-group block in Appendix A and the corresponding g/m lines in the generated sysusers.d fragment agree pairwise: every g <name> <gid> line in the fragment has a matching shared-credential row in Appendix A with identical name and gid, and vice versa. Every m <user> <group> line names a <user> that exists as a daemon-identity entry (non-tombstoned) and a <group> that exists as a shared-credential-group entry (non-tombstoned). Every m <user> <group> line in the fragment SHALL appear after both its referenced u <user> line and its referenced g <group> line (per §9.1), so that systemd-sysusers cannot silently materialize a referent with defaults via its implicit-creation behavior. No shared-credential-group <name> collides with any daemon-identity <name>, and no shared-credential-group <gid> collides with any daemon-identity <uid>.
L17The citizen-identity block in Appendix A and the corresponding citizen u lines in the generated sysusers.d fragment agree pairwise: every live citizen row has exactly one matching u cosmix-<name> <uid> fragment line with identical name and uid (and no :gid suffix), and every citizen u line in the fragment has a matching live Appendix A citizen row; every retired: row projects exactly one # quarantine: comment and no u line; every reclaimed: row projects exactly one # reclaimed: comment and no u line (§4.3). No citizen-identity <name> collides with any daemon-identity or shared-credential-group <name>, and no citizen-identity <uid> collides with any daemon-identity <uid> or shared-credential-group <gid> (the 600–699 band is disjoint from 500–599 so this is structural; the lint asserts it as defence-in-depth). Citizen u lines appear after the daemon-identity u lines and after the shared-credential g/m lines.

The lint SHALL be invoked by CI on every pull request and SHALL block merge on any failure. It MAY also be invoked by the install-time preflight (§6) for additional defense in depth.


9. Examples

9.1 Generated sysusers.d fragment

The fragment below is the canonical projection of Appendix A into systemd-sysusers syntax. It SHALL be installed at /usr/lib/sysusers.d/cosmix.conf (vendor) or /etc/sysusers.d/cosmix.conf (admin override). The order of u lines SHALL match the append-only order of the daemon-identity block in the canonical registry; the order of g and m lines SHALL match the append-only order of the shared-credential-group block. m <user> <group> lines SHALL appear after the g <group> line they reference and after the u <user> lines they name; this is the natural order produced by emitting daemon-identity entries first. Citizen-identity u lines (§2.2, v1.2.0) are emitted in their own sub-block after the shared-credential g/m lines, and only for live citizen rows; retired:/reclaimed: rows project as comment lines (§4.3) and SHALL NOT emit a u line.

# /usr/lib/sysusers.d/cosmix.conf
# Generated from Appendix A of cosmix-daemon-identity v1.4.4.
# DO NOT EDIT — regenerate from the canonical Markdown registry.

# --- Daemon-identity entries (POSIX user + same-numbered group) ---
#Type Name              ID   GECOS                                Home           Shell
u     cosmix-noded      500  "Cosmix node daemon (ABP broker)"    /nonexistent   /usr/sbin/nologin
u     cosmix-maild      501  "Cosmix mail daemon"                 /nonexistent   /usr/sbin/nologin
u     cosmix-webd       502  "Cosmix web daemon"                  /nonexistent   /usr/sbin/nologin
u     cosmix-indexd     503  "Cosmix knowledge daemon"            /nonexistent   /usr/sbin/nologin
u     cosmix-agentd     504  "Cosmix agent runtime"               /nonexistent   /usr/sbin/nologin
u     cosmix-mcp        505  "Cosmix MCP bridge"                  /nonexistent   /usr/sbin/nologin
u     cosmix-dnsd       506  "Cosmix authoritative DNS daemon"    /nonexistent   /usr/sbin/nologin
u     cosmix-cron       507  "Cosmix scheduler"                   /nonexistent   /usr/sbin/nologin
u     cosmix-prometheus 508  "Cosmix Prometheus (obs tier)"       /nonexistent   /usr/sbin/nologin
u     cosmix-grafana    509  "Cosmix Grafana (obs tier)"          /nonexistent   /usr/sbin/nologin
u     cosmix-loki       511  "Cosmix Loki (obs tier)"             /nonexistent   /usr/sbin/nologin
u     cosmix-alloy      512  "Cosmix Grafana Alloy (obs tier)"    /nonexistent   /usr/sbin/nologin
u     cosmix-pveexport  513  "Cosmix proxmox-exporter (obs tier)" /nonexistent   /usr/sbin/nologin
u     cosmix-nodeexport 514  "Cosmix node_exporter (obs tier)"    /nonexistent   /usr/sbin/nologin
u     cosmix-wgd        515  "Cosmix WireGuard mesh control plane" /nonexistent   /usr/sbin/nologin
u     cosmix-interactd  517  "Cosmix interaction broker"          /nonexistent   /usr/sbin/nologin
u     cosmix-nspawnd    518  "Cosmix nspawn host executor"        /nonexistent   /usr/sbin/nologin

# --- Shared-credential groups (group only; no associated user) ---
# cosmix-tls mediates read access to TLS keypairs shared by ≥2 daemons
# (SPEC 10 §3.3). Membership lines below add the consuming daemons; new
# consumers add their own `m cosmix-<d> cosmix-tls` line in this fragment
# rather than via per-host setfacl.
g     cosmix-tls     510
m     cosmix-maild   cosmix-tls
m     cosmix-webd    cosmix-tls
# cosmix-mesh mediates read access to the signed mesh inventory (SPEC-13
# INV-1) under /var/lib/cosmix/noded/, shared by mesh daemons, WITHOUT
# granting cosmix-noded's group any read on the private d2 seed (§3.3).
g     cosmix-mesh    516
m     cosmix-wgd     cosmix-mesh

# --- Citizen-identity entries (POSIX user + same-numbered group) ---
# Citizen-identity entries (SPEC 10 §2.2, §2.5, v1.2.0) have the exact
# same on-disk shape as a daemon-identity entry but live in the disjoint
# 600–699 band and are governed by scoped, gated reuse (§2.3 R7/R8): a
# retired UID re-enters the free pool only after mesh-wide automated
# purge-verification AND a 30-day quarantine. Only LIVE citizen rows
# materialize a `u` line here; retired:/reclaimed: rows are comment-only
# in the canonical registry (Appendix A) and SHALL NOT appear below
# (SPEC 10 §4.3, §9.1). The consumer is SPEC 18 (Mix Citizen Runtime).
u     cosmix-statecache 600 "Cosmix SPEC-18 reference citizen" /nonexistent   /usr/sbin/nologin

Home=/nonexistent matches §4.3; daemons and citizens own state under /var/lib/cosmix/<d>/ created by systemd StateDirectory= (§5.1, §3.4), not under a traditional home. Shell=/usr/sbin/nologin satisfies the no-interactive-login rule of §4.3. A citizen u line is byte-for-byte the same shape as a daemon u line — the difference is purely registry governance (§2.3 R7/R8), invisible to systemd-sysusers.

9.2 Service unit fragment

The fragment below is a minimal, normative example of a Cosmix daemon unit at conformance Level 2 (§8.1). It demonstrates §5.1 required directives, §5.2 hardening directives, and §5.4 ordering. Local installations MAY add directives, but SHALL NOT remove or weaken any directive shown here.

# /usr/lib/systemd/system/cosmix-maild.service
[Unit]
Description=Cosmix mail daemon
Documentation=https://cosmix.dev/spec/10
After=network-online.target cosmix-noded.service
Wants=network-online.target
Requires=cosmix-noded.service

[Service]
Type=notify
User=cosmix-maild
Group=cosmix-maild
StateDirectory=cosmix/maild
StateDirectoryMode=0750
RuntimeDirectory=cosmix/maild
RuntimeDirectoryMode=0750
ConfigurationDirectory=cosmix/maild
ConfigurationDirectoryMode=0755
WorkingDirectory=%S/cosmix/maild
# %E/cosmix/maild expands to /etc/cosmix/maild — the per-daemon config
# leaf defined in §3.1. The daemon reads from this path. This example
# is the non-secret case (root:root 0755 dir, root:root 0644 file) —
# see §3.3. For a secret-config variant the dir is created in advance
# by the package or a tmpfiles fragment as root:cosmix-maild 0750, and
# ConfigurationDirectoryMode= is set to 0750 to match.
ExecStart=/opt/cosmix/bin/cosmix-maild --config %E/cosmix/maild/config.toml
Restart=on-failure
RestartSec=5s

# §5.1 required (Level 1)
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes

# §5.2 mandatory hardening (Level 2)
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
RestrictNamespaces=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
ProcSubset=pid
CapabilityBoundingSet=
AmbientCapabilities=
UMask=0027

# Optional belt-and-braces: device namespace lock-down
PrivateDevices=yes

[Install]
WantedBy=multi-user.target

Notes:

  • User= and Group= match the canonical registry entry for UID 501 (Appendix A); CI lint L8 (§8.2) verifies this.
  • StateDirectory=cosmix/maild causes systemd to create /var/lib/cosmix/maild/ owned cosmix-maild:cosmix-maild mode 0750 before the daemon starts, satisfying §3.4.
  • ConfigurationDirectory=cosmix/maild materialises /etc/cosmix/maild/ (§3.1) and exposes it as %E/cosmix/maild. The daemon reads config.toml from there. Per §3.3 the directory is read-only at runtime — the unit does not list /etc/cosmix/ in ReadWritePaths= (lint L10).
  • Requires=cosmix-noded.service plus After= realises the §5.4 ordering invariant: the local ABP broker is up before any mesh citizen starts.
  • CapabilityBoundingSet= and AmbientCapabilities= are emptied; bind-to-port-25 daemons (e.g. cosmix-maild SMTP inbound) SHOULD prefer AmbientCapabilities=CAP_NET_BIND_SERVICE over running as root, and SHALL document the deviation in an in-line comment per §5.2.

9.3 Verification — pseudocode

The pseudocode below illustrates the §6.2 post-sysusers verification pass and the §6.3 startup verification pass. It is not a language reference and is not a normative implementation; the contract is the prose in §6.1–§6.5. A real implementation MAY be written in Mix, Rust, or shell, provided it observes fail-closed semantics (§6.5) and emits diagnostics matching §6.3.

# Post-sysusers verification (§6.2) and startup verification (§6.3).
# Difference: §6.2 expects sysusers has just run; §6.3 expects the
# users to have existed since the last successful install.
#
# The registry has three classes (§2.2 v1.2.0):
#   * DAEMON_IDENTITY:  rows that own a POSIX user + same-numbered group.
#   * SHARED_GROUPS:    group-only rows with declared memberships.
#   * CITIZEN_IDENTITY: live rows verify exactly like DAEMON_IDENTITY;
#                       a retired:/reclaimed: row's NAME MUST NOT
#                       resolve to a live user or group (by-name only;
#                       an R7-re-allocated UID is held under a
#                       different live name, verified by its own live
#                       row) (§2.5, §6.2).
# Each class verifies under its own rule set; failures from any
# halt installation under §6.5 fail-closed semantics.

DAEMON_IDENTITY = [
  (500, "cosmix-noded"),      (501, "cosmix-maild"),    (502, "cosmix-webd"),
  (503, "cosmix-indexd"),     (504, "cosmix-agentd"),   (505, "cosmix-mcp"),
  (506, "cosmix-dnsd"),       (507, "cosmix-cron"),     (508, "cosmix-prometheus"),
  (509, "cosmix-grafana"),    (511, "cosmix-loki"),     (512, "cosmix-alloy"),
  (513, "cosmix-pveexport"), (514, "cosmix-nodeexport"),
  (515, "cosmix-wgd"),
  (517, "cosmix-interactd"),
  (518, "cosmix-nspawnd"),
]

SHARED_GROUPS = [
  # (gid, name, members)
  (510, "cosmix-tls", ["cosmix-maild", "cosmix-webd"]),
  (516, "cosmix-mesh", ["cosmix-wgd"]),
]

CITIZEN_IDENTITY = [
  # (uid, name, state)  state ∈ {"live", "retired", "reclaimed"}
  (600, "cosmix-statecache", "live"),
]

errors = []

# --- Daemon-identity entries ---
for (want_uid, want_name) in DAEMON_IDENTITY:
    user_by_uid   = getent("passwd", want_uid)    # numeric lookup
    user_by_name  = getent("passwd", want_name)   # name lookup
    group_by_gid  = getent("group",  want_uid)
    group_by_name = getent("group",  want_name)

    # Absence — fail-closed at §6.2 and §6.3
    if user_by_uid is None or user_by_name is None:
        errors.append((want_name, "absent"))
        continue
    if group_by_gid is None or group_by_name is None:
        errors.append((want_name, "group absent"))
        continue

    # Name/UID disagree — somebody else holds the slot
    if user_by_uid.name != want_name:
        errors.append((want_name, f"uid {want_uid} held by {user_by_uid.name}"))
    if user_by_name.uid != want_uid:
        errors.append((want_name, f"name held by uid {user_by_name.uid}"))
    if group_by_gid.name != want_name:
        errors.append((want_name, f"gid {want_uid} held by {group_by_gid.name}"))
    if group_by_name.gid != want_uid:
        errors.append((want_name, f"group held by gid {group_by_name.gid}"))

    # GID must equal UID (§2.2 daemon-identity rule)
    if user_by_uid.uid != user_by_uid.gid:
        errors.append((want_name, f"uid {user_by_uid.uid} != gid {user_by_uid.gid}"))

# --- Shared-credential group entries ---
for (want_gid, want_name, want_members) in SHARED_GROUPS:
    group_by_gid  = getent("group",  want_gid)
    group_by_name = getent("group",  want_name)
    user_by_name  = getent("passwd", want_name)   # MUST be empty (§2.2)

    if group_by_gid is None or group_by_name is None:
        errors.append((want_name, "shared group absent"))
        continue
    if group_by_gid.name != want_name:
        errors.append((want_name, f"gid {want_gid} held by {group_by_gid.name}"))
    if group_by_name.gid != want_gid:
        errors.append((want_name, f"group held by gid {group_by_name.gid}"))
    if user_by_name is not None:
        # §2.2: shared-credential entries SHALL NOT have a same-named user.
        errors.append((want_name, f"shared-credential name collides with user uid {user_by_name.uid}"))

    # §6.2: every declared membership SHALL be present in getent.
    live_members = group_by_name.members  # 4th colon-field, comma-split
    for m in want_members:
        if m not in live_members:
            errors.append((want_name, f"missing membership: {m}"))

# --- Citizen-identity entries (§2.5 lifecycle) ---
for (want_uid, want_name, state) in CITIZEN_IDENTITY:
    user_by_uid   = getent("passwd", want_uid)
    user_by_name  = getent("passwd", want_name)
    group_by_gid  = getent("group",  want_uid)
    group_by_name = getent("group",  want_name)

    if state == "live":
        # A live citizen verifies under the exact daemon-identity rule
        # set (§2.2 — citizens have the daemon shape; GID==UID).
        if user_by_uid is None or user_by_name is None:
            errors.append((want_name, "citizen absent")); continue
        if group_by_gid is None or group_by_name is None:
            errors.append((want_name, "citizen group absent")); continue
        if user_by_uid.name != want_name:
            errors.append((want_name, f"uid {want_uid} held by {user_by_uid.name}"))
        if user_by_name.uid != want_uid:
            errors.append((want_name, f"name held by uid {user_by_name.uid}"))
        if group_by_gid.name != want_name:
            errors.append((want_name, f"gid {want_uid} held by {group_by_gid.name}"))
        if group_by_name.gid != want_uid:
            errors.append((want_name, f"group held by gid {group_by_name.gid}"))
        if user_by_uid.uid != user_by_uid.gid:
            errors.append((want_name, f"uid {user_by_uid.uid} != gid {user_by_uid.gid}"))
    else:
        # state in {"retired", "reclaimed"}: §2.5/§4.3 — the row is
        # comment-only and MUST NOT materialize. The check is by-NAME
        # ONLY, fail-closed: the retired/reclaimed NAME MUST NOT resolve
        # to any live user or group, regardless of UID. A by-UID check
        # would be laxer (partial-truth): a reclaimed UID may be
        # legitimately R7-re-allocated to a *different-named* live
        # citizen — that row is its own live CITIZEN_IDENTITY tuple,
        # checked above — so a by-UID test would false-NEGATIVE the
        # genuine "name still bound" violation whenever the UID happens
        # to be re-held by a differently-named live citizen. This
        # matches §6.2 and `spec10_postcheck.mix` exactly (by-name).
        if user_by_name is not None:
            errors.append((want_name,
                f"{state} citizen name still resolves to a live user "
                f"(uid {user_by_name.uid})"))
        if group_by_name is not None:
            errors.append((want_name,
                f"{state} citizen name still resolves to a live group "
                f"(gid {group_by_name.gid})"))

# Fail-closed reporting (§6.5)
if errors:
    log_local("cosmix.spec=10 cosmix.preflight=fail", errors)
    if amp_broker_is_up():
        amp_emit("preflight.failed", {spec: 10, version: "1.4.4", errors: errors})
    exit(1)

live_citizens = [c for c in CITIZEN_IDENTITY if c[2] == "live"]
log_local("cosmix.spec=10 cosmix.preflight=ok",
          daemons=len(DAEMON_IDENTITY), shared=len(SHARED_GROUPS),
          citizens=len(live_citizens))
if amp_broker_is_up():
    amp_emit("preflight.ok", {
        spec: 10, version: "1.4.4",
        daemons:  len(DAEMON_IDENTITY),
        shared:   len(SHARED_GROUPS),
        citizens: len(live_citizens),
    })
exit(0)

Notes on the ABP-emit branch:

  • §6.3 runs before cosmix-noded binds its ABP socket. The verifier therefore SHALL log to the local journal and SHALL NOT treat ABP being down as a failure.
  • §6.2 runs after systemd-sysusers and before any cosmix-*.service is started. ABP MAY be up if a prior cosmix-noded is already running (e.g. an in-place upgrade); if so, the verifier MAY emit on the preflight topic. If not, local logging is sufficient.

Appendix A. Initial UID/GID Registry (1.4.4)

# Cosmix daemon identity registry — version 1.4.4
# Date: 2026-08-08
# Daemon/shared window: 500-599 (preferred fixed-ID window; see §2.1)
# Citizen window:        600-699 (citizen-identity stream, v1.2.0; §2.1, R7)
# Daemon/shared allocation rule: append-only, no reuse (R1, R2, R2.a)
# Citizen allocation rule:       lowest-free in 600-699; scoped gated
#   reuse only after R8 (mesh-wide purge-verification AND a 30-day
#   quarantine window) — see §2.3 R7/R8 and §2.5.
# ABP service name defaults to <name> minus the "cosmix-" prefix unless
# an explicit `amp:` field overrides it (R6). The ABP name is the
# identity the daemon registers with on the node-local broker if it
# ABP-registers; L14 uniqueness binds the name across the daemon and
# live citizen blocks whether or not the daemon currently registers.
# The v1.4.0 observability-tier entries (508/509/511/512/513) hold
# their R6 default names (`prometheus`, `grafana`, `loki`, `alloy`,
# `pveexport`) without currently ABP-registering — see Appendix D for
# the precedent and the package-native-unit masking requirement. The
# v1.4.1 addition (514 `cosmix-nodeexport`, R6 name `nodeexport`)
# follows the same deployment shape.

# --- Daemon-identity entries (POSIX user + same-numbered group) ---
uid  name              amp     gecos                                  tier         tombstoned
---  ----------------  ------  -------------------------------------  -----------  ----------
500  cosmix-noded      -       "Cosmix node daemon (ABP broker)"      substrate    -
501  cosmix-maild      -       "Cosmix mail daemon"                   application  -
502  cosmix-webd       -       "Cosmix web daemon"                    application  -
503  cosmix-indexd     -       "Cosmix knowledge daemon"              substrate    -
504  cosmix-agentd     -       "Cosmix agent runtime"                 substrate    -
505  cosmix-mcp        -       "Cosmix MCP bridge"                    substrate    -
506  cosmix-dnsd       -       "Cosmix authoritative DNS daemon"      substrate    -
507  cosmix-cron       -       "Cosmix scheduler"                     substrate    -
508  cosmix-prometheus -       "Cosmix Prometheus (obs tier)"         substrate    -
509  cosmix-grafana    -       "Cosmix Grafana (obs tier)"            substrate    -
511  cosmix-loki       -       "Cosmix Loki (obs tier)"               substrate    -
512  cosmix-alloy      -       "Cosmix Grafana Alloy (obs tier)"      substrate    -
513  cosmix-pveexport  -       "Cosmix proxmox-exporter (obs tier)"   substrate    -
514  cosmix-nodeexport -       "Cosmix node_exporter (obs tier)"      substrate    -
515  cosmix-wgd        -       "Cosmix WireGuard mesh control plane"  substrate    -
517  cosmix-interactd  interact "Cosmix interaction broker"            session-reserved -
518  cosmix-nspawnd    -       "Cosmix nspawn host executor"          substrate    -

# --- Shared-credential group entries (group only; no associated user) ---
gid  name        purpose                                                   tombstoned
---  ----------  --------------------------------------------------------  ----------
510  cosmix-tls  Read access to TLS keypairs shared by ≥2 daemons (§3.3)   -
516  cosmix-mesh Read access to the signed mesh inventory (SPEC-13 INV-1) shared by mesh daemons (§3.3)  -

# --- Citizen-identity entries (POSIX user + same-numbered group; §2.5 scoped reuse) ---
# Columns after gecos: tier, then the §2.5 lifecycle audit triple
# (retired / reclaimed / verifier). State is DERIVED: `-` retired ⇒
# live; retired set, reclaimed `-` ⇒ in quarantine (R8); both set with
# a verifier token ⇒ reclaimed (UID back in the R7 free pool). A
# retired:/reclaimed: row is KEPT here (never deleted — R7) and does
# NOT project a `u` line (§4.3, §9.1).
cid  name               amp  gecos                               tier     retired     reclaimed   verifier
---  -----------------  ---  ----------------------------------  -------  ----------  ----------  --------
600  cosmix-statecache  -    "Cosmix SPEC-18 reference citizen"  citizen  -           -           -

# `-` in the amp column means the default derivation applies (name minus
# "cosmix-" prefix). Entry 500 follows the default — it registers as
# `noded` (the historical `hub` alias was removed in the 2026-05-09
# cosmix-noded rename; the substrate has no central hub role, every
# mesh node runs its own `cosmix-noded`). The v1.4.0 observability-tier
# entries (508/509/511/512/513) likewise carry the default; their R6
# ABP names are reserved per L14 even though the upstream Go binaries
# do not currently ABP-register (see Appendix D 1.4.0 for the
# deployment-shape requirement that the package-native systemd unit
# be masked and the daemon be supervised by the Cosmix-managed
# `cosmix-<d>.service` unit). The v1.4.1 addition (514
# `cosmix-nodeexport`, R6 default `nodeexport`) carries the same
# deployment shape — see Appendix D 1.4.1.
#
# The shared-credential group block sits in the same 500–599 window as
# the daemon-identity block per §2.1. Group GIDs are picked from the
# next free number that does not collide with daemon-identity numbering.
# The 500–509 daemon-only reserve was fully consumed by v1.4.0 (508
# `cosmix-prometheus`, 509 `cosmix-grafana`); the daemon-identity stream
# has now crossed into the 510+ shared zone (511 `cosmix-loki`, 512
# `cosmix-alloy`, 513 `cosmix-pveexport`), skipping 510 which is held by
# `cosmix-tls`. The two-stream non-collision invariant still holds (each
# number appears in at most one stream). v1.4.2 adds a second shared-cred
# group, `cosmix-mesh` (GID 516, signed-inventory read access §3.3); the
# daemon stream skips 516 to preserve non-collision. Future shared-
# credential groups continue from the next free GID that does not collide
# with the daemon-identity frontier (519 as of v1.4.4, after nspawnd 518).
#
# The citizen-identity block lives in its own 600–699 window (§2.1),
# disjoint from 500–599, so citizen numbering never collides with
# daemon or shared-credential numbering. Citizen allocation is
# lowest-free in 600–699 (R7); a UID re-enters the free pool only
# after R8 (mesh-wide purge-verification AND the 30-day quarantine).
#
# The former 506 gap was RESOLVED by v1.3.0: `cosmix-dnsd` consumed the
# R2.a-reclaimed `506` slot (freed from `cosmix-cloudd` by bb87724,
# 2026-05-12 — never-functional pre-deployment reclamation). No daemon-
# stream gap remains. v1.4.0 makes no R2.a claim and introduces no new
# gap: 508 and 509 advance the append-only frontier in sequence, then
# (skipping the existing 510 shared-cred entry) 511/512/513 continue
# the sequence. v1.4.1 appends one further daemon-identity entry (514
# `cosmix-nodeexport`) at the next-sequential slot — no R2.a, no
# tombstone, no new gap. v1.4.2 appends one further daemon-identity
# entry (515 `cosmix-wgd`, the WireGuard mesh control plane, SPEC-13
# D0) at the next-sequential slot — no R2.a, no tombstone, no new gap.
# v1.4.3 appends 517 `cosmix-interactd`, skipping the already-deployed
# `cosmix-mesh` shared-credential GID 516. Its row reserves the stable
# namespace/props ownership identity; §7.3 governs the desktop sink's distinct
# logged-in-user runtime shape.
# v1.4.4 appends 518 `cosmix-nspawnd` (nspawn host executor, nspawn
# cluster-lite C1) at the next-sequential slot — no R2.a, no tombstone,
# no new gap. R6 default ABP/Bus service name `nspawnd`.
# The two-stream non-collision rule (§2.2) is preserved.
#
# Next free daemon UID: 519 (516 is held by the deployed `cosmix-mesh` shared-
#   credential group; 517 is `cosmix-interactd`, 518 is `cosmix-nspawnd`).
# Next free shared-credential GID: 519 (the shared-cred stream holds 510
#   `cosmix-tls` and 516 `cosmix-mesh`; the daemon stream now also holds
#   517 and 518).
# Next free citizen UID: 601 (lowest-free in 600–699; reclaimed UIDs
#   re-enter this pool only after R8 — §2.3 R7/R8, §2.5).
# Tombstones (kept for audit; SHALL NOT be reused per R2): none.
# Citizens in quarantine (retired:, not yet R8-reclaimed): none.
# Citizens reclaimed (UID returned to the R7 free pool): none.

The tier column is informational only. It does not influence numeric allocation (R3) and MAY be revised without renumbering.


Appendix B. Known distro UID reservations in 100–999 (survey 2026-05-08)

This appendix records the empirical survey that motivated the choice of the 500–599 window. It is informational and does not impose normative requirements.

B.1 Debian 13 / Trixie

Per base-passwd master, the only static UID reservations are 0–65 (the traditional low-numbered system users) plus 65534 (nobody) and 65539 (_apt). Per Debian Policy §9.2.2, all of 100–999 is dynamically allocated. No static reservations exist in Cosmix's preferred window.

B.2 Arch Linux

Empirical sample of well-known service packages with sysusers.d fragments. UID reservations in the system range:

UIDNameSource package
102polkitdpolkit
197rabbitmqrabbitmq
207grafanagrafana
208consulconsul
209cupscups
210prometheusprometheus
333amavisamavisd-new
335opendmarcopendmarc
439ldapopenldap
440dirsrv389-ds-base
493oidentdoidentd

No reservations were found in the 500–599 window in this sample. The sample is not exhaustive; the install-time preflight (§6) is the mechanism that defends against unsurveyed reservations, not this table.

B.3 OpenWRT

OpenWRT pre-allocates a small set of low-numbered UIDs (typically < 100) and uses dynamic allocation for service users. No conflicts with 500–599 are known.

B.4 Citizen-band re-survey (2026-05-16, v1.2.0)

R5 obliges a fresh empirical survey before any new fixed-ID window is opened. The v1.2.0 citizen-identity stream introduces the 600–699 band, so the B.1–B.3 sources were re-examined for that window:

  • Debian 13 / Trixie — unchanged from B.1: Debian Policy §9.2.2 makes all of 100–999 dynamically allocated; the only static reservations remain 0–65, 65534, 65539. 600–699 carries no static Debian reservation.
  • Arch Linux — the B.2 sysusers.d sample contains no reservation in 600–699 (the highest sampled system UID is oidentd@493; the next populated region is 7790+). 600–699 is unclaimed in this sample, with the same not-exhaustive caveat as B.2; the §6 install preflight remains the defence against unsurveyed reservations, not this table.
  • OpenWRT — unchanged from B.3: low-numbered static UIDs only; no conflict with 600–699.

This matches Appendix C's pre-existing assessment of the 600 anchor ("No known reservations; Clean; equivalent to 500"). The citizen band is therefore as clean as the daemon window by the same evidence; the choice of 600–699 over a 500–599 sub-range is the disjoint-window safety argument in §2.1, not a survey difference.


Appendix C. Rationale: Why 500-anchor

This appendix is informational. It records the comparative analysis that selected 500 as the anchor over alternatives.

AnchorWindow sizeSurvey resultNS 3.0 interactionDecision
333333–399 (67 slots)Collides with amavis@333, opendmarc@335 on ArchCleanRejected — known collisions
400400–438 (31 slots)Verified clean to 438; ldap@439, dirsrv@440 immediately aboveCleanConsidered; rejected for tight headroom
444444–491 (48 slots)Same upstream pins (439/440/493) within blockCleanRejected — known pins inside block
500500–599 (100 slots)No known reservations in 500–599 across surveyed sources (Arch sample jumps from oidentd@493 to 7790+, supporting evidence that 500–599 is unclaimed in this sample but not a guarantee for unsurveyed packages)CleanSelected
600600–699No known reservations (re-confirmed Appendix B.4, 2026-05-16)CleanEquivalent to 500 for daemons; chose 500 for memorability. Allocated to the citizen-identity stream by v1.2.0 — a disjoint window from 500–599 so citizen scoped-reuse (R7/R8) can never collide with daemon/shared no-reuse numbering (§2.1).
700700–799No known reservationsCleanEquivalent to 500
800800–899No known reservations, but ~30 slots clearance from auto-allocation downward frontierCleanRejected — too close to the downward auto-allocation pool
900900–999Auto-allocation pool actively allocating downward from 999, currently at ~930 on a loaded boxCleanRejected — active conflict zone
90009000–9099Empty by every surveyConflicts with NS 3.0 vhost users (auto-allocate upward from 1001 toward 10000)Rejected — punches a hole in the customer namespace
60000–64999Debian global allocationReserved by Debian projectN/ARejected — explicitly reserved upstream

The 500 anchor was selected because it:

  1. Has no known reservations across Debian, Arch, or OpenWRT.
  2. Provides 100 contiguous slots — sufficient headroom for the ~30 forecast daemon count plus tombstones and growth.
  3. Sits well above the downward auto-allocation frontier (~930 on loaded systems) and well below the NS 3.0 vhost numbering (1001+).
  4. Is memorable as a round number, easing cold-recall.

Appendix D. Changes to this Specification

1.4.4 — 2026-08-08

Additive amendment: register cosmix-nspawnd (UID/GID 518) as a daemon-identity entry — the per-host nspawn executor introduced by the nspawn cluster-lite plan (milestone C1). It owns every supported start path for managed systemd-nspawn@ instances on its host: generation-fenced start (local grant = authority, durable tombstone = revocation floor), stop, list/status via org.freedesktop.machine1, per-instance flock serialisation, and durable operation records under /var/lib/cosmix/nspawnd/. It is a Cosmix-authored daemon that Bus-registers under its R6 default service name nspawnd. Actuation rides org.freedesktop.systemd1 StartUnit/StopUnit; the daemon runs as 518 with a polkit rule scoped to systemd-nspawn@*.service start/stop — it does not run as root.

518 is the next-sequential daemon-stream slot above v1.4.3's frontier of 517 (R1 — append-only; 516 remains held by the cosmix-mesh shared-credential group). No rule change: R1–R8 and R2.a are unchanged. No existing entry, ownership, or registry rule is altered. No tombstone, no quarantine, no reclamation; the citizen stream is untouched (next free citizen UID remains 601).

Stream-accounting consequences: both "next free" pointers advance to 519 (daemon stream consumed 518; the shared-credential stream's next free GID moves past it to preserve the two-stream non-collision invariant). Daemon UIDs used = 500–515, 517, 518.

Projection changes are limited to the 518 cosmix-nspawnd row in §2.2, Appendix A (registry header → v1.4.4 / 2026-08-08, both next-free pointers → 519), §9.1's sysusers fragment (one u cosmix-nspawnd 518 … line at the bottom of the daemon-identity sub-block; generated-from version → v1.4.4), and §9.3's preflight list ((518, "cosmix-nspawnd"); ABP-emit version1.4.4), plus the version fields carried by those projections. The checked-in _etc/sysusers/cosmix.conf projection moves together with this amendment. cosmix-nspawnd is NOT a member of cosmix-mesh in this projection; if C2's controller mode needs signed-inventory read access, that membership is a future amendment's explicit m line, not a deploy-local edit. Frozen historical changelog entries retain their original wording by design.

1.4.3 — 2026-07-23

Additive amendment: register cosmix-interactd at UID/GID 517, the next free daemon-stream number after 515 because the deployed shared-credential group cosmix-mesh remains fixed at GID 516. R1 forbids renumbering that group; this amendment skips 516 and advances both current next-free pointers to 518. No UID/GID is reclaimed, renumbered, tombstoned, or reused.

The interactd row is an explicit namespace/props ownership reservation rather than a claim about the desktop process's runtime UID. notify.v1 delivers through org.freedesktop.Notifications on the logged-in user's session D-Bus, so the shipped service shape is a systemd --user unit and the process runs as that logged-in user (§7.3). A system unit with User=cosmix-interactd would not have the required session bus and is not shipped; any future system-owned persisted props helper using UID/GID 517 requires its own specified trust boundary.

Projection changes are limited to the 517 cosmix-interactd row in §2.2, Appendix A, §9.1's sysusers fragment, and §9.3's preflight list, plus the version fields carried by those current projections. The checked-in sysusers projection and the NS5 factory copy installed by both PKGBUILD variants, plus Rust SPEC-10 declarers, move together to 1.4.3. Historical 1.4.2 changelog text remains unchanged and continues to record cosmix-mesh at GID 516. The registry row records the explicit R6 ABP-name override interact, which matches the service name the binary registers instead of the default-derived interactd.

This amendment also introduces the closed registered systemd-user daemon classification (§5.1.2) and applies it only to interactd. The class runs as the logged-in user, permits the session bus, omits system-service identity/state directives, orders against graphical-session.target, and is exempt from L15's impossible cross-manager After=cosmix-noded.service; L8/L13/L15 now distinguish this explicit class from ordinary system-service daemons.

1.4.2 — 2026-07-06

Additive amendment: register cosmix-wgd (UID/GID 515) as a daemon-identity entry — the WireGuard mesh control-plane daemon (SPEC-13 D0). It is the first daemon-identity entry outside the observability tier since v1.3.0's cosmix-dnsd: unlike the v1.4.0/v1.4.1 obs-tier batch (upstream Go binaries pinned to a Cosmix identity), cosmix-wgd is a Cosmix-authored daemon that ABP-registers under its R6 default service name wgd on the node-local broker. The consumer is the SPEC-13 mesh control plane (D0 — the WireGuard peering/rekey control surface). No rule change: R1–R8 and R2.a are unchanged and continue to bind all three streams. No existing entry, ownership, or registry rule is altered. The entry takes the default ABP service name (R6 — name minus the cosmix- prefix, i.e. wgd) and does not tombstone any prior allocation.

This amendment also registers a second shared-credential group, cosmix-mesh (GID 516), mirroring the cosmix-tls (510) shape (group only, no associated user, no ABP service). It mediates read access to the signed mesh inventory (SPEC-13 INV-1) under /var/lib/cosmix/noded/, whose payload is public and signature-verified. cosmix-wgd is the first and, in this v1.4.2 projection, the ONLY member (the fixture/m-line set is ["cosmix-wgd"]). Making /var/lib/cosmix/ noded/ group-readable by cosmix-mesh — the noded StateDirectory group + any m cosmix-noded cosmix-mesh line, if the owner is added — is a deploy-local / provisioning step OUTSIDE this sysusers projection, not part of v1.4.2. The group exists so cosmix-wgd can read the inventory without joining the cosmix-noded group — which would also grant read on cosmix-noded's private d2 seed. This is the §3.3 shared-credential pattern applied to a signed-data-read boundary: the trust win is that the WireGuard control plane sees only the public, signed peer set, never cosmix-noded's signing key.

Stream-accounting consequences:

  • Daemon-identity stream consumes 515 (cosmix-wgd) as the next-sequential slot above v1.4.1's frontier of 515 (R1 — append-only sequence, no R2.a in this amendment).
  • Shared-credential stream consumes 516 (cosmix-mesh) — the second shared-credential group after cosmix-tls (510). Its sole member so far is cosmix-wgd; cosmix-noded is added as the other member at deploy time.
  • Both "next free" pointers advance to 517. The daemon stream skips 516 (now held by the shared-cred group) to preserve the two-stream non-collision invariant (§2.2); the shared-cred stream's own next-free GID is likewise 517. Daemon UIDs used = 500–515; shared-cred GIDs used = 510 + 516; 510 remains the only skip inside the daemon band.
  • Citizen-identity stream is untouched: 600 cosmix-statecache remains the sole entry; "next free citizen UID" remains 601; no R7/R8 lifecycle event.
  • No tombstones, no quarantine, no reclamation. R2 and R8 are not exercised.

The spec body changes only where the registry is materialized:

  1. §2.2 summary table — inserts the 515 cosmix-wgd daemon row between 514 cosmix-nodeexport and 600 cosmix-statecache, and the 516 cosmix-mesh shared-credential (GID-only) row grouped with the 510 cosmix-tls row; both tagged (v1.4.2) for provenance.
  2. Appendix A — inserts the 515 cosmix-wgd daemon-identity row at the bottom of the daemon-identity block and the 516 cosmix-mesh row at the bottom of the shared-credential block, bumps the registry header to v1.4.2 / 2026-07-06, advances both "Next free" pointers to 517 (daemon and shared-credential — the daemon stream skips 516, now held by the new shared-cred group), and extends the canonical-block narrative. The frozen v1.1.0 / v1.2.0 / v1.3.0 / v1.4.0 / v1.4.1 changelog entries retain their original wording by design — each records the pointer values as of that amendment.
  3. §9.1 sysusers fragment — inserts one u cosmix-wgd 515 … line at the bottom of the daemon-identity sub-block, and g cosmix-mesh 516 plus m cosmix-wgd cosmix-mesh in the shared-credential sub-block (the m line lands after both its u cosmix-wgd and g cosmix-mesh referents, per the §9.1 ordering rule). The canonical block and the generated _etc/sysusers/cosmix.conf carry the same order, keeping the §8.2 L7/L16 invariants green. Generated-from version bumps to v1.4.2.
  4. §9.3 preflight example — adds (515, "cosmix-wgd") to the DAEMON_IDENTITY list (now 15) and (516, "cosmix-mesh", ["cosmix-wgd"]) to the SHARED_GROUPS list, and bumps the ABP-emit version field to 1.4.2.

No new entry class is introduced (both the daemon-identity and shared-credential classes already exist), so _bin/spec10_lint.mix and _bin/spec10_postcheck.mix need no structural change: the daemon row is covered by the existing L1–L7/L14 rules (L3 monotonicity — 515 is append-only above 514) and the shared-cred row by the L16 block↔fragment pairwise-agreement / member-validity / ordering rules (516 is append-only above 510). This allocation gives the SPEC-13 WireGuard control-plane daemon its SPEC-10 L2 daemon identity, and gives it group-mediated read on the signed mesh inventory without widening the cosmix-noded trust boundary. (The unqualified word citizen in SPEC-10 refers to the disjoint 600–699 citizen-identity stream (§2.5); cosmix-wgd is a daemon-identity entry in the 500–599 stream and is governed by R1–R6, not by R7/R8.)

1.4.1 — 2026-05-28

Additive amendment: register cosmix-nodeexport (UID/GID 514) as a daemon-identity entry. This pairs with the v1.4.0 observability-tier batch by completing the per-host metric path: node_exporter runs on every host under Cosmix observation (ABP mesh nodes and foreign hosts such as pve2/3/4/5 and PBS) and exports OS-level CPU/RAM/disk/network metrics — the per-host counterpart to cosmix-pveexport's PVE-API path. The consumer is the observability-tier deployment plan (the 2026-05-28 observability-tier plan — tier live; plan retired 2026-07-23, git history) which mandates SPEC-10 L2 from first install for all obs-tier daemons (§7 of that doc — v1.4.1 extends the §7 list to six identities). No rule change: R1–R8 and R2.a are unchanged and continue to bind all three streams. No existing entry, ownership, or registry rule is altered. The entry takes the default ABP service name (R6 — name minus the cosmix- prefix, i.e. nodeexport) and does not tombstone any prior allocation.

The entry is the sixth daemon-identity entry whose upstream binary is not authored by Cosmix — the v1.4.0 batch (five) established the precedent, and v1.4.1 extends it to the per-host exporter. The deployment shape from Appendix D 1.4.0 (package-native systemd unit SHALL be masked; package-native POSIX user SHALL NOT be named in any cosmix-*.service; Cosmix-managed cosmix-<d>.service at SPEC-10 §5 L2) applies identically. cosmix-nodeexport is unusual in deployment shape — it runs on every host under observation, not just epsilon.amp, per the obs-tier plan §4 / §7 / §8 (P2). The SPEC-10 registry entry is identical across hosts (uniform cosmix-nodeexport:cosmix-nodeexport 514); the per-host sysusers fragment lands at install time on each scraped host, exactly as cosmix-alloy does in P4.

Stream-accounting consequences:

  • Daemon-identity stream consumes 514 as the next-sequential slot above v1.4.0's frontier of 514. No skip (510 was the prior skip and remains the only one in the band).
  • Daemon-identity append-only frontier advances from 514 to 515 (R1 — append-only sequence, no R2.a in this amendment).
  • Shared-credential stream "next free GID" is overtaken from 514 to 515. No shared-credential entry is added, removed, retired, or renumbered.
  • Citizen-identity stream is untouched: 600 cosmix-statecache remains the sole entry; "next free citizen UID" remains 601; no R7/R8 lifecycle event.
  • No tombstones, no quarantine, no reclamation. R2 and R8 are not exercised.

The spec body changes only where the registry is materialized:

  1. §2.2 summary table — inserts one row at numeric slot 514 between 513 cosmix-pveexport and 600 cosmix-statecache, tagged with its observability-tier role and (v1.4.1) for provenance.
  2. Appendix A — inserts the 514 cosmix-nodeexport daemon- identity row at the bottom of the daemon-identity block, bumps the registry header to v1.4.1 / 2026-05-28, advances "Next free daemon UID" to 515, advances "Next free shared-credential GID" to 515 (overtaken), and extends the canonical-block narrative recording the v1.4.1 single-row append. The frozen v1.1.0 / v1.2.0 / v1.3.0 / v1.4.0 changelog entries retain their original wording by design — each records the pointer values as of that amendment.
  3. §9.1 sysusers fragment — inserts one u cosmix-nodeexport 514 … line at the bottom of the daemon-identity sub-block; the canonical block and the generated _etc/sysusers/cosmix.conf carry the same order, keeping the §8.2 L7 generated-fragment- matches-canonical invariant green. Generated-from version bumps to v1.4.1.
  4. §9.3 preflight example — adds (514, "cosmix-nodeexport") to the DAEMON_IDENTITY list and bumps the ABP-emit version field to 1.4.1 (so len(DAEMON_IDENTITY) reports 14 consistently).

No new entry class is introduced, so _bin/spec10_lint.mix and _bin/spec10_postcheck.mix need no structural change: the new row is covered by the existing daemon-identity L1–L7/L14 rules (L3 monotonicity is satisfied — 514 is append-only above 513). This allocation makes the obs-tier plan's P2 phase (node_exporter on PVE/PBS and ABP mesh hosts) buildable as a SPEC-10 L2 daemon identity on every scraped host. (The unqualified word citizen in SPEC-10 refers to the disjoint 600–699 citizen-identity stream (§2.5); cosmix-nodeexport is a daemon-identity entry in the 500–599 stream and is governed by R1–R6, not by R7/R8.)

1.4.0 — 2026-05-28

Additive amendment: register the observability tier as five new daemon-identity entries — cosmix-prometheus (UID/GID 508), cosmix-grafana (UID/GID 509), cosmix-loki (UID/GID 511), cosmix-alloy (UID/GID 512), and cosmix-pveexport (UID/GID 513). The consumer is the observability-tier deployment plan (the 2026-05-28 observability-tier plan — tier live; plan retired 2026-07-23, git history) which mandates SPEC-10 L2 from first install for all five (§7 of that doc). No rule change: R1–R8 and R2.a are unchanged and continue to bind all three streams. No existing entry, ownership, or registry rule is altered. All five entries take the default ABP service name (R6 — name minus the cosmix- prefix), and none of them tombstone any prior allocation.

These five entries are the first batch of daemon-identity entries whose upstream binary is not authored by Cosmix — they are hardened Go binaries from Prometheus, Grafana Labs, and Starttoaster (proxmox-exporter), installed via the host distribution's package manager and pinned to a Cosmix-allocated POSIX identity by the substrate. The substrate identity contract (cosmix-<d> user + same-numbered group, GID==UID, no-reuse R2, SPEC-10 §5 hardening) applies identically — provenance of the binary is orthogonal to the identity it runs under. The ABP service name R6 derives (prometheus, grafana, loki, alloy, pveexport) is the identity the daemon would register with on the node-local broker; in the v1.4.0 deployment shape the upstream binaries do not register, but the name is held by the registry and L14-uniqueness applies regardless, so a future Cosmix-side bridge (e.g. a metrics-side ABP shim) inherits the reserved name without further amendment.

Deployment shape — package-native identities SHALL NOT be used. The host distribution's package for each of these binaries typically ships its own POSIX user (e.g. prometheus@distro-uid, grafana@distro-uid) and its own systemd unit (e.g. prometheus.service, grafana-server.service). The Cosmix substrate identity contract requires the daemon to run as cosmix-<d> and to be supervised by a Cosmix-managed cosmix-<d>.service unit at SPEC-10 §5 L2 (the obs-tier plan §7 mandate). Therefore for every obs-tier daemon installed via the distribution package manager:

  • The package's native systemd unit SHALL be masked (systemctl mask <package>.service) and replaced by the Cosmix-managed cosmix-<d>.service invoking the same binary with User=cosmix-<d>, Group=cosmix-<d>, StateDirectory=cosmix/<d>, and the §5.2 hardening directives. Leaving the package's native unit enabled is a SPEC violation: the §8.2 L8/L13 lint covers cosmix-*.service only, so a live package-native unit running as the package-native UID would satisfy the lint and the registry while silently bypassing both.
  • The package's native POSIX user MAY remain present (apt removal often leaves it; harmless because nothing under /var/lib/cosmix/<d>/ is owned by it), but it SHALL NOT be named in any cosmix-*.service unit's User=/Group= and SHALL NOT be added to the cosmix-tls shared-credential group or any future Cosmix shared-credential group.
  • The Cosmix-managed unit SHALL follow §5.4: After=cosmix-noded.service is declared on every cosmix-<d>.service unit other than the broker-provider, satisfying L15 — the obs-tier daemons do not currently ABP-register, but the broker-ordering declaration is free and keeps lint coverage uniform.

cosmix-alloy is unusual in deployment shape — it runs on every mesh node, not just epsilon.amp, per the obs-tier plan §7. The SPEC-10 registry entry is identical for all hosts (uniform cosmix-alloy:cosmix-alloy 512 everywhere); the per-node sysusers fragment lands at install time on each mesh node, exactly as cosmix-noded does today.

Stream-accounting consequences:

  • Daemon-identity stream consumes 508 and 509 in sequence (the two slots v1.3.0 explicitly reserved as "the next two append-order daemon allocations"). It then skips 510 (held by cosmix-tls, a shared-credential entry) and continues at 511, 512, 513. The skip is required by the two-stream non-collision invariant (§2.2) and is the first concrete realisation of v1.3.0's pre-stated rule "Future shared-credential groups continue from 511 unless the next free daemon UID has overtaken that number first" — v1.4.0 is exactly the daemon-stream overtake the v1.3.0 language anticipated.
  • Daemon-identity append-only frontier advances from 508 to 514 (R1 — append-only sequence, no R2.a in this amendment).
  • Shared-credential stream "next free GID" is overtaken from 511 to 514. No shared-credential entry is added, removed, retired, or renumbered.
  • Citizen-identity stream is untouched: 600 cosmix-statecache remains the sole entry; "next free citizen UID" remains 601; no R7/R8 lifecycle event.
  • No tombstones, no quarantine, no reclamation. R2 and R8 are not exercised.

The spec body changes only where the registry is materialized:

  1. §2.2 summary table — inserts five rows at their numeric slots: 508/509 between 507 cosmix-cron and 510 cosmix-tls, and 511/512/513 between 510 cosmix-tls and 600 cosmix-statecache. Each is tagged with its observability-tier role and (v1.4.0) for provenance.
  2. Appendix A — inserts the five daemon-identity rows in append- order at the bottom of the daemon-identity block, bumps the registry header to v1.4.0 / 2026-05-28, advances "Next free daemon UID" to 514, advances "Next free shared-credential GID" to 514 (overtaken), and rewrites the canonical-block comment block: the 500–509 daemon-only reserve is now fully consumed; the daemon stream has crossed into the 510+ shared zone skipping 510; the two-stream non-collision invariant is preserved. The frozen v1.1.0 / v1.2.0 / v1.3.0 changelog entries retain their original wording by design — each records the pointer values as of that amendment. A clarifying sentence is added to the header comment recording the R6 ABP-name reservation for the new daemon-identity entries (the obs-tier binaries do not ABP-register today; the reservation is per L14).
  3. §9.1 sysusers fragment — inserts five u cosmix-<d> <uid> … lines at their numeric slots in the daemon-identity sub-block; the canonical block and the generated _etc/sysusers/cosmix.conf carry the same order, keeping the §8.2 L7 generated-fragment-matches-canonical invariant green (L7 is the daemon-identity↔sysusers pairwise check; L17 is the citizen-only analogue and is not exercised by this amendment). Generated-from version bumps to v1.4.0.
  4. §9.3 preflight example — adds (508, "cosmix-prometheus"), (509, "cosmix-grafana"), (511, "cosmix-loki"), (512, "cosmix-alloy"), (513, "cosmix-pveexport") to the DAEMON_IDENTITY list and bumps the ABP-emit version field to 1.4.0 (so len(DAEMON_IDENTITY) reports 13 consistently).

No new entry class is introduced, so _bin/spec10_lint.mix and _bin/spec10_postcheck.mix need no structural change: the five new rows are covered by the existing daemon-identity L1–L7/L14 rules (L3 monotonicity is satisfied — the 511/512/513 sequence is append-only within the daemon block; the 510-skip is a shared-credential row in the separate shared block and does not break daemon-block monotonicity). This allocation makes the obs-tier plan's §7 "registry entries for all five daemons" gate buildable from P1 onward.

1.3.0 — 2026-05-18

Additive amendment: register cosmix-dnsd (UID/GID 506), the authoritative WG-mesh DNS daemon, as a daemon-identity entry, and in doing so resolve the former 506 daemon-stream gap. 506 was the cosmix-cloudd slot, R2.a-reclaimed by bb87724 (2026-05-12, never-functional pre-deployment reclamation — bb87724 records the then-applicable R2.a conjunctive preconditions as holding, no tombstone). Per R1, an R2.a-reclaimed UID re-enters the free pool and is preferred over the next sequential UID for the next eligible daemon allocation; cosmix-dnsd is that next eligible allocation, so it takes 506 out of append-order rather than 508. This is the R1/R2.a path the spec already defines — no rule change; R1–R8 and R2.a are unchanged and continue to bind all three streams with no semantic change, and no existing entry or on-disk ownership is altered. cosmix-dnsd registers under the default ABP service name dnsd (name minus the cosmix- prefix, R6). The append-only frontier is unchanged at 508 because an R2.a slot sits numerically below the frontier and does not advance it (R1). The spec body changes only where the registry is materialized:

  1. §2.2 summary table — inserts the 506 cosmix-dnsd dnsd row at its numeric slot (between 505 and 507), tagged R2.a-reclaimed.
  2. §2.3 — the R7/R8 justification now records the 506/cloudd question as resolved by this allocation (R1's preferred-reclaimed rule), with the frontier explicitly unchanged at 508; the v1.2.0 citizen amendment still never touched the 500–599 stream.
  3. Appendix A — inserts the 506 cosmix-dnsd daemon-identity row at its numeric slot, bumps the registry header to v1.3.0 / 2026-05-18, leaves the "Next free daemon UID" pointer at 508 (the R2.a slot is below-frontier), and rewrites the canonical-block comment block: the former 506 gap is resolved (no daemon-stream gap remains), and 508–509 remain reserved to the next two append-order daemon allocations. The frozen v1.1.0 / v1.2.0 changelog entries retain their original "508" wording by design — each records the pointer value as of that amendment.
  4. §9.1 sysusers fragment — inserts the u cosmix-dnsd 506 … line at its numeric slot (between mcp/505 and cron/507); the canonical block and generated _etc/sysusers/cosmix.conf carry the same order, keeping the §8.2 L17 registry↔fragment pairwise-agreement invariant green.
  5. §9.3 preflight example — adds (506, "cosmix-dnsd") to the DAEMON_IDENTITY list and bumps the ABP-emit version field to 1.3.0 (so len(DAEMON_IDENTITY) reports 8 consistently).

No new entry class is introduced, so _bin/spec10_lint.mix and _bin/spec10_postcheck.mix need no structural change: the new row is covered by the existing daemon-identity L1–L5/L14/L17 rules (L17 checks order agreement, not numeric monotonicity, so the below-frontier 506 row is accepted). This allocation resolves the dnsd P2 gate: the daemon-identity slot is recorded and the live deployment contract is _etc/systemd/cosmix-dnsd.service, in concert with _doc/2026-05-17-mesh-dns-topology.md §5's SPEC-10/SPEC-18 split.

1.2.0 — 2026-05-16

Additive amendment: introduce the citizen-identity entry class and register cosmix-statecache (UID/GID 600), the SPEC-18 Phase-1 reference citizen. No daemon-identity or shared-credential entry changes; no existing rule on those two streams relaxes. The new class has the same on-disk shape as a daemon-identity entry but lives in a disjoint 600–699 window and is governed by a scoped, gated reuse rule (mesh-wide automated purge-verification AND a 30-day quarantine) rather than the strict daemon no-reuse invariant. The spec body grows in eleven places:

  1. §1.4 Glossary — adds citizen-identity entry, citizen band (600–699), quarantine window (30 days), and purge-verification; re-scopes the tombstone definition so it applies to daemon-identity and shared-credential entries only (the citizen-class analogue is the §2.5 retire→quarantine→reclaim lifecycle, not a tombstone).
  2. §2.1 Numbering window — introduces 600–699 as the citizen-identity stream's preferred fixed-ID window, disjoint from the 500–599 daemon/shared window. The disjointness is the safety argument: citizen scoped-reuse can never collide with daemon or shared-credential no-reuse numbering. Allocation within the band is governed by R7/R8, not by §2.1's append-only frontier.
  3. §2.2 Initial registry — defines the citizen-identity entry class (POSIX cosmix-<d> user + same-numbered group, GID==UID, ABP name = name minus cosmix- — identical to daemon-identity) and pins which §2.2 invariants bind it (the GID==UID and two-name rules do; the daemon no-reuse rule does not — R7/R8 governs the citizen stream instead).
  4. §2.3 R7/R8 — adds R7 (citizen allocation: lowest-free in 600–699; free pool = never-allocated ∪ R8-reclaimed; append-only ordering binds only never-reclaimed rows; the R8 gate, not numeric monotonicity, is the safety mechanism) and R8 (the conjunctive reuse gate: automated mesh-wide purge-verification AND a 30-day quarantine window, both required). R1–R6 are unchanged and remain daemon/shared-scoped. The R7/R8 justification paragraph explains why scoped reuse is sound for citizens (uniform machine-enumerable footprint makes "no file/unit/state owned by N anywhere" an automatable exhaustive predicate) but unsound for daemons, and records that the unrelated 506/cloudd daemon-stream gap and the "next free daemon UID: 508" pointer are untouched and out of scope.
  5. §2.4 Tombstones — re-scopes tombstoning to daemon-identity and shared-credential entries only; citizen-identity entries are never tombstoned (a permanently-retired:, never-reclaimed: row is the citizen analogue).
  6. §2.5 Citizen retirement, quarantine, and reclamation (new section) — defines the three lifecycle states (live → retired → reclaimed), their projection (live materializes a u line; retired/reclaimed are comment-only), the at-most-one-non-reclaimed- row-per-UID invariant, the every-reclaimed:-row-carries-a-date- and-verifier:-token invariant, and the rule that a reclaimed UID may be re-allocated only to a new live row with a different name.
  7. §4.3 sysusers projection — adds the citizen projection rules: a live citizen row projects a daemon-shaped u line (no :gid suffix, /nonexistent home, nologin shell); a retired: row projects a # quarantine: comment and no u line; a reclaimed: row projects a # reclaimed: comment and no u line. Citizen u lines occupy their own sub-block after the daemon-identity u lines and the shared-credential g/m lines.
  8. §5.4 Unit ordering — citizen units take the same After=/Requires=cosmix-noded.service ordering invariant as daemon units (the local ABP broker is up before any registered serve-mode citizen starts).
  9. §6.1 / §6.2 preflight & post-sysusers verification — extend both passes to the citizen class: a live citizen entry verifies under the daemon-identity rule set (passwd/group by uid and name, GID==UID); a retired:/reclaimed: citizen entry's name MUST NOT resolve to a live user or group (by-name only — never by UID: an R7-re-allocated UID is held under a different live name, verified by that citizen's own live row, so the by-name rule needs no R7 exception). Failures halt installation under §6.5 fail-closed semantics like any other class.
  10. §8.2 CI lint — re-clauses L1 to recognise a third block (header begins cid), re-clauses L2/L3/L4/L5/L14 to apply the citizen rules per-class (UID==GID for citizens; append-only only within never-reclaimed: rows; 600–699 band; at-most-one-non- reclaimed: row and the reclaimed: date/verifier: invariants; ABP-name uniqueness across the union of live daemons and live citizens), and adds new L17 (citizen block ↔ generated u lines pairwise agreement, projection-by-lifecycle-state, cross-stream name/number collision defence, and the citizen-u- lines-after-daemon-and-shared ordering rule).
  11. §9.1 / §9.3 examples — regenerates the canonical sysusers fragment to v1.2.0 with the citizen sub-block (u cosmix-statecache 600 …), and extends the §9.3 verification pseudocode with a CITIZEN_IDENTITY pass (live rows verify under the daemon rule; retired/reclaimed rows assert non-materialization) and the ABP-emit version field to 1.2.0.

The amendment is non-breaking for daemon-identity and shared-credential entries: the seven daemon entries and the cosmix-tls shared group are unchanged, R1–R6 and R2.a continue to bind those two streams with no semantic change, and no on-disk ownership for any already-deployed daemon is altered. The §6.2 and §8.2 changes only extend the verifier and lint to cover the new class — they relax no existing check. The _bin/spec10_lint.mix and _bin/spec10_postcheck.mix scripts SHALL be updated in the same change as this amendment to implement the cid-block parser extension, the L1–L5/L14/L17 citizen rules, and the §6.2 live-vs-retired/reclaimed citizen validator. The consumer of this entry class is SPEC 18 (Mix Citizen Runtime) §2 — the registered serve-mode citizen — and this amendment resolves the SPEC-10-citizen-sub-range sub-question deferred at SPEC 18 §10.2. See _journal/2026-05-16-spec10-citizen-identity-amendment.md for the mesh-wide rollout record.

1.1.0 — 2026-05-10

Additive amendment: introduce shared-credential group entry class and register cosmix-tls (GID 510). No daemon-identity entries change; no existing rules tighten on daemon-identity entries; the spec body grows in nine places:

  1. §2.2 Initial registry — adds the cosmix-tls row (GID-only, no ABP service). Re-clauses the GID==UID rule as applying only to daemon-identity entries; defines the shared-credential group entry class explicitly, including the separate-append-only-stream allocation rule (start at GID 510, advance independently of daemon UIDs, no collisions). Cross-references §3.4 to constrain shared groups to read mediation only — write delegation continues to use the §3.4 mechanisms.
  2. §2.3 R6 — clarifies that group-only entries have no ABP service name. The two-name rule (POSIX/systemd + ABP) continues to apply to daemon-identity entries unchanged. R1–R5 remain daemon-scoped and unchanged.
  3. §3.3 Configuration ownership — adds a third bullet ("TLS keypairs read by more than one daemon") that pins the canonical pattern root:cosmix-tls 0640 and forbids per-host setfacl workarounds for this case. The rationale is portability: the ACL-based workaround required the acl package on Debian 13 (which does not ship setfacl by default), turning a SPEC 11 host-precondition addition into a permanent dependency. The shared-group pattern removes both the per-host op and the package dependency, while preserving daemon mutual isolation.
  4. §6.1 Install preflight — splits the preflight pseudocode by entry class. Daemon-identity entries continue to use the original four-lookup pattern (passwd-by-uid, passwd-by-name, group-by-gid, group-by-name with GID==UID). Shared-credential group entries use a group-only pattern (group-by-gid, group-by-name, plus an explicit passwd <name> MUST-be-empty check enforcing §2.2's no-same-named-user rule). Membership lines (m) are not preflighted on their own; §6.2 re-asserts every declared membership after systemd-sysusers runs.
  5. §6.2 Post-sysusers verification — splits the verification rules by entry class. Daemon-identity entries continue to require uid == gid == registered UID and matching name. Shared-credential group entries require gid == registered GID, matching name, no same-named user, and that every m <user> <group> membership pair in the canonical fragment appears in getent group <group>.
  6. §8.2 CI lint — re-clauses L1 to recognise the two-block Appendix A format, re-clauses L2/L3/L4 to apply per-class (UID==GID and monotonic-allocation rules apply to each block independently), scopes L14 to daemon-identity entries only (shared groups have no ABP service name to derive), and adds new L16 verifying pairwise agreement between the shared-credential block and the generated g/m fragment lines, member-name validity (each m <user> is a live daemon-identity entry, each m <group> is a live shared-credential entry), absence of name/number collisions across the two blocks, and the §9.1 ordering rule (every m <user> <group> line MUST appear after both its u <user> and its g <group> line).
  7. §9.1 Example fragment — regenerates the canonical sysusers example to v1.1.0: adds the shared-credential sub-block with g cosmix-tls 510 and the two m membership lines, and pins the ordering rule (m lines after both their g and their u referents).
  8. §9.2 Reference verifier pseudocode — splits the verifier loop into two passes (DAEMON_IDENTITY and SHARED_GROUPS), adds the group-only check (no same-named user, every declared m membership present in getent group's 4th colon-field), and updates the ABP-emit version field to 1.1.0. The structured counts on the OK path now report daemons and shared separately so consumers of the preflight.ok topic can distinguish entry classes.
  9. Appendix A — splits the registry block into two sub-blocks (daemon-identity + shared-credential) and adds the cosmix-tls row at GID 510. Daemon UID allocation continues from 508; the 510 GID leaves 508–509 to the next two daemons so the two numbering streams stay visually separable.

The amendment is non-breaking for daemon-identity entries: the existing eight daemon entries remain unchanged, the existing rules R1–R5 continue to bind daemon-identity entries with no semantic change, and no on-disk ownership for already-deployed daemons is altered. The §6.2 and §8.2 changes tighten the verifier and lint to cover the new entry class — they do not relax any existing check. Per-host migration from the prior ACL workaround is mechanical: chgrp cosmix-tls <keyfile>; chmod 0640 <keyfile>; setfacl -b <keyfile> after the updated sysusers fragment is in place. The _bin/spec10_lint.mix and _bin/spec10_postcheck.mix scripts SHALL be updated in the same change as this amendment to implement the L1/L16 parser extension and the §6.2 group/membership validator. See _journal/2026-05-10-spec10-cosmix-tls-shared-group.md for the mesh-wide rollout record.

1.0.0 stable — 2026-05-09

Status flip draft → stable. Two stabilization gates cleared:

  1. Replication to a different host shape. The gamma.amp first deployment (Incus unprivileged CT, example.org test maild) on 2026-05-09 was successfully replicated to beta.amp (Proxmox CT, Debian 13, production example.com maild) on the same day, reusing the canonical sysusers fragment, cosmix-maild.service, and _bin/spec10_{preflight,postcheck,audit} scripts unmodified. Per-host divergence was limited to config.toml content. See _journal/2026-05-09-spec10-beta-replication.md.

  2. Workaround portability. The Environment=HOME=%S/cosmix/<d> workaround for cosmix-lib-daemon::log_dir() writing daily logs under $HOME/.local/log/cosmix/ (which ProtectSystem=strict makes read-only against the registry user's /nonexistent HOME) functioned identically on Incus and Proxmox CTs. The long-term $LOGS_DIRECTORY / $STATE_DIRECTORY preference fix in cosmix-lib-daemon remains a code follow-up; it does not gate stabilization because the unit-level workaround is sufficient and portable.

1.0.0 draft — 2026-05-08

Initial publication. Establishes the registry, filesystem layout, sysusers.d derivation, systemd unit requirements, install-time preflight, session-scoped exclusions, conformance levels, and CI lint shape.


© 2026 Mark Constable / Cosmix Project. Licensed under the same terms as the Cosmix repository (LICENSE).