Peios Learn
Products
PePeios pkpekit PvProvium UDUniversal Directory TrTrail Ckclackatoo PrProject WiWispist
Using Peios Security Basics Technical Documentation Source
Using Peios Security Basics Technical Documentation Source
Peios

Peios technical reference

Kernel and component TRMs, security specifications, constants and wire formats for implementation and detailed reference.

Single-page view · as markdown

Linux compatibility

Peios / Peios technical reference / Linux compatibility

Peios runs Linux applications with little to no modification. The Linux identity APIs — getuid, getgid, getgroups, setuid, setgid, capset, capget, the credential xattr, the credential-bearing socket controls — all continue to work. Many programs built against the Linux ABI need no changes; they call the same syscalls, see the same return values, and observe semantics close enough to Linux that nothing breaks. Some programs need adjustments; a few cannot work at all on Peios without restructuring.

The boundary between "works as-is" and "needs work" comes from a single principle: Peios does not carve out exceptions in the KACS model for Linux compatibility. The compatibility layer is built on top of KACS; it does not bend KACS to fit Linux. When a Linux API can be satisfied by deriving the answer from KACS state — getuid() returns a UID computed from the token — that works cleanly. When a Linux API expects to modify state that KACS owns — setuid() expects to change the kernel's notion of the calling process's identity — the compatibility layer either no-ops (preserving the syscall's contract without actually changing KACS state) or routes the request through KACS-aware mechanisms (and only succeeds when the calling token has the appropriate KACS privileges).

The result is best-effort compatibility. The values Linux APIs return are derived from the KACS model, not parallel to it. The token is the authoritative identity; the Linux credentials are a projection of it. The Linux compatibility layer is the bridge between "what Linux applications expect" and "how Peios actually works" — but it is a bridge that respects KACS at every point. Where a Linux semantic conflicts with KACS, KACS wins; the Linux call gets the closest reasonable approximation.

This page covers the model: how the projection works, what the Linux APIs see, and where the compatibility boundary lies.

The projection model in one sentence #

The token's identity is projected into Linux's UID/GID/capability fields, but only flows that direction — Linux APIs never write back to the token.

A process's primary identity is the user SID on its token. The kernel computes a corresponding UID (via the directory's SID-to-UID mapping) and stores it as part of the token's projection state. When the process calls getuid(), the value returned is the projection. The kernel does not consult the actual cred->uid field that Linux usually uses for this; it consults the token.

The same applies to GID and supplementary GIDs. They are projections from the token, not authoritative on their own.

This is the one-way rule. Token → cred always; cred → token never. There is no API in the compatibility layer that lets Linux semantics override the token. A setuid() call either causes the token to change (via authd, on the rare paths where that is allowed) or does nothing. It does not write a new UID into the token.

What you see vs what counts #

A Linux process running on Peios sees:

  • getuid(), getgid() return UID/GID values consistent with the token's projection.
  • getgroups() returns supplementary GIDs from the projection.
  • stat() on a file returns UID/GID values consistent with the file's owner SID (also projected).
  • /proc/<pid>/status shows the token's projected UIDs and capabilities.
  • capget() returns the mandatory capability substrate (covered in the capabilities page).

What the kernel actually uses for access decisions is the token, not what getuid() returns. If a process's token has user SID S-1-5-21-...-1001 and that SID projects to UID 1001, then getuid() returns 1001 — but the access check uses the SID. The two values are kept consistent by the projection, but the SID is authoritative.

Tools that work this way include: ls -l, ps, top, who, anything that uses standard POSIX APIs to query identity and ownership. They all see the projection; the underlying KACS state is what actually controls access.

What works, what changes #

A handful of Linux behaviours map cleanly into KACS; others are deliberately redirected. Quick summary:

Linux APIWhat Peios does
getuid, getgid, getgroupsReturns the projection from the token.
setuid, setgidNo-op without SeAssignPrimaryTokenPrivilege. With the privilege, becomes a full identity swap via authd.
setuid-on-exec (setuid bit)Cosmetic euid change without privilege; full token swap with privilege.
capget, capsetReturns the kernel's mandatory capability substrate; capset cannot clear ALLOW bits.
prctl(PR_CAPBSET_*)Operates on the capability substrate per the same rules.
setfacl / POSIX ACL xattrsUnconditionally denied — KACS replaces POSIX ACLs.
fchmod, chmodGated on WRITE_DAC: fchmod needs it in the fd's cached granted mask, chmod runs a fresh access check. The mode bits change, but FACS never consults them for access.
fchown, chownSame, gated on WRITE_OWNER. The Linux uid/gid changes, but the SD's owner SID does not — use kacs_set_sd for that.
setcap on files (file capabilities)Denied. Linux file capabilities are dead under KACS.
SO_PEERCRED, SCM_CREDENTIALSReturns projected UIDs (compat-only). Services needing real identity read the peer token with getsockopt(SOL_KACS, KACS_SO_PEER_TOKEN).
getxattr/setxattr on security.peios.sd / system.ntfs_securityDenied; use kacs_get_sd / kacs_set_sd.
auditd and the Linux audit subsystemReplaced by KMES and eventd. The legacy audit records projected UIDs only.

The pattern: identity-querying APIs see the projection; identity-modifying APIs either no-op (preserving the Linux contract) or are redirected through KACS-aware paths.

Why the compatibility layer exists #

A reasonable question: why bother with Linux compatibility at all? Why not require applications to be rewritten?

The answer is operational: the ecosystem of Linux software is large. Forcing every application to be ported to a Peios-native API surface would be a barrier to adoption that the value of native integration does not justify. The compatibility layer lets most Linux software run with little or no modification.

The cost is the indirection of the projection — every getuid() consults the token rather than cred->uid, every setuid() is interpreted through the privilege model. The cost is small at runtime; the operational benefit is large.

The compatibility layer is not a translation layer. It does not "convert Linux access control to KACS"; KACS is what runs. The compatibility layer is the set of rules that make Linux APIs return sensible values when called on a system whose actual access control is KACS. Where a Linux behaviour cannot be satisfied without compromising KACS, the layer prefers approximation to special-casing — the Linux call returns something reasonable rather than the kernel making a KACS exception. This is why "best-effort" is the right framing: most things work, some things approximate, a few things require the application to be aware of the underlying model.

DAC neutralisation #

A specific aspect of the compatibility layer worth flagging: the kernel sets every process up with a mandatory set of Linux capabilities — CAP_DAC_OVERRIDE, CAP_DAC_READ_SEARCH, CAP_FOWNER, CAP_CHOWN, CAP_SETUID, CAP_SETGID — that effectively neutralise the legacy Linux DAC checks.

The reason: Linux's traditional DAC (file mode bits, UID/GID checks) would otherwise run before the KACS LSM hooks. A Linux file mode of 400 (read-only by owner) would block a write attempt by a non-owner before KACS could evaluate the DACL. The mandatory capabilities tell the Linux DAC to defer to the LSM layer; KACS then makes the authoritative decision.

This is why the file's mode bits don't enforce access in Peios — DAC is neutralised. The mode is informational (derived from the SD); KACS is the authority.

The capability story is more nuanced than just "neutralise DAC"; it's covered in detail in DAC neutralisation and capabilities.

What about the root user #

A common question: what about root? Linux's root is UID 0; programs that check geteuid() == 0 to detect administrative privilege rely on this. On Peios, who is "root"?

The mapping is:

  • The SYSTEM principal (S-1-5-18) projects to UID 0. Processes running on the SYSTEM token see getuid() return 0.
  • A user in BUILTIN\Administrators (S-1-5-32-544) projects to their normal UID, typically non-zero. They are administratively privileged in KACS terms but getuid() does not return 0.
  • The uid0 utility lets a process with SeAssignPrimaryTokenPrivilege and the right authority cosmetically set its UID to 0 without changing the underlying token. Useful for legacy applications that hard-code geteuid() == 0 checks but where the actual identity should remain non-SYSTEM.

The uid0 mechanism is covered in setuid and uid0. The short version: legacy "is this root?" checks see UID 0 when the calling code has explicitly arranged for them to; otherwise the real projection is what they see.

Where to start #

If you want the projection mechanism in detail — how the token's identity becomes UID/GID values, how the projection handles impersonation, how the kernel maintains consistency — read Credential projection.

If you want the capability story — DAC neutralisation, the 41 Linux capabilities classified, why security_capable() is authoritative — read DAC neutralisation and capabilities.

If you want setuid semantics and the uid0 utility, read setuid and uid0.

If you want peer credentials — SO_PEERCRED, SCM_CREDENTIALS, the rules for migrating Linux services to KACS-aware peer-identity APIs — read Peer credentials.

If you want the map of every Linux IPC mechanism — which carry identity, what protects each object, and the three rules that fall out — read IPC on Peios.

If you want the Linux features that survive only as relics — superseded mechanisms that still work but sit outside Peios's recommended surface, and where to look instead — read Linux relics.

For the merged filesystem used to combine packaged and local directory trees — now covered with the rest of the storage tooling — read StrataFS.

Credential projection

Peios / Peios technical reference / Linux compatibility

The projection model is straightforward: when authd mints a token, it resolves the user's SID against the directory's SID-to-UID mapping and stores the resulting UID, GID, and supplementary GIDs on the token. These values are the projection — derived from the SID, not parallel to it. From then on, any Linux API that asks "what's this process's UID?" gets the answer from the projection.

The projection is one-way. Token state determines the projection; the projection never determines token state. There is no API that lets a Linux call modify the token through the projection layer; the token is authoritative.

This page covers how the projection is constructed, what Linux APIs see when they consult it, how impersonation changes the visible projection, and the rule that keeps the model consistent.

What's on the token #

Every token carries three projection-related fields:

FieldMeaning
projected_uidThe Linux UID corresponding to the token's user SID. 65534 if the SID has no mapping.
projected_gidThe Linux GID corresponding to the token's primary group SID. Same fallback.
projected_supplementary_gidsAn array of GIDs for the supplementary groups on the token.

These fields are set when the token is created. authd reads the user's SID-to-UID mapping from the directory at authentication time, fills in the projection, and includes the values in the wire-format token specification passed to kacs_create_token.

The mapping is per-directory. On a standalone Peios system, loregd's registry holds the mapping; on a domain-joined system, the domain's directory provides it. The same SID can in principle map to different UIDs on different systems if the directories are different, though in practice deployments aim for consistency.

A SID with no mapping (a principal that exists in the directory but has no UID assignment) projects to 65534 — the conventional Linux "nobody" UID. This lets the projection produce a value even when the directory does not have one, while signalling that the principal is unknown to the projection.

What syscalls see #

The standard Linux identity syscalls all return projected values:

SyscallReturns
getuid()The primary token's projected_uid.
geteuid()The effective token's projected_uid — primary if not impersonating, impersonation token's value if impersonating.
getgid()The primary token's projected_gid.
getegid()The effective token's projected_gid.
getresuid(&r, &e, &s)Same: r and s from primary, e from effective.
getresgid(&r, &e, &s)Same.
getgroups()The effective token's projected_supplementary_gids.
getlogin() (via libc)Reads /proc/self/status which reads the primary token's projection.
stat(), fstat(), lstat()File's owner SID projected to UID, primary group SID projected to GID.
/proc/<pid>/statusThe primary and effective token's projected fields.
/proc/<pid>/loginuidThe primary token's projected UID.

The split between getuid (primary) and geteuid (effective) preserves Linux semantics where the two diverge during impersonation-like operations. On Peios, the divergence is when the thread is actually impersonating.

The kernel maintains the synchronisation between token state and these queries automatically. Adjusting privileges, adjusting groups, installing/reverting impersonation — each updates whatever the next getuid()-style call would see.

What impersonation does #

A thread that is currently impersonating has two tokens: its primary and its impersonation. The projection layer follows the effective token (the impersonation, while it's in effect) for geteuid-style queries and the primary for getuid-style queries.

The result:

  • Before impersonating: getuid() == geteuid() == projected_uid(primary).
  • During impersonation: getuid() == projected_uid(primary), geteuid() == projected_uid(impersonation).
  • After reverting: back to the first state.

A program checking "am I running as user X right now?" via geteuid() sees the impersonated user, not the service's own user. A program checking "what's my real identity?" via getuid() sees the service's own user.

This is the standard Linux divergence: real (primary) vs effective (current). Peios preserves it through the projection. A service that captures a client's identity via impersonation and then opens a file as the client sees the file open with the client's projection (which is what allows the open to succeed if the client has access); other Linux calls on the same thread see the same effective identity.

The threads that are not impersonating see only the primary projection. Impersonation is per-thread; the projection is too.

current_fsuid() and the fsuid path #

A subtle case: the Linux kernel internally uses a function called current_fsuid() (and the corresponding current_fsgid()) for file-related credential lookups — file ownership, disk quotas, keyring lookups, NFS credentials. These are different from getuid()/geteuid(); they go through their own code path inside the kernel.

Peios patches current_fsuid() (and its variants) to return the projected UID from the effective token, not from cred->fsuid. This is what makes the projection consistent across all the places the kernel might consult it.

The practical effect: a thread impersonating a client opens a file. The Linux kernel might internally call current_fsuid() to record who created the file in the filesystem's metadata. The patched current_fsuid() returns the impersonated client's UID, so the file appears to be created by the client. This is consistent with the file's owner SID being the client's SID (which KACS would set), and consistent with stat() later returning the client's UID.

Without the patch, current_fsuid() would return cred->fsuid, which might be the service's own UID — and the file's metadata would be inconsistent with the file's actual KACS owner.

Creates through a stacking filesystem #

Reading the projection rather than cred->fsuid has one consequence worth knowing about, because it inverts an assumption the rest of Linux makes.

A stacking filesystem — overlayfs, which the live root is — does not perform a create as the calling process. It switches to the credential it captured when it was mounted, because writing the upper layer needs authority the caller may not have. To keep the new file owned by the caller rather than by the mounter, it assigns the caller's ids to that credential's fsuid and fsgid and lets the underlying filesystem read them back.

On Peios that assignment decides nothing, because current_fsuid() reads the projection instead of the field. The credential the stacking filesystem prepared still carries the mounter's token, so the underlying filesystem would stamp the mounter's ids on every file, whoever created it.

The kernel therefore hands the projection across explicitly, through the dentry_create_files_as LSM hook that exists for this purpose: KACS gives the prepared credential the calling principal's projected ids. Only the projection moves — the token stays the mounter's, which is what the stacking filesystem needs in order to write at all, so no access decision is affected. The descriptor stamped on the new file is computed separately, from the caller's token.

Two things follow. A filesystem that creates under the caller's own credentials, as StrataFS does, needs none of this. And ownership set this way is decided at creation: it is the projection of whoever created the file, captured then, not a value re-derived afterwards.

The one-way rule #

The projection flows token → cred. Never cred → token.

A Linux call that would, on a non-Peios system, modify the kernel's notion of the process's UID is not allowed to modify the token. Specifically:

  • setuid() and friends do not modify the token. They either become a no-op (without SeAssignPrimaryTokenPrivilege) or trigger a full identity swap through authd (with the privilege). See setuid and uid0.
  • The setuid-on-exec bit (S_ISUID in a file's mode) similarly does not modify the token without the privilege.
  • prctl(PR_SET_SECUREBITS) and related operations do not affect the token's projection.

The reason: the token is the authoritative identity. Letting Linux APIs write to it would mean the token's value depends on what Linux code thinks. Peios's model is that the token is decided by authd (at mint time) and adjusted only through KACS APIs (AdjustPrivileges, AdjustGroups, etc.). Linux is a consumer; it does not get to be a producer.

File ownership in stat() #

When stat() returns a file's owner and group, the values come from the file's SD — specifically, the owner SID's projection and the primary group SID's projection.

The flow:

  1. The file has an SD with owner_sid = S-1-5-21-...-1001 and group_sid = S-1-5-21-...-513.
  2. Each SID is run through the SID-to-UID mapping.
  3. stat() returns st_uid = 1001, st_gid = 513.

The values are computed from the SD's SIDs every time, not stored as separate fields. The file's underlying filesystem may have its own i_uid and i_gid fields (ext4 does), but the kernel maintains them consistent with the SD-derived values. A change to the file's owner SID via kacs_set_sd updates the SD and re-derives the projected UIDs, which then become visible to stat().

This consistency is what makes ls -l work. The output shows ownership; the ownership is the projection of the SD; the SD is what KACS evaluates against. The three views agree.

SO_PEERCRED and SCM_CREDENTIALS #

Two Linux APIs let one process learn another's identity over Unix sockets:

  • SO_PEERCRED — getsockopt option returning the peer's pid, uid, gid.
  • SCM_CREDENTIALS — sendmsg/recvmsg control message allowing the sender to attach (or the receiver to extract) similar credentials.

On Peios, these return the peer's projected UID/GID values. The values are correct in the projection sense — they correspond to the peer's token's projection. But they are not the right tool for security-relevant identity:

  • They don't distinguish between an authenticated user and an unauthenticated process running as the same UID.
  • They don't carry the token's SIDs, groups, integrity level, or privileges.
  • They don't reflect impersonation correctly in all cases.

For security purposes, the right tool is the peer-token socket option, getsockopt(SOL_KACS, KACS_SO_PEER_TOKEN) (peios_token_open_peer in libpeios) — it returns a token fd carrying the full identity. See Peer credentials.

SO_PEERCRED and SCM_CREDENTIALS are kept for compatibility with Linux applications that use them for non-security purposes (logging, debugging, friendly identification). Code that needs to make access decisions on peer identity uses the KACS-aware API.

What the projection is not #

A few clarifications:

  • The projection is not the access decision input. KACS uses the token (SIDs, groups, privileges, integrity, PIP); the projection is for Linux API compatibility only. AccessCheck does not consult projected_uid.
  • The projection is not historical. A token reflects identity now; if the SID-to-UID mapping changes (rare but possible), existing tokens continue to use their cached projection. New tokens (created after the mapping change) would see the new value. The projection is fixed when the token is minted.
  • The projection is not the file system's notion of ownership. ext4 stores i_uid and i_gid; Peios keeps these consistent with the SD's owner-projection, but the SD is authoritative. A file whose i_uid somehow diverges from the SD owner's projection is inconsistent; KACS uses the SD.
  • The projection is not a substitute for the token. Code that needs to make security decisions on identity should use the token, not the projection. The projection is for compatibility; the token is for authority.

The cleanest mental model: the projection is the Linux-shaped view of an identity that fundamentally lives in KACS. It exists to satisfy POSIX programs that ask "what's my UID?" and have to get a number back. The number is computed; the actual identity is something else.

Where to go next #

For how Linux's own DAC and capability checks are made to defer to KACS, read DAC neutralisation and capabilities.

For what the setuid family does — and does not do — to the projection, read setuid and uid0.

For the security-grade replacement for projected peer credentials, read Peer credentials.

DAC neutralisation and capabilities

Peios / Peios technical reference / Linux compatibility

Linux has its own access-control mechanisms below the LSM layer — DAC (file mode bits, owner/group checks) and capabilities (POSIX-style fine-grained privileges). When Peios's KACS runs as an LSM, it sees an access after these other layers have already had their say. If DAC has already refused the access for legacy reasons, KACS never gets a chance to evaluate.

The fix is DAC neutralisation — Peios sets up every process with a specific set of mandatory Linux capabilities that effectively defer the DAC and capability decisions to the LSM layer. Combined with a classification of the 41 standard Linux capabilities (some always-on, some mapped to KACS privileges, some always-off), this lets KACS be the authoritative access-decision layer.

This page covers the model — what DAC neutralisation is, how it works, and the three-way classification of Linux capabilities.

The problem: DAC runs first #

In a standard Linux kernel, an access proceeds through several layers:

  1. The syscall (open, read, etc.) is called.
  2. The kernel does its DAC check — file mode bits, owner UID, group GID. If DAC refuses, the syscall fails with EACCES.
  3. The kernel does capability checks for operations that require specific capabilities.
  4. LSM hooks fire. SELinux, AppArmor, etc. get to make additional decisions.

KACS is an LSM. It fires at step 4. By the time it runs, DAC has already had its say. If a file has mode 400 (read-only by owner), a non-owner attempting to read it gets EACCES from DAC before KACS sees the call. KACS may have a DACL granting the access, but it cannot help — DAC already refused.

The same applies to capabilities. Operations gated by Linux capabilities (CAP_SYS_ADMIN, CAP_NET_BIND_SERVICE, etc.) check the capability at the relevant point. If the process lacks the capability, the operation fails. KACS's privilege model (SeTcbPrivilege, SeDebugPrivilege, etc.) is parallel but separate.

The naive answer would be to remove DAC from the kernel. But that breaks Linux compatibility — applications expect the kernel to enforce mode bits. Peios's answer is more subtle: keep DAC and capabilities, but neutralise them so they always defer to LSM.

The fix: mandatory capabilities #

Every process on a Peios system is set up with a mandatory set of Linux capabilities always present in their effective set:

  • CAP_DAC_OVERRIDE — bypass DAC read/write checks.
  • CAP_DAC_READ_SEARCH — bypass DAC read/search checks on directories.
  • CAP_FOWNER — bypass ownership-based permission checks.
  • CAP_CHOWN — bypass restrictions on chown.
  • CAP_SETUID — bypass restrictions on setuid.
  • CAP_SETGID — bypass restrictions on setgid.

These caps are present on every process's credentials, mandatorily, regardless of what the application's binary or the user requested. They cannot be removed by capset().

Their effect: the DAC layer of the kernel sees these caps and treats the corresponding checks as already-passed. The check proceeds to the LSM layer, where KACS gets to make the actual decision.

This is the "neutralisation". DAC is not gone — the code paths still exist — but it never refuses anything by itself. Every access flows through to KACS.

The same approach is taken for the capability checks: capabilities relevant to access decisions are pre-allowed so the kernel does not stop the operation before LSM gets to see it. KACS then makes the call.

The capability classification #

There are 41 Linux capabilities defined in v0.20 (the standard set from Linux 5.x). Peios classifies each as one of three things:

ClassMeaning
ALLOWAlways present in the process's effective set. Cannot be cleared. Used to neutralise the DAC and capability checks that need to defer to LSM.
PRIVILEGEMapped to a KACS privilege. cap_capable() returns "granted" iff the calling token holds the corresponding KACS privilege.
DENYPermanently denied. The check always says "no", regardless of the credential state. Used for capabilities that have no useful semantic on Peios.

The classification is per-capability and is hard-coded into the kernel.

ALLOW capabilities #

The DAC-neutralisation set is the core of ALLOW:

CapabilityWhat it would normally gateWhy ALLOW
CAP_DAC_OVERRIDEDAC mode-bit checksKACS replaces DAC entirely; the bit must be on so DAC always defers.
CAP_DAC_READ_SEARCHDAC read/search on directoriesSame.
CAP_FOWNEROwner-based bypasses (chmod own files, etc.)Same.
CAP_CHOWNRestriction on chown()Linux normally requires CAP_CHOWN to chown; Peios redirects chown through KACS anyway.
CAP_SETUIDRestriction on setuid()setuid() is itself reinterpreted by Peios; the cap must be present for the syscall to even get to the LSM hook.
CAP_SETGIDSame for setgid()Same.
CAP_NET_BIND_SERVICEThe privileged-port floor (bind() below 1024)Binding a port is decided by the port's reservation SD at the socket_bind hook, for every port; the Linux floor must never refuse first. See port reservations.

These are mandatory. No process can clear them; capset() ignores attempts to remove them.

PRIVILEGE capabilities #

Many Linux capabilities map cleanly to a KACS privilege. Examples:

CapabilityMaps to KACS privilege
CAP_NET_ADMIN(administrative network operations, gated by appropriate KACS privileges in v0.20)
CAP_SYS_TIMESeSystemtimePrivilege
CAP_SYS_BOOTSeShutdownPrivilege
CAP_SYS_NICESeIncreaseBasePriorityPrivilege
CAP_IPC_LOCKSeLockMemoryPrivilege
CAP_SYS_RESOURCESeIncreaseQuotaPrivilege
CAP_AUDIT_CONTROL, CAP_AUDIT_READ, CAP_MAC_ADMINSeSecurityPrivilege
CAP_PERFMONSeSystemProfilePrivilege OR SeProfileSingleProcessPrivilege OR SeLoadDriverPrivilege (OR-mapped — see below)

OR-mapping. Most capabilities map to a single KACS privilege. A small number span multiple Peios privilege tiers, where no single privilege covers everything the Linux capability gates — these OR-map: security_capable() returns granted if the token holds any of the listed privileges. CAP_PERFMON is the current example. The cap_capable() answer is only a ceiling that prevents false denials; the specific privilege for the specific operation is enforced at the relevant syscall hook. For perf, the perf_event_open path distinguishes own-task profiling (no privilege), cross-task profiling (SeProfileSingleProcessPrivilege + PIP dominance), and system-wide profiling (SeSystemProfilePrivilege). OR-mapping never grants authority the holder does not already have via one of the listed privileges.

When the kernel asks "does this caller have CAP_X?", the answer is computed by consulting the token's privileges. The kernel's security_capable() hook is what does this lookup — Peios's PKM module overrides the default capability check to consult KACS privileges instead of (or in addition to) the credential's capability set.

This means a process's Linux-capability state and its KACS-privilege state are kept consistent for the PRIVILEGE-class capabilities. A user with SeSystemtimePrivilege enabled on their token gets the answer "yes" from cap_capable(CAP_SYS_TIME); a user without it gets "no".

DENY capabilities #

A few Linux capabilities have no useful semantics on Peios and are always denied:

CapabilityWhy DENY
CAP_SETFCAPLinux file capabilities (security.capability xattr) are dead on Peios. The xattr can't be set; there's nothing for this capability to do.
Reserved / future capabilitiesCaps the kernel has defined but Peios has no mapping for, default to DENY.

These are denied unconditionally. No combination of token state grants them.

security_capable() is authoritative #

The kernel exposes a function security_capable() that callers use to check "is this caller allowed to do the thing this capability gates?". Peios's PKM module hooks this function:

  • For ALLOW caps: always returns granted.
  • For PRIVILEGE caps: returns granted iff the corresponding KACS privilege is enabled on the calling token.
  • For DENY caps: always returns denied.

security_capable() is authoritative. The actual bit state of the credentials (the cap masks visible to capget()) is informational; the security decision comes from security_capable().

Why this matters: a program that reads /proc/self/status sees a capability bitmask. That bitmask is the "what the credentials currently say" view — for compatibility with tools that parse the file. But the kernel's enforcement uses security_capable(), which goes through KACS.

The two views are kept consistent for ALLOW (the bits are always on) and DENY (the bits are always off). For PRIVILEGE, the visible bitmask reflects the KACS privilege state — a privilege enabled on the token corresponds to the cap appearing in the bitmask.

capset() limitations #

capset() lets a process modify its capability set. Under Peios:

  • It cannot clear ALLOW bits. Attempts to remove CAP_DAC_OVERRIDE etc. silently leave them set. The process cannot opt out of DAC neutralisation.
  • It cannot grant DENY bits. Attempts to add CAP_SETFCAP etc. silently fail (the bit stays clear).
  • For PRIVILEGE bits, capset() cannot grant capabilities the corresponding KACS privilege does not provide. A process can drop the bit (which would normally drop the capability), but this does not affect the underlying KACS privilege — the privilege is the source of truth.

The kernel maintains the apparent capability state consistent with the KACS privileges (and the ALLOW/DENY rules). A program using capset() to try to drop privileges sees the visible mask drop, but the underlying KACS state is what security_capable() consults.

Warning

This is intentional: programs that drop capabilities for security hardening (the standard "drop CAP_NET_ADMIN before processing untrusted input" pattern) do not get the semantics they expect, because the kernel's authoritative decision uses KACS. The drop appears to succeed but does not take effect. To actually drop authority on Peios, use AdjustPrivileges to remove the corresponding KACS privilege.

prctl(PR_SET_KEEPCAPS) and the secure bits #

A program that does setuid and wants to preserve some capabilities afterward sets PR_SET_KEEPCAPS via prctl(). Under Peios:

  • prctl(PR_SET_KEEPCAPS, 1) is accepted but does not change the effective behaviour. The Linux capability state is reset across setuid by default; the KACS token state is unaffected by setuid (unless SeAssignPrimaryTokenPrivilege triggers the full identity swap path).
  • prctl(PR_SET_SECUREBITS, ...) is similarly accepted but does not modify the KACS state.

These are operations that affect the Linux credentials view. The KACS authority continues to live on the token.

Capabilities at exec — file capabilities are dead #

Linux supports file-bearing capabilities via the security.capability xattr. A binary with this xattr gets the listed capabilities at exec, regardless of who launched it. This is how (for example) ping gets CAP_NET_RAW on a Linux system.

Under Peios, file capabilities are dead:

  • Writing the security.capability xattr is unconditionally denied.
  • Existing security.capability xattrs on files are ignored at exec.
  • The mechanism's role on Peios is filled by KACS privileges, applied at token creation time by authd.

A binary that needs special capabilities on Peios doesn't carry them as a file xattr. Instead, authd's privilege policy decides which principals get which privileges; the binary running under one of those principals' tokens gets the corresponding KACS privileges naturally.

The capability CAP_SETFCAP (set file capabilities) is in the DENY class as a consequence — there are no file capabilities to set.

What this looks like in practice #

For an application running on Peios:

  • Linux DAC checks are invisible. The application's mode-bit-aware code paths work, but the mode bits don't refuse anything. KACS makes the call.
  • Capability checks (via security_capable()) return the KACS-derived answer. A process that holds SeSystemtimePrivilege on its token sees CAP_SYS_TIME as granted; one that doesn't sees it as denied.
  • capset() does not grant or remove ALLOW/DENY capabilities. The mask returned by capget() is informational.
  • File capabilities don't work. Binaries that depended on security.capability need to be run under tokens with the appropriate KACS privileges instead.

For most applications this is transparent. They make their normal calls; the access decisions come out as KACS decides. The exceptions are programs that explicitly manipulate Linux capabilities — a paranoid daemon that calls prctl(PR_CAPBSET_DROP) to drop unneeded capabilities — and these programs see their drops not take effect in the way they expect. Migrating to use KACS privileges via AdjustPrivileges is the right pattern.

Where to go next #

For the projection that feeds getuid and friends, read Credential projection.

For the setuid family's reinterpretation, read setuid and uid0.

For the KACS privileges the PRIVILEGE-class capabilities map to, read Privileges.

setuid and uid0

Peios / Peios technical reference / Linux compatibility

The Linux setuid() family of syscalls — setuid, seteuid, setreuid, setresuid, plus the GID variants — is reinterpreted under Peios. Without a specific privilege, calling setuid() succeeds (returns 0) but does not actually change anything. With the privilege, it triggers a full identity swap through authd. The setuid-bit-on-exec mechanism (S_ISUID in a file's mode) follows the same rule.

For legacy programs that check getuid() == 0 to detect administrative privilege, a separate utility — uid0 — provides a cosmetic UID-0 view without actually changing the underlying token. This page covers all three: setuid syscalls, the setuid bit, and uid0.

The setuid syscalls #

The Linux setuid family has several variants:

  • setuid(uid_t uid) — set real and effective UIDs to uid.
  • seteuid(uid_t euid) — set effective UID to euid.
  • setreuid(uid_t ruid, uid_t euid) — set real and effective independently.
  • setresuid(uid_t ruid, uid_t euid, uid_t suid) — set real, effective, and saved-set independently.
  • Corresponding setgid, setegid, setregid, setresgid for GIDs.

On Linux, these change the kernel's cred->uid (and related fields). The credential is updated; the next getuid() returns the new value.

On Peios, the behaviour depends on whether the calling token holds SeAssignPrimaryTokenPrivilege:

Caller has SeAssignPrimaryTokenPrivilegeBehaviour
NoThe syscall returns 0 but nothing changes. The token is unchanged. The projection is unchanged. getuid() returns the same value before and after.
YesThe kernel makes an upcall to authd to perform a full identity swap. authd authenticates the new identity (or constructs the appropriate token), and the calling process's primary token is replaced with the new one.

The no-op path is the default. The privileged path is reserved for a narrow set of components — typically authd itself, plus a small number of bootstrap services.

Why no-op without privilege #

A reasonable question: why does setuid succeed but do nothing? Why not just return an error?

The answer is compatibility. Linux programs assume setuid() works in the documented way. A program that does setuid(geteuid()) to "drop" privileges expects success. A setuid(0) followed by setuid(non-root) is a common idiom for "elevate, do work, drop". If setuid simply returned an error, every Linux program that did this would fail on Peios.

By making the call succeed but not change anything, Peios preserves the contract — programs see success — without actually changing the token. The programs continue running on whatever identity they had; the kernel's KACS-level access control is unaffected.

The cost: programs that rely on the post-setuid identity having changed might do incorrect things. A program that drops to a less-privileged UID expecting that to limit it will find that KACS access checks are unaffected, and the process retains whatever access its token had. For most programs this is fine (they're using setuid as defence in depth, and KACS's gates are stricter); for a few it could be surprising.

The escape hatch: a program that genuinely needs to change identity at runtime needs to be running with SeAssignPrimaryTokenPrivilege (so the call actually does something) or to be re-architected to do the identity-swap work via the proper KACS channels (call authd directly, get a token, install it via KACS_IOC_INSTALL).

What the privileged path does #

When the calling token holds SeAssignPrimaryTokenPrivilege and setuid(N) is called, the kernel:

  1. Looks up the SID corresponding to UID N (via the directory's reverse mapping).
  2. Asks authd to construct a token for that principal.
  3. Replaces the calling process's primary token with the new one.
  4. Updates the credentials so subsequent getuid() returns N.

The token is genuinely different now. Privileges, groups, integrity level are whatever authd produced for that principal. The previous token is released (its references drop).

This is what login frontends do. A user signs in; the login frontend (running with SeAssignPrimaryTokenPrivilege) calls setuid(target_user); the frontend's token is replaced with the user's token; the rest of the user's session runs as the user.

This is also the path for su and similar utilities — they run with the privilege and use the setuid syscall to actually become a different user.

The privilege is rare #

SeAssignPrimaryTokenPrivilege is held by:

  • authd itself (which actually needs it to mint tokens).
  • Login frontends that handle user sign-in (sshd, the console login, terminal services).
  • A few specific TCB tools that need to launch processes as specific users (peinit at boot, certain administrative utilities).

Ordinary programs do not have it. A user-installed application that calls setuid() falls through to the no-op path.

This is the right distribution: only components that legitimately swap identities have the privilege. Everything else gets the compatibility behaviour.

The setuid bit on exec #

A file with S_ISUID set in its mode runs as the file's owner when exec'd (on Linux). The setuid bit is what makes ping run as root, makes passwd able to modify /etc/shadow, makes sudo work.

On Peios, the setuid bit's behaviour mirrors the setuid syscall:

Calling token has SeAssignPrimaryTokenPrivilegeSetuid-bit behaviour
NoThe euid/suid fields are cosmetically updated to match the binary's owner UID, but the KACS token is unchanged. The binary runs as the calling principal, just with a different getuid() return.
YesA full identity swap occurs at exec, as if setuid(file_owner) had been called between fork and exec.

The cosmetic-only path is what most setuid-bit binaries get on Peios. The binary runs as the user who invoked it; the euid that geteuid() returns is the binary's owner; KACS sees no identity change.

For most uses this is correct. A setuid-root binary on a Linux system that just wants to do "is the caller root?" check sees geteuid() == 0 even on Peios; the cosmetic euid update is enough for that.

For uses that actually need to perform privileged operations on the user's behalf, the cosmetic-only path is insufficient. The KACS access control sees the calling user, not the binary's owner. The binary fails its access checks. The fix is to either:

  • Run the binary as a service launched by peinit with the appropriate token.
  • Have the binary connect to a service (running with the appropriate identity) and ask the service to do the work.

This is the pattern for "privileged operations" on Peios. Rather than setuid-bit binaries, services run with the right token; clients connect to the services and ask for what they need.

The setuid-bit-as-identity-swap path (with SeAssignPrimaryTokenPrivilege) exists for compatibility with tooling that genuinely needs it — but it requires the parent process to hold the privilege.

uid0 — cosmetic root for legacy programs #

A specific class of legacy programs hard-code getuid() == 0 (or geteuid() == 0) checks to detect root and refuse to run otherwise. The programs may have legitimate logic that requires elevated privileges, or they may just be checking out of paranoia.

For these programs, Peios provides the uid0 utility. It is a small wrapper that:

  1. Runs as a user with SeAssignPrimaryTokenPrivilege (or chains through one).
  2. Sets the calling credentials' cred->uid, cred->euid, and cred->suid all to 0.
  3. Execs the target binary.

The result: the target binary sees getuid() == 0, satisfies its root check, and proceeds.

Crucially, uid0 does not change the KACS token. The current_fsuid() patch (from Credential projection) ensures that file-related credential lookups still return the projected UID from the token — not the cosmetic 0. So uid0 is purely a cosmetic adjustment for legacy "am I root?" checks; it doesn't grant any actual privileges or change the security-relevant identity.

The use case: a legacy script that does [ $(id -u) -eq 0 ] || exit 1 at the top. The script's actual work might be perfectly fine to run as the calling user, but the check refuses. uid0 makes the check pass.

The uid0 utility itself is signed and runs with the appropriate privilege; an ordinary user invoking uid0 does not gain root authority — they gain a cosmetic UID-0 view for the duration of the wrapped program. The KACS token is unchanged; the access decisions still flow through KACS.

This is the right way to handle the "is root?" check pattern. The check is satisfied; the actual authority comes from KACS; the legacy code path works.

Comparison: setuid syscall, setuid bit, uid0 #

A handy summary:

MechanismCaller needsEffect on tokenEffect on getuid() / geteuid()
setuid(N) without privilege(none)UnchangedUnchanged (call returns 0 but doesn't actually set anything)
setuid(N) with privilegeSeAssignPrimaryTokenPrivilegeFull swap via authdReflects the new identity
exec of setuid-bit binary without privilege(none)Unchangedeuid/suid cosmetically updated to binary owner; uid unchanged
exec of setuid-bit binary with privilegeSeAssignPrimaryTokenPrivilegeFull swap to binary ownerReflects new identity
uid0 wrapperThe wrapper itself runs with the privilegeUnchangedCosmetic uid/euid/suid all = 0; current_fsuid() still returns projected UID

The pattern: real identity changes are gated by the privilege and go through authd. Cosmetic changes (for legacy compatibility) don't require the privilege and don't touch the token.

What setuid semantics are not #

A few clarifications:

  • They are not a way to elevate privilege. A program calling setuid(0) without the privilege does not gain root authority — the call is a no-op. To genuinely gain authority, you need a different token, which requires authd.
  • They are not the way Peios changes identity. The setuid syscall is a compatibility layer. The native way to change identity is to be assigned a different token by authd (typically through a re-authentication or through being launched with a specific token by peinit).
  • They are not bypass mechanisms for KACS. Whatever the setuid syscall does, the KACS access checks continue to operate against the token. There is no setuid combination that gets around a DACL.
  • They are not how login frontends actually work. Login frontends do use setuid() (with the privilege), but the actual identity-establishment work is in authd — minting the token, setting up the session, applying the privileges and claims. The setuid() call is the final step that installs the result on the calling process.

The cleanest mental model: setuid is a Linux-compatibility veneer on the actual Peios identity machinery. The veneer is enough for legacy programs to think they're operating on Linux; the actual identity changes (when they happen) go through authd.

Migrating away from setuid #

For new code or refactored services, the cleaner pattern is to avoid setuid entirely:

  • Services should be launched with the right token from the start. peinit fork-installs the correct token before exec; the service never needs to setuid.
  • User-facing operations should be done as the user, not via setuid-to-root. Impersonation (capturing the user's token via peer-token capture) is the right pattern — the service can act on the user's behalf without changing its own identity.
  • Cross-identity work happens through IPC. A service needing to do something as another identity sends an IPC request to a service running with that identity; the latter does the work.

Setuid is the legacy compatibility path. Native Peios code paths look different.

Where to go next #

For the projection behind the cosmetic UID values, read Credential projection.

For why setuid's Linux-level checks defer to KACS in the first place, read DAC neutralisation and capabilities.

For acting on another identity without changing your own, read Peer credentials.

Peer credentials

Peios / Peios technical reference / Linux compatibility

When one process connects to another over a Unix socket, the recipient often wants to know who is connecting. Linux provides two mechanisms for this — SO_PEERCRED (a socket option) and SCM_CREDENTIALS (a control message). Both return basic credential information about the peer.

On Peios, both continue to work and return projected UID/GID values — useful for compatibility with Linux applications, but insufficient for making security decisions. For services that need to authenticate the connecting peer's identity to decide access, the right tool is the KACS peer-token socket option, getsockopt(SOL_KACS, KACS_SO_PEER_TOKEN), which returns a full KACS token reflecting the peer's complete identity.

This page covers the two Linux mechanisms and the right replacement.

SO_PEERCRED #

SO_PEERCRED is a getsockopt option on a connected Unix socket. The caller queries the socket and gets back a struct ucred:

struct ucred {
    pid_t pid;
    uid_t uid;
    gid_t gid;
};

The fields are:

  • pid — the connecting peer's PID at connect time.
  • uid — the projected UID of the peer's effective token at connect time.
  • gid — the projected GID similarly.

On Peios, the projection is computed from the token at the moment the connection was made. If the peer was impersonating at connect, the projected UID is the impersonated client's. If not, it's the peer's own.

The values are stable for the life of the socket. If the peer changes identity later (impersonation install/revert, token adjustment), the values returned by SO_PEERCRED do not update.

What SO_PEERCRED does not capture #

The struct ucred is fundamentally lossy. It returns three numbers — pid, uid, gid. It does not return:

  • Group memberships beyond the primary GID.
  • Privileges.
  • Integrity level.
  • PIP fields.
  • Claims (user or device).
  • The session ID.
  • Whether the connecting principal is authenticated, anonymous, or somewhere in between.
  • The token's restricted-SID list, confinement state, audit policy, or any other non-trivial structure.

For most security purposes, the missing information matters more than what's present. A service that grants access based on UID does not distinguish "user 1001 connecting as their normal Medium-integrity session" from "user 1001 connecting from a Low-integrity sandbox where they shouldn't be doing this". The UID is the same; KACS treats the two cases differently; SO_PEERCRED cannot tell them apart.

What SO_PEERCRED is for #

SO_PEERCRED is useful for:

  • Logging. "Connection from PID 12345 (UID 1001) at time T". This is a friendly identification, not a security claim.
  • Display. A daemon that wants to show "currently connected user: bob" can use the projected UID to look up the name.
  • Coarse compatibility. Linux applications that already use SO_PEERCRED for their own non-security checks continue to work.

For security purposes, it is the wrong tool. The information is too coarse; the connection between projected UID and authoritative identity is too easy to misread.

SCM_CREDENTIALS #

SCM_CREDENTIALS is a control message used with sendmsg/recvmsg on Unix sockets (including datagram sockets where SO_PEERCRED doesn't apply). The sender attaches a struct ucred to a message; the receiver extracts it.

On Linux, the sender can attach any struct ucred they like (subject to capability checks if the values would be different from their actual credentials). Peios projects the same way:

  • The sender's projected UID/GID is attached to the message.
  • The receiver can extract it via the control-message API.

The same caveats as SO_PEERCRED apply: the projected values do not carry the full identity, do not survive non-trivial state changes, and should not be used for security decisions.

SCM_CREDENTIALS is the way to attach credential info to datagram messages or socketpair-style sockets, where there is no "connect time" to capture peer info at.

Why these are insufficient for security #

The fundamental issue: the projection is for compatibility, not authority. UID 1001 corresponds to a SID, and that SID is what KACS uses for access decisions — but the projection doesn't carry the SID. It carries the projected UID, which is a derived value. A service that grants access based on the projected UID is making a decision based on a derivation, not the source of truth.

In practice, this leads to two classes of issue:

Identity collisions. Two different principals can in principle project to the same UID. The SID-to-UID mapping is typically 1-to-1, but reasonable failure modes (a misconfigured directory, a colliding mapping during a migration) can produce collisions. KACS sees them as different; UID-based code sees them as the same.

State that doesn't project. A token's integrity level, PIP, privileges, restricted-SID status — none of these project to UID/GID. A service that does "if the UID is admin's UID, grant access" cannot tell that the calling process is running on a restricted token with admin's SID but no actual privileges. KACS would deny most accesses; the UID-only check would grant them.

For services that need to be secure, neither SO_PEERCRED nor SCM_CREDENTIALS is the right tool. The right tool is the KACS-aware peer-token mechanism.

The replacement: the peer-token socket option #

The KACS-native equivalent is a socket option on the same connection, under the SOL_KACS option level:

int token_fd;
socklen_t len = sizeof token_fd;
getsockopt(socket_fd, SOL_KACS, KACS_SO_PEER_TOKEN, &token_fd, &len);

libpeios wraps it as peios_token_open_peer(socket_fd), which returns the fd directly. The call

Returns a token fd reflecting the peer's complete identity at connect time. The fd carries TOKEN_QUERY | TOKEN_IMPERSONATE | TOKEN_DUPLICATE access — enough to inspect the token's full state, to install it as an impersonation token, and to duplicate it.

What the token includes:

  • The peer's user SID, group SIDs (with attributes), restricted SIDs, logon SID.
  • Their integrity level, mandatory_policy, PIP type and trust.
  • Their privileges (present, enabled, used, removed states).
  • Their confinement state (sid, capabilities, exempt).
  • Their session reference (auth_id).
  • Their claims (user and device).
  • Their default DACL, owner index, primary group index.
  • Their projected UID/GID (for completeness — the same values SO_PEERCRED would have returned).

Everything. The full identity, atomic, captured at connect time. From the receiver's perspective, this token is what KACS would have access-decided against if the peer had made the request directly.

The service can:

  • Inspect the token (via KACS_IOC_QUERY) to make authorisation decisions on real identity, not projected UID.
  • Impersonate the peer (via KACS_IOC_IMPERSONATE on the fd, or the fused peios_token_impersonate_peer) to perform operations on their behalf — with the just-in-time pattern from Peer tokens and capture.

This is the security-grade peer-identity API. For services that need real access control on connecting peers, this is the tool.

When to use which #

A handful of guidelines:

Want toUse
Log who connected for diagnostic purposesSO_PEERCRED or projected UID-style API
Display a "connected as user X" indicatorSame
Make an access decision based on peer identityKACS_SO_PEER_TOKEN then KACS-aware logic
Act on the peer's behalf (impersonate)peios_token_impersonate_peer, or KACS_SO_PEER_TOKEN + KACS_IOC_IMPERSONATE
Capture peer identity at connect for later useKACS_SO_PEER_TOKEN (store the fd)
Send an identity along a messageSCM_CREDENTIALS for compat; for security, KACS_SCM_TOKEN — the kernel-verified, token-grade equivalent (or KACS_SO_PASS_TOKEN to attach it automatically)

The pattern: compatibility-grade peer ID uses the Linux APIs; security-grade peer ID uses the KACS APIs. The distinction matters most for services that handle untrusted callers — a public-facing daemon should use KACS_SO_PEER_TOKEN; an internal diagnostic tool can use SO_PEERCRED.

What about TCP sockets #

Linux's SO_PEERCRED does not work on TCP sockets — the peer is potentially remote, and there's no kernel-side credential to query. The same is true on Peios: KACS does not capture peer tokens over TCP. The peer is whatever the network layer says it is, identified by IP / connection state, not by a token.

For services accepting TCP connections from authenticated peers, the authentication is at a different layer — TLS with mutual certificates, Kerberos via authd, application-layer protocols. The result of that authentication is what determines the connecting principal's identity; the kernel-level peer-credential APIs are not involved.

Once authentication has produced a token (typically by authd), the service can install that token as an impersonation token via the normal KACS_IOC_IMPERSONATE path. The token-installation API is uniform regardless of whether the token came from a Unix-socket peer or a TCP-authenticated session.

What goes wrong if you use the wrong tool #

A few concrete failure modes from using SO_PEERCRED for security:

  • Sandbox bypass. A user runs a sandboxed application that connects to a privileged daemon. The application's token has the user's SID (restricted, with privileges removed). The daemon does SO_PEERCRED, sees UID 1001, looks the user up, sees they're in the administrators group, grants admin access. The sandbox is bypassed — KACS would have refused based on the restricted token, but the daemon never asked KACS.
  • Impersonation confusion. A service that connects to another service while impersonating a client should appear (to the receiver) as the client. With SO_PEERCRED, the receiver sees the client's projected UID — correct in this case. But if the impersonation changed mid-session (the service reverted, then made another call), SO_PEERCRED still shows the original peer's UID. The receiver can be confused about who they're actually serving.
  • Identity not present in projection. A service that wants to make decisions based on the peer's integrity level cannot — there's no projection for it. A SO_PEERCRED-using service sees nothing about integrity; the call effectively ignores that axis of access control.

For each of these, the peer token would produce the correct result because it carries the full identity. The migration path for an existing service is: replace SO_PEERCRED with KACS_SO_PEER_TOKEN on the same socket, use the token to make decisions instead of the projected UID. The code surface changes; the semantic is more accurate.

Compatibility, not removal #

SO_PEERCRED and SCM_CREDENTIALS are not being removed. They continue to work with sensible semantics for compatibility with Linux software. The recommendation is to migrate security-sensitive code to KACS-aware APIs, not to deprecate the Linux APIs.

For most services this means: keep the Linux API for friendly identification; add KACS-aware logic for access decisions. Both can coexist on the same connection; the security paths use the KACS-aware data, the diagnostic paths use the Linux API.

Where to go next #

For how the projected UID/GID values are computed, read Credential projection.

For capturing and impersonating a peer's full token, read Peer tokens and capture.

For what a token carries that a struct ucred cannot, read Tokens.

Linux relics

Peios / Peios technical reference / Linux compatibility

A handful of Linux features are relics: they still exist and still work the way they do on Linux — so software depending on them keeps running — but they have been superseded and are not part of how anything is meant to be done on Peios. Peios does not document them in depth. For the mechanics of the underlying Linux feature, the authoritative source is third-party Linux documentation (the relevant man pages and the kernel's own docs). Where Peios has a native replacement for what the relic was used for, this page points to it — that, not the relic, is the path to take.

A feature belongs here only if it is genuinely superseded, has no role in Peios's future, and gives essentially no reason to reach for it. This is a short list by design, not a place to park anything inconvenient to document.

Process accounting (acct) #

acct() is BSD process accounting. Called with a filename it turns on system-wide accounting, appending a fixed binary record for every process as it terminates — resource usage, timing, exit status, and a few behaviour flags; called with no argument it turns accounting off. Enabling or disabling it requires SeTcbPrivilege (the privilege Linux's CAP_SYS_PACCT maps to). For the record format and per-field detail, see the Linux acct(2) and acct(5) documentation.

On Peios you almost certainly want eventd and KMES instead: process lifecycle is already an event stream there, keyed on each process's GUID and carrying its real identity, exit status, and resource usage — structured and queryable, where acct produces an opaque append-only file built around the Linux UID model. See the eventd and KMES material for the native way to account for what processes do.

Where to go next #

For the compatibility model that decides what survives and how, read Linux compatibility.

For the native event pipeline that replaces process accounting, read Events and transport.

For following that event stream from the command line, read The event stream.

Identity for POSIX programs

Peios / Peios technical reference / Linux compatibility

A Linux program calls getpwuid and has never heard of a token. Between that call and the authority sits one shared object:

/usr/lib/libnss_peios.so.2

It asks authd over /run/ident.sock and renders the answer as a struct passwd.

There is nothing to configure #

On other systems /etc/nsswitch.conf decides where identity comes from. On Peios it does not, for passwd, group, shadow or initgroups. glibc is patched so those four reach the authority and nothing else, whatever any file says.

That is not tidiness. A second search order the authority cannot see is a second answer to the question who is jack, and a program acting on one principal's behalf while its access is checked against another is a confused-deputy bug rather than a cosmetic inconsistency. The authority resolves a name across the principal sources it is configured to have, in a configured order, applying domain and numeric confinement — none of which nsswitch.conf can express, and all of which a line in it would bypass.

It also closes an extension point Peios does not want. Naming a module in nsswitch.conf injects a shared object into every address space on the system. That is the in-process-plugin pattern this design rejects everywhere else it appears. Adding a source of identity to a Peios machine means writing a principal source — a separate process the authority confines, which cannot mint.

hosts is fixed the same way, for the same reason, to a different module: libnss_peios_net.so.2, which forwards to resolvd, the stub resolver. A files dns line would be a second resolver with none of resolvd's routing — and a second answer to which address is git.corp. There is no /etc/hosts; static names live in the registry and resolvd answers them at every door. See name resolution. services, networks and the rest are untouched.

Certificates are not in a directory you write to #

The same shape appears again for TLS trust, and for the same reason. Other systems have update-ca-certificates and a directory to drop a .crt into; Peios has neither. What the machine trusts is composed by trustd from the roots the ca-certificates package ships and the decisions under Machine\System\Trust, and rendered to /etc/ssl.

So /etc/ssl/certs/ca-certificates.crt, /etc/ssl/cert.pem and the hashed /etc/ssl/certs/ directory all exist and hold what you would expect — OpenSSL, curl, Go and Python find them where they look — but they are generated. Adding a certificate to one of them lasts until the next change and no longer. trust add writes the registry instead, where the question "who may widen what this machine trusts" is an access check rather than a matter of who can write to a directory.

There is no /etc/passwd #

No files entry, and nothing behind the authority to fall back to.

There is no root to hold there. uid 0 is where the SYSTEM token projects, not an account — nothing on the system looks for a principal called root, and peinit projects SYSTEM itself without resolving anything. A flat file of accounts would be a second identity store with none of the confinement the real one has, holding entries for principals that do not exist.

shadow and gshadow return nothing, always. A Peios verifier lives in its source's store and cannot be read out at all, so there is no entry to return and never will be.

PAM does not exist on Peios. There is no libpam, no /etc/pam.d, and no stack to configure. Authentication goes through PGSS Logon, where a client collects what it is asked for and an authority decides — and the module-stacking model PAM is built on is the same one the paragraph above rejects.

Before the authority, nothing resolves #

Anything running before authd — peinit, an initramfs — gets numbers rather than names.

That is the correct answer rather than a gap. Identity comes from the authority; until it exists there is none to have, and a file that answered anyway would be answering for principals nobody had vouched for.

Note

A program that treats an unresolved uid as a fatal error will fail early in boot. Displaying the number is the expected behaviour.

One call, one round trip #

The module holds itself to a rule: each libc call costs exactly one request.

  • getpwuid asks for the six fields a passwd record needs, together.
  • getgrnam asks for the group's members with their names already resolved, so filling gr_mem costs nothing further. A reply of bare identifiers would have turned one call into one per member.
  • initgroups asks about the principal, which is the direction sources actually store memberships — it never walks a group's membership to get there.

It opens a connection per call rather than holding one. A shared object cannot see its process fork, and a connection inherited by a child that then interleaves requests on it with its parent is a well-known way for a name resolver to hand back somebody else's answer. Connecting to a Unix socket is cheap; the caching that would be cheaper belongs in the authority, where every process shares it and something can tell it when it goes stale.

What the return values mean #

Resultglibc statusEffect
The principal existsSUCCESSThe record is returned.
No such principalNOTFOUNDThe caller sees no such user.
A source did not answerTRYAGAIN (EAGAIN)The caller retries. Not an absence.
authd is not reachableUNAVAILNothing is behind it; the lookup fails.

The third row is the one that matters. A source that could have answered and did not is not the same as an account that does not exist, and reporting it as one would let an outage be remembered as a fact — the account comes back when the source does, but a cached "no such user" would not.

What POSIX cannot express #

The rendering is one-directional and lossy, and each loss is a property of struct passwd rather than of anything underneath it.

No claims. A principal's claims feed conditional ACEs and have no field in a passwd record. Peios-native tools see them; getpwnam does not.

No domain. A uid_t is a number. Which source and which domain it came from is recoverable — the ranges are laid out so it is — but nothing in the record carries it.

Empty member lists. gr_mem for Everyone is empty, because nothing records who is in Everyone; the authority adds it to every token it mints. Inventing a list of this machine's principals would be a wrong answer rather than a partial one. See resolving names for the three kinds of membership and which of them can be listed.

Unnumbered groups are skipped. Interactive and its siblings have no POSIX group id, because membership in them is a property of a session rather than of an account. They are omitted from a supplementary group list rather than rendered as nobody, which would grant whatever nobody can reach.

getent and the whole list #

getent passwd works, and pages through every source in turn.

A source is never required to enumerate — a directory able to answer any single question may be quite unable to answer all of them — so a listing can legitimately be partial. The authority records which sources did not contribute; a Peios-native tool can show that, though getent itself has nowhere to put it.

Tip

A short getent passwd on a machine with a directory source is worth checking against authd's log before concluding an account is missing.

System V IPC

Peios / Peios technical reference / Linux compatibility

System V IPC — msgget, shmget, semget and the operations on the objects they create — keeps working on Peios exactly as Linux software expects, with one change to who is allowed to do what: every SysV object carries a security descriptor, and that descriptor, not the object's nine-bit mode, decides access.

What you get by default #

When a process creates a queue, segment or semaphore array, KACS stamps it with a descriptor built from the creator's token: the creator's user, Administrators and SYSTEM get full control; nobody else gets anything. Two consequences fall out that Linux does not give you:

  • A sandboxed process (a restricted token) cannot reach segments its parent created, even though the projected UID is the same — the restricted-SID pass fails, as it would for a file.
  • A different user is denied outright, with no way to loosen it through chmod-style mode bits. To share, edit the descriptor.

Mode bits are informational #

IPC_SET still updates the object's uid, gid and mode, and ipcs and IPC_STAT still show them — but nothing consults them for access, exactly as file mode bits work under FACS. IPC_SET itself needs WRITE_DAC and WRITE_OWNER on the descriptor (one command changes all three fields), on top of Linux's own rule that the caller be the creator or owner or hold SeTcbPrivilege.

Reading and changing the descriptor #

A SysV object has no path and no descriptor number, so the SD calls address it by kind and id: pass KACS_SD_AT_SYSV_SHM, KACS_SD_AT_SYSV_MSG or KACS_SD_AT_SYSV_SEM in the flags of kacs_get_sd / kacs_set_sd (peios_sysv_get_sd / peios_sysv_set_sd in libpeios, file::sysv_get_sd / sysv_set_sd in the Rust crate), the object id where a directory fd would go, and no path. The rights are in Peios Kernel TRM §3.11; the descriptor format is the ordinary one.

The key is not protected #

The descriptor protects the object once it exists. It cannot protect the name: keys are first-come, and a process that creates key 0x5432 before your service does owns whatever your service then finds there. This is the same trap as the abstract socket namespace and /dev/shm. Create with IPC_EXCL and treat failure as a signal — and if what you actually need is to know who sent a message, use a Unix socket, which carries the sender's identity; a message queue carries only bytes.

Where to go next #

For how DAC is switched off so that descriptors always decide, read DAC neutralisation and capabilities.

For the descriptor format and how to write one, read Security descriptors.

IPC on Peios

Peios / Peios technical reference / Linux compatibility

Peios does not add IPC primitives: Linux's are the substrate. What it adds is a consistent answer, for each of them, to two questions that Linux mostly leaves to convention — whose identity, if any, does a message carry, and what decides who may use the object. This page is the map. The rule of thumb it boils down to: identity travels only where one kernel sees both ends and the channel can carry an attested token; everything else is protected as an object, with a security descriptor, and conveys no identity at all.

Identity flows over Unix sockets, and nowhere else #

AF_UNIX is where identity lives. Every connection end has a conveyed-identity register: initialised when the connection is made — the accepted end sees the client, the connecting end sees the listener — and advanced by tokens attached in-band as the reader's position passes them. KACS_SO_PEER_TOKEN reads the register; KACS_SCM_TOKEN attaches a token to a send, gated exactly as impersonation would be; KACS_SO_PASS_TOKEN automates that on the sender's side; KACS_SO_IMPERSONATION_LEVEL bounds what the other end may do with what it receives. Stream and seqpacket sockets carry the register; datagram sockets carry identity per message only; a socketpair() starts with an empty register until one side conveys something. Peer tokens covers the programming model and Peer credentials the migration from SO_PEERCRED.

That is the whole list. In particular:

  • TCP and UDP, loopback included carry no kernel-attested identity: the kernel attests only what it saw both ends of, and the other end of a network socket is a claim. Cross-machine identity is authd's job, and the token it produces re-enters through the ordinary install path.
  • Netlink needs nothing: kernel-bound requests run synchronously in the sender's context, and the capability checks on both the socket's opener and the sender go through the capability switchboard, so they are already token decisions (Peios Kernel TRM §3.10.4).
  • AF_VSOCK spans two kernels; process identity across it is a claim, not an attestation.
  • Pipes and FIFOs carry no identity, structurally: there is no ancillary channel, and with several writers there is no "whose bytes". Linux has none either, so nothing is lost — but note the trap below.
  • System V and POSIX queues deliver bytes and a type. SI_MESGQ and ucred-style crumbs carry projected values and are informational.
  • Signals name a sender by projected si_pid/si_uid; the decision to deliver was made on the token (Peios Kernel TRM §3.7), the crumb is not evidence.

Everything is an object with a descriptor #

Where no identity flows, a descriptor decides who may use the thing, exactly as for a file:

MechanismWhat protects it
Socket fileThe directory's descriptor for bind, the socket file's for connect (write-class right)
Abstract socketA descriptor stamped at bind, checked on connect and datagram send
PipeDescriptor possession, nothing else
FIFOThe file's descriptor at open; the directory's for creation
System V queue, segment, semaphore arrayA descriptor stamped at creation, addressed by kind and id (System V IPC)
POSIX shm, named semaphore, message queueThey are files — tmpfs under /dev/shm, mqueuefs inodes — with file descriptors
memfd, eventfd, pidfd, io_uring ringDescriptor possession; pidfd operations are process-access checks
Shared mappingsRights consumed from the descriptor at mmap; see the note on revocation below
Watches (inotify, fanotify)The watched object's read-class right (Peios Kernel TRM §3.9.4)

Descriptor passing (SCM_RIGHTS) is untouched: it moves descriptors, and a token descriptor riding it is delegation — the receiver gets a handle — as distinct from KACS_SCM_TOKEN, which is attestation of who sent the bytes.

Three rules that fall out #

A FIFO is not a named pipe. NT programs use named pipes for identity-carrying request/response with impersonation. The Linux object with that name — a FIFO — carries no identity and cannot; porting that pattern means an AF_UNIX socket with KACS_SO_PEER_TOKEN, never a FIFO.

A name you did not create is not evidence. Abstract socket names, System V keys, and entries under /dev/shm and /dev/mqueue are claim-on-create: whoever gets there first owns the object, and the descriptor then protects the object — not the name. Peios-native services bind filesystem paths in directories they own and clients authenticate the peer, not the name; anyone using a first-come namespace creates with an exclusive flag and treats failure as a signal, not a retry.

Revocation does not reach established mappings. Editing a descriptor narrows future opens; it cannot take pages back from a process that has already mapped them. If that matters, replace the file instead of editing it, or end the process. The general form of this rule — decisions are made when a handle is created — is in Access decisions.

Where to go next #

For the socket options and the register in detail, read Peer tokens.

For how System V objects get and expose their descriptors, read System V IPC.

Device firmware

Peios / Peios technical reference / Device firmware

Many devices carry no firmware of their own: the driver has to upload a vendor-supplied blob into the device before it does anything. Almost every Wi-Fi adapter works this way, as do modern GPUs (AMD, Intel, NVIDIA), many Ethernet controllers, laptop audio codecs and amplifiers, and Bluetooth. Without the blob the driver loads, finds the device, asks the kernel for a file under /usr/lib/firmware/, and — if it is not there — gives up. The symptom is a device that is present (lspci lists it, the module is loaded) but never comes up, and a line in dmesg like firmware: failed to load iwlwifi-so-a0-gf-a0-89.ucode (-2).

The kernel loads firmware itself. There is no daemon and no udev step involved: the driver's request is answered from the filesystem, transparently decompressing the .zst files Peios ships. That makes firmware a packaging matter — the right file in the right place — and this page is about which package that is.

The org.kernel.linux-firmware-<family> packages #

Upstream collects device firmware in one tree, linux-firmware, which is over a gigabyte and mostly for hardware Peios does not run on. Peios does not ship it whole. One recipe builds it into packages by vendor or driver family, each carrying only that family's blobs and only that family's licence texts:

PackageCovers
org.kernel.linux-firmware-iwlwifiIntel Wi-Fi
org.kernel.linux-firmware-atherosQualcomm Atheros Wi-Fi and Bluetooth (ath9k, ath10k/11k/12k, btqca)
org.kernel.linux-firmware-mediatekMediaTek Wi-Fi and Bluetooth (mt76, mt79xx)
org.kernel.linux-firmware-realtekRealtek Wi-Fi, Bluetooth, USB/PCIe Ethernet
org.kernel.linux-firmware-broadcomBroadcom and Cypress Wi-Fi, Bluetooth, tg3/bnx2 Ethernet
org.kernel.linux-firmware-marvellMarvell and NXP Wi-Fi and Bluetooth (mwifiex, libertas)
org.kernel.linux-firmware-ralinkRalink rt2x00 Wi-Fi
org.kernel.linux-firmware-amd-graphicsAMD Radeon GPUs (amdgpu, radeon)
org.kernel.linux-firmware-amd-platformAMD PSP/SEV, Platform Management Framework, XDNA NPU
org.kernel.linux-firmware-intel-graphicsIntel GPUs (i915, xe: GuC, HuC, DMC)
org.kernel.linux-firmware-intel-platformIntel Bluetooth, sensor hub, IPU cameras, NPU, QAT, E800 NICs
org.kernel.linux-firmware-intel-soundIntel audio DSP firmware for pre-2019 parts (AVS, catpt, Skylake SST)
org.sofproject.sof-firmwareIntel Sound Open Firmware — the audio DSP firmware, loadable modules and topologies for Ice Lake and newer (from SOF's own release, not linux-firmware)
org.kernel.linux-firmware-nvidia-graphicsNVIDIA GPUs for nouveau/nova (GSP)
org.kernel.linux-firmware-audio-codecsCirrus, TI and Creative laptop codecs and amplifiers
org.kernel.linux-firmware-nicWired NICs: Chelsio, QLogic qed, Myricom, Tehuti, 3Com, legacy USB Ethernet
org.kernel.linux-firmware-storageFC/SCSI HBAs (qla2xxx, QLogic BR, AdvanSys) and the ENE card reader
org.kernel.linux-firmware-miscUSB serial adapters, DVB/V4L tuners, legacy Wi-Fi, PCMCIA, sound cards

The names are the canonical reverse-DNS ones. Each package also provides the shorter firmware-<family> name it shipped under first, so an image manifest or an install command written against firmware-iwlwifi still resolves; that is a migration capability, not a second package.

Which ones a machine needs is a question about its hardware, and the answer is usually two or three: an Intel laptop wants org.kernel.linux-firmware-iwlwifi, org.kernel.linux-firmware-intel-graphics, org.kernel.linux-firmware-intel-platform (Bluetooth), org.sofproject.sof-firmware (audio DSP) and org.kernel.linux-firmware-audio-codecs; an AMD one swaps the graphics and platform packages. dmesg | grep firmware after boot names every file a driver asked for and did not get, and the file's directory (intel/, amdgpu/, rtw89/) maps onto the table.

Every package installs under /usr/lib/firmware/, which is where the kernel looks (it reads /lib/firmware, and /lib is a view of /usr/lib). The blobs are zstd-compressed on disk; the kernel decompresses them on load and no tool needs to know.

Licensing, and license_class = "firmware" #

Firmware blobs are almost never free software. Vendors permit redistribution of the unmodified binary and nothing more, each under their own terms, and there is no source. Peios does not pretend otherwise: each org.kernel.linux-firmware-<family> package declares its vendor licences as LicenseRef- SPDX identifiers and carries license_class = "firmware" in its manifest.

The licences come from upstream's WHENCE manifest, file by file. Each package's SPDX expression is derived from exactly the licences its own files cite, vendor terms and the GPL, MIT or Apache texts a few blobs are under alike. Those texts are shipped under /usr/share/licenses/org.kernel.linux-firmware-<family>/, together with a WHENCE excerpt: upstream's own licence statements and copyright notices for every entry the package draws on, verbatim. Where upstream hedges ("Allegedly GPL, but no source visible"), the excerpt keeps that wording rather than stating more than upstream does.

That class is the machine-readable fact. peipkg info org.kernel.linux-firmware-iwlwifi shows it, a composed image's /usr/share/licenses.json lists it for every package, and it is what lets a tool answer "what non-free is on this machine" without parsing licence expressions. firmware is a class of its own — distinct from proprietary — because it is a different bargain: it does not run on the CPU, it is needed to use hardware you already own, and you cannot get it any other way. Peios ships firmware and does not ship proprietary userspace; the class is how an image policy can say exactly that.

Trust #

Firmware runs on a device that can DMA into host memory, so a tampered blob is a compromise of the whole system — at least as bad as an unsigned binary running with kernel privilege, and it happens without an exec for the kernel to intercept. Peios treats firmware as part of the trusted computing base and signs it the same way it signs TCB binaries.

Every blob in every org.kernel.linux-firmware-<family> package carries an ML-DSA-65 signature made with the TCB key when the package is built. Blobs are not ELF, so the signature travels as a <blob>.peios.sig entry beside the blob in the package, and peipkg turns it into the file's security.peios.sig extended attribute as it installs the blob (see Signature format). The kernel verifies that attribute every time a driver asks for firmware, on the exact bytes it is about to hand to the device, and refuses a file that is unsigned, altered, or signed with a key below the TCB tier. The signature covers the compressed .zst bytes as they sit on disk, so verification happens before decompression.

In 2026.8 the check runs in log mode: a file that would be refused is reported to the kernel log and still loaded, so a system can be brought up on a mixed firmware set and the gaps found. Refusal becomes the default in the release after the whole farm has shipped signed; until then kacs_fwsig=enforce on the kernel command line turns it on for a boot, and kacs_fwsig=log turns it back off. Either way, /usr/lib/firmware is not a place to put files by hand: an unsigned blob dropped there, or one placed on a path added to the loader's search list, is exactly what the check exists to catch.

What is not shipped #

The upstream tree also carries firmware for ARM SoCs (Qualcomm, MediaTek video processors, Rockchip, Amlogic, NXP i.MX), datacentre switches and SmartNICs (Mellanox, Netronome), InfiniBand and crypto accelerators. None of it is packaged: Peios is x86-64, and a family is only added when there is hardware to use it. CPU microcode is a separate mechanism entirely — intel-ucode and com.amd.amd-ucode are early-loaded from the initramfs before the firmware loader exists.

Nothing under /usr/lib/firmware is needed to reach a root filesystem (NVMe, AHCI, USB and virtio storage need no blobs), so the initramfs carries no firmware. A driver that probes in the initramfs and cannot find its blob is re-probed once the real root is mounted and the device manager replays device events.

Intel Sound Open Firmware is not in linux-firmware at all; it is a separate upstream (thesofproject's sof-bin), which is why it is its own package, org.sofproject.sof-firmware, rather than a family of the linux-firmware recipe. The old firmware-intel-sof name remains a migration capability, not the package's canonical identity. Only the Intel-signed images ship — the community builds signed with the public development key run only on Chromebooks and development boards whose DSPs accept that key. Install org.sofproject.sof-firmware-debug separately when DSP debugging tools need the much less commonly used .ldc data.

1.1 Scope

Peios / Peios technical reference / Conventions / Introduction

This document defines how the technical documents of Peios are written and how they are read. It governs two classes of document:

  • the specifications of this anthology — the four books that state what must be true for a Peios system and for the parties that interoperate with one; and
  • the technical reference manuals — the per-component books that describe exhaustively how one piece of Peios actually behaves.

It covers normative language, the structure a document of each class takes, how any part of either is cited, and the style rules both share.

1.1.1 What this document does not cover #

  • Task documentation — the tutorial and how-to material written for people using or building on Peios. That has its own house style, maintained separately, and neither the normative apparatus of a specification nor the exhaustiveness of a reference manual applies to it.
  • Any behaviour of Peios itself. Nothing here specifies what a Peios system does. This document constrains documents.
  • The renderer. The static site generator that builds these books supplies the chapter and article numbering this document's addressing scheme relies on, and is specified separately.
  • Editorial process. How a document is reviewed, by whom, and when it is considered ready are project conventions rather than document conventions.

1.1.2 Position in the anthology #

This document sits ahead of the four specification books because it is read first. It is not itself a specification: it defines no Peios behaviour, and no implementation conforms to it. What conforms to it are documents.

1.2 The Two Document Classes

Peios / Peios technical reference / Conventions / Introduction

Every technical document in this corpus is either a specification or a technical reference manual, and the difference is not a matter of subject, length, or tone. It is a matter of how many independently written parties have to reproduce the same computation in order to agree.

  • If one party computes something and everyone else calls it, that is a manual.
  • If a second party must reproduce it identically, or the two produce divergent results, that is a specification.

1.2.1 The sharper form of the test #

A third party wanting to interact with something directly is necessary but not sufficient. What makes something a specification is a second role — a party whose behaviour the other side depends on.

A system-call surface has no second role. It has callers, and callers are documented rather than specified. The question that settles it is which party is asking: a registry source answers the kernel's questions, and a package producer emits what a consumer validates, so both of those are specifications. Nobody serves mount(2), so that is a manual.

1.2.2 Most subsystems split #

The expected outcome for any given subsystem is both: a specification stating the contract, and a manual covering the nuances of how one implementation fulfils it.

Binary signing is a specification because a third party can sign binaries; the kernel's verification machinery behind it is a manual. Security descriptor inheritance is a specification even though there is one kernel, because the kernel does not propagate a descriptor change and every userspace tool that propagates one has to agree. A package's manifest schema is a specification; how a package manager decides which of several candidates to install is a manual.

A subsystem whose only external surface is its own syscalls contributes no specification at all, and that is a legitimate outcome rather than an oversight.

1.2.3 Consequences for the reader #

A specification tells you what you may rely on. Its statements are commitments, and an implementation that contradicts one has a defect.

A manual tells you what a component does. Its authority comes from the software: where the two disagree, the software is right and the manual is stale. A manual makes no stability commitment, and nothing in one is a promise about a future version.

That difference is why the two classes are written so differently, and why a reader must be able to tell at a glance which one is in front of them (§4.2).

1.3 How to Read This Book

Peios / Peios technical reference / Conventions / Introduction

1.3.1 Normative keywords #

The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY in this document are to be interpreted as described in RFC 2119. Their meaning within a specification is defined in §2.1.

Text set off as a note is informative.

1.3.2 The two halves carry different weight, deliberately #

Chapters 2 and 3 govern specifications and are normative throughout. A specification that violates one of their requirements is malformed, and the requirement is stated as such.

Chapter 4 governs technical reference manuals and is deliberately lighter. Most of it is SHOULD and MAY, and some of it is offered as guidance carrying no normative weight at all. A manual is a description of software rather than a contract, so prescribing its shape tightly would be prescribing the shape of the software.

Two rules in chapter 4 are exceptions and are stated as MUST NOT, because they are what makes the class a class rather than a matter of taste: a manual carries no RFC 2119 keywords (§4.2), and a manual does not narrate the difference between itself and a specification (§4.4).

Chapters 5 and 6 apply to both classes.

1.3.3 Reading order #

An implementer needs §2.1 and chapter 5, and nothing else.

An author needs the chapter for the class they are writing, then chapters 5 and 6.

A reader trying to work out which class a document belongs to, or why a given fact is documented in one place rather than another, wants §1.2.

2.1 RFC 2119 Keywords

Peios / Peios technical reference / Conventions / Normative Language

A specification MUST declare its use of RFC 2119 keywords in its conventions article (§3.1).

The following keywords, when they appear in uppercase, MUST be interpreted as described in RFC 2119:

KeywordMeaning
MUST, MUST NOTAn absolute requirement or prohibition
SHALL, SHALL NOTSynonyms for MUST and MUST NOT
SHOULD, SHOULD NOTA requirement that may be set aside for a stated reason, the consequences of which are understood
MAYGenuinely optional
REQUIRED, OPTIONALSynonyms for MUST and MAY, used adjectivally

A specification MAY use a subset. Its conventions article MUST list which keywords it uses.

2.1.1 Lowercase is not a keyword #

The same words in lowercase carry no normative weight. This is not a loophole to be exploited: a lowercase "must" in a passage that reads as a requirement is an editing defect, because the reader cannot tell whether an obligation was intended.

An author writing a requirement MUST use the uppercase form. An author writing description SHOULD reach for a verb that is not a keyword at all — "is", "carries", "produces" — rather than relying on case to carry the distinction.

2.1.2 Requirements are stated against a role #

A specification with more than one party MUST state each requirement against the role rather than the program (§3.2). An obligation on a consumer binds whatever process is acting as the consumer, and one program may serve different roles on different interfaces.

2.1.3 SHOULD means something #

A SHOULD is not a soft MUST and not a decorative MAY. It marks a requirement with a real exception, and a specification using one SHOULD say what the exception is — either inline or in an adjacent note.

Note

A SHOULD whose exception nobody can name is a MUST that the author was not confident enough to write. A SHOULD that nothing would ever satisfy is a MAY. Both are worth catching before publication, because an implementer reading either has to guess at an intent that was never formed.

2.2 Informative Text

Peios / Peios technical reference / Conventions / Normative Language

2.2.1 Everything is normative by default #

All text in a specification is normative unless it is explicitly marked otherwise.

2.2.2 Marking #

Informative text MUST be marked, using a note callout:

> [!NOTE]
> This paragraph gives context and defines no behaviour.

An inline aside introduced by "For example" or "Note:" is also informative.

2.2.3 What informative text may not do #

Informative text MUST NOT contain an RFC 2119 keyword used in its normative sense. Where a note needs to refer to required behaviour, it MUST cite the normative statement that defines it rather than restating it.

Note

The restriction exists because a requirement stated twice is a requirement that can drift. When a note paraphrases a rule and the rule later changes, the note becomes a second, contradictory specification that a reader has no way to rank against the first.

2.2.4 What informative text is for #

Rationale, worked examples, the attack a rule defends against, and the alternative that was considered and rejected.

A specification SHOULD carry that material rather than omit it. A rule whose reason is unrecorded is a rule that a later revision will remove as redundant, or preserve for the wrong reason — and a wrong reason is what the revision after that will reason from.

2.3 Pseudocode

Peios / Peios technical reference / Conventions / Normative Language

A specification that includes pseudocode MUST document its conventions in its conventions article (§3.1).

The conventions below are established across this corpus and SHOULD be used, so that a reader moving between books does not have to relearn the notation.

SymbolMeaning
& (parameter prefix)In-out parameter — the caller's value is read and may be modified
|Bitwise OR
& (in an expression)Bitwise AND
~Bitwise NOT
|=, &=Augmented assignment
=Assignment
==Equality comparison
->Field access through a pointer or reference
→Return type in a signature
//Single-line comment

Pseudocode MUST appear in a fenced code block.

A specification MAY use further conventions — and, or, not for boolean operators, or named error returns — and MUST document any it uses.

2.3.1 Pseudocode is normative #

Pseudocode in a specification is a normative statement like any other, and MUST be read as one. It is not an illustration of a rule stated elsewhere; where it is meant as illustration, it belongs in a note (§2.2).

Note

This matters most for algorithms with an ordering that the prose does not make explicit. Two rules that can both fire on one input need a stated order, and pseudocode is often where that order actually lives.

3.1 Required Articles

Peios / Peios technical reference / Conventions / Specification Structure

A specification book and each of its chapters carry a fixed opening and closing shape, so that a reader arriving at any chapter finds the same things in the same places.

3.1.1 Book level #

A book MUST open with an introduction chapter containing:

ArticlePurpose
ScopeWhat the book covers, and what it does not — with each exclusion naming where the excluded thing is covered
ConventionsThe book's RFC 2119 declaration and any notation specific to it (§3.3)

3.1.2 Chapter level #

A chapter specifying a protocol or a format MUST open with:

ArticlePurpose
Scope and RolesWhat the chapter specifies, and the roles that speak it (§3.2)
TerminologyTerms specific to the chapter

and SHOULD close with an Extension article (§3.4) and a Conformance article (§3.4), in that order, before any appendices.

A chapter defining a data structure rather than a protocol has no roles and no conformance obligations of its own, and is exempt from both.

3.1.3 Terminology delegates rather than repeats #

A terminology article MUST define the terms the chapter introduces. A term already defined elsewhere in the corpus MUST be delegated by reference rather than redefined:

Terms defined in §5.2 — package, manifest, payload, repository — are
used here with the same meaning and are not redefined.

Note

A redefinition is a second definition, and two definitions of one term drift. Delegation costs the reader one click and costs the corpus nothing.

3.1.4 Scope names its exclusions #

A scope article's exclusion list MUST identify, for each excluded item, which document covers it. An exclusion that names nothing tells a reader the subject is out of scope without telling them where to go, which is the least useful thing a scope article can do.

3.2 Roles

Peios / Peios technical reference / Conventions / Specification Structure

A specification with more than one party MUST name its roles in its scope article, and MUST state every requirement against a role rather than against a program.

A role is a position in an interaction, not a piece of software. One program frequently occupies several — a repository operator is usually also a package producer, and a daemon that answers one protocol may consume another.

3.2.1 Why the distinction is load-bearing #

Naming a program in a requirement makes the requirement untestable against anything else, which defeats the purpose of writing the specification down. The whole reason a contract is published is that somebody other than the current implementation may satisfy it.

3.2.2 Enumerating roles #

A chapter's scope article SHOULD present its roles as a table giving, for each, the obligation it carries:

RoleObligation
ProducerBuilds the artifact. Everything the artifact contains is a producer obligation.
ConsumerValidates and applies it. Every validation and rejection rule binds the consumer.

Two or three roles is typical. A specification finding itself with six has probably merged two interactions that want separate chapters.

3.2.3 Conformance follows the roles #

Because requirements attach to roles, a conformance article (§3.4) states what each role must do, separately. A reader implementing one side needs to know which requirements are theirs without reading the other side's.

3.3 Data Conventions

Peios / Peios technical reference / Conventions / Specification Structure

The conventions below hold across every book in this anthology. A book MUST NOT restate them, and MUST state any point on which it differs.

3.3.1 Byte order #

All multi-byte integer fields are little-endian unless explicitly stated otherwise.

3.3.2 Sizes #

All sizes and offsets are in bytes. u8, u16, u32, and u64 denote unsigned integers of 8, 16, 32, and 64 bits.

3.3.3 Layout tables #

A field layout is given as a table in declaration order. A layout table is normative: fields appear on the wire in the order listed, with no padding between them.

3.3.4 Notation #

Hexadecimal values carry the 0x prefix. Byte sequences are written as space-separated hex pairs: 0a 0b 0c.

3.3.5 Strings #

Strings are UTF-8 (RFC 3629). String comparison is byte-for-byte equality unless stated otherwise.

3.3.6 Hashes and signatures #

Hash values are lowercase hexadecimal unless stated otherwise, and hash algorithms are named by their IANA-registered identifiers.

3.3.7 Timestamps #

Timestamps are RFC 3339, in UTC, and end with Z.

3.3.8 What a book's conventions article carries #

Given the above, a book's conventions article (§3.1) is short. It MUST carry the book's RFC 2119 declaration, and SHOULD carry only:

  • notation the book introduces that is not listed here;
  • any point on which the book departs from this article;
  • pseudocode conventions, if the book uses pseudocode (§2.3).

Note

Before this article existed, four books each carried a near-identical conventions chapter. Three of them listed the same seven headings. Duplicated text of that kind does not stay identical: it is where contradictions breed, and the contradiction is invisible because nobody reads two copies of the same thing side by side.

3.4 Extension and Conformance

Peios / Peios technical reference / Conventions / Specification Structure

3.4.1 Extension #

A chapter specifying a wire format, a file format, or a protocol SHOULD close with an extension article stating how the thing may grow.

An extension article SHOULD distinguish:

  • Additive changes, which an implementation of the current version can ignore safely — a new optional field, a new entry in an open-ended set.
  • Changes requiring a version bump, which it cannot — a new required field, a new value in a closed enumeration, any change to an algorithm the format has frozen.
  • Reserved space, where the format deliberately leaves room, and what an implementation does when it encounters a value there.

A specification MUST state, for every enumeration it defines, whether that enumeration is closed. An implementation cannot decide whether to ignore or reject an unknown value without being told.

Note

The test for whether a change is additive is not whether it is small. It is whether an implementation that ignores it still behaves correctly. An optional field whose absence changes what gets installed is not additive, however optional it looks.

3.4.2 Conformance #

A chapter SHOULD close with a conformance article summarising, per role (§3.2), what that role must do. The article is a summary and MUST NOT introduce a requirement stated nowhere else.

A conformance article SHOULD also state plainly what conformance does not require. A reader who has just been given a list of obligations benefits more from knowing where their freedom lies than from a longer list.

Note

The useful shape is: here is what you must reach the same answer on as everybody else, and here is everything you are free to do differently. A specification that never says the second half reads as though it constrains the whole implementation, and implementers respond by over-constraining themselves or by ignoring it.

4.1 What a TRM Is

Peios / Peios technical reference / Conventions / Technical Reference Manuals

A technical reference manual is an exhaustive descriptive reference for one component: what it does, how it behaves at every edge, and what happens when it fails.

The hardware analogy is deliberate. If the specification anthology is the architecture reference manual, a TRM is the per-implementation technical reference manual that sits beside it — the document that tells you what this thing actually does, given that the architecture told you what any conforming thing must do.

4.1.1 One book per component #

A TRM covers one component, where the boundary is drawn by what ships and is maintained together rather than by subject matter. Several subsystems that live in one codebase and version together are chapters of one manual, not manuals of their own.

4.1.2 Its authority comes from the software #

This is the property that governs everything else in this chapter. A specification is authoritative over its implementations: where the two disagree, the implementation has a defect. A TRM is the reverse. Where a manual and the software disagree, the software is right and the manual is stale.

A TRM therefore makes no stability commitment. Nothing in one is a promise about a future version, and a reader who needs a promise needs a specification instead.

4.1.3 What belongs in one #

Everything about the component that is true and not specified elsewhere: its architecture, its state, its configuration, its algorithms where they are its own, its failure modes, its on-disk artifacts, and the reasoning behind its design decisions.

A manual SHOULD carry rationale generously. It is the only document in the corpus with room for it, and a component's design decisions are otherwise recorded only in the history of the work that produced them.

4.1.4 What does not #

Anything a third party must reproduce in order to interoperate. That is a contract, and a contract belongs in a specification (§1.2). A manual cites it (§4.6) rather than restating it.

4.2 Voice

Peios / Peios technical reference / Conventions / Technical Reference Manuals

4.2.1 No normative keywords #

A TRM MUST NOT use RFC 2119 keywords. Not uppercase, and not in the lowercase constructions that read as obligation.

This is the one rule in this chapter with no exceptions, because it is what makes the class a class. A manual describes; it does not require. A reader must be able to tell, from the first paragraph of any document in this corpus, whether they are being told what something does or what they must do.

4.2.2 The indicative mood #

Write what the component does.

Not thisThis
The daemon MUST reject a malformed requestThe daemon rejects a malformed request
A client SHOULD retry after a timeoutA client that times out can retry
The cache MAY be discardedThe cache can be discarded at any time
Callers must hold the lockCallers hold the lock

The third and fourth rows are the ones that catch people. "May" and "must" survive into descriptive prose easily, because an author describing something real slips into obligation without noticing — particularly when the source material was a specification.

4.2.3 Lowercase keywords are the actual hazard #

Uppercase keywords are trivially caught by searching. Lowercase ones are not, and they are far more common: a conversion from specification prose typically leaves a dozen or more.

An author SHOULD search a finished manual for lowercase must, should, shall, may not, and must not, and rewrite every instance that reads as a requirement. Idiomatic uses survive — "what the content should be", "two files that cannot both be present" — and the test is whether a reader could mistake the sentence for an obligation.

Note

A validator that flags documents lacking normative keywords is useless here, and worse than useless if it is trusted: for a manual the signal is inverted. The check has to be run deliberately and in the opposite direction.

4.2.4 Register #

Plain declarative prose, addressed to a competent engineer. Not tutorial and not chatty. A manual SHOULD assume its reader knows the platform and wants the specifics.

4.3 Structure

Peios / Peios technical reference / Conventions / Technical Reference Manuals

A TRM's chapter structure follows the component rather than a template. The conventions below are what has worked, offered as guidance.

4.3.1 The introduction #

A manual SHOULD open with an introduction containing:

ArticlePurpose
OverviewWhat the component is, and what is unusual about it
What This Manual CoversAnd what is covered elsewhere, naming where
TerminologyTerms specific to the component, delegating the rest
CompatibilityVersions, architectures, and what interoperates

The overview article is the most valuable page in the book and the one most often written last and least. It SHOULD say what is surprising about the component — the two or three decisions that would not be guessed from the name — rather than restating the component's purpose in a paragraph.

4.3.2 Body chapters #

Ordered so that a reader following the component's own flow reads them in order: what it is made of, what it does, in the sequence it does it.

4.3.3 A failure-modes chapter #

A manual SHOULD close with a chapter describing what goes wrong: the common failures, what each looks like from outside, what signal is available, and how to get back to a known state.

This is the chapter readers arrive at from a search engine at two in the morning, and it is the one most often omitted. A manual that documents every success path and no failure path has documented the easy half.

4.3.4 Appendices #

Consolidated reference material: constants, paths, on-disk state, configuration keys, event types. See §6.1.

4.3.5 Length is not a virtue, but exhaustiveness is #

A manual SHOULD document the edge cases. The odd interaction, the counter-intuitive default, the thing that happens only when two features meet — those are why the manual exists. A summary of the happy path is already in the component's README.

4.4 Describing the True State

Peios / Peios technical reference / Conventions / Technical Reference Manuals

A TRM describes what the component does now. This has a consequence that authors reliably find uncomfortable, and it is worth stating directly.

4.4.1 Divergences are not narrated #

A TRM MUST NOT frame anything as a difference between itself and a specification. No "the specification requires X but the implementation does Y", anywhere, in any form.

Where an implementation does not satisfy a contract, that is a defect. It is recorded as one, in the project tracker, against the work that would fix it — and the manual simply describes what the software does.

Note

The reason is that a manual written as a divergence log rots into a second changelog nobody maintains, and undermines itself while doing it: a reader who is told the document is describing something wrong stops trusting the parts that are describing something right.

It is also the wrong audience. The person who needs to know about a divergence is whoever will fix it, and they are looking at the tracker, not at a reference manual.

4.4.2 Which does not mean writing around it #

Describing the true state is not the same as being vague about it. A manual SHOULD state plainly what a component does not do, where a reader would otherwise assume it did:

peipkg does not check free space before it starts. Exhaustion is discovered when a write fails.

That sentence is descriptive, useful, and carries no comparison to any contract. The distinction is between what is absent — which a reader needs — and what was promised — which belongs in the tracker.

4.4.3 Interim states #

Where behaviour is deliberately provisional, a manual MAY say so and describe the intended end state, provided it describes the current one first and at greater length. A manual SHOULD NOT let a description of an intention displace the description of what actually runs.

4.5 Proposals

Peios / Peios technical reference / Conventions / Technical Reference Manuals

A technical reference manual proposal is a TRM for a component that does not exist yet.

It is written exactly as a TRM is written — indicative mood, no normative keywords, exhaustive — and it describes software that has not been built.

4.5.1 Why the class exists #

A design that has been written out at reference-manual depth is a design whose gaps are visible. Every field, limit, and interaction has to be decided in order to be described, and describing it is how the contradictions surface.

4.5.2 Saying what it is #

A proposal MUST identify itself as one. It SHOULD do so in two places: in its description, and in an opening article stating plainly that a manual's authority comes from the software (§4.1) and that this one has none of that yet — so a surprising statement in it is a design decision that has not survived contact with an implementation.

The document class carries that warning by itself, which is why no banner on every page is needed.

4.5.3 It graduates in place #

A proposal sits alongside the finished manuals and MUST NOT be moved when the software lands. Moving it breaks every inbound link. Graduating consists of correcting it against the implementation and dropping the word "proposal".

4.5.4 Reviewing one #

A proposal cannot be verified against code, so review is internal composition instead: for each field, rule, limit, and configured value, find every other place the document constrains it, and compose them by hand.

The failure to look for is two rules that are individually right and jointly wrong — a default stated in one chapter and a validity constraint stated in another, which together reject the default. The related one is two rules with no stated order, where both can fire on one input and the document never says which runs first.

Composing every pair of independently configured size limits on one data path, at their default values, is worth doing every time. Two documents in a row have carried a ceiling on one side larger than the ceiling on the other, letting one party accept what the other cannot carry.

A proposal MAY still contribute a specification chapter, where the component owes one. Publishing a contract with no implementation to keep it honest is a real cost, and it is a decision to take deliberately rather than by default.

4.6 Citing a Specification

Peios / Peios technical reference / Conventions / Technical Reference Manuals

Where a component implements a published contract, its manual cites the contract rather than restating it.

4.6.1 Cite, do not paraphrase #

A manual SHOULD state that the contract exists, name where it is, and describe what this implementation does about it:

A dependency is satisfied by a candidate when the conditions of PSPU §5.21 hold. Three consequences of those rules shape how peipkg behaves.

A manual MUST NOT restate the contract's normative content in descriptive prose. A paraphrase is a second copy that drifts, and because the paraphrase carries no normative keywords, a reader cannot tell which of the two they are supposed to rely on.

4.6.2 What the manual adds #

The nuances of one implementation: the order it does things in, the limits it applies, what it does where the contract leaves a choice, and what it does not implement.

A manual is the right place to say that a component is more restrictive than the contract requires, or that it makes a choice the contract leaves open. Those are facts about the software, not comparisons against it.

4.6.3 Where the split falls #

The recurring question is whether a given fact belongs in the manual or in the specification, and the test in §1.2 answers it: if a second independently written party must reproduce it, it is contract.

Worked, from one component: what satisfies a dependency is contract; which of several satisfying candidates gets chosen is implementation. The schema of a declaration is contract; the command-line flag that overrides it is implementation. The layout of an artifact is contract; where the tool keeps its own database is implementation.

Note

The most reliable way to settle a borderline case is to read the target specification's own scope article. It states what that book excludes, and it has overturned a placement more often than any argument from first principles.

4.7 Citing a Manual

Peios / Peios technical reference / Conventions / Technical Reference Manuals

§4.6 covers the ordinary direction: a manual cites the contract it implements. The reverse direction needs a rule of its own, because a specification that defers to a manual has given something up, and a reader is entitled to know that it did.

4.7.1 When it is allowed #

A specification MAY cite a manual for material it deliberately does not specify. It MUST NOT cite one for material within its own scope.

The test of §1.2 decides which case applies. If a second, independently written party must reproduce the behaviour, it is contract — and a citation to a manual there is a specification failing at its job, since the other party has no manual for their own implementation. If no second party is expected to reproduce it, because the component is Peios' alone and is described rather than standardised, then the manual is the only document that holds the material, and naming it beats implying a standard exists somewhere.

4.7.2 Say that it is a deferral #

A specification citing a manual MUST make the deferral legible rather than let it read as an ordinary cross-reference. "Described in the Peios Kernel TRM §3.8" says what it is; "see §3.8" does not.

Where a scope article excludes a whole area to a manual, it SHOULD say why once, in that article, rather than re-arguing it at each citation site. The sites then only need to name the article.

4.7.3 The standing case #

KACS is the one that recurs. Peios' access-control implementation is described in the Peios Kernel TRM §3 and is not separately specified, so PCDS — which specifies the structures KACS consumes — defers to that manual wherever a structure's meaning depends on what KACS does with it. PCDS §1.1 records the arrangement; the individual citations name the articles.

Note

This is not licence to let a specification thin out over time. Each deferral is a decision that a given area will not be standardised, and the scope article is where that decision is visible. A citation to a manual appearing anywhere else, without a scope article behind it, is a defect.

4.8 Anchoring Testable Statements

Peios / Peios technical reference / Conventions / Technical Reference Manuals

A specification's testable statements are already findable: they carry RFC 2119 keywords, so MUST can be searched for and enumerated. A manual has none, by the rule that defines the class (§4.2). Nothing in its text distinguishes a behavioural claim from the prose around it.

A named citation (§5.3) is therefore not a convenience for a manual. It is the only handle a manual can give a test suite, and the reason to anchor one is usually that something outside the corpus needs to point at it.

4.8.1 Anchor after writing, not during #

An author SHOULD write a manual, or a chapter of one, to completion first, and add anchors in a separate pass afterwards.

Marking anchors while drafting interrupts the writing for a decision that has nothing to do with the sentence being written. Choosing which statements are testable is also a different kind of attention from composing them: it reads the finished text as an implementer would, looking for what could be observed, and that reading is easier when the text is finished.

Where a component's manual is written before its tests exist — which is usual — the anchoring pass is still worth running, because it is what tells the test author where to start.

4.8.2 Put the anchor on the heading #

By default an anchor goes on a section's heading, and a section gets one. A manual that is well structured already devotes a section to a behaviour, so the section is the thing a test wants to cite, and the heading is where the anchor belongs:

### `ro` [*strata.ro-is-independent-of-filesystem]

An anchor on a heading rides the document's structure rather than one sentence of prose. A sentence gets rewritten in the ordinary course of keeping a manual true; a section is a more stable thing to have named, and the anchor moves with it.

The exception is a section that states several distinct behaviours — most often a table of error conditions, or a numbered list of rules that each hold independently. There the section is the wrong unit: one name for fourteen rules tells a test suite no more than the article number did. Anchor those individually, in the table row or list item that states each rule.

The test is not a count. It is whether a reader asking "which behaviour does this name?" could get more than one answer.

4.8.3 Choosing what to anchor #

Anchor a statement that could be observed: a rule about what is accepted or refused, a value, an ordering, a returned error, an invariant that holds while the component runs.

Do not anchor rationale, comparison with prior art, an account of how the source is organised where no behaviour follows from it, or a restatement of something already anchored nearby. An anchor on prose that no test could ever cite is worse than no anchor: it will be counted as uncovered forever.

4.8.4 Anchor where the rule is defined, once #

A manual states its important rules more than once — in the article that defines a rule, in a summary of consequences, in a table collecting every error condition. Only the defining occurrence carries the anchor.

A second anchor on a restatement does not add coverage; it splits it. Two names for one behaviour means two entries in a report, one test between them, and no way for a reader to tell which name the other tests were meant to cite.

The signals that a passage is a restatement rather than a definition are usually explicit: it closes with a § reference to the article that owns the rule, or it sits under a heading that disclaims authority. Where a section says the articles above remain authoritative, it carries no anchors at all.

The mirror of this also has to be checked. A summary left unanchored is correct only if the rules it summarises are anchored where they are defined — a table of error conditions whose rows appear nowhere else in prose is a definition, however much it looks like a summary, and an author SHOULD anchor its rows rather than leave the most testable surface in the manual uncitable.

4.8.5 One anchor is not one test #

Coverage against a manual is "does this citation have at least one passing test". It is not a mapping.

In a specification a normative statement is often close to a single testable behaviour. In a manual it frequently is not — an anchor is as much a way of dividing a long descriptive document into pieces a test suite can reason about as it is a single assertion, and several tests may reasonably cite one name. An author SHOULD move toward one anchor per behaviour where the prose allows it, and SHOULD NOT contort the prose to achieve it.

Note

The count of anchors in a manual is not a measure of its quality, and a chapter with few is not deficient. Descriptive chapters — an overview, a discussion of the model — legitimately carry almost none, because almost nothing in them is a claim a test could check.

5.1 Section Addressing

Peios / Peios technical reference / Conventions / Citation and Addressing

Any part of any book in this corpus is addressable with the § symbol followed by a number derived from the book's structure.

FormMeansExample
§NChapter NPSPU §5
§N.MArticle M of chapter NPSPU §5.23
§N.AAppendix A of chapter NPSPU §5.A
§AAppendix A of the bookthe peipkg TRM §A

The examples are qualified because a bare § reference means "this book" (§5.2). Within this document, §2.1 is the RFC 2119 article and §A is the normative-reference list.

5.1.1 Where the numbers come from #

Chapter and article numbers derive from the ordering of directories and files, and are supplied by the renderer. An author does not write them into the text.

A chapter appendix — a file whose name marks it as an appendix, within a chapter directory — is lettered rather than numbered, and cited in the form §N.A. A book-level appendix, at the book root, is rendered as "Appendix A" and is cited by its letter alone, with no chapter component at all.

The two are easy to confuse and produce silently wrong references. An author adding a second appendix to a chapter SHOULD check the rendered index before relying on the citation.

5.1.2 Stability #

Section numbers are positional. Inserting a chapter shifts every following chapter; inserting an article shifts the rest of its chapter.

A book that other documents cite SHOULD therefore append rather than insert, where the choice exists. Where a restructure is unavoidable, every inbound reference has to be revalidated (§5.4).

5.2 Citing Between Books

Peios / Peios technical reference / Conventions / Citation and Addressing

5.2.1 Within a book #

A reference to another part of the same book omits the book name:

The claim-path set is the union defined in §5.23.

5.2.2 Between books #

A reference to another book leads with that book's short name:

A dependency is satisfied when the conditions of PSPU §5.21 hold.

The short name is the one declared in the book's own metadata — PCDS, PGSS, PSPK, PSPU. A reference to a manual names the component: the peipkg TRM §7.4.

5.2.3 Referring to a whole book #

All identifiers in this chapter conform to PCDS.

5.2.4 Prose and the reference are different things #

A citation MAY be accompanied by prose naming what is at the other end:

Sender authentication is performed by peer credential (§4.18).

The descriptor store article (§4.20) defines the retention rules.

The § reference is the durable part. The prose is a readability aid, and it MAY drift across revisions without invalidating the reference — which is also why prose alone is not a citation.

5.2.5 In code and in commit messages #

A code comment or a test referring to a documented rule SHOULD carry the full citation, so that a search finds every site depending on it:

/* Per PCDS §5.6: inherited ACEs precede explicit ones. */

Where the rule carries a named citation (§5.3), cite it by name instead. It resolves to the statement rather than to the article holding it, so a test says exactly what it verifies:

// Verifies PCDS *sd.inherited-aces-precede-explicit

Note

This is the mechanism that makes a specification change tractable. When a rule moves, the citations are what tell you which code was relying on it. Prose references — "as the security descriptor spec says" — do not survive a grep.

5.3 Granularity

Peios / Peios technical reference / Conventions / Citation and Addressing

The finest unit a § reference addresses is the article — the §N.M form of §5.1. Headings within an article are not numbered, and there is no § form that reaches inside one.

For prose that is the right size. A reader following a reference wants the surrounding argument, not one sentence stripped of it.

It is the wrong size for a test. A test verifies one statement, and an article holds many, so "this test covers §5.9" cannot say which of §5.9's statements remain unverified. For that there is a second scheme, addressing a statement by name.

5.3.1 Named citations #

A document MAY anchor an individual statement with a name:

A source MUST reject a relative identifier outside its declared
range. [*psi.rejects-out-of-range-relative-id]

which is then cited from outside the corpus — from a test, or a code comment — by qualifying it with the book's short name:

// Verifies PGSS *psi.rejects-out-of-range-relative-id

An anchor is a locator, not a delimiter. It marks a place; it does not record where the statement it names begins or ends, and nothing computes that. A reader follows the name and reads.

Four rules, all enforced by the builder:

  • A name MUST be unique within its book. Citations from outside are qualified by the book's short name, so book scope is enough.
  • A name MUST be lowercase, in dot- or hyphen-separated segments, beginning and ending with an alphanumeric.
  • Only a book may carry an anchor. A citation is addressed through its book, so an anchor anywhere else could never be cited.
  • An anchor MUST NOT appear inside a code block or inline code, where it is shown rather than declared.

A retired name MUST NOT be reused for a different statement. Nothing can detect the reuse, and every citation that already existed silently comes to mean something else.

5.3.2 Why not clause numbers #

An earlier corpus defined a finer § form: clauses numbered implicitly by counting normative statements within the deepest enclosing heading, cited as §3.2.1(4), with a validation rule for checking that a clause number still resolved.

It was specified in detail and never adopted. Across thirteen documents it was used eighteen times, and never once in the current corpus. What it cost was real: every editorial change to an article renumbered the clauses after it, so every citation into that article had to be checked against a count that nothing computed.

A name is not a count. It is written once, it does not shift when a statement is inserted above it, and a change that would break it — deleting the statement — is exactly the change that ought to break it. The failure that retired clause numbering does not reach the named form, which is why the two can coexist.

5.3.3 Why a name and not an extent #

A name identifies a statement; it does not describe one. Naming a statement copy-up.preserves-ownership says which statement is meant, not what it says, and the name does not grow when the statement gains an exception. This is the same division as §5.2's: the reference is the durable part, and the prose around it may drift.

An author MAY be tempted to mark where a statement ends as well as where it is. The corpus does not offer that, deliberately. A name is arbitrary, so nobody disputes it and nobody revises it; an extent appears to have a correct answer, so it invites both, and every revision of one buys nothing for the test or the code comment that cites it. Statements also overlap and nest in prose — a rule and its exception — in ways no bracketing scheme resolves cleanly.

5.3.4 Writing for citability #

Because the article is the unit a § reference reaches, an author SHOULD size articles so that one is a sensible thing to point at. An article covering one rule is easy to cite; an article covering nine unrelated rules forces every citation to be approximate.

The same applies one level down. Where a paragraph holds several statements that need separate names, an author SHOULD split the paragraph rather than crowd it, since a paragraph is also the unit of context a tool can report around an anchor.

Where a single statement needs picking out and no anchor exists, a citation MAY name it in prose alongside the article reference:

the duplicate-key rule of §5.9

5.3.5 Reading an old citation #

A (N) clause suffix in older material, or in a code comment predating this corpus, refers to the retired scheme. It SHOULD be resolved to an article reference, or to a named citation, when the surrounding text is next touched.

5.4 Validation

Peios / Peios technical reference / Conventions / Citation and Addressing

5.4.1 Every reference resolves #

A published book MUST NOT contain a reference that does not resolve. A reference to a chapter, article, or appendix that does not exist is an error, not a cosmetic defect: it is indistinguishable, to a reader, from a reference to something that was deleted.

5.4.2 Checking #

Because section numbers are positional (§5.1), any structural change invalidates references — including references from other books, which the author making the change is least likely to be looking at.

After a structural change, an author SHOULD:

  1. Build the affected books and extract the actual chapter and article numbering from the rendered index.
  2. Collect every § reference in the corpus.
  3. Check each against that numbering.

Steps 1 to 3 are mechanical and worth automating. What is not mechanical is whether a reference that still resolves still means what the citing text implies. A reference to "the ordering rules of PSPU §5.6" resolves happily after that article is rewritten to cover something else.

Note

The failure this catches is specific and common: an article count changes by one, every reference past that point is off by one, and every one of them still resolves — to the wrong article. Nothing reports an error, and the document is wrong in a way that reads as correct.

5.4.3 Named citations are checked for you #

Unlike § references, named citations (§5.3) are validated by the builder: a duplicated name, a malformed one, or an anchor outside a book fails the build. What the builder cannot check is the other direction — whether a citation from outside the corpus, in a test or a code comment, still names a statement that exists. Deleting an anchor is therefore a deliberate act: grep the name across the repositories that cite it before removing it.

5.4.4 Two traps #

Line-wrapped references. A citation split across a line break — the book's short name at the end of one line and the § number at the start of the next — is easy for a checker to miss and easy for an author to introduce.

Appendix forms. A chapter appendix and a book appendix take different forms (§5.1), and a reference using the wrong one resolves to nothing — or worse, to something.

6.1 Appendices

Peios / Peios technical reference / Conventions / Shared Style

An appendix consolidates reference material that is defined in the body: constants, limits, enumerated values, paths, configuration keys, event types, wire vocabularies.

6.1.1 Appendices and the body do not duplicate #

Where a constant, table, or layout is defined in an appendix, the body MUST reference it rather than restate it — and where it is defined in the body, the appendix MUST reference that.

Exactly one of the two is the definition. The other points at it.

Note

A value written down twice is a value that will eventually be written down differently. The appendix is where this bites hardest, because an appendix is exactly the kind of page that gets updated in isolation by someone who has not read the body — or skipped entirely by someone who has.

An appendix entry SHOULD carry a citation to the article that defines what it lists, so that a reader who needs the semantics can get there in one step.

6.1.2 What earns an appendix #

Material a reader looks up rather than reads: something they arrive at knowing what they want.

A limits appendix is the clearest case. Every size bound, count cap, and default in one table is genuinely more useful than the same figures scattered across twenty articles, and the body articles then cite it rather than repeating the number.

6.1.3 Generated appendices #

An appendix listing values that exist in source — an ABI table, a constants list, an error enumeration — SHOULD be generated from that source rather than transcribed, with a check mode that fails when the committed file is stale.

Note

The argument is empirical. In one case, a hand-written ABI appendix had every numeric value correct and a systematic error in the names. Transcription reproduces a wrong name faithfully and produces offsets nobody can cheaply re-check; generation cannot.

6.2 Tables and Enumerations

Peios / Peios technical reference / Conventions / Shared Style

6.2.1 One canonical home #

Every exhaustive table or enumeration lives in exactly one place. Everywhere else that needs it gets a clearly framed subset and a reference to the canonical one.

This applies across the whole corpus, not within a single book: a table in a specification is not re-tabulated in a manual, and a manual's configuration reference is not duplicated into task documentation.

6.2.2 Subsets are labelled as subsets #

A partial table MUST be framed as partial. A reader who cannot tell a subset from the full set will treat the subset as exhaustive, which is how a table that was correct becomes a document that is wrong.

6.2.3 Disagreeing copies are never averaged #

Where two copies of a table disagree, the resolution is to check the source — the implementation, or the specification that defines it — and then to delete one of the copies. Splitting the difference produces a third value that matches nothing.

6.2.4 Layout tables #

A table describing a binary layout is normative in a specification (§3.3) and descriptive in a manual, and in both cases gives fields in declaration order.

6.2.5 Formatting #

A table row MUST NOT contain an unescaped |. A pipe inside a cell — common when documenting bitwise expressions — silently eats a column, and the resulting table renders as though it always had one fewer.

A table SHOULD have a header row with a meaningful label per column. Field | Type | Description beats three unlabelled columns.

Very wide tables SHOULD be broken up or transposed. A table that needs horizontal scrolling is a table nobody reads the right-hand end of.

6.3 Notes

Peios / Peios technical reference / Conventions / Shared Style

A note carries what the surrounding text cannot: why the rule is the way it is, what it defends against, what was tried instead.

> [!NOTE]
> Text.

In a specification a note is informative and is bound by §2.2. In a manual, where nothing is normative, a note is a change of register rather than of authority.

6.3.1 What a good note does #

  • Names the failure the rule prevents. Concretely, with the mechanism. "Without this, an attacker who controls a cache edge replays an older signed index and the consumer never notices" is worth ten lines of abstract rationale.
  • Records the alternative that was rejected, and why. The next person to look at the design will otherwise propose it again.
  • Flags the counter-intuitive. Where a reader's first instinct is wrong, a note is where to say so.

6.3.2 What a note is not #

  • A restatement of the rule above it in different words.
  • A place to hide a requirement (§2.2).
  • An apology for a design.

6.3.3 Density #

Roughly one note per two or three articles is what has worked. A book with a note under every heading has diluted the marker to the point where readers skip them, which wastes the ones that matter.

6.4 Formatting

Peios / Peios technical reference / Conventions / Shared Style

Mechanical conventions. None of these affects meaning; all of them affect whether a diff is readable.

6.4.1 Line length #

Prose SHOULD wrap at 78 columns. Tables, code blocks, and link-heavy lines are exempt — breaking those hurts more than it helps.

The reason is diffs. A paragraph on one long line produces a whole-paragraph diff for a one-word change, which makes review of a large document impractical.

6.4.2 Frontmatter #

Every article carries YAML frontmatter with a title and a description:

---
title: Freshness and Rollback Protection
description: An index that verifies is not necessarily current — monotonic
  versions, staleness limits, and the defences against rollback and freeze.
---

Titles are in title case, and name the subject rather than describing it. "Freshness and Rollback Protection", not "How freshness works".

The description is one sentence saying what the article covers. It is what a reader sees in a search result, so it does the work the title deliberately does not: where the title names the subject, the description says what is in there. It SHOULD name the specific things — "monotonic versions, staleness limits" beats "the freshness rules" — because a corpus this size has many articles called Overview, Structure and Conventions, and the description is what tells them apart.

Do not restate the title. title: Persistence with description: How persistence works has spent a line and told the reader nothing.

Books were written without descriptions for a time, and the omission was invisible until search made it obvious. The site build takes --strict, which fails on any article missing one; run it that way.

6.4.3 Headings #

Articles use ## for their top-level headings. An article does not repeat its own title as a heading — the renderer supplies it.

Heading text is short and specific. Because headings are not addressable (§5.3), they are navigation rather than citation targets, and they SHOULD read as scannable labels.

6.4.4 Code and identifiers #

Identifiers, paths, field names, and literal values are in backticks. Fenced blocks carry a language tag where one applies.

Code spans are not translated: a field named size_installed is written that way in prose, not as "size installed".

6.4.5 Text hygiene #

Straight quotes and apostrophes rather than typographic ones; a regular hyphen rather than a non-breaking one; a normal space rather than a non-breaking space; and no zero-width characters. Each of those renders identically and greps differently, which is the worst combination.

An em dash is written as —, spaced as the surrounding prose requires.

6.4.6 Spelling #

British English, except in identifiers, code, and the names of external standards, which are reproduced exactly as their source spells them.

6.5 Naming External Constants

Peios / Peios technical reference / Conventions / Shared Style

Much of what this corpus documents already has names — given by an external standard, by a published header, or by both, spelled differently. A reader arrives holding one of those names and expects to find it.

6.5.1 Each document uses its own reader's vocabulary #

A specification is written for an implementer who has the external standard and does not have this implementation's source. It uses the external standard's spelling, exactly as that standard spells it.

A technical reference manual is written for someone reading alongside the implementation. It uses the implementation's spelling, exactly as the header declares it.

Neither adopts the other's vocabulary. A specification that named private headers would be documenting something its reader cannot see, and a manual that named only the standard would not match the code in front of the reader.

6.5.2 Divergences are recorded once per book #

Where the two vocabularies disagree, the disagreement is stated once — in a table, in an appendix — and not repeated at every use. Inline double-naming makes prose unreadable to both audiences at once.

The table gives both spellings and nothing else. It is a lookup, not an explanation.

6.5.3 Where a standard names a structure and its constant separately #

Many standards give one name to a structure and another to the constant that selects it: a record type and the numeric tag identifying that record. A reader may arrive with either — one from a declaration, the other from a hex dump — so a table that gives a value MUST name the constant that carries it, not only the structure it selects.

Naming the structure in a column headed with the constant's value is the common form of this mistake. It reads correctly and is unfindable by half the people who need it.

6.5.4 The test #

For any name a reader could plausibly arrive with, searching the corpus for that exact string finds something. A name that appears only as prose — "the offset to the owner SID", where the standard calls the field OffsetOwner — fails this test even though the surrounding text is correct.

Correct and unfindable is the failure this rule exists to prevent. Reference material is read by search.

Appendix A Normative References

Peios / Peios technical reference / Conventions

The external standards this corpus depends on. A book MUST NOT restate this list; it cites into it.

A.1 Deferral #

Where a document defers to an external standard, that standard's requirements apply as though written out. A document citing one MUST name the specific part it relies on where the whole would be ambiguous.

A.2 Citation format #

FormMeans
RFC 2119The whole document
RFC 8259 §7A specific section
Unicode 16.0A version of a standard
MS-DTYP §2.4.2A section of a Microsoft Open Specification

A.3 IETF #

ReferenceTitleUsed for
RFC 2119Key words for use in RFCsNormative keywords (§2.1)
RFC 3339Date and Time on the InternetTimestamps (§3.3)
RFC 3629UTF-8String encoding (§3.3)
RFC 3986Uniform Resource IdentifierURL syntax
RFC 4648Base16, Base32, Base64 EncodingsBase64, §4 alphabet
RFC 7468Textual Encodings of PKIX StructuresPEM key files
RFC 8032EdDSAEd25519 signing and verification
RFC 8259JSONJSON documents
RFC 8478ZstandardCompression
RFC 8915Network Time SecurityAuthenticated time

A.4 Microsoft Open Specifications #

The Windows security model is the design source for several Peios subsystems, and its documents are cited for parity mappings.

IdentifierTitle
MS-DTYPWindows Data Types
MS-ERREFWindows Error Codes
MS-LSADLocal Security Authority (Domain Policy) Remote Protocol
MS-SAMRSecurity Account Manager Remote Protocol
MS-ADTSActive Directory Technical Specification
MS-GPOLGroup Policy: Core Protocol
MS-GPREGGroup Policy: Registry Extension Encoding

A.5 Unicode #

ReferenceUsed for
Unicode 16.0Normalization forms, case folding
CaseFolding.txt, status S and C entriesCase-insensitive comparison

A.6 POSIX and other standards #

ReferenceUsed for
IEEE Std 1003.1-2017, Chapter 14The pax interchange format
MessagePack specificationEvent payload encoding
SPDX License ListLicense identifiers

A.7 External conventions #

Some documents describe interoperation with conventions that have no formal specification — the sd_notify readiness protocol and the freedesktop os-release file among them. Where a document relies on one, it MUST describe the behaviour it depends on rather than citing the convention alone, because there is no normative text at the other end of the citation.

1.1 Scope

Peios / Peios technical reference / PCDS / Introduction

This document defines the common binary data structures shared across Peios subsystems: three identifier types — the Globally Unique Identifier (GUID), the Locally Unique Identifier (LUID), and the Security Identifier (SID) — and the Security Descriptor (SD) family.

This document covers:

  • GUID — binary format, string representation, comparison semantics, and generation requirements
  • LUID — binary format, comparison semantics, and allocation model
  • SID — binary format, string representation, comparison semantics, and the well-known SID catalogue
  • SD — the security descriptor structure and its subtypes: ACL and ACE formats, access masks, ACE ordering, inheritance, ownership, conditional ACEs and their bytecode, claim attributes, and resource attributes

This document does not cover:

  • Well-known GUID and LUID values — defined in the specifications of the subsystems that declare them
  • SID-bearing aggregate structures such as SID_AND_ATTRIBUTES — described in the Peios Kernel TRM §3.2.2
  • The access-check algorithm that evaluates these structures — described in the Peios Kernel TRM §3.8
  • Per-object-type SD storage locations — described in the Peios Kernel TRM §3.3.3 for processes and §3.9.5 for files
  • Application-specific identifier namespaces

Three of those exclusions point at a manual rather than at another specification, which is deliberate and worth stating once. KACS — the kernel's access-control implementation — is described in the Peios Kernel TRM §3 and is not separately specified. Peios does not offer a standard from which a second, independent access-control implementation could be built; it offers a manual describing the one that exists.

What a third party does need is the other half: the structures that cross the boundary into that implementation, and the rules for reading and writing them. That is this document, and it is specified. Where a structure's meaning depends on what KACS does with it, this document names the manual article that describes the behaviour rather than restating it (Conventions §4.7).

1.2 Conventions

Peios / Peios technical reference / PCDS / Introduction

The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY in this document are to be interpreted as described in RFC 2119. Text set off as a note is informative, not normative.

Everything else — roles, byte order, sizes, layout tables, notation, strings, timestamps, citation, and the external standards this anthology depends on — is defined in the Conventions book and is not restated here. PCDS departs from none of it.

Where a chapter needs a convention of its own, that chapter states it.

2.1 Binary Format

Peios / Peios technical reference / PCDS / GUID

A GUID (Globally Unique Identifier) is a 128-bit identifier with global uniqueness guarantees, used to identify registry hives, layers, object types, and other entities that require stable identity across systems and reboots. The GUID with all 128 bits set to zero is the nil GUID, a sentinel value meaning "no GUID" or "unset."

A GUID is a 128-bit (16-byte) value with the following binary layout:

OffsetSizeFieldType
04Data1uint32, little-endian
42Data2uint16, little-endian
62Data3uint16, little-endian
88Data4uint8[8]

The total size of a GUID MUST be exactly 16 bytes with no padding.

Data1, Data2, and Data3 MUST be stored in little-endian byte order.

Data4 is a raw byte array with no endianness interpretation.

Note

This is the mixed-endian layout inherited from DCE RPC and MS-DTYP. The first three fields follow the platform's native byte order (little-endian on x86), while Data4 is a byte sequence with no integer interpretation.

2.1.1 Nil GUID #

The nil GUID is the GUID with all 16 bytes set to zero.

The nil GUID is a valid GUID value. Specifications that require a non-nil GUID MUST state this requirement explicitly.

The nil GUID MUST NOT be produced by the generation algorithm defined in §2.4.

2.2 String Format

Peios / Peios technical reference / PCDS / GUID

2.2.1 Canonical form #

The canonical string representation of a GUID MUST use the following format:

{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}

The string MUST be exactly 38 characters: an opening brace, 32 hex digits arranged in 8-4-4-4-12 groups separated by hyphens, and a closing brace.

The fields map to the string as follows:

GroupDigitsSource
18Data1, most significant nibble first
24Data2, most significant nibble first
34Data3, most significant nibble first
44Data4[0] and Data4[1], in byte order
512Data4[2] through Data4[7], in byte order

For Data1, Data2, and Data3, the hex representation is of the numeric value (most significant nibble first), not of the stored byte order. For Data4, each byte is encoded in sequence with the high nibble before the low nibble.

Note

Example. Given the 16 bytes (in storage order):

04 03 02 01  06 05  08 07  09 0a  0b 0c 0d 0e 0f 10

Data1 = 0x01020304 (little-endian), Data2 = 0x0506, Data3 = 0x0708. The string representation is:

{01020304-0506-0708-090a-0b0c0d0e0f10}

2.2.2 Case #

Canonical output MUST use lowercase hex digits (a–f).

2.2.3 Parsing #

Parsers MUST accept both uppercase and lowercase hex digits.

Parsers MUST accept the braced form {...} and SHOULD accept the unbraced form xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx.

Parsers MUST reject strings that do not have exactly the right number of hex digits in each group.

2.3 Comparison

Peios / Peios technical reference / PCDS / GUID

2.3.1 Equality #

Two GUIDs are equal if and only if their 16-byte binary representations are identical.

GUID comparison MUST be performed on the binary representation, not on string representations.

Note

Byte-for-byte comparison of the binary form avoids case sensitivity and brace-presence issues that would arise from comparing string representations.

2.3.2 Ordering #

This document does not define a total ordering for GUIDs. Specifications that require an ordered GUID collection MUST define their ordering convention explicitly.

2.4 Generation

Peios / Peios technical reference / PCDS / GUID

GUIDs generated by Peios MUST be version 4 (random) as defined in RFC 4122 §4.4.

2.4.1 Algorithm #

To generate a version 4 GUID:

  1. Fill all 16 bytes with cryptographically random data.
  2. Set the four most significant bits of Data3 to 0100 (version 4).
  3. Set the two most significant bits of Data4[0] to 10 (RFC 4122 variant).

The resulting GUID has 122 random bits, 4 version bits, and 2 variant bits.

Note

In terms of bit manipulation on the binary layout:

Data3 = (Data3 & 0x0fff) | 0x4000
Data4[0] = (Data4[0] & 0x3f) | 0x80

2.4.2 Randomness source #

The random data MUST be obtained from a cryptographically secure source.

In the kernel, this MUST be get_random_bytes() or equivalent.

In userspace, this MUST be getrandom(2) with no flags (blocking until the entropy pool is initialised) or equivalent.

GUIDs MUST NOT be generated using a pseudorandom number generator seeded from a predictable source.

Appendix 2.A Prior Art

Peios / Peios technical reference / PCDS / GUID

2.A.1 MS-DTYP #

The GUID type defined in this document derives from the Microsoft Data Types specification (MS-DTYP), §2.3.4. The Peios GUID binary format is identical to the MS-DTYP GUID. The Peios GUID string format follows the same hyphenated hex convention but normalises to lowercase hex digits on output, where Microsoft implementations typically produce uppercase.

2.A.2 RFC 4122 #

RFC 4122 ("A Universally Unique IDentifier (UUID) URN Namespace") defines the UUID format and generation algorithms. The GUID binary layout used by Microsoft and adopted by Peios is the mixed-endian variant of the RFC 4122 UUID: the first three fields are little-endian integers and the last field is a raw byte array. This differs from the RFC 4122 network byte order representation where all fields are big-endian.

Peios generates version 4 (random) GUIDs as defined in RFC 4122 §4.4.

2.A.3 DCE RPC #

The GUID structure originates from the DCE 1.1 RPC specification, which defined the uuid_t type with the same field layout. The mixed-endian encoding reflects the DCE convention of encoding integer fields in the sender's native byte order (little-endian on x86).

3.1 Binary Format

Peios / Peios technical reference / PCDS / LUID

An LUID (Locally Unique Identifier) is a 64-bit identifier with boot-scoped local uniqueness, used to identify transient entities such as logon sessions and privilege instances that do not persist across reboots. The LUID with all 64 bits set to zero is the nil LUID, a sentinel value meaning "no LUID" or "unset."

An LUID is a 64-bit (8-byte) value with the following binary layout:

OffsetSizeFieldType
04LowPartuint32, little-endian
44HighPartuint32, little-endian

The total size of an LUID MUST be exactly 8 bytes with no padding.

Both fields MUST be stored in little-endian byte order.

Note

MS-DTYP defines HighPart as a signed 32-bit integer (LONG). Peios uses unsigned uint32 for both fields. The signed type in MS-DTYP is a Win32 API convention with no semantic purpose — LUID values are never negative. See §3.A for the full divergence rationale.

3.1.1 Nil LUID #

The nil LUID is the LUID with all 8 bytes set to zero (LowPart = 0, HighPart = 0).

The nil LUID is a valid LUID value. The nil LUID MUST NOT be assigned by the allocation algorithm defined in §3.3.

Specifications that require a non-nil LUID MUST state this requirement explicitly.

3.2 Comparison

Peios / Peios technical reference / PCDS / LUID

3.2.1 Equality #

Two LUIDs are equal if and only if both their LowPart and HighPart fields are identical.

3.2.2 Ordering #

This document does not define a total ordering for LUIDs. Although LUIDs are allocated monotonically within a boot session (see §3.3), consumers MUST NOT rely on numeric ordering to infer temporal relationships between LUIDs obtained from different contexts.

3.3 Allocation

Peios / Peios technical reference / PCDS / LUID

LUIDs MUST be allocated by the kernel.

3.3.1 Uniqueness scope #

Each LUID MUST be unique within a single boot session.

LUID values MUST NOT be assumed unique across reboots. A value allocated in one boot session MAY be reused in a subsequent boot session.

3.3.2 Monotonicity #

The kernel MUST allocate LUIDs in strictly monotonically increasing order within a boot session, treating the two fields as a single unsigned 64-bit integer (HighPart << 32 | LowPart).

The starting value of the allocation sequence after each boot is implementation-defined.

Note

Subsystems that define well-known LUID values (such as privilege identifiers) typically reserve values below the allocation starting point. The starting value should be chosen to leave room for current and future well-known values.

3.3.3 Fabrication prohibition #

Userspace code MUST NOT fabricate LUID values. All LUIDs MUST be obtained through the kernel allocation interface or from well-known constants defined in a Peios specification.

Appendix 3.A Prior Art

Peios / Peios technical reference / PCDS / LUID

3.A.1 MS-DTYP #

The LUID type defined in this document derives from the Microsoft Data Types specification (MS-DTYP), §2.3.7.

The Peios LUID binary format diverges from MS-DTYP in one detail: HighPart is an unsigned 32-bit integer (uint32) rather than the signed 32-bit integer (LONG) used in MS-DTYP. The signed type in MS-DTYP is a Win32 API convention with no semantic purpose — LUID values are never negative. Making the field unsigned simplifies comparison and eliminates a class of sign-extension bugs.

4.1 Binary Format

Peios / Peios technical reference / PCDS / SID

A SID (Security Identifier) is a variable-length binary value that uniquely identifies a principal — a user, group, service, machine, or well-known entity. SIDs are the fundamental identity primitive of the Peios security model: they appear in tokens as identity, in security descriptors as access rules, and as references throughout the system.

A SID is encoded as a contiguous binary structure with the following layout:

OffsetSizeFieldDescription
01RevisionMUST be 1.
11SubAuthorityCountNumber of sub-authorities. MUST be between 0 and 15 inclusive.
26IdentifierAuthorityA 6-byte big-endian value identifying the authority that issued the SID.
84 × SubAuthorityCountSubAuthority[]Array of 32-bit unsigned integers in little-endian byte order.

The total size of a SID in bytes is 8 + (4 × SubAuthorityCount). The minimum size is 8 bytes (zero sub-authorities). The maximum size is 68 bytes (15 sub-authorities).

The last sub-authority in a SID is the Relative Identifier (RID) — the portion that distinguishes individual principals within a domain.

Note

The IdentifierAuthority is the one big-endian field among the structures in this document — an MS-DTYP inheritance. The sub-authorities that follow it are ordinary little-endian integers.

4.2 String Format

Peios / Peios technical reference / PCDS / SID

SIDs are represented in string form as:

S-1-{authority}-{sub1}-{sub2}-...-{subN}

Where:

  • S is a literal prefix.
  • 1 is the revision number.
  • {authority} is the IdentifierAuthority. If the upper 2 bytes are zero, this is the decimal representation of the lower 4 bytes. Otherwise, it is the lowercase hexadecimal representation of all 6 bytes, zero-padded to 12 hex digits and prefixed with 0x.
  • Each {subN} is the decimal representation of the corresponding 32-bit sub-authority.

Note

Example SIDs: S-1-5-18 (Local System), S-1-5-32-544 (BUILTIN\Administrators), S-1-5-21-3623811015-3361044348-30300820-1013 (a domain user). The meanings of well-known SID values are catalogued in §4.4.

4.3 Comparison

Peios / Peios technical reference / PCDS / SID

4.3.1 Equality #

Two SIDs are equal if and only if their binary representations are byte-for-byte identical.

SID comparison MUST be performed on the binary encoding, not the string form. There is no case sensitivity, normalisation, or equivalence relation — equality is exact binary match.

4.3.2 Ordering #

This document does not define a total ordering for SIDs. Specifications that require an ordered SID collection MUST define their ordering convention explicitly.

4.4 Well-Known SIDs

Peios / Peios technical reference / PCDS / SID

The following SIDs have fixed values and well-defined meanings. An implementation MUST recognise these SIDs and apply their defined semantics wherever they are referenced. The access-check behaviour attached to them is described in the Peios Kernel TRM §3.8.

4.4.1 Universal authorities #

SIDNameDescription
S-1-0-0NobodyThe null SID. No principal.
S-1-1-0EveryoneMatches all principals, including anonymous.
S-1-2-0LocalPrincipals that log on locally (physically).
S-1-2-1Console LogonPrincipals that log on via the physical console.

4.4.2 Creator authorities #

SIDNameDescription
S-1-3-0Creator OwnerPlaceholder in inheritable ACEs. Replaced with the creating principal's SID during inheritance.
S-1-3-1Creator GroupPlaceholder in inheritable ACEs. Replaced with the creating principal's primary group SID during inheritance.
S-1-3-4Owner RightsWhen present in a DACL, overrides the owner's implicit READ_CONTROL and WRITE_DAC grants. AccessCheck treats this SID as matching the object's owner.

4.4.3 NT Authority (S-1-5) #

SIDNameDescription
S-1-5-2NetworkPrincipals that authenticated over the network.
S-1-5-3BatchPrincipals that logged on to run a scheduled job.
S-1-5-4InteractivePrincipals that logged on interactively.
S-1-5-6ServicePrincipals that authenticated as a service.
S-1-5-7AnonymousThe anonymous identity. Carried by tokens at Anonymous impersonation level.
S-1-5-10Principal SelfPlaceholder in ACEs on directory objects. Matches the caller when the caller's identity corresponds to the object's associated principal. Resolved via the self_sid parameter to AccessCheck.
S-1-5-11Authenticated UsersAll principals that have been authenticated (excludes Anonymous).
S-1-5-14Remote InteractivePrincipals that logged on interactively to a remote graphical desktop. Carried in addition to Interactive, never in place of it.
S-1-5-18Local System (SYSTEM)The operating system's own identity. Highest privilege level.
S-1-5-19Local ServiceA built-in service account with reduced privileges.
S-1-5-20Network ServiceA built-in service account that can authenticate to remote services.

4.4.4 Logon SIDs #

SIDNameDescription
S-1-5-5-X-YLogon SIDA per-authentication-event SID generated at LogonSession creation. X and Y are unique values. Injected into the token's groups with SE_GROUP_LOGON_ID.

4.4.5 BUILTIN domain (S-1-5-32) #

SIDNameDescription
S-1-5-32-544BUILTIN\AdministratorsThe built-in administrators group.
S-1-5-32-545BUILTIN\UsersThe built-in users group.
S-1-5-32-546BUILTIN\GuestsThe built-in guests group.
S-1-5-32-551BUILTIN\Backup OperatorsMembers can bypass file security for backup and restore.

Note

Additional BUILTIN SIDs (S-1-5-32-547 through S-1-5-32-583) are defined by Active Directory. KACS does not assign special semantics to these SIDs — they participate in normal ACE matching like any other group SID. Their meaning is an administrative convention, not a kernel enforcement property.

4.4.6 Domain SIDs (S-1-5-21) #

Domain-specific SIDs follow the pattern S-1-5-21-{DA1}-{DA2}-{DA3}-{RID}, where the three domain authority sub-authorities identify the domain and the RID identifies the principal within that domain.

RIDNameDescription
500Domain AdministratorThe built-in administrator account.
501Domain GuestThe built-in guest account.
512Domain AdminsThe domain administrators group.
513Domain UsersThe domain users group.
514Domain GuestsThe domain guests group.
515Domain ComputersComputer accounts in the domain.

Note

Domain SIDs are assigned by the domain controller and replicated through Active Directory. KACS does not create or manage domain SIDs — it evaluates them as opaque binary values during AccessCheck.

4.4.7 Mandatory integrity labels (S-1-16) #

SIDNameNumeric levelDescription
S-1-16-0Untrusted0Lowest trust. Sandboxed or experimental code.
S-1-16-4096Low4096Reduced trust. Services handling untrusted input.
S-1-16-8192Medium8192Standard trust. Default for interactive logons and most services.
S-1-16-12288High12288Elevated administrative logons.
S-1-16-16384System16384The kernel, peinit, and TCB services.

The five levels above are the standard, well-known integrity levels; in practice they behave like an enum. Technically the level is the SID's single sub-authority as an unsigned integer: any S-1-16-<n> with exactly one sub-authority is a valid level, and MIC compares levels numerically. Non-standard values occur in Windows-interop SDs — e.g. S-1-16-8448 (medium-plus) or S-1-16-20480 (protected). A mandatory-label SID with a different identifier authority or more than one sub-authority is malformed and rejected. The standard order is System > High > Medium > Low > Untrusted.

4.4.8 Process trust labels (S-1-19) #

SIDNameDescription
S-1-19-0-0None / No trustDefault for unsigned processes.
S-1-19-512-1024Protected, AuthenticodeThird-party signed binaries.
S-1-19-512-1536Protected, AntiMalwareSecurity tooling.
S-1-19-512-2048Protected, AppPeios-distributed applications.
S-1-19-512-4096Protected, PeiosCore Peios components.
S-1-19-512-8192Protected, PeiosTcbPeios Trusted Computing Base.
S-1-19-1024-8192Isolated, PeiosTcbMaximum isolation and trust.

Trust labels encode two dimensions in the SID: the first sub-authority is the PIP type axis and the second is the trust axis (higher = more trusted). Dominance requires both dimensions to be greater than or equal.

KACS currently standardises these PIP type values:

  • 0 = None
  • 512 = Protected
  • 1024 = Isolated

These values are standardised labels, not a closed enum for AccessCheck. Other numeric type values remain valid and are compared numerically by the same dominance rule.

4.4.9 Confinement SIDs (S-1-15) #

SIDNameDescription
S-1-15-2-hashConfinement SIDIdentifies a confined application. The sub-authorities are derived from the application identity.
S-1-15-2-1ALL_APPLICATION_PACKAGESMatches all confined applications in normal confinement mode.
S-1-15-2-2ALL_RESTRICTED_APPLICATION_PACKAGESMatches confined applications in both normal and strict confinement modes. Strict confinement is the mode where ALL_APPLICATION_PACKAGES is omitted from the capabilities.

4.4.10 Capability SIDs (S-1-15-3) #

SIDNameDescription
S-1-15-3-1internetClientOutbound internet access.
S-1-15-3-2internetClientServerInbound and outbound internet access.
S-1-15-3-3privateNetworkClientServerLAN/private network access.
S-1-15-3-8enterpriseAuthenticationDomain credential access.
S-1-15-3-9sharedUserCertificatesCertificate store access.
S-1-15-3-10removableStorageRemovable media access.

Capability SIDs 4–7 (picturesLibrary, videosLibrary, musicLibrary, documentsLibrary) are reserved. Their SID values MUST NOT be redefined.

Derived capabilities use 8 sub-authorities computed from the SHA-256 hash of the capability name: S-1-15-3-{h0}-{h1}-{h2}-{h3}-{h4}-{h5}-{h6}-{h7}. The same name always produces the same SID.

4.4.11 Service SIDs #

Service SIDs follow the pattern SERVICE\{service_name} (e.g., SERVICE\jellyfin, SERVICE\loregd) and are added as a group in the service's token. The token's primary user SID is the account the service runs as (typically SYSTEM, LocalService, or NetworkService); the service SID enables per-service access control — a file's DACL can grant access to SERVICE\jellyfin specifically, rather than to the broad account the service runs under.

The SID value is derived from the service name using a SHA-1 hash: the UTF-16LE encoding of the uppercased service name is hashed, and the 20-byte digest is split into five little-endian 32-bit sub-authorities: S-1-5-80-{h0}-{h1}-{h2}-{h3}-{h4}. The same service name always produces the same SID. This matches the Windows service SID derivation (MS-DTYP compatible).

Appendix 4.A Prior Art

Peios / Peios technical reference / PCDS / SID

4.A.1 MS-DTYP #

The SID type defined in this document derives from the Microsoft Data Types specification (MS-DTYP), §2.4.2. The Peios binary encoding is identical to the MS-DTYP packet representation, and the string form follows the same S-R-I-S convention. The structure itself originates in Windows NT, where it has been the principal identity primitive since NT 3.1.

5.1 SD Structure

Peios / Peios technical reference / PCDS / Security Descriptor

A Security Descriptor (SD) defines the complete security policy for a protected object. Every protected object in Peios — every file, registry key, IPC endpoint, service, token, and process — MUST have a Security Descriptor.

An SD has four components and a set of control flags:

  • Owner SID — the principal that owns the object. The owner has implicit rights (READ_CONTROL and WRITE_DAC) unless suppressed by an OWNER RIGHTS ACE.

  • Group SID — an optional primary group associated with the object. When present, it is stored and returned on query and used during CREATOR GROUP substitution during inheritance. When absent, no primary group is available for those metadata operations. No access control decision depends on the group SID directly. If an inheritance or ACL-rewrite operation must materialize a CREATOR GROUP SID and the source object SD has no group SID, KACS MUST fail closed rather than substitute another SID or silently drop the ACE.

  • DACL (Discretionary Access Control List) — an ordered list of ACEs that define who is allowed or denied access. The object's owner controls the DACL (via WRITE_DAC).

  • SACL (System Access Control List) — an ordered list of ACEs that define system-level policy. Despite the name, the SACL carries several distinct ACE types:

    • Audit ACEs — which access attempts to log.
    • Mandatory label ACE — the object's integrity level for MIC.
    • Resource attribute ACEs — name-value attributes for conditional ACE evaluation.
    • Scoped policy ID ACEs — references to central access policies.
    • Process trust label ACE — the object's PIP trust level.

    Modifying the SACL requires ACCESS_SYSTEM_SECURITY. ACCESS_SYSTEM_SECURITY is privilege-controlled: it is normally granted by SeSecurityPrivilege, and may also be granted by restore-intent SeRestorePrivilege in the kacs_set_sd cases described in the Peios Kernel TRM §3.9.6.

Both the DACL and SACL use the standard binary ACL format defined in §5.2.

5.1.1 Binary format #

SDs use the self-relative binary format defined in MS-DTYP §2.4.6. The format is a 20-byte header followed by the owner SID, optional group SID, SACL, and DACL at offsets specified in the header.

OffsetSizeFieldDescription
01RevisionMUST be 1.
11Sbz1Reserved. Preserved for format compatibility but not interpreted by KACS. When SE_RM_CONTROL_VALID is set in the control flags, this byte carries resource manager control bits defined by the originating system.
22ControlControl flags, little-endian.
44OffsetOwnerOffset to the owner SID, little-endian. 0 if absent.
84OffsetGroupOffset to the group SID, little-endian. 0 if absent.
124OffsetSaclOffset to the SACL, little-endian. 0 if absent.
164OffsetDaclOffset to the DACL, little-endian. 0 if absent.

Field names are those of MS-DTYP §2.4.6.

The self-relative format packs everything into a contiguous byte buffer with no pointers. This makes it suitable for storage (xattrs, database fields) and wire transmission (IPC, SMB).

KACS MUST use the self-relative format exclusively.

5.1.2 Control flags #

The SD's 16-bit Control field records metadata about the descriptor:

FlagBitValueDescription
SE_OWNER_DEFAULTED (OD)00x0001The owner was established by default means.
SE_GROUP_DEFAULTED (GD)10x0002The group was established by default means.
SE_DACL_PRESENT (DP)20x0004A DACL is present. If clear, AccessCheck treats the DACL as null (grants all access).
SE_DACL_DEFAULTED (DD)30x0008The DACL was established by default means.
SE_SACL_PRESENT (SP)40x0010A SACL is present.
SE_SACL_DEFAULTED (SD)50x0020The SACL was established by default means.
SE_DACL_TRUSTED (DT)60x0040Reserved metadata. Preserved during round-trip serialisation. No operational semantics.
SE_SERVER_SECURITY (SS)70x0080Create a server ACL based on the input ACL.
SE_DACL_AUTO_INHERIT_REQ (AR)80x0100Requests that the DACL be auto-inherited from the parent. When clear on a creator SD, parent inheritance is suppressed even if SE_DACL_PROTECTED is not set.
SE_SACL_AUTO_INHERIT_REQ90x0200Requests that the SACL be auto-inherited from the parent. Same semantics as bit 8 for the SACL.
SE_DACL_AUTO_INHERITED (DI)100x0400The DACL was created through automatic inheritance.
SE_SACL_AUTO_INHERITED (SI)110x0800The SACL was created through automatic inheritance.
SE_DACL_PROTECTED (PD)120x1000The DACL is protected from inheritance. Inheritable ACEs from parent objects MUST NOT be merged.
SE_SACL_PROTECTED (PS)130x2000The SACL is protected from inheritance.
SE_RM_CONTROL_VALID (RM)140x4000The Sbz1 byte is interpreted as resource manager control bits.
SE_SELF_RELATIVE (SR)150x8000The SD is in self-relative format. MUST always be set for stored SDs.

The architectural maximum SD size is 65,535 bytes. KACS MUST reject any parsed or computed SD whose serialised self-relative byte length exceeds 65,535 bytes.

The DEFAULTED flags are metadata. During object-SD creation, KACS MUST set the corresponding DEFAULTED flag when it supplies that component from a default source rather than from an explicit creator SD component or inherited ACL. KACS MUST NOT grant or deny access based solely on a DEFAULTED flag.

This document records the SE_SERVER_SECURITY flag value but does not define the server-ACL construction algorithm. An implementation MUST fail closed when a creator SD attempts to use SE_SERVER_SECURITY.

The PROTECTED flags are operationally significant. Setting SE_DACL_PROTECTED prevents inheritance from parent objects — the object keeps its current ACEs and stops accepting new inheritable ACEs from above.

5.1.3 Null DACL vs empty DACL #

The SE_DACL_PRESENT flag distinguishes two states with very different security consequences:

  • Null DACL (SE_DACL_PRESENT not set) — no discretionary access control. AccessCheck grants all requested access to every caller. This SHOULD almost never be used.

  • Empty DACL (SE_DACL_PRESENT set, zero ACEs) — AccessCheck grants no access to any caller (except the owner's implicit rights). An explicit statement that no principal has discretionary access.

Objects SHOULD always have a DACL. This is a preferred-object-shape recommendation, not a fail-closed requirement. If object creation has no explicit DACL, no inherited DACL, and the creator token has no default DACL, the resulting object SD has a null DACL: SE_DACL_PRESENT is clear and the DACL offset is zero.

5.2 ACL Format

Peios / Peios technical reference / PCDS / Security Descriptor

An Access Control List (ACL) is the binary container for ACEs. DACLs, SACLs, and CAAP policy ACL blobs all use the same standard binary ACL format.

5.2.1 Binary format #

An ACL begins with an 8-byte header followed by AceCount ACEs packed contiguously:

OffsetSizeFieldDescription
01AclRevisionACL revision number. Determines which ACE families the ACL formally permits.
11Sbz1Reserved. Preserved for compatibility but not interpreted by KACS.
22AclSizeTotal size of the ACL in bytes, including the 8-byte header. Little-endian.
42AceCountNumber of ACEs in the ACL. Little-endian.
62Sbz2Reserved. Preserved for compatibility but not interpreted by KACS.

The ACE array begins immediately at offset 8. Each ACE is self-delimiting via its AceSize field. The parser walks the ACL by iterating exactly AceCount ACEs within the AclSize boundary.

5.2.2 Parsing rules #

  • AclSize MUST be at least 8 bytes.
  • AclSize MUST NOT exceed the containing buffer.
  • The ACL body MUST contain exactly AceCount ACEs within the declared AclSize.
  • Truncated ACEs, ACE overruns, or leftover bytes within AclSize are malformed.
  • The architectural maximum ACL size is 64 KB because AclSize is a 16-bit field.

ACE structure, ACE-type definitions, and revision-versus-ACE-family rules are specified in §5.4.

5.3 Access Masks

Peios / Peios technical reference / PCDS / Security Descriptor

Every ACE carries an access mask — a 32-bit integer where each bit represents a specific right. The same 32-bit layout is used in three contexts: the ACE's mask (what the rule grants or denies), the requested access (what the caller asks for), and the granted access (what AccessCheck returns).

5.3.1 Bit layout #

The 32 bits are divided into four regions:

5.3.1.1 Object-specific rights (bits 0–15) #

Defined by the object type. Different object types assign different meanings to these bits. A file uses bits for read, write, append, execute; a registry key uses bits for query value, set value, create subkey; a token uses bits for query, duplicate, impersonate. Each subsystem defines its own mapping.

5.3.1.2 Standard rights (bits 16–20) #

Common to all object types:

BitNameValueMeaning
16DELETE0x00010000Delete the object.
17READ_CONTROL0x00020000Read the object's SD (excluding SACL).
18WRITE_DAC0x00040000Modify the object's DACL.
19WRITE_OWNER0x00080000Change the object's owner.
20SYNCHRONIZE0x00100000Wait on the object.

5.3.1.3 Special rights (bits 24–25) #

BitNameValueMeaning
24ACCESS_SYSTEM_SECURITY0x01000000Read or write the SACL. Requires SeSecurityPrivilege.
25MAXIMUM_ALLOWED0x02000000Not a real right. Request flag that tells AccessCheck to compute and return the maximum set of rights the caller would be granted. MUST NOT appear in an ACE.

5.3.1.4 Generic rights (bits 28–31) #

Abstract rights mapped to object-specific rights before evaluation:

BitNameValue
28GENERIC_ALL0x10000000
29GENERIC_EXECUTE0x20000000
30GENERIC_WRITE0x40000000
31GENERIC_READ0x80000000

5.3.1.5 Reserved bits #

Bits 21–23 and 26–27 are reserved and MUST NOT be used. An access mask setting any of them is rejected: a desired access mask carrying one fails the request, and an ACE mask carrying one makes the containing SD unparseable.

5.3.2 Generic mapping #

Generic rights exist because SDs need to be portable across object types. A central access policy might say "allow GENERIC_READ on all objects" — and GENERIC_READ means different specific bits for files versus registry keys.

Each object type defines a GenericMapping table:

FieldDescription
readSpecific + standard bits that GENERIC_READ maps to.
writeSpecific + standard bits that GENERIC_WRITE maps to.
executeSpecific + standard bits that GENERIC_EXECUTE maps to.
allSpecific + standard bits that GENERIC_ALL maps to.

Generic mapping happens once, at request time. AccessCheck MUST map any generic bits in the desired mask to object-specific bits using the object type's GenericMapping table, then clear the generic bits. The DACL walk operates exclusively on specific and standard bits.

Note

KACS maps ACE masks via GenericMapping at evaluation time (using a local variable — the ACE itself is never mutated). This is an intentional divergence: the reference model expects ACE masks to be pre-mapped at SD construction time. Defensive mapping at evaluation time handles two cases: (1) imported/external SDs that may contain unresolved generic bits, and (2) central access policy recovery ACEs that use GENERIC_ALL. Note: SD inheritance also maps generic bits on all ACEs when computing a child SD (see §5.6), so newly created SDs will not contain generic bits. The evaluation-time mapping is defense-in-depth for SDs from other sources.

5.4 ACE Types

Peios / Peios technical reference / PCDS / Security Descriptor

An Access Control Entry (ACE) is a single rule in an ACL. Each ACE has a header, an access mask, and a principal SID, with optional extensions for object-type and conditional ACEs.

5.4.1 ACE header #

Every ACE begins with a 4-byte header:

OffsetSizeFieldDescription
01AceTypeIdentifies the ACE type.
11AceFlagsInheritance and audit flags.
22AceSizeTotal size of the ACE in bytes, including the header. MUST be a multiple of 4.

The size includes trailing padding. Builders zero-pad a variable-length raw body to the next four-byte boundary, including resource-attribute claims whose final octet value has an arbitrary length. This padding belongs to the ACE; it does not increase the claim's declared octet-value length.

5.4.2 ACE body layouts #

The ACE header is followed by a type-specific body. Every multibyte integer in the body is little-endian.

5.4.2.1 Single-SID ACE family #

The following ACE types share the same binary layout:

  • ACCESS_ALLOWED_ACE
  • ACCESS_DENIED_ACE
  • SYSTEM_AUDIT_ACE
  • SYSTEM_ALARM_ACE
  • SYSTEM_MANDATORY_LABEL_ACE
  • SYSTEM_SCOPED_POLICY_ID_ACE
  • SYSTEM_PROCESS_TRUST_LABEL_ACE

Layout:

OffsetSizeFieldDescription
04AceHeaderStandard ACE header.
44MaskAccess mask.
8variableSidPrincipal SID. Consumes the remainder of the ACE.

Parsing rules:

  • AceSize MUST be at least 16 bytes (header + mask + minimum SID).
  • The SID MUST consume the remainder of the ACE exactly.

5.4.2.2 Object ACE family #

The following ACE types share the object-ACE binary layout:

  • ACCESS_ALLOWED_OBJECT_ACE
  • ACCESS_DENIED_OBJECT_ACE
  • SYSTEM_AUDIT_OBJECT_ACE
  • SYSTEM_ALARM_OBJECT_ACE

Layout:

OffsetSizeFieldDescription
04AceHeaderStandard ACE header.
44MaskAccess mask.
84FlagsBitfield describing which GUIDs are present.
120 or 16ObjectTypePresent when ACE_OBJECT_TYPE_PRESENT is set.
12 or 280 or 16InheritedObjectTypePresent when ACE_INHERITED_OBJECT_TYPE_PRESENT is set.
variablevariableSidPrincipal SID. Begins immediately after the optional GUID fields and consumes the remainder of the ACE.

Object ACE flags:

FlagValueDescription
ACE_OBJECT_TYPE_PRESENT0x00000001ObjectType GUID is present.
ACE_INHERITED_OBJECT_TYPE_PRESENT0x00000002InheritedObjectType GUID is present.

Parsing rules:

  • AceSize MUST be large enough to contain the header, mask, flags, all GUIDs selected by Flags, and a complete SID.
  • Unknown bits in Flags MUST be ignored.
  • If neither GUID-presence bit is set, the ACE has no GUID fields and behaves like the corresponding basic ACE.
  • GUID fields are opaque 16-byte values at this layer. Their interpretation is described in the Peios Kernel TRM §3.8.5.

5.4.2.3 Callback ACE family #

The following ACE types extend the corresponding non-callback ACE layout by appending ApplicationData at the end of the ACE:

  • ACCESS_ALLOWED_CALLBACK_ACE
  • ACCESS_DENIED_CALLBACK_ACE
  • SYSTEM_AUDIT_CALLBACK_ACE
  • SYSTEM_ALARM_CALLBACK_ACE
  • ACCESS_ALLOWED_CALLBACK_OBJECT_ACE
  • ACCESS_DENIED_CALLBACK_OBJECT_ACE
  • SYSTEM_AUDIT_CALLBACK_OBJECT_ACE
  • SYSTEM_ALARM_CALLBACK_OBJECT_ACE

For non-object callback ACEs, the body layout is:

OffsetSizeFieldDescription
04AceHeaderStandard ACE header.
44MaskAccess mask.
8variableSidPrincipal SID.
variablevariableApplicationDataTrailing type-specific bytes. Consumes the remainder of the ACE.

For callback object ACEs, the body layout is:

OffsetSizeFieldDescription
04AceHeaderStandard ACE header.
44MaskAccess mask.
84FlagsObject ACE flags.
120 or 16ObjectTypePresent when ACE_OBJECT_TYPE_PRESENT is set.
12 or 280 or 16InheritedObjectTypePresent when ACE_INHERITED_OBJECT_TYPE_PRESENT is set.
variablevariableSidPrincipal SID.
variablevariableApplicationDataTrailing type-specific bytes. Consumes the remainder of the ACE.

Parsing rules:

  • The SID begins after the fixed fields and any optional GUIDs, exactly as in the corresponding non-callback ACE family.
  • ApplicationData MAY be empty. Semantics for empty or malformed callback payloads are defined by the relevant subsystem.
  • For conditional ACEs, ApplicationData carries the conditional expression bytecode defined in the Conditional ACE Bytecode Reference.

5.4.2.4 Resource attribute ACE #

SYSTEM_RESOURCE_ATTRIBUTE_ACE uses the single-SID ACE prefix followed by trailing application data:

OffsetSizeFieldDescription
04AceHeaderStandard ACE header.
44MaskReserved for compatibility. Not used for access decisions.
8variableSidMUST be Everyone (S-1-1-0).
variablevariableApplicationDataOne claim entry using §5.9. Consumes the remainder of the ACE.

Parsing rules:

  • The SID MUST be Everyone.
  • ApplicationData MUST contain exactly one claim entry using §5.9.

5.4.3 DACL ACE types #

5.4.3.1 Basic ACEs #

StructureValueEffect
ACCESS_ALLOWED_ACE0x00Grants the specified rights to the SID.
ACCESS_DENIED_ACE0x01Denies the specified rights to the SID.

5.4.3.2 Object-type ACEs #

Extend basic ACEs with one or two GUIDs that scope the rule to a specific property or object class. Used for Active Directory access control.

StructureValueEffect
ACCESS_ALLOWED_OBJECT_ACE0x05Grants rights scoped to a property/class GUID.
ACCESS_DENIED_OBJECT_ACE0x06Denies rights scoped to a property/class GUID.

The ObjectType GUID identifies the property or property set the ACE applies to. The InheritedObjectType GUID restricts inheritance to child objects of a specific class. Either or both GUIDs MAY be absent (indicated by a flags field), in which case the ACE behaves like a basic ACE for that dimension.

5.4.3.3 Conditional ACEs #

Extend basic and object-type ACEs with a conditional expression. The ACE only takes effect if the expression evaluates to TRUE against the caller's token attributes and the object's resource attributes.

StructureValueEffect
ACCESS_ALLOWED_CALLBACK_ACE0x09Conditional allow.
ACCESS_DENIED_CALLBACK_ACE0x0AConditional deny.
ACCESS_ALLOWED_CALLBACK_OBJECT_ACE0x0BConditional allow, scoped to GUID.
ACCESS_DENIED_CALLBACK_OBJECT_ACE0x0CConditional deny, scoped to GUID.

Note

The term "callback" is historical. KACS evaluates conditional expressions inline during AccessCheck. The name is preserved for binary format compatibility. UI and UX layers MAY refer to callback ACEs exclusively as "conditional ACEs" for simplicity.

5.4.4 SACL ACE types #

5.4.4.1 Audit ACEs #

Trigger audit log entries when matching access attempts occur. The AceFlags field carries SUCCESSFUL_ACCESS_ACE_FLAG (0x40) and/or FAILED_ACCESS_ACE_FLAG (0x80).

StructureValueEffect
SYSTEM_AUDIT_ACE0x02Audit access matching the SID and mask.
SYSTEM_AUDIT_OBJECT_ACE0x07Audit access scoped to a GUID.
SYSTEM_AUDIT_CALLBACK_ACE0x0DConditional audit.
SYSTEM_AUDIT_CALLBACK_OBJECT_ACE0x0FConditional audit, scoped to GUID.

5.4.4.2 Alarm ACEs (continuous auditing) #

Note

These ACE types are reserved but unimplemented in the reference model. KACS repurposes them for continuous per-operation auditing: unlike standard audit ACEs (which emit a single event at handle creation), alarm ACEs configure per-operation audit masks that persist on the open handle. This creates no interoperability conflict because external sources never contain alarm ACEs.

StructureValueEffect
SYSTEM_ALARM_ACE0x03Continuous audit for matching SID and mask.
SYSTEM_ALARM_OBJECT_ACE0x08Continuous audit scoped to a GUID.
SYSTEM_ALARM_CALLBACK_ACE0x0EConditional continuous audit.
SYSTEM_ALARM_CALLBACK_OBJECT_ACE0x10Conditional continuous audit, scoped to GUID.

5.4.4.3 Mandatory label ACE #

Defines the object's integrity level for MIC. Conforming producers SHOULD emit at most one non-inherit-only mandatory-label ACE per SACL. Imported or existing SACLs MAY contain multiple mandatory-label ACEs; MIC uses the first non-inherit-only mandatory-label ACE as described in the Peios Kernel TRM §3.8.3. Inherit-only mandatory-label ACEs do not apply to the current object. The SID encodes the integrity level. The access mask encodes the MIC policy (which operations are blocked for non-dominant callers).

StructureValueEffect
SYSTEM_MANDATORY_LABEL_ACE0x11Sets the object's integrity level and MIC policy.

5.4.4.4 Resource attribute ACE #

Attaches name-value attributes to the object for conditional ACE evaluation. The ACE's SID is always Everyone (S-1-1-0).

StructureValueEffect
SYSTEM_RESOURCE_ATTRIBUTE_ACE0x12Defines a resource attribute on the object.

5.4.4.5 Scoped policy ID ACE #

References a central access policy by SID. During AccessCheck, the referenced policy's rules are evaluated in addition to the object's own DACL.

StructureValueEffect
SYSTEM_SCOPED_POLICY_ID_ACE0x13References a central access policy.

5.4.4.6 Process trust label ACE #

Defines the object's PIP trust level. The SID encodes the PIP type and trust level. The access mask specifies the exact rights that non-dominant callers are allowed.

StructureValueEffect
SYSTEM_PROCESS_TRUST_LABEL_ACE0x14Sets the object's PIP trust level.

5.4.5 Allocated but unimplemented ACE types #

Two values in the range have a name but no KACS behaviour.

StructureValueNotes
ACCESS_ALLOWED_COMPOUND_ACE0x04Never implemented. Reserved.
SYSTEM_ACCESS_FILTER_ACE0x15Defined by MS-DTYP. Named by the KACS ABI for format parity; no KACS semantics.

ACCESS_ALLOWED_COMPOUND_ACE was specified and then abandoned before any conforming system implemented it. Nothing produces it.

SYSTEM_ACCESS_FILTER_ACE is different: it is a live MS-DTYP ACE type that KACS has not implemented. The kernel ABI defines a constant for it (KACS_ACE_TYPE_SYSTEM_ACCESS_FILTER) so that a decoder can put a name to the byte, but no section of this specification assigns it meaning. A 0x15 ACE therefore takes the unrecognised-ACE path described at the end of this section: skipped during evaluation, preserved byte-for-byte on round-trip. An implementation MUST NOT grant, deny, audit, or filter access on the basis of a 0x15 ACE.

Values above 0x15 are unallocated.

5.4.6 AceType constants #

The tables above name each ACE structure. The AceType header field carries a constant with its own name, which is what a reader decoding a descriptor by hand will be holding. Both spellings, in value order:

ValueAceType constantStructure
0x00ACCESS_ALLOWED_ACE_TYPEACCESS_ALLOWED_ACE
0x01ACCESS_DENIED_ACE_TYPEACCESS_DENIED_ACE
0x02SYSTEM_AUDIT_ACE_TYPESYSTEM_AUDIT_ACE
0x03SYSTEM_ALARM_ACE_TYPESYSTEM_ALARM_ACE
0x04ACCESS_ALLOWED_COMPOUND_ACE_TYPEACCESS_ALLOWED_COMPOUND_ACE
0x05ACCESS_ALLOWED_OBJECT_ACE_TYPEACCESS_ALLOWED_OBJECT_ACE
0x06ACCESS_DENIED_OBJECT_ACE_TYPEACCESS_DENIED_OBJECT_ACE
0x07SYSTEM_AUDIT_OBJECT_ACE_TYPESYSTEM_AUDIT_OBJECT_ACE
0x08SYSTEM_ALARM_OBJECT_ACE_TYPESYSTEM_ALARM_OBJECT_ACE
0x09ACCESS_ALLOWED_CALLBACK_ACE_TYPEACCESS_ALLOWED_CALLBACK_ACE
0x0AACCESS_DENIED_CALLBACK_ACE_TYPEACCESS_DENIED_CALLBACK_ACE
0x0BACCESS_ALLOWED_CALLBACK_OBJECT_ACE_TYPEACCESS_ALLOWED_CALLBACK_OBJECT_ACE
0x0CACCESS_DENIED_CALLBACK_OBJECT_ACE_TYPEACCESS_DENIED_CALLBACK_OBJECT_ACE
0x0DSYSTEM_AUDIT_CALLBACK_ACE_TYPESYSTEM_AUDIT_CALLBACK_ACE
0x0ESYSTEM_ALARM_CALLBACK_ACE_TYPESYSTEM_ALARM_CALLBACK_ACE
0x0FSYSTEM_AUDIT_CALLBACK_OBJECT_ACE_TYPESYSTEM_AUDIT_CALLBACK_OBJECT_ACE
0x10SYSTEM_ALARM_CALLBACK_OBJECT_ACE_TYPESYSTEM_ALARM_CALLBACK_OBJECT_ACE
0x11SYSTEM_MANDATORY_LABEL_ACE_TYPESYSTEM_MANDATORY_LABEL_ACE
0x12SYSTEM_RESOURCE_ATTRIBUTE_ACE_TYPESYSTEM_RESOURCE_ATTRIBUTE_ACE
0x13SYSTEM_SCOPED_POLICY_ID_ACE_TYPESYSTEM_SCOPED_POLICY_ID_ACE
0x14SYSTEM_PROCESS_TRUST_LABEL_ACE_TYPESYSTEM_PROCESS_TRUST_LABEL_ACE
0x15SYSTEM_ACCESS_FILTER_ACE_TYPESYSTEM_ACCESS_FILTER_ACE

5.4.7 ACL revision #

ACLs carry a revision number that constrains which ACE types MAY appear:

  • ACL_REVISION (0x02) — basic ACE types (0x00, 0x01, 0x02, 0x03), mandatory label (0x11), resource attribute (0x12), scoped policy (0x13), and process trust label (0x14).
  • ACL_REVISION_DS (0x04) — additionally permits object-type ACEs (0x05–0x08), callback ACEs (0x09–0x0C, 0x0D–0x10). Required for Active Directory access control.

When creating new ACLs containing only recognised ACE types, the revision MUST be set to the minimum required by the ACE types present. When rewriting an existing ACL while preserving one or more unrecognised ACE types, KACS MUST set the revision to the greater of the minimum required by recognised ACE types present and the source ACL revision. When parsing ACLs, KACS MUST NOT reject an ACL based on revision-vs-ACE-type mismatch — accept permissively, write correctly.

Note

This diverges from strict MS-DTYP interpretation, where ACL_REVISION (0x02) does not permit SACL types 0x11-0x14. KACS accepts them under either revision to handle SDs that may have been constructed with a less strict tool. The evaluator handles all ACE types correctly regardless of ACL revision.

Unrecognised ACE types — every value not given semantics above, which today means 0x04, 0x15, and everything from 0x16 up — MUST be silently skipped during evaluation and preserved byte-for-byte during round-trip serialisation. The ACE's raw bytes (from AceType through AceType + AceSize) are stored opaquely and written back unchanged. ACEs with AceSize not a multiple of 4 MUST be rejected (the containing ACL is malformed).

5.5 ACE Ordering

Peios / Peios technical reference / PCDS / Security Descriptor

The order of ACEs in a DACL determines the outcome of AccessCheck. AccessCheck walks the DACL from first ACE to last, and the first-writer-wins principle means each bit is decided at most once. ACE ordering is semantically load-bearing.

5.5.1 Canonical ordering #

Tools that author SDs SHOULD produce canonically-ordered DACLs. KACS MUST NOT reject non-canonical DACLs — it evaluates whatever order it receives — but non-canonical ordering can produce results that contradict administrative intent.

The canonical order is:

  1. Explicit deny ACEs — deny rules placed directly on this object (not inherited).
  2. Explicit allow ACEs — allow rules placed directly on this object.
  3. Inherited deny ACEs — deny rules inherited from parent objects (nearest parent first).
  4. Inherited allow ACEs — allow rules inherited from parent objects.

Within each category, object-type ACEs (those scoped to a specific property GUID) SHOULD be ordered after whole-object ACEs.

This ordering guarantees: explicit rules override inherited rules, denials override allows at the same level, and whole-object rules override property-scoped rules.

KACS kernel paths that evaluate, store, query, or set caller-supplied DACLs MUST preserve the caller-supplied ACE order. KACS MUST NOT canonicalise those DACLs by reordering deny ACEs, allow ACEs, whole-object ACEs, or object-type ACEs.

When KACS constructs a file DACL by combining an explicit source DACL with an inherited source DACL, it MUST emit the explicit ACE sequence before the inherited ACE sequence. Within each sequence, KACS MUST preserve the source-relative ACE order. This recombination rule is not a general canonical sorting step.

5.5.2 SACL ordering #

SACLs do not have a canonical ordering requirement. Audit ACEs are evaluated independently (each matching ACE generates its own audit event). The mandatory label ACE, resource attribute ACEs, and scoped policy ID ACEs are located by type scan, not by position.

5.6 SD Inheritance

Peios / Peios technical reference / PCDS / Security Descriptor

Security Descriptors propagate structurally. When a file is created in a directory, the directory's inheritable ACEs flow down to the new file's SD. This automatic propagation is inheritance.

Inheritance applies to objects with a container/child relationship: directories contain files and subdirectories, registry keys contain subkeys and values. Objects without a container parent (standalone IPC endpoints, tokens, processes) do not inherit.

5.6.1 Inheritance flags #

Four flags in the ACE header's AceFlags field control propagation:

FlagValueDescription
OBJECT_INHERIT_ACE (OI)0x01Inherited by non-container children (files). For container children (subdirectories), inherited as inherit-only unless NP is also set.
CONTAINER_INHERIT_ACE (CI)0x02Inherited by container children (subdirectories). The inherited ACE remains inheritable (propagates to grandchildren) unless NP is also set.
NO_PROPAGATE_INHERIT_ACE (NP)0x04When inherited, OI and CI flags are cleared on the copy. One-level inheritance.
INHERIT_ONLY_ACE (IO)0x08Does not apply to the object it is attached to. Exists only to be inherited by children.

A fifth flag records provenance:

FlagValueDescription
INHERITED_ACE0x10Set on ACEs created through inheritance (not explicitly placed). Determines ordering in canonical form.

5.6.2 Common flag combinations #

FlagsMeaning
CI | OIInherit to everything — containers and non-containers, recursively.
CIInherit to containers only, recursively.
OIInherit to non-containers only. Containers receive it as inherit-only.
CI | OI | IOInherit to everything, but do not apply to this object.
CI | OI | NPInherit to immediate children only.
CI | NPInherit to immediate child containers only.
(none)No inheritance. Applies only to this object.

5.6.3 CREATOR OWNER and CREATOR GROUP #

Two well-known SIDs receive special treatment during inheritance:

  • CREATOR OWNER (S-1-3-0) — when an ACE with this SID is inherited by a child object, the SID is replaced with the owner SID of the new object (as determined by the owner computation above).

  • CREATOR GROUP (S-1-3-1) — replaced with the primary group SID of the creating principal.

Substitution happens at inheritance time, in each ACE that applies to the new object. An inherit-only ACE keeps the placeholder, so that each object further down resolves it to its own owner or group (see the inherited ACEs below).

5.6.4 The ACEs a child inherits #

Each ACE of the parent's ACL contributes to a new child as follows. The same rule applies to the DACL and the SACL.

  1. Whether it passes. An ACE passes to a container child if it has CI, or OI without NP. It passes to a non-container child if it has OI. INHERIT_ONLY on the parent's ACE plays no part. An ACE that does not pass contributes nothing.

  2. The copy's flags. The copy has INHERITED_ACE set and INHERIT_ONLY cleared, with three exceptions:

    • An ACE with OI and neither CI nor NP reaches a container as inherit-only (OI, IO): it applies to the objects inside, not to the container.
    • NP clears OI, CI and NP from the copy. The copy applies to the child and goes no further.
    • A non-container's copy has no OI, CI, NP or IO.

    Flags other than these (SUCCESSFUL_ACCESS_ACE, FAILED_ACCESS_ACE) are kept.

  3. Resolution. A copy that applies to the child (IO clear) has CREATOR OWNER and CREATOR GROUP substituted, and its generic rights mapped through the child's GenericMapping (see below).

  4. The split. An ACE that names CREATOR OWNER or CREATOR GROUP, or carries generic rights, and whose copy both applies to a container child and goes on from it (OI or CI still set), gives the child two ACEs, written one after the other:

    • the resolved copy, with OI, CI, NP and IO cleared, applying to the child alone; and
    • an inherit-only copy (the copy's flags with IO set), left as written: the placeholder unsubstituted and the generic rights unmapped.

    Resolving the ACE once and carrying the result down would grant the first creator over everything beneath, and map rights through one object type's mapping for objects of another. Carrying it unresolved would grant nothing here. A non-container gets the resolved copy alone. Every other ACE is inherited as one copy.

For example, a directory whose DACL holds (A;OICIIO;GA;;;CO) and (A;OICI;GR;;;AU) gives a new subdirectory owned by alice (A;ID;FA;;;alice)(A;OICIIOID;GA;;;CO)(A;ID;FR;;;AU)(A;OICIIOID;GR;;;AU), and a new file in it (A;ID;FA;;;alice)(A;ID;FR;;;AU).

5.6.5 Inheritance algorithm #

When a new object is created, its SD is computed from up to three sources:

  1. Parent SD — provides inheritable ACEs.
  2. Creator SD — an explicit SD provided by the caller (if any).
  3. Creator token — provides the default owner, primary group, and default DACL.

A creator SD with SE_SERVER_SECURITY set is rejected; see §5.1.

5.6.5.1 Owner #

If the creator SD specifies an owner, use it. Otherwise, use the token's owner SID.

5.6.5.2 Group #

If the creator SD specifies a group, use it. Otherwise, use the token's primary group SID.

5.6.5.3 DACL #

The DACL is computed by merging explicit ACEs from the creator SD with inheritable ACEs from the parent SD:

  • If no creator SD is supplied and the parent has inheritable ACEs: the new object's DACL consists entirely of inherited ACEs from the parent.

  • If no creator SD is supplied and the parent has no inheritable ACEs: the new object's DACL is the token's default DACL. If the token has no default DACL, the new object's DACL is null: SE_DACL_PRESENT is clear and the DACL offset is zero.

  • If a creator SD is supplied but has no DACL (SE_DACL_PRESENT not set): the new object's DACL is computed as if no creator SD was supplied (inherit from parent, or fall back to the token's default DACL, or null DACL if the token has no default DACL).

  • If a creator SD is supplied with a DACL (SE_DACL_PRESENT set):

    • Explicit ACEs from the creator SD are preserved.
    • If the creator SD's DACL is not protected (SE_DACL_PROTECTED not set) and SE_DACL_AUTO_INHERIT_REQ is set on the creator SD: inheritable ACEs from the parent are appended after the explicit ACEs. If SE_DACL_AUTO_INHERIT_REQ is not set, only the creator's explicit ACEs are used (no parent inheritance).
    • If the creator SD's DACL is protected: parent inheritance is blocked. Only the creator's explicit ACEs are used.

In all cases, the resulting DACL is post-processed:

  • CREATOR OWNER / CREATOR GROUP SIDs are substituted with the actual owner and group in every ACE that applies to the object (INHERIT_ONLY clear). This substitution applies to the ACE's SID field only. ApplicationData — conditional expression bytecode — is copied verbatim: no SID substitution, no generic mapping, no offset adjustment. An implementation MUST NOT scan ApplicationData for CREATOR OWNER or CREATOR GROUP SIDs.
  • Generic rights in every ACE that applies to the object (both explicit and inherited) are mapped to object-specific rights via the object type's GenericMapping, so no generic bits persist on an ACE the access check reads. An inherit-only ACE keeps its generic rights and placeholders as written, for the objects further down to resolve. Generic rights appearing inside ApplicationData are not mapped.
  • The INHERITED_ACE flag is set on all ACEs that came from the parent.
  • If any ACE was inherited from the parent, SE_DACL_AUTO_INHERITED is set on the new SD's control flags; if the DACL came from the token's default DACL instead, SE_DACL_DEFAULTED is set. The equivalent applies to SE_SACL_AUTO_INHERITED for the SACL.

An ACE of an unrecognised type is carried to the child unchanged apart from its AceFlags byte (§5.4). Its mask is not mapped and its SID is not substituted, because neither can be located within an opaque ACE.

5.6.5.4 SACL #

Computed identically to the DACL, substituting SACL for DACL throughout. The token has no "default SACL" — if no creator SACL is supplied and the parent has no inheritable SACL ACEs, the new object has no SACL.

5.6.6 Eager evaluation #

Inheritance is eager. The new object's SD is fully computed at creation time. There is no lazy inheritance — the kernel MUST NOT walk up the directory tree at access time to find inheritable ACEs.

A consequence of eager evaluation: modifying an inheritable ACE on a parent object does not automatically update existing children. Existing children retain the SD they were created with. Propagating the change to descendants is an explicit operation, done by userspace, as below.

5.6.7 Re-propagation #

Re-propagation brings existing descendants up to date with their parent. The kernel has no primitive for it: the program that changed the parent walks the tree. Every program that does MUST give the same result, which is this.

For one child and one ACL (the DACL, the SACL, or each in turn):

  1. If the child protects that ACL (SE_DACL_PROTECTED or SE_SACL_PROTECTED), it is left exactly as it is.
  2. Otherwise, the child's ACEs with INHERITED_ACE set are removed, its other ACEs are kept in their order, and the ACEs the parent's ACL gives it (above, resolved against the child's own owner and group, through the child's object type's GenericMapping) are appended after them. The ACL is marked SE_DACL_AUTO_INHERITED or SE_SACL_AUTO_INHERITED.
  3. A parent without that ACL gives nothing, so only the inherited ACEs are removed. A child without that ACL gets one only if something is inherited into it.

The owner, group and the ACL not being re-propagated are unchanged. A child is re-propagated from its parent as the parent is after its own re-propagation, so a walk goes parents first. A protected child still passes its own ACEs on: its descendants are re-propagated from it as it is. A failure on one child does not stop the walk: the rest are re-propagated and the failures reported.

libpeios implements this as reinherit_with (peios_sd_reinherit_ex).

5.7 Ownership

Peios / Peios technical reference / PCDS / Security Descriptor

Every SD has an owner. Ownership confers two implicit rights that AccessCheck grants regardless of what the DACL says:

  • READ_CONTROL — the owner can always read the object's SD.
  • WRITE_DAC — the owner can always modify the object's DACL.

These implicit grants are the "you can't lock yourself out" guarantee. Even if the DACL grants the owner nothing, the owner can read and rewrite the DACL to restore access.

Ownership is determined by SID equality against the caller's user SID or token group SIDs. Deny-only flags affect ACE matching, but they do not change the ownership relation itself.

5.7.1 OWNER RIGHTS (S-1-3-4) #

The implicit READ_CONTROL and WRITE_DAC grants MAY be suppressed or modified by including an ACE for the OWNER RIGHTS SID (S-1-3-4) in the DACL.

When AccessCheck detects any non-inherit-only access-control ACE targeting the OWNER RIGHTS SID in the DACL (via a pre-scan before the DACL walk), the implicit grant is suppressed. The owner's access then comes entirely from the DACL walk — through ACEs matching their user SID, group SIDs, and any ACEs targeting S-1-3-4.

This enables three patterns:

  • Suppress owner rights — a deny ACE for S-1-3-4 with READ_CONTROL | WRITE_DAC.
  • Expand owner rights — an allow ACE for S-1-3-4 with additional rights beyond the default.
  • Restrict owner rights — an allow ACE for S-1-3-4 with only READ_CONTROL (no WRITE_DAC).

The OWNER RIGHTS pre-scan checks only for the presence of any non-inherit-only access-control ACE targeting the OWNER RIGHTS SID in the DACL, not whether any condition on the ACE evaluates to TRUE. A conditional ACE targeting OWNER RIGHTS suppresses the implicit grant even if the condition later evaluates to FALSE.

During the DACL walk, S-1-3-4 is treated as a normal SID. If the caller is the owner, ACEs targeting S-1-3-4 match the caller. The suppression only removes the automatic implicit grant; it does not isolate the owner from the rest of the DACL.

5.7.2 Ownership transfer #

Changing an object's owner requires WRITE_OWNER on the object. Without SeTakeOwnershipPrivilege, the new owner MUST be the caller's own SID or a group on the caller's token with SE_GROUP_OWNER (flag value 0x00000008; see the Peios Kernel TRM §3.2.2).

SeTakeOwnershipPrivilege grants WRITE_OWNER on any object regardless of the DACL (deny-proof, but subject to MIC/PIP). SeRestorePrivilege bypasses the ownership SID constraint entirely — the kacs_set_sd syscall checks for SeRestorePrivilege and, when present, skips the "own SID or SE_GROUP_OWNER group" validation, allowing the caller to set ownership to any arbitrary SID.

5.8 Conditional ACEs

Peios / Peios technical reference / PCDS / Security Descriptor

Standard ACEs match on SID alone. Conditional ACEs add a boolean expression that MUST also evaluate to TRUE for the rule to take effect. This enables attribute-based access control (ABAC).

A conditional ACE is structurally identical to its non-conditional counterpart with a conditional expression appended after the SID. The expression is stored in a binary format defined by MS-DTYP §2.4.4.17.

5.8.1 Three-valued evaluation #

Conditional expressions produce one of three results:

  • TRUE — the condition is satisfied.
  • FALSE — the condition is not satisfied.
  • UNKNOWN — the condition could not be determined (missing attribute, type mismatch, malformed expression).

How the result affects the ACE depends on the ACE type:

ACE typeTRUEFALSEUNKNOWN
AllowACE takes effectACE skippedACE skipped
DenyACE takes effectACE skippedACE takes effect
AuditEvent emittedEvent skippedEvent emitted

This asymmetry is the fail-safe principle: uncertainty about whether to grant results in no grant; uncertainty about whether to deny results in denial.

5.8.2 Expression language #

The expression supports:

  • Relational operators: ==, !=, <, <=, >, >=
  • Set operators: Contains, Any_of, Not_Contains, Not_Any_of
  • Membership operators: Member_of, Member_of_Any, Not_Member_of, Not_Member_of_Any, Device_Member_of, Device_Member_of_Any, Not_Device_Member_of, Not_Device_Member_of_Any
  • Logical operators: AND, OR, NOT
  • Existence tests: Exists, Not_Exists
  • Literal values: integers, strings, SIDs, octet strings, composites

5.8.3 Three-valued logic #

ANDTRUEFALSEUNKNOWN
TRUETRUEFALSEUNKNOWN
FALSEFALSEFALSEFALSE
UNKNOWNUNKNOWNFALSEUNKNOWN
ORTRUEFALSEUNKNOWN
TRUETRUETRUETRUE
FALSETRUEFALSEUNKNOWN
UNKNOWNTRUEUNKNOWNUNKNOWN

NOT: TRUE↔FALSE; UNKNOWN→UNKNOWN.

Boolean coercion for logical operands: integer nonzero → TRUE, zero → FALSE. String non-empty → TRUE, empty → FALSE. NULL → UNKNOWN. SID, octet, composite → UNKNOWN. Literal-origin values (values pushed directly from the bytecode, not obtained via attribute lookup) used as operands in AND/OR/NOT → UNKNOWN for the entire expression.

5.8.4 Attribute sources #

Four attribute namespaces exist, resolved via bytecode opcodes:

OpcodePrefixSourceDescription
0xf9@User.token.user_claimsToken-level claims set by authd at creation.
0xfb@Device.token.device_claimsDevice-level claims from the device token.
0xfa@Resource.SD's SACL resource attribute ACEsPer-object attributes, extracted in Pre-SACL walk.
0xf8@Local.local_claims parameter to AccessCheckPer-call contextual attributes passed by the caller. Structured as a KACS claim array of length-prefixed claim entries, using §5.9.

Attribute names are matched case-insensitively. @User.Clearance, @User.clearance, and @User.CLEARANCE all resolve the same attribute.

5.8.5 Claim flags #

Claim flags apply to token claims (@User., @Device.) and resource attributes (@Resource.). @Local. claims also carry flags.

  • DISABLED (0x0010) — the attribute is invisible to all conditions. Resolves as absent.
  • USE_FOR_DENY_ONLY (0x0004) — the attribute participates only in deny-side conditional evaluation. Deny-side conditional evaluation includes deny ACE conditions and audit/alarm ACE conditions. For allow ACE conditions, it resolves as absent.

Empty attributes (zero values) are normalised to absent (NULL) at resolution time (when the expression evaluator reads the attribute value during AccessCheck).

Comparisons involving absent attributes evaluate to UNKNOWN. This includes comparing two absent attributes with == or !=: @User.Missing == @Device.AlsoMissing is UNKNOWN, not TRUE.

5.8.6 SID matching in expressions #

The Member_of family evaluates group membership on the token. These operators are polarity-aware: deny-only groups do not satisfy allow-ACE conditions. Deny ACE conditions and audit/alarm ACE conditions use deny-side membership polarity, so enabled groups and deny-only groups both participate.

An empty SID operand set uses normal set semantics: Member_of({}) and Device_Member_of({}) return TRUE, while Member_of_Any({}) and Device_Member_of_Any({}) return FALSE. The Not_* forms are the logical inverse of those results.

Note

KACS makes virtual groups (S-1-3-4 for owner, S-1-5-10 for PRINCIPAL_SELF) visible to conditional expressions. Member_of({S-1-3-4}) returns TRUE when the active SID-matching view treats the caller as the owner. Normal evaluation, restricted evaluation, and confinement evaluation may supply different virtual-group views as defined in their respective sections. This is an intentional divergence for semantic consistency between the SID matcher and the expression evaluator.

Note

KACS promotes between INT64 and UINT64 for relational operators: negative INT64 is always less than any UINT64. Without promotion, UINT64 claims are unusable in conditions because the bytecode encodes integer literals as signed.

5.8.7 Binary format #

Conditional expressions are encoded as a stack-based bytecode program in reverse Polish notation. The binary format is defined by MS-DTYP §2.4.4.17.4 and MUST be byte-compatible.

The expression bytecode begins with a 4-byte magic: 0x61 0x72 0x74 0x78 ("artx"). If the magic is absent or the expression is shorter than 4 bytes, evaluation MUST return UNKNOWN.

Evaluation succeeds only if the final stack contains exactly one tri-state result. If evaluation ends with zero entries, more than one entry, or a raw non-boolean value still on the stack, the expression MUST evaluate to UNKNOWN.

The full operator bytecodes and literal encodings are specified in §5.11. KACS implementations MUST be byte-compatible with MS-DTYP §2.4.4.17.4.

5.8.8 Limits #

Implementations SHOULD enforce a maximum evaluation stack depth (recommended: 1024) and SHOULD return UNKNOWN for expressions that exceed it. Any bounds violation during parsing (reading beyond the expression buffer, underflowing the stack, integer overflow) MUST return UNKNOWN.

5.9 Claim Attribute Format

Peios / Peios technical reference / PCDS / Security Descriptor

KACS v0.20 uses a Windows-compatible CLAIM_SECURITY_ATTRIBUTE_RELATIVE_V1 entry format for:

  • resource attributes in SYSTEM_RESOURCE_ATTRIBUTE_ACE
  • token user_claims
  • token device_claims
  • local_claims passed to AccessCheck

The claim entry format itself is shared across all four surfaces. When multiple entries are carried in one buffer (token claims or local_claims), KACS wraps the Windows-compatible entry format in a simple length-prefixed sequence so the buffer can be parsed deterministically without external metadata.

5.9.1 Supported types #

KACS v0.20 supports these claim value types:

TypeValueNotes
INT640x0001Signed 64-bit integer.
UINT640x0002Unsigned 64-bit integer.
STRING0x0003UTF-16LE string.
SID0x0005Binary SID.
BOOLEAN0x0006Stored as u64; normalised to true/false at resolution time.
OCTET0x0010Byte array.

FQBN (0x0004) is reserved and not supported in KACS v0.20. Any unsupported claim type makes the containing claim entry invalid.

5.9.2 Entry layout #

All multibyte integers are little-endian. All offsets are relative to the start of the claim entry.

OffsetSizeFieldDescription
04NameOffsetOffset to the UTF-16LE null-terminated attribute name.
42ValueTypeOne of the supported claim value types above.
62ReservedReserved. Ignored by AccessCheck. Producers SHOULD set to 0.
84FlagsClaim flags.
124ValueCountNumber of values. May be 0.
164 * ValueCountValueOffsets[]One relative offset per value. Interpretation depends on ValueType.

Claim flags use the same meanings everywhere this format appears:

FlagValueMeaning
CLAIM_SECURITY_ATTRIBUTE_VALUE_CASE_SENSITIVE0x0002String/octet comparisons using this attribute are case-sensitive.
CLAIM_SECURITY_ATTRIBUTE_USE_FOR_DENY_ONLY0x0004The attribute is visible only to deny-side conditional evaluation: deny ACE conditions and audit/alarm ACE conditions.
CLAIM_SECURITY_ATTRIBUTE_DISABLED0x0010The attribute is invisible to conditional evaluation.
CLAIM_SECURITY_ATTRIBUTE_MANDATORY0x0020The attribute MUST NOT be removed or modified by unprivileged callers. kacs_set_sd rejects attempts to remove or modify a MANDATORY attribute unless the caller has SeTcbPrivilege.

Unknown flag bits are preserved but have no defined semantics.

5.9.3 Value encodings #

5.9.3.1 INT64 / UINT64 / BOOLEAN #

For INT64, UINT64, and BOOLEAN, each ValueOffsets[i] points directly to an 8-byte scalar:

  • INT64: signed 64-bit integer
  • UINT64: unsigned 64-bit integer
  • BOOLEAN: unsigned 64-bit integer, normalised at resolution time:
    • 0 = false
    • any non-zero value = true

5.9.3.2 STRING #

For STRING, each ValueOffsets[i] points to a 4-byte u32 named StringOffset. StringOffset then points to the actual UTF-16LE null-terminated string.

Strings are stored without a separate length field. The terminating UTF-16 null (0x0000) MUST appear within the containing claim entry.

5.9.3.3 SID #

For SID, each ValueOffsets[i] points to a 4-byte u32 named SidOffset. SidOffset then points to a binary SID in the standard SID wire format.

5.9.3.4 OCTET #

For OCTET, each ValueOffsets[i] points to a 4-byte u32 named OctetOffset. OctetOffset then points to:

OffsetSizeFieldDescription
04LengthByte length of the octet string.
4LengthDataRaw bytes.

5.9.4 Single-entry containers #

SYSTEM_RESOURCE_ATTRIBUTE_ACE.ApplicationData contains exactly one claim entry and consumes the remainder of the ACE.

5.9.5 Multi-entry containers #

Token claim buffers (user_claims, device_claims) and local_claims use a KACS claim-array wrapper:

repeat until buffer exhausted:
  [entry_len:u32le]
  [entry_bytes: entry_len bytes]

Rules:

  • entry_len MUST be non-zero.
  • entry_len MUST fit entirely within the containing buffer.
  • entry_bytes is one complete claim entry using the layout above.
  • The parser consumes entries sequentially until the containing buffer length is exhausted exactly.

5.9.6 Validation rules #

  • The fixed header and ValueOffsets[] array MUST fit within the entry.
  • Every offset and nested offset MUST remain within the entry bounds.
  • Every string name and string value MUST terminate within the entry.
  • Every referenced SID MUST be structurally valid.
  • A malformed claim entry invalidates the containing surface:
    • malformed resource attribute ACE payload -> malformed SD for AccessCheck
    • malformed token claim buffer -> invalid token spec
    • malformed local_claims buffer -> invalid AccessCheck input

ValueCount = 0 is valid. Empty attributes normalise to absent at resolution time, as defined in §5.8.

5.10 Resource Attributes

Peios / Peios technical reference / PCDS / Security Descriptor

A Security Descriptor MAY carry metadata about the object it protects — descriptive properties rather than access rules. These are resource attributes: name-value pairs stored as SYSTEM_RESOURCE_ATTRIBUTE_ACEs in the SACL.

Resource attributes do not grant or deny access. They exist so that conditional ACEs in the DACL can reference properties of the object during evaluation. A conditional allow ACE might say "grant read access if @User.clearance >= @Resource.confidentiality."

Each resource attribute ACE encodes a single named, typed, multi-valued attribute. The name is a string. Values MAY be integers, strings, booleans, SIDs, or byte arrays. The attribute data uses the claim entry format defined in §5.9.

Multiple resource attribute ACEs MAY appear in the same SACL, each carrying a different attribute. If two ACEs carry the same attribute name, the first one wins — duplicates are silently ignored. Name comparison is case-insensitive, matching the conditional expression evaluator's attribute name matching.

An inherit-only SYSTEM_RESOURCE_ATTRIBUTE_ACE does not apply to the object it is attached to and MUST be ignored during resource-attribute extraction.

Resource attributes are extracted from the SACL before the DACL walk begins, so they are available when conditional expressions need them.

A resource attribute marked CLAIM_SECURITY_ATTRIBUTE_MANDATORY is protected metadata. Set-security operations MUST preserve each mandatory resource attribute unless the caller has SeTcbPrivilege, as described in the Peios Kernel TRM §3.4.2.

5.10.1 Claim types #

TypeValueDescription
INT640x0001Signed 64-bit integer.
UINT640x0002Unsigned 64-bit integer.
STRING0x0003Unicode string.
FQBN0x0004Fully Qualified Binary Name. Reserved — not supported in KACS v0.20.
SID0x0005Security identifier.
BOOLEAN0x0006Boolean value.
OCTET0x0010Byte array.

Boolean values MUST be normalised to 1 (true) or 0 (false) at resolution time (when the conditional expression evaluator reads the attribute value), regardless of the wire encoding.

5.11 Conditional ACE Bytecode Reference

Peios / Peios technical reference / PCDS / Security Descriptor

This section specifies the binary encoding for conditional ACE expressions. The format is byte-compatible with MS-DTYP §2.4.4.17.4. Conditional expressions are stored in the ApplicationData member of CALLBACK ACE types, encoded in postfix (reverse Polish) notation.

5.11.1 Magic signature #

A CALLBACK ACE contains a conditional expression if the ApplicationData begins with 0x61 0x72 0x74 0x78 (the string "artx"). If the signature is absent or the expression is shorter than 4 bytes, evaluation MUST return UNKNOWN.

5.11.2 Token formats #

Each token begins with a single byte-code identifying the token type. All multibyte integers, including Unicode characters, are stored least-significant byte first (little-endian). Expressions end at the ACE boundary; any bytes needed for DWORD alignment MUST be set to 0x00.

5.11.3 Literal tokens #

Token typeByte-codeToken data encoding
Padding0x00No data. Used for DWORD alignment padding at end of expression.
Signed int80x011 QWORD (8 bytes LE) for the value (2's complement, range -128 to +127). 1 byte for sign. 1 byte for base. Total: 10 bytes.
Signed int160x021 QWORD (8 bytes LE) for the value (2's complement, range -32768 to +32767). 1 byte for sign. 1 byte for base. Total: 10 bytes.
Signed int320x031 QWORD (8 bytes LE) for the value (2's complement). 1 byte for sign. 1 byte for base. Total: 10 bytes.
Signed int640x041 QWORD (8 bytes LE) for the value (2's complement). 1 byte for sign. 1 byte for base. Total: 10 bytes.
Unicode string0x101 DWORD (4 bytes LE) for length in bytes. Then UTF-16LE code units (2 bytes each, LSB first). Not null-terminated.
Octet string0x181 DWORD (4 bytes LE) for length in bytes. Then raw bytes.
Composite0x501 DWORD (4 bytes LE) for total length in bytes of all contained elements. Then elements stored contiguously, each encoded per its own type rules. May be heterogeneous.
SID0x511 DWORD (4 bytes LE) for length in bytes. Then SID in binary representation (revision, sub-authority count, identifier authority, sub-authorities).

5.11.3.1 Sign codes #

Integer literals include a sign byte after the QWORD value:

SignCodeDescription
+0x01Explicit positive sign.
-0x02Negative.
None0x03No sign. Relational operators treat as positive.

During relational evaluation, the sign byte determines the literal sign. Positive (0x01) and no-sign (0x03) literals MUST be evaluated as the positive magnitude of the QWORD value. Negative (0x02) literals MUST be evaluated as the negative magnitude of the QWORD value. If the resulting signed value does not fit the declared signed-width token, evaluation MUST return UNKNOWN.

5.11.3.2 Base codes #

Integer literals include a base byte after the sign byte. The base is for display purposes only — the value is always stored as binary 2's complement regardless of base:

BaseCodeDescription
Octal0x01Display as octal.
Decimal0x02Display as decimal.
Hexadecimal0x03Display as hexadecimal.

5.11.3.3 Integer encoding example #

The decimal value -1 encoded as a signed int64:

0x04 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0xFF 0x02 0x02
 ^    ^-------- QWORD (2's complement) --------^  ^    ^
 |                                                 |    base=decimal
 byte-code=int64                                   sign=negative

5.11.4 Relational operator tokens #

5.11.4.1 Binary relational operators #

LHS is the second element on the stack, RHS is the top. If LHS and RHS are different types, the entire conditional expression evaluates to UNKNOWN — with the exception of INT64 and UINT64 which are promoted for comparison (see §5.8 for promotion rules). If either operand is UNKNOWN, the operation returns UNKNOWN.

Token typeByte-codeProcessing
==0x80TRUE if RHS equals LHS (single or set value); FALSE otherwise.
!=0x81FALSE if RHS equals LHS; TRUE otherwise.
<0x82TRUE if LHS < RHS; FALSE otherwise.
<=0x83TRUE if LHS <= RHS; FALSE otherwise.
>0x84TRUE if LHS > RHS; FALSE otherwise.
>=0x85TRUE if LHS >= RHS; FALSE otherwise.
Contains0x86TRUE if LHS value(s) include all of RHS value(s); FALSE otherwise.
Any_of0x88TRUE if RHS includes any of LHS value(s); FALSE otherwise.
Not_Contains0x8eLogical inverse of Contains.
Not_Any_of0x8fLogical inverse of Any_of.

String and octet string comparisons are byte-by-byte, case-insensitive by default. If the CLAIM_SECURITY_ATTRIBUTE_VALUE_CASE_SENSITIVE flag (0x0002) is set on either operand's attribute, comparison is case-sensitive.

5.11.4.2 Unary relational operators (SID membership) #

The operand is the top of the stack and MUST be a SID literal or a composite of SID literals.

Token typeByte-codeProcessing
Member_of0x89TRUE if the token's group SIDs contain all SIDs in the operand.
Device_Member_of0x8aTRUE if the token's device group SIDs contain all SIDs in the operand.
Member_of_Any0x8bTRUE if the token's group SIDs contain any SID in the operand.
Device_Member_of_Any0x8cTRUE if the token's device group SIDs contain any SID in the operand.
Not_Member_of0x90Logical inverse of Member_of.
Not_Device_Member_of0x91Logical inverse of Device_Member_of.
Not_Member_of_Any0x92Logical inverse of Member_of_Any.
Not_Device_Member_of_Any0x93Logical inverse of Device_Member_of_Any.

For an empty SID operand set:

  • Member_of({}) and Device_Member_of({}) return TRUE (vacuous truth).
  • Member_of_Any({}) and Device_Member_of_Any({}) return FALSE.
  • The Not_* forms are the logical inverse of those results.

5.11.5 Logical operator tokens #

Logical operators test the logical value of operands and produce TRUE, FALSE, or UNKNOWN. The logical value of an operand is determined by:

  • Literal-origin value → error (entire expression returns UNKNOWN)
  • Attribute with null value → UNKNOWN
  • Attribute with integer value → TRUE if nonzero, FALSE if zero
  • Attribute with string value → TRUE if non-empty, FALSE if empty
  • Result value → the result's tri-state value

5.11.5.1 Unary logical operators #

Token typeByte-codeProcessing
Exists0x87TRUE if the operand is an attribute (@Local., @Resource., @User., or @Device.) with a non-null value. FALSE if the attribute is absent or null. Returns error (→ UNKNOWN) for literal operands. KACS divergence: extends Exists to all four namespaces (MS-DTYP restricts to Local/Resource only).
Not_Exists0x8dLogical inverse of Exists.
NOT (!)0xa2TRUE→FALSE, FALSE→TRUE, UNKNOWN→UNKNOWN.

5.11.5.2 Binary logical operators #

LHS is the second element on the stack, RHS is the top.

Token typeByte-codeProcessing
AND (&&)0xa0If either operand is FALSE, return FALSE. Else if either is UNKNOWN, return UNKNOWN. Else return TRUE.
OR (||)0xa1If either operand is TRUE, return TRUE. Else if either is UNKNOWN, return UNKNOWN. Else return FALSE.

5.11.6 Attribute reference tokens #

Attribute names are encoded as Unicode strings (same format as the 0x10 literal: DWORD length + UTF-16LE code units). The byte-code determines which namespace to look up the attribute in.

Attribute lookup is case-insensitive: the encoded name is matched against the namespace's attribute names without regard to case.

Token typeByte-codeNamespace
@Local.0xf8Local claims (passed as AccessCheck parameter).
@User.0xf9User claims (from token.user_claims).
@Resource.0xfaResource attributes (from SACL resource attribute ACEs).
@Device.0xfbDevice claims (from token.device_claims).

5.11.7 Complete byte-code summary #

For quick reference, all byte-codes in numeric order:

Byte-codeToken
0x00Padding
0x01Signed int8 literal
0x02Signed int16 literal
0x03Signed int32 literal
0x04Signed int64 literal
0x10Unicode string literal
0x18Octet string literal
0x50Composite literal
0x51SID literal
0x80==
0x81!=
0x82<
0x83<=
0x84>
0x85>=
0x86Contains
0x87Exists
0x88Any_of
0x89Member_of
0x8aDevice_Member_of
0x8bMember_of_Any
0x8cDevice_Member_of_Any
0x8dNot_Exists
0x8eNot_Contains
0x8fNot_Any_of
0x90Not_Member_of
0x91Not_Device_Member_of
0x92Not_Member_of_Any
0x93Not_Device_Member_of_Any
0xa0AND (&&)
0xa1OR (||)
0xa2NOT (!)
0xf8@Local. attribute
0xf9@User. attribute
0xfa@Resource. attribute
0xfb@Device. attribute

KACS implementations MUST be byte-compatible with these encodings.

Note

This byte-code table is reproduced from MS-DTYP §2.4.4.17.4 through §2.4.4.17.8 for specification self-containment. KACS-specific evaluation semantics (three-valued logic tables, INT64/UINT64 promotion, virtual group visibility in Member_of, polarity-aware SID matching) are defined in §5.8.

Appendix 5.A Prior Art

Peios / Peios technical reference / PCDS / Security Descriptor

5.A.1 MS-DTYP #

The security descriptor family defined in this chapter derives from the Microsoft Data Types specification (MS-DTYP): the self-relative security descriptor (§2.4.6), the ACL and ACE binary formats, the CLAIM_SECURITY_ATTRIBUTE_RELATIVE_V1 claim entry, and the conditional expression bytecode (§2.4.4.17). Peios keeps the binary formats byte-compatible so security descriptors round-trip with Windows systems — over the wire and on NTFS volumes.

Deliberate divergences from the reference model are flagged inline as notes where they occur: permissive ACL-revision parsing, the repurposing of alarm ACEs for continuous auditing, evaluation-time generic mapping, the extension of Exists to all four attribute namespaces, INT64/UINT64 promotion in relational operators, and virtual-group visibility in Member_of.

1.1 Scope

Peios / Peios technical reference / PGSS / Introduction

This document defines the Peios Generic System Standards (PGSS): the cross-platform protocols and standards a system MUST implement in order to be Peios.

A standard belongs in this document when three things are true of it.

It is a conformance requirement. A system that does not offer the protocol, at the specified path, with the specified semantics, is not Peios. Each standard here is a bar to clear, not a recommendation to weigh.

It belongs to no implementation. A PGSS standard describes a contract between two roles, not the behaviour of a particular program. Mainline ships an implementation of each role; nothing in this document depends on that implementation, or describes it.

Either role may be replaced. A third party MAY ship its own implementation of one role — or of both — and interoperate with the other unchanged. A standard that cannot survive that substitution is a description of one system rather than a contract between two, and does not belong here.

For each standard, this document covers:

  • the channel it is offered on, and the access control governing it
  • message framing, encoding, and the rules under which the format may be extended
  • the messages exchanged, their fields, and the order in which they are exchanged
  • the obligations binding on each role, including what a role MUST establish for itself rather than believe from a message
  • the conformance requirements for each role

This document does not cover:

  • How an implementation reaches the answers it gives — that is precisely what different systems exist to do differently
  • The binary structures these protocols carry — defined in PCDS
  • Protocols spoken across the kernel boundary — defined in PSPK
  • Protocols between foundational userspace components — defined in PSPU
  • Interfaces particular to one Mainline component — defined in the specification of the component that offers them

1.1.1 Distinguishing a standard from a protocol #

The anthology's three protocol documents are told apart by what happens when you disagree with one.

Disagreeing with a standard in this document means shipping something that is not Peios.

Disagreeing with PSPK or PSPU means shipping a system built from different parts — a different kernel subsystem, or a different set of userspace components. That is a design choice, not a conformance failure.

Note

The consequence of the second and third properties together is that conformance is checked against the protocol, never against Mainline. A system that speaks a standard correctly conforms to it, whatever it runs behind the channel and however it reaches its answers.

1.2 Conventions

Peios / Peios technical reference / PGSS / Introduction

The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY in this document are to be interpreted as described in RFC 2119. Text set off as a note is informative, not normative.

Everything else — roles, byte order, sizes, layout tables, notation, strings, timestamps, citation, and the external standards this anthology depends on — is defined in the Conventions book and is not restated here. PGSS departs from none of it.

Where a chapter needs a convention of its own, that chapter states it.

2.1 Scope and Roles

Peios / Peios technical reference / PGSS / Logon

This chapter specifies PGSS Logon: the protocol by which a caller obtains a KACS token and a logon session from an authentication authority, and the protocol by which any program on the system resolves an identity it already holds into a name, a SID, or the attributes a POSIX program expects of one.

Two roles participate.

The authority is the process that listens on the logon socket. It decides whether a logon succeeds, mints the resulting token, creates the logon session, and answers identity lookups. There MUST be at most one authority on a running system.

The client connects and speaks on a principal's behalf. It proposes what kind of logon it wants, renders prompts and returns answers without interpreting them, and receives the token and installs it itself. A client is not trusted: everything it sends is a claim (§2.4).

The principal is the identity a logon is for — the person or service being authenticated. The principal is not a party to the conversation.

Both roles are publicly implementable. A third party MAY ship a different authority, a different logon originator, or both, and interoperate with the other half unchanged.

This chapter covers:

  • the two channels an authority offers, and the access control governing each (§2.5, §2.14)
  • message framing, header layout, and the rules under which the format may be extended (§2.6)
  • the messages of the logon conversation, their fields, and their encodings (§2.7 to §2.10)
  • the shape of a conversation: who speaks, in what order, and how it terminates (§2.3)
  • the transfer of the minted token to the caller, and the session-profile values a logon originator needs in order to start a session (§2.9)
  • credential handling obligations binding on both roles (§2.11, §2.12)
  • what an authority MUST establish for itself rather than believe from a message (§2.4)
  • resolving a principal to a name, a SID, a POSIX identifier, or the attributes a POSIX program expects of one (§2.13 to §2.18)
  • how a bare name is resolved when more than one source could answer it (§2.15)
  • how a service manager obtains a token for a service, where there is no credential to exchange (§2.19)
  • how a principal changes their own credential (§2.20)
  • the obligations binding on each role (§2.21)
  • how a caller asks the authority to end a logon session — its own, or, where policy permits, somebody else's (§2.22)
  • how a principal adds a credential of their own, such as an SSH public key, or removes one (§2.23)

This chapter does not cover:

  • Tokens, SIDs, privileges, integrity levels, logon sessions, logon types, and access checks — described in the Peios Kernel TRM. SIDs and security descriptors are specified in PCDS.
  • How an authority decides whether a credential is valid, where it keeps identity, which principals exist, or what they are called. Those belong to the authority's own design. Mainline's authority federates them over PSI, the Principal Source Interface, specified in PSPU §2.
  • Credential enrolment — giving a principal a credential it does not have — and account administration, including setting a credential on another principal's behalf. Neither is specified.
  • Service startup and ordering, described in the peinit TRM.

The distinction in the second point is the load-bearing one. PGSS Logon specifies how a caller asks and how an authority answers. It says nothing about how the authority reaches its answer, because that is exactly what different systems will do differently, and constraining it would make the standard a description of one implementation rather than a contract between two.

Note

Mainline's authority is authd, which federates authentication to separate principal-source processes over PSI (PSPU §2). That architecture is one way to satisfy this chapter. An authority that consults a single built-in database, or a remote directory, or a hardware token reader, conforms equally well provided it speaks this protocol correctly.

2.1.1 What the authority does not do #

An authority MUST NOT be a process factory. It does not fork the principal's shell, does not learn about controlling terminals, environments, or session leadership, and does not decide what the caller does with the token it receives.

The caller installs the token and proceeds. This keeps the most privileged process on the system out of the business of launching arbitrary programs, and it means a client can obtain a token for a purpose the authority need never have anticipated.

The authority still starts nothing when it is asked to end a logon session (§2.22). It MAY then end the processes running in that session, and only those: ending what exists launches nothing, and the prohibition is on launching.

Note

This is why AccessGranted carries a descriptor and a session identifier and nothing else. There is no field describing what should happen next, because nothing about what happens next is the authority's concern.

2.1.2 Authentication and derivation #

Two acts, deliberately separated.

Authentication establishes that the principal is who they claim. Its output is an identity and nothing more.

Derivation constructs the token: which SIDs it carries, which privileges, at what integrity level, with what projected identifiers. Its inputs are the authenticated identity and local policy.

The separation matters because the second is where a machine's own rules apply. An identity established elsewhere — by a directory, by a remote authority — does not carry entitlements onto this machine with it. What that identity means here is decided here, every time, by the authority applying local policy.

An authority MUST perform derivation itself. It MUST NOT accept a token, a privilege set, or an integrity level supplied by any other party, whatever its trust level.

2.1.3 The token this chapter does not describe #

This chapter governs the conversation and the delivery of its result. It does not govern the contents of the token that results. What SIDs, privileges, and integrity level a principal receives is derivation, and derivation is the authority's judgement applied to local policy.

A caller that receives a token from this protocol has been told who it may act as. It has not been told, and MUST NOT infer, anything further about how that conclusion was reached.

2.2 Terminology

Peios / Peios technical reference / PGSS / Logon

Terms defined in the Peios Kernel TRM (token, logon session, privilege, integrity level, logon type) and in PCDS (SID, security descriptor, DACL) are used here with the same meaning and are not redefined.

Authority. The process that listens on the logon socket, decides whether a logon succeeds, mints the resulting token, creates the logon session, and answers identity lookups. There is at most one authority on a running system.

Client. A process that connects to the authority. Also called the originator when the emphasis is on whose identity the authority verifies. There are four client roles — originating a logon, changing the caller's own credential, looking an identity up, and ending a logon session — and they are independent (§2.21).

Principal. The identity a logon is for — the person or service being authenticated. The principal is not a party to the conversation; the client speaks on their behalf.

Conversation. One connection's exchange, from the client's opening message — a logon's, a credential change's (§2.20), an enrolment's (§2.23), or a request to end a logon session (§2.22) — to a terminal message from the authority. One connection carries exactly one conversation.

Round. One CredentialRequest from the authority and the CredentialResponse that answers it. A conversation MAY take several rounds.

Terminal message. AccessGranted or AccessDenied for a logon; CredentialChanged or AccessDenied for a credential change (§2.20) or an enrolment (§2.23); SessionEnded, SessionEndAllowed or AccessDenied for a request to end a logon session or to ask whether one may (§2.22). Exactly one is sent, and nothing follows it.

Credential material. Any byte sequence a principal supplies as proof of identity — a password, a one-time code, a response from a token. Distinguished throughout from a verifier, which is what an authority stores and which MUST NOT be usable as credential material.

Prompt. A request from the authority for one piece of credential material, carrying enough description for a client to render it without understanding what it is for.

Derivation. The authority's construction of a token's contents — its SIDs, privileges and integrity level — from the authenticated identity and local policy. Distinct from authentication, which establishes only that the principal is who they claim.

Attestation. A trusted process's assertion that a logon should happen, standing in for a credential where none exists and none should. Distinct from authentication, which establishes that a principal is who they claim by something only they could present; an attestation establishes only that the party asking is one the authority permits to ask (§2.19).

Source. A party an authority consults in order to answer a question about identity. Whether an authority has sources at all, and what they are, is its own design; the term appears here only where the protocol's behaviour depends on there being more than one possible answerer (§2.15, §2.18).

2.2.1 A note on "logon type" #

LogonType (§2.7) describes the kind of session being established — interactive, network, batch, service. It is a property of the situation, not of the principal, and the same principal may hold several sessions of different types at once. Its values and meanings are defined by KACS and described in the Peios Kernel TRM; this chapter carries it but does not define it. The values are listed for reference in §2.B.

2.3 The Conversation

Peios / Peios technical reference / PGSS / Logon

A logon is a conversation, not a call. The client opens it, the authority asks for whatever the principal's policy requires, and the authority ends it.

client                                   authority
  |                                          |
  |------------- LogonStart ---------------->|
  |                                          |
  |<--------- CredentialRequest -------------|   round 1
  |---------- CredentialResponse ----------->|
  |                                          |
  |<--------- CredentialRequest -------------|   round 2 (if required)
  |---------- CredentialResponse ----------->|
  |                                          |
  |<-- AccessGranted (+ token fd) -----------|   terminal
  |         or AccessDenied                  |

2.3.1 Why a conversation #

The value of this shape is that the client stays generic. It does not know what a password is. It renders the prompts it is given, collects the answers, and returns them.

That is what makes multi-factor authentication, password-expiry-forces-change, smartcards, or a policy that asks for a second factor only from an unfamiliar host, changes to authorities rather than to every client on the system. A protocol that named its credential kinds would have to be revised, and every client rebuilt, for each one.

Most logons are one round: the authority asks for everything the principal's policy requires, in one array, and decides. The conversational shape exists for what one round cannot express — choosing between authentication paths, or a shared account where one credential unlocks a requirement for another.

2.3.2 Sequence rules #

A conversation MUST proceed as follows.

  1. The client sends exactly one opening message: LogonStart, CredentialChangeStart to change its own credential (§2.20), CredentialEnrollStart to add or remove one of its own (§2.23), or SessionEnd or SessionEndQuery about a logon session (§2.22). It MUST be the first message. An authority MUST reject any conversation that opens with something else.
  2. The authority sends zero or more CredentialRequest messages. Each MUST be answered by exactly one CredentialResponse before the authority sends anything further.
  3. The authority sends exactly one terminal message: AccessGranted or AccessDenied for a logon, CredentialChanged or AccessDenied for a credential change or an enrolment, SessionEnded or AccessDenied for a SessionEnd, and SessionEndAllowed or AccessDenied for a SessionEndQuery.
  4. Both parties close the connection.

SessionEnd and SessionEndQuery exchange no credentials: the authority sends no CredentialRequest in reply to either, and the conversation is the opening message and its terminal.

The authority MAY send a terminal message at any point after LogonStart, including before any CredentialRequest. Zero rounds is a conforming conversation: an authority that can decide from LogonStart alone — a pre-authenticated caller, or a refusal on logon type — is not required to ask for anything.

A client MUST NOT send a CredentialResponse that was not solicited by a CredentialRequest. An authority MUST reject one that was not.

2.3.3 Bounding the conversation #

An authority MUST bound the number of rounds it will conduct and MUST bound the time it will wait for a CredentialResponse. Neither bound is fixed by this chapter, since both are policy. An authority that exhausts either MUST terminate with AccessDenied carrying ConversationLimit rather than closing silently, so that the client can distinguish a policy limit from a crash.

2.3.4 Termination #

Exactly one terminal message is sent. After it, the authority MUST NOT send anything further on that connection, and MUST close it.

A connection that closes without a terminal message is an abnormal termination. A client MUST treat it as a failed logon and MUST NOT retry automatically, since the reason is unknown and may be a policy refusal the authority could not express.

2.4 What an Authority Must Not Trust

Peios / Peios technical reference / PGSS / Logon

Nothing a client sends is trusted. This section states the rule once, because every field in §2.7 is subject to it.

2.4.1 Peer identity #

An authority MUST establish the connected peer's identity from the connected socket, by reading the peer's token from the kernel. It MUST NOT take the peer's identity from any message body, because there is no field in which a client could put it that the client could not also lie in.

An authority MUST NOT use SO_PEERCRED for this purpose. It answers a similar-looking question and is the wrong answer: it returns the projected UID, which cannot distinguish an authenticated principal from an unauthenticated process running under the same projection, and carries none of the token's SIDs, groups, integrity level or privileges.

Note

On Peios many principals project to the same identifier, so a projected UID frequently identifies nobody in particular. Two different principals can share one. The peer token is the only thing that answers "who is this?" exactly.

2.4.2 Logon type #

LogonStart.logon_type is a proposal, not an instruction.

Only the caller knows whether an inbound connection is an interactive shell or a batch command, so the caller has to be the one to say. But an authority MUST constrain the proposal against what that verified peer is permitted to request. Otherwise anything that can reach the socket can mint itself an interactive session, and the logon type — which access control decisions depend on — becomes a value chosen by the least trusted party in the exchange.

2.4.3 Identifier #

The identifier names the principal a logon is for. It is a claim about who is being authenticated, and it is what the subsequent credential exchange exists to test. An authority MUST NOT treat an identifier as established until authentication has succeeded.

2.4.4 Everything else #

tty and remote_host are unverified context. An authority MAY record them, MAY use them in policy, and MUST NOT treat them as established facts. A client that lies about its remote host is not prevented from doing so by this protocol.

2.4.5 Access to the socket #

Access to the logon socket MUST be controlled by a security descriptor.

It MUST NOT be controlled by process integrity level. PIP can gate a socket, but using it here would require every future caller — a graphical greeter, a web console, a remote access daemon — to be signed at high trust merely to collect a password. Collecting a credential is not a privileged act; deciding whether it is correct is, and that decision happens on the other side of the socket.

The descriptor decides who may reach the socket, not what a peer may do once there. Where it admits principals so that they can change their own credential (§2.20) or enrol one (§2.23), it admits peers that may not originate logons, and an authority MUST decide from each peer's identity whether it may originate one.

Rate limiting is defence in depth. It is not the access control, and an authority MUST NOT rely on it as such.

2.5 The Logon Channel

Peios / Peios technical reference / PGSS / Logon

2.5.1 Socket #

An authority MUST listen on a SOCK_STREAM Unix domain socket at:

/run/logon.sock

The path is normative. It is the standard's path, not an implementation's, and a client MUST NOT require configuration to find it.

2.5.2 Access control #

The socket MUST carry a security descriptor granting connect access to the principals permitted to originate logons, to the principals permitted to change their own credential (§2.20) or enrol one (§2.23), and to those permitted to end a logon session (§2.22). See §2.4 for why this, and not process integrity, is the control — and why reaching the socket is not, on its own, permission to originate a logon.

2.5.3 One conversation per connection #

A connection carries exactly one conversation. The connection is the conversation's identity.

There is therefore no correlation identifier in the header, and none is needed: a message belongs to the conversation it arrived on. This removes a class of error and attack — a forged or confused identifier cannot attach a credential response to somebody else's logon, because there is no identifier to forge.

It also bounds the credential's lifetime by the connection's, which makes that the kernel's job to enforce rather than the authority's to remember.

An authority MUST close the connection after sending its terminal message. A client MUST close after receiving one.

A ServiceAttest (§2.19) is not a conversation and exchanges no credential, but it is bound by the same rule: one per connection, answered with one terminal message, and the connection closed. It is carried here rather than on its own socket because it is a logon in every respect except how the authority was satisfied — it creates a session and mints a token — and because the volume is a handful of requests at start-up rather than the sustained load §2.14 exists to separate.

SessionEnd and SessionEndQuery (§2.22) are bound by the same rule: one request per connection, one terminal message, and the connection closed. They are here because ending a session is a decision about logons, made by the party that created the session.

Note

A protocol whose connections are long-lived and shared reaches the opposite conclusion, and multiplexes. PSI does (PSPU §2.7), and so does this chapter's own identity channel (§2.14). The difference is not inconsistency: a logon client's connection exists for one logon and is discarded, so paying for multiplexing here would buy nothing and a correlation identifier would only add something to forge.

2.5.4 Concurrency #

An authority MUST serve conversations concurrently. A logon that stalls — a principal who walks away mid-prompt — MUST NOT prevent other logons from proceeding.

An authority MUST bound the number of conversations it will serve at once, and MUST bound the time a conversation may remain open. Both are policy; neither is fixed here.

2.5.5 Descriptor passing #

The channel MUST support ancillary data (SCM_RIGHTS). The token is transferred as a file descriptor alongside AccessGranted — see §2.9.

2.6 Message Framing

Peios / Peios technical reference / PGSS / Logon

Both of an authority's channels use the framing specified here without modification. The identity socket's departures are stated in §2.14; they concern which message types are served where, not the encoding.

2.6.1 Header #

Every message begins with a 12-byte header:

OffsetSizeFieldValue
04magicPGSL (50 47 53 4c)
42version1
62msg_typeSee §2.A
84total_lenHeader plus body, in bytes

total_len counts the header. A message is therefore self-delimiting from its first 12 bytes, and a reader can size its buffer before reading the body.

2.6.2 Magic #

The magic MUST be checked on every message and MUST cause an immediate, whole-connection failure when wrong.

This is not decoration. Peios protocols share this codec and this header layout, and some of them share message bodies. A socket plugged into the wrong daemon would otherwise partially work, which is far worse than failing outright — a subtle misbehaviour several fields in, rather than a hard error on byte zero. A protocol reusing this codec MUST take a distinct magic.

2.6.3 Version #

A peer receiving a version it does not implement MUST refuse the message. An authority MUST refuse with AccessDenied carrying UnsupportedVersion where it can still encode one.

2.6.4 Size limit #

A message MUST NOT exceed 65536 bytes in total. A decoder MUST reject a header declaring more, without reading the body, and MUST reject one declaring fewer than the header's own length.

2.6.5 Message type #

The high bit of msg_type marks a message sent by the authority. Client messages have it clear. This is a readability property rather than a security one — a peer MUST validate the message type it received against what it expected, not merely against the direction bit.

2.6.6 Encoding #

All multi-byte integers are little-endian.

Strings are UTF-8 and are not NUL-terminated. A string is encoded as a u32 byte count followed by exactly that many bytes. A decoder MUST reject a string whose bytes are not valid UTF-8.

An empty string and an absent optional string are encoded identically, as a zero byte count. A field documented as optional is therefore absent when empty, and an implementation MUST NOT distinguish the two.

Byte strings are encoded the same way and carry no encoding requirement.

Arrays are encoded as a u32 element count followed by that many length-framed structures. Every array in this chapter has a stated maximum; an encoder MUST refuse to produce a longer one and a decoder MUST reject one it receives. The same holds for every stated byte limit.

Two distinct length mechanisms therefore appear, and confusing them is the most likely implementation error:

  • Byte strings and strings are prefixed with a u32 byte count, and the bytes follow immediately.
  • Structures and array elements are prefixed with a u32 byte count of the whole structure, and a decoder MUST skip to the structure's declared end after reading the fields it knows.

The second is what makes the format extensible.

2.6.7 Body extensibility #

The body is a single length-framed structure, and every structure and every array element within it is length-framed too.

A decoder reads the fields it knows and then skips to the structure's declared end. A field appended by a newer peer is therefore stepped over rather than mistaken for the next field — which is what makes the format extensible inside arrays, where ignoring trailing bytes at the message level would not help.

A structure that ends before a field an older encoder never wrote is not an error. A decoder that reaches the end of a structure's body where an optional trailing field would have been MUST substitute that field's documented default (§2.7, §2.9) rather than failing.

The rules this keeps working under are binding on anyone revising this chapter:

  • Fields are appended only. Never reordered, never removed, never changed in meaning.
  • A new field MUST be optional with a safe default, because older peers will not send it and will not read it.
  • Adding a value to an enumeration is a breaking change and requires a version bump, because every peer is required to understand every value it is sent.

The last rule is the one that surprises people. An unknown enumeration value cannot be safely ignored: a client that skipped an unrecognised credential type would silently fail to collect something the authority required, and an authority that skipped an unrecognised logon type would grant the wrong kind of session.

There are exactly four exceptions, and each is stated where it applies: LogonStart.supported_credential_types, which is a statement of capability rather than an instruction (§2.7); LogonStart.logon_type, which only the authority ever receives (§2.7); the field mask of Lookup, whose reply says which bits it answered (§2.16); and a denial code that only ever answers a message newer than every peer that does not know it, so that no such peer is ever sent it (§2.B, §2.22, §2.23).

2.6.8 Initial unpublished layout replacement #

The coordinated SSH credential extension replaced the unpublished version-1 layout without retaining older codecs. Current LogonStart capability, required-method and binding fields, Prompt parameters, and all current Assertion fields are mandatory, including explicit empty/zero values. Omitted mandatory fields fail decoding. The compatibility rules above apply to future extensions of this replacement contract; they do not require accepting the superseded development layout. See PGSS Logon §2.D.

2.7 LogonStart

Peios / Peios technical reference / PGSS / Logon

msg_type = 0x0001. Client to authority. Opens the conversation; MUST be the first message.

2.7.1 Layout #

FieldEncodingLimit
logon_typeu8§2.B
identifier_typeu8§2.B
identifierlength-framed bytes1024
ttystring128
remote_hoststring256
supported_credential_typeslength-framed bytes, one u8 per type32
required_credential_typeu8; 0 none, 1 Password, 2 SshPublicKey§2.D
has_ssh_bindingu8, exactly 0 or 1
ssh_bindingstructure, present when the flag is 1§2.D

Everything here is asserted by the client, and §2.4 governs all of it.

2.7.2 logon_type #

The kind of session the client is asking for. A proposal, which the authority MUST constrain against the verified peer — see §2.4.

Values are defined by KACS and listed for reference in §2.B.

2.7.2.1 The exception this field carries #

A value MAY be added to this enumeration without a version bump. This is the third departure from §2.6, and it turns on the same question that rule turns on: which party has to understand the value. The rule binds because every peer must understand every value it is sent, and logon_type only ever travels client to authority. The authority is therefore the only party that must understand a new one.

An authority MUST refuse a logon_type it does not recognise, with MalformedRequest (§2.10). A message carrying a value the authority cannot name is one it could not understand, and it is refused before any question of what the peer is permitted arises.

Neither direction of mismatch is left to chance by that. An older client never proposes a value it has not heard of, and a newer client proposing one to an older authority is refused — so the failure is no session rather than a session of the wrong kind, which is the outcome §2.6 exists to prevent.

2.7.3 identifier_type and identifier #

Together these name the principal. identifier_type says how to read identifier; identifier is opaque bytes, not a string.

Bytes rather than a string because an identifier is not always text. A certificate thumbprint, a smartcard serial, or a binary principal name are all reasonable identifiers, and a protocol that insisted on UTF-8 would exclude them. An authority that expects text MUST validate the encoding itself.

An identifier MAY be empty. An empty identifier means the principal is not named here — the authority is expected to determine it from the credential, as with a smartcard that carries its own identity.

identifier_type is distinct from credential_type (§2.8): this is the claim of identity, that is the proof. A passkey names a principal and proves them in one artefact; a username names them and proves nothing.

2.7.4 tty and remote_host #

Unverified context, both optional, both empty when absent.

tty names the terminal the logon is happening on, where there is one. remote_host names where a network logon came from.

An authority MAY use either in policy and MAY record either in its audit trail. It MUST NOT treat either as established. A client that lies about them is not prevented from doing so.

2.7.5 supported_credential_types #

Every credential type this client can render, one byte each.

This is the client declaring its capabilities, and it is binding on the authority: an authority MUST NOT send a prompt for a credential type absent from this list (§2.8).

A client that supports nothing sends an empty list. That is meaningful rather than degenerate — it says "I can complete a logon that requires no interaction, and nothing else" — and an authority MUST either complete the logon without prompting or deny it.

2.7.5.1 Mandatory capability and method fields #

The capability list and following method/binding fields are mandatory in the current unpublished version-1 layout. Missing tails are malformed; there is no absent-field Password default. An explicit empty list means that the client cannot collect a credential.

A nonzero required method must appear in the capability list. The authority rejects any source assertion that did not authenticate with that method. The SSH binding is present exactly when SshPublicKey is advertised. It is an enclosing length-framed structure containing 16 raw connection-ID bytes, a session-identifier byte string (16–64 bytes), and a UTF-8 username (1–256 bytes, no controls), equal to the Username identifier byte for byte. Only the authorized SSH monitor may supply it; see §2.D.

An unrecognised value is dropped, not refused. A decoder MUST discard a credential type it does not recognise and proceed with the rest, leaving the intersection of what the client claims and what the decoder understands. This is safe here and nowhere else, because the field is a statement of capability rather than an instruction: an authority that ignores a type it has never heard of merely declines to use it, which is the correct outcome.

Without this, a client that learned a new credential type could not speak to an older authority at all — the authority would be obliged by §2.6 to refuse the whole message. The capability list exists precisely so that an authority using a new type cannot reach an older client; it would be self-defeating if a client using a new type could not reach an older authority either.

2.8 Credential Exchange

Peios / Peios technical reference / PGSS / Logon

2.8.1 CredentialRequest #

msg_type = 0x8001. Authority to client.

FieldEncodingLimit
messagesarray of Message8
promptsarray of Prompt16

Message:

FieldEncodingLimit
severityu8§2.B
textstring512

Prompt:

FieldEncodingLimit
credential_refu32—
credential_typeu8§2.B
credential_namestring128
parameterslength-framed bytes4096

Both arrays MAY be empty.

2.8.1.1 Messages #

Text for the principal to read, before any prompt is presented. "Your password expires in three days"; "Authenticating against the local source".

A client MUST display messages it receives, in order, before the prompts in the same request. It MUST NOT interpret them, and MUST NOT vary its behaviour on their content.

2.8.1.2 Prompts #

Each prompt asks for one piece of credential material.

credential_ref identifies the prompt within the conversation. The client echoes it back unchanged in its answer. It MUST be unique among the prompts of a single request. An authority MAY reuse a value in a later round.

credential_name is what to show the principal — "Password", "Verification code". It is a display string. A client MUST NOT branch on it; credential_type is what says how to collect the answer.

2.8.1.3 The client capability rule #

An authority MUST NOT send a prompt whose credential_type is absent from the client's supported_credential_types (§2.7).

This is a hard requirement rather than a courtesy. A client that receives a prompt it cannot render has no good option: failing the logon punishes the principal for a mismatch neither party chose, and guessing — collecting a line of text for a credential type that is not a password — may echo a secret to the screen. An authority that cannot proceed within the client's declared capabilities MUST deny the logon instead.

A client that nevertheless receives an unrenderable prompt MUST fail the conversation rather than guess.

2.8.1.4 Empty requests #

A CredentialRequest with no prompts is valid. It carries messages only, and the client MUST answer it with a CredentialResponse carrying no answers. This is how an authority conveys information mid-conversation without asking for anything.

2.8.2 CredentialResponse #

msg_type = 0x0002. Client to authority.

FieldEncodingLimit
answersarray of Answer16

Answer:

FieldEncodingLimit
credential_refu32—
datalength-framed bytes32768

credential_ref MUST match a prompt from the request being answered. data is the material the principal supplied — opaque bytes, no encoding implied.

A client SHOULD answer every prompt it was given. An authority MUST NOT assume it has: a missing answer is a failed logon, not a protocol violation, and MUST be treated as such rather than as grounds to tear down the connection.

An authority MUST match answers to prompts by credential_ref and MUST NOT rely on ordering.

Both parties encode and decode this message under the obligations of §2.12. The encoded message holds the credential just as much as the data field inside it does.

Note

A client that cannot collect an answer — the principal pressed escape — MAY send a response omitting it rather than closing the connection. That gives the authority the opportunity to deny cleanly with a reason the principal can read.

2.8.3 Typed parameters #

Prompt parameters are mandatory, empty for Password, and carry the bounded SshPublicKey disposition structure described in §2.D. They are protocol input to the matching collector, never instructions to render or execute. An ordinary password client must refuse an unsupported SSH prompt.

2.9 AccessGranted

Peios / Peios technical reference / PGSS / Logon

msg_type = 0x8002. Authority to client. One of the two terminal messages; nothing follows it.

FieldEncoding
session_idu64
profilelength-framed structure, optional

Ancillary data: the token, as one file descriptor via SCM_RIGHTS.

2.9.1 The descriptor #

The token MUST be transferred as a file descriptor attached to this message. It MUST NOT be named, and there MUST be no path, handle, or identifier by which another process could reach it.

Possession of the conversation is what confers the token. There is no window in which a minted token exists under a name something else could open, and no lookup that could be raced or guessed.

A client MUST read the descriptor from the ancillary data of this message. An AccessGranted arriving without one is a protocol violation and MUST be treated as a failed logon — a client MUST NOT proceed as though a logon succeeded when it holds no token.

2.9.2 session_id #

The logon session the token belongs to. The client MAY record it, MAY report it, and needs it to relate this logon to the kernel's session records.

It is informational to the protocol. The token is the thing that confers authority; the session identifier merely names the session the token already belongs to.

2.9.3 profile #

Where the session starts, and what to call the principal. A length-framed structure, so it can grow without displacing anything appended to AccessGranted after it.

FieldEncodingLimit
homestring4096
shellstring4096
display_namestring256

profile is an optional trailing field. Every field may be empty, and an empty field means the authority did not say. An authority that knows nothing about home directories is conforming, and so is one that omits the whole structure — a client MUST treat an absent profile exactly as it treats one whose fields are all empty.

A client MUST have a fallback for each field and MUST NOT treat an empty value as an error.

home and shell, when non-empty, MUST be absolute paths. A client MUST NOT execute a relative shell or resolve a relative home against its own working directory. An authority with no value for a field MUST leave it empty rather than invent one.

2.9.3.1 Why this is on the terminal message #

Nothing here is an access-control input. No ACL names a home directory, and the token carries none of these fields — so this is not identity, and an authority that got it wrong would produce an inconvenient session rather than an unsafe one.

It is here because the authority has just read these values in order to decide the logon, and a caller that needs them in order to start a session would otherwise have to ask a second time. A logon originator cannot chdir or exec without them.

2.9.3.2 What this is not #

It is not a directory lookup, and it MUST NOT be treated as one. It answers for exactly one principal — the one who just authenticated — at exactly one moment. It cannot answer for anybody else, it cannot answer for a principal who never logged on, and nothing in this protocol says what it means an instant later.

Resolving arbitrary principals to home directories or shells is §2.16's, and a client that caches these values as though they had come from there has misread them.

2.9.3.3 display_name #

A human's name for a human to read.

It is deliberately not a GECOS field. GECOS is a comma-separated /etc/passwd artefact carrying a name, an office, and two telephone numbers in one string; a protocol that carried one would oblige every client to parse it apart, and would tie this chapter to a file format it has nothing to do with.

An implementation that must produce a GECOS field renders it from this, not the other way round.

Note

A client is not obliged to use any of this. Falling back to a fixed shell, or ignoring display_name entirely, is conforming. The fields exist so that a client which wants them does not need a second protocol.

2.9.4 What the client does next #

Installs the token, if that is what it wanted. The authority is not involved and MUST NOT be told (§2.1).

A client that decides not to install the token MUST close the descriptor. A logon session whose token is never installed still exists as far as the kernel is concerned.

A client SHOULD NOT fail a logon because a home it was given does not exist. The principal has authenticated and holds a token; refusing to start their session over a missing directory turns a cosmetic problem into being locked out. Starting elsewhere and saying so is the better failure.

2.10 AccessDenied

Peios / Peios technical reference / PGSS / Logon

msg_type = 0x8003. Authority to client. The terminal failure message of a logon, of a credential change (§2.20), of an enrolment (§2.23), and of a request to end a logon session or to ask whether one may (§2.22) alike; nothing follows it.

FieldEncodingLimit
denialu32§2.B
reasonstring512

2.10.1 denial #

A code from a deliberately small vocabulary (§2.B), for a client that needs to act differently — retry, offer a different account, report a system fault.

PermissionDenied and LogonTypeNotPermitted are distinct on purpose: the first says the peer may not use this socket for this at all, the second that it may originate logons but not of this kind. A client can act differently on each.

NoSuchSession answers only SessionEnd and SessionEndQuery, and only a caller who would otherwise have been permitted (§2.22). Anybody else is told PermissionDenied, so the code cannot be used to learn which sessions exist.

CredentialRejected answers only CredentialEnrollStart, and says the credential offered or named was refused, not the principal's proof of who they are (§2.23).

2.10.2 reason #

Text for the principal to read. A client SHOULD display it and MUST NOT interpret it.

The division is the same one CredentialRequest makes between credential_type and credential_name: the machine-readable field is small and stable, the human-readable one is free.

reason MUST NOT narrow an AuthenticationFailed into a specific cause.

2.10.3 What a denial must not reveal #

The denial vocabulary deliberately does not distinguish "no such principal" from "wrong credential". Both are AuthenticationFailed.

A protocol that distinguished them would make account enumeration a supported feature: anyone able to reach the socket could test names and learn which exist. An authority MUST NOT provide that distinction through the denial code, through reason, or through observable timing.

The timing obligation is the one most often missed, and it binds even though this chapter cannot check it. An authority whose unknown-principal path returns faster than its wrong-credential path has published the distinction it just declined to state — see §2.12.

Note

An authority MAY record the distinction in an audit trail an administrator can read. What it must not do is return it to the caller.

2.11 Credential Types

Peios / Peios technical reference / PGSS / Logon

A credential type tells a client how to collect an answer. It does not tell it what the answer means.

ValueNameCollection
1PasswordA line of text, not echoed
2SshPublicKeyBound SSH candidate or signature proof; §2.D

The registry is deliberately short. A type is added when a client would need to collect something differently, not when an authority acquires a new way of checking something.

Note

A one-time code is collected exactly as a password is — a line of text, not echoed — so it needs no new type. An authority asks for it with credential_type = Password and a credential_name of "Verification code". A hardware token that requires a challenge to be relayed to a reader would need a new type, because no existing collection method produces the answer.

This is the test to apply: does an existing client, understanding only the types it already has, collect the right thing? If yes, no new type is needed.

2.11.1 Adding a type #

The initial unpublished type-2 extension replaced the version-1 layout and all consumers together (§2.D). Future incompatible changes to a published contract require the version discipline in §2.6.

An authority MUST NOT send a prompt for a type absent from the client's supported_credential_types, so an authority using a new type simply cannot reach an older client — which is the correct outcome, and is why the capability list exists. The converse — a newer client reaching an older authority — is what the dropping rule in §2.7 exists for.

2.11.2 Why passwords cross the local socket in the clear #

Passwords travel this local socket as plaintext. SSH public-key authentication instead carries public keys and signatures; private keys never cross it. Remote SSH transport encrypts both authentication methods. The local password choice allows the source to retain a memory-hard password verifier without storing a password-equivalent secret.

Challenge-response requires the verifier to store something it can recompute the response from: either the plaintext, or a value that is password-equivalent. NTLM works exactly this way — the stored hash is as good as the password, forever, which is why pass-the-hash has been the most valuable credential on a Windows network for twenty years.

A modern verifier — argon2id and its relatives — is deliberately not password-equivalent and cannot answer a challenge. That is the property that makes stealing the store meaningfully weaker than knowing the passwords.

So challenge-response would trade a permanent weakness at rest for protection of a channel that is not the weak point. Anyone able to read this socket can already ptrace the process holding the password.

2.11.2.1 What would change the answer #

A PAKE — OPAQUE, SRP — gives both properties at once: nothing password-equivalent at rest, and no plaintext on the wire. It is worth revisiting if an authority ever authenticates across a network without TLS.

It is not worth its complexity for a local, kernel-mediated socket, where the threat it defends against is already able to do worse.

2.11.2.2 What follows #

Because plaintext crosses the wire, bounding how long it survives becomes an obligation of both roles rather than a nicety. See §2.12.

2.12 Credential Handling

Peios / Peios technical reference / PGSS / Logon

These bind both roles. They are stated normatively because plaintext on the wire (§2.11) is only defensible if its lifetime is short.

2.12.1 Bounding lifetime #

An implementation MUST hold credential material in memory that is erased before it is released, and the erasure MUST NOT be removable by an optimising compiler.

The obligation extends to the encoded message, not only to the credential field. A CredentialResponse holds the password just as much as the answer inside it does, and a buffer wiped at one level while another copy is dropped unerased achieves nothing.

An implementation MUST erase:

  • the buffer credential material was read into;
  • any buffer it was copied into during encoding or decoding, including an intermediate allocation abandoned by a buffer that grew;
  • any structure holding it once the conversation reaches a terminal state.

The middle clause is the one an implementation is most likely to miss. A growable buffer that reallocates while a message is being encoded leaves a complete copy of everything written so far in the abandoned allocation, and erasing the buffer that survives does not touch it. Reserving the encoded size before writing avoids the problem entirely.

2.12.2 What this does not promise #

It guarantees that these buffers are erased before their memory is reused. It cannot guarantee anything about copies made elsewhere — a string the caller parsed a credential out of, a register or stack slot the optimiser chose, a terminal's own input buffer. Those belong to whoever made them.

Nor does it defend against a hostile kernel, a core dump, or swap. Those are addressed elsewhere: process integrity protection, and disabling core dumps for the authority.

2.12.3 Logging and diagnostics #

An implementation MUST NOT write credential material to a log, an audit record, an error message, or a debugging aid.

A type carrying credential material SHOULD render, under whatever debug formatting its language provides, as a redaction rather than as its contents. A stray diagnostic print is the most common way material escapes a process that was otherwise careful, and the defence is to make the careless thing produce nothing useful.

2.12.4 Collection #

A client MUST collect a Password without echoing it. Where it cannot establish that echo is suppressed, it MUST NOT collect the credential anyway.

A client MUST NOT silently truncate an answer. data admits 32768 bytes (§2.8); a client whose collection method admits fewer MUST fail the conversation rather than send a prefix of what the principal supplied, which would present as a wrong credential and leave the remainder in whatever buffer it was read from.

2.12.5 Timing #

An authority MUST NOT allow the time it takes to reach a denial to distinguish an unknown principal from a bad credential (§2.10).

This is more demanding than it looks with a memory-hard verifier, because the natural implementation returns immediately when a principal does not exist and spends tens of milliseconds when one does. An authority MUST perform equivalent work in both cases — verifying against a decoy verifier that no credential matches is the usual construction.

Note

A decoy derived from randomness at start-up, rather than from a constant, is not merely unguessable but not stable between boots either. An implementation built this way runs exactly one verification on both paths, including for a principal that requires no credential at all.

2.13 Identity Lookup

Peios / Peios technical reference / PGSS / Logon

Sections 2.3 to 2.12 specify how a caller obtains a token. The remainder of this chapter specifies how any program on the system turns an identity it already holds into something it can display or compare: a name into a SID, a SID into a name, a POSIX identifier into either.

It exists because a Linux program calls getpwuid and has never heard of a token. Without this surface, every principal an authority mints appears throughout the system as a bare number.

2.13.1 Why the authority answers this #

An authority may federate identity to separate sources, and a source counts POSIX identifiers relative to a range the authority assigns it. The authority adds the base. No message in either direction carries an absolute identifier to or from a source: a source asserts relative numbers, and is asked by relative number or by SID.

The arithmetic that makes a number absolute therefore exists in exactly one place, and only that place can invert it. No source can be asked "who is uid 1001000", because no source is ever asked an absolute number at all. The authority is not merely a convenient place to put this — it is the only party the protocol permits to answer.

The same holds for names. A bare name may exist in more than one source, and which one wins is a property of the system rather than of any source in it (§2.15).

Note

An authority that keeps all identity in one built-in database still satisfies this, and for it the rebasing question does not arise. The requirement is on the answer, not on how the answer is reached — as everywhere else in this chapter.

2.13.2 A second socket, not a second standard #

Identity lookup is served on /run/ident.sock (§2.14), separately from /run/logon.sock, by the same authority speaking the same framing (§2.6).

The separation is not for isolation. One authority answers both, so a defect or a hang in either reaches the other regardless; a second socket buys nothing there and this chapter does not pretend otherwise.

It is for admission. A listening socket has one accept queue. A single directory listing is thousands of lookups and a filesystem walk is millions, where logons are a handful per boot — so a shared socket would let an ordinary find fill the queue that an administrator needs in order to sign in and stop it. Two sockets means the two populations of caller cannot starve each other, whatever load either is under.

The second reason is access control. The set of programs that may originate a logon is small and enumerable; the set that may look up a name is every program on the system. Those want different security descriptors, and a descriptor is a property of a socket.

2.13.3 Not a conversation #

A logon is stateful: the connection is the conversation, and needs no correlation identifier (§2.5).

A lookup is not. Requests are independent, a connection carries as many as a client cares to send, and replies MAY return in an order other than the one requests arrived in. Each request therefore carries a tag that its reply echoes (§2.14).

Note

That is what allows an authority to satisfy several outstanding requests from one connection together — against a remote source, in a single query — without a delay window that would tax a caller waiting on a single answer. Nothing here requires it, and an authority that answers strictly in order, one request at a time, is fully conformant.

2.13.4 Not authentication #

No credential ever crosses this socket. There is no message with which a client could offer one and none with which an authority could ask.

Everything here returns the kind of data a POSIX system has historically kept in a world-readable file. An authority MAY nonetheless restrict individual fields (§2.16), and the request names the fields it wants precisely so that it can.

2.13.5 Not in this version #

Privileges, integrity levels, owner and default DACL are not returned by any message here. They are not identity: nothing stores them, and an authority computes them from local policy at the moment it derives a token (§2.1).

They are, however, deliberately reserved rather than excluded. An authority that cannot be asked what a policy would produce can only be verified by signing someone in and observing the result. A future revision is expected to add a distinct request of the shape:

Evaluate { key, logon_type } -> { privileges, integrity, owner, default_dacl }

— a token that is derived and then discarded. It is parameterised by logon type because the answer genuinely depends on it: an authority adds SIDs reflecting how a principal signed in (§2.16), so "what privileges does this principal have" has no single answer.

A revision MUST NOT instead add privileges or integrity as fields of Lookup (§2.16). A field of a record describes something a source holds; this does not.

2.14 The Identity Channel

Peios / Peios technical reference / PGSS / Logon

2.14.1 Socket #

An authority MUST listen on a SOCK_STREAM Unix domain socket at:

/run/ident.sock

The path is normative, for the same reason /run/logon.sock is: a name resolver linked into every process on the system cannot be asked to find it by configuration.

An authority MUST listen on both sockets. Offering one without the other is not conformance — a system whose principals cannot be named is as broken as one whose principals cannot sign in.

2.14.2 Access control #

The socket MUST carry a security descriptor granting connect access to every principal that runs ordinary programs.

This is the opposite posture to /run/logon.sock, and deliberately so. A descriptor that withheld connect access would not protect anything: it would make ls -l print numbers for the principals it excluded, while a principal who can connect learns the same names either way. Restriction, where an authority wants it, belongs on individual fields (§2.16) — not on reaching the socket at all.

2.14.3 Framing #

Messages use the header, magic, version, size limit and body extensibility rules of §2.6 without modification. The message types served here are disjoint from those of the logon socket (§2.A).

A message of one socket's range arriving on the other MUST be refused. An authority MUST NOT serve a Lookup received on /run/logon.sock, and MUST NOT serve a LogonStart received on /run/ident.sock.

Note

Sharing the magic is what makes that a clean refusal rather than a parse failure. A peer connected to the wrong socket gets "this message is not served here" from byte six, instead of discovering the mistake several fields into a structure it has misread.

2.14.4 Multiplexing #

A connection carries any number of requests. A client MAY have more than one outstanding at a time, and an authority MAY answer them in any order.

Every request carries a tag, a u32 chosen by the client, and its reply carries the same value. The tag is in the message body, not the header, so that the header of §2.6 is unchanged.

A client MUST NOT reuse a tag while a request bearing it is outstanding. An authority MUST echo the tag it received and MUST NOT interpret it otherwise.

A client MUST NOT assume replies arrive in request order. An authority that answers strictly in order is conformant; a client that depends on it is not.

Note

The contrast with §2.5 is deliberate rather than inconsistent. A logon connection exists for one logon and is discarded, so multiplexing would buy nothing and a correlation identifier would only add something to forge. A lookup connection is opened by a name resolver that may issue thousands of requests, and the identifier costs four bytes.

2.14.5 Concurrency and bounds #

An authority MUST serve connections concurrently, and MUST NOT let a slow answer on one connection delay answers on another.

An authority MUST bound the number of connections it will accept and the number of requests it will hold outstanding per connection, and SHOULD refuse further requests on a connection that exceeds the second rather than closing it.

An authority MUST NOT wait indefinitely for a source. A request that cannot be answered within the authority's own bound MUST be answered Unavailable (§2.18). The bound is on the request, not on each source consulted: an authority that walks several sources in turn MUST NOT let the total exceed what it would have allowed one.

2.14.6 No descriptor passing #

Nothing is transferred over this channel by descriptor. An authority MUST ignore ancillary data received here.

2.14.7 Peer identity #

An authority that restricts any field (§2.16) MUST establish peer identity from the connected socket's peer token, as §2.4 requires, and MUST NOT take it from a message body.

An authority that restricts no field MAY skip establishing peer identity, since it would make no decision with it.

2.15 Names and Resolution

Peios / Peios technical reference / PGSS / Logon

2.15.1 The name is opaque to the client #

A name is carried as a single string. A client MUST forward what it was given, unchanged, and MUST NOT parse, split, qualify, case-fold, or otherwise interpret it.

All interpretation is the authority's.

This is the load-bearing rule of the section. If a client resolved part of a name itself — chose which source to consult, or which of two candidates wins — then that policy would live in every client separately, and a native tool could resolve jack to a different principal than a POSIX name resolver on the same machine. Two components disagreeing about who a name refers to is not a display inconsistency; it is a program acting on one principal's behalf while checking another's access.

One authority means one answer, and it means a name resolves identically here and in a logon (§2.7), because the same resolution serves both.

2.15.2 Comparison #

Name comparison is ASCII case-insensitive.

This is normative rather than each source's own choice, because a system may have more than one source and they must not disagree about whether JACK is jack. An authority MUST establish the comparison for itself rather than delegate it to whatever a source happens to do.

2.15.3 Reserved characters #

A principal or group name MUST NOT contain any of:

CharacterReserved for
@Qualified names (below)
\Qualified names, in the form some callers will type
/Path separation — a name reaches a filesystem as a home directory
:Field separation in POSIX passwd and group records
,Member and subfield separation in POSIX group and GECOS records

A name MUST NOT contain a byte outside the printable ASCII range 0x20–0x7e, and MUST NOT begin or end with a space.

An authority MUST refuse to create a name violating these rules, MUST refuse one it is asked to resolve, and MUST refuse one asserted by a source. The third is the one that matters: a name reaching a caller from a source has crossed a boundary the other two never did, and it is the one that ends up in a passwd-format record, an audit line, or a log.

The excluded control characters are the record separators. A name carrying a newline could forge a whole line in a passwd-format file, an audit record, or a log — and the damage is done by the reader, so it cannot be prevented at the point the name is displayed.

The rules apply to every name an authority emits, including the names carried alongside SIDs in references (§2.16), not only to the name a request was keyed on.

Note

The ASCII restriction is not an assumption that names are English. It is a decision to defer confusable and normalisation handling rather than get them wrong: jack spelled with a Cyrillic а renders identically to jack and is a different principal, and the same name in NFC and NFD is two byte sequences that a comparison will call two people. Relaxing this later is backward compatible; tightening it later would mean renaming principals that already exist.

2.15.4 Qualified names #

A name MAY be qualified with a realm, written name@realm.

No realm syntax is defined in this version. An authority MUST refuse a name containing @, and the character is reserved so that a principal literally named jack@local cannot come into existence before the syntax does.

Nothing here changes when realms arrive: a qualified name is a different string in the same field, parsed by the same authority. That is why the field is one opaque string rather than a structured pair — a structure would have put the parsing in the client, which the first rule of this section forbids.

2.15.5 Search order #

A bare name is resolved by consulting sources in an order that is local configuration of the authority.

An authority MUST resolve in a configured order, MUST NOT derive that order from the order in which sources registered, and MUST stop at the first source that answers.

Note

Registration order is whatever the boot happened to do. Making it decide who jack is would mean a slow disk could change which principal a name refers to.

2.15.6 Answers an authority holds itself #

An authority MAY answer from its own knowledge, without consulting any source. Well-known principals and groups — those whose SIDs are fixed by the system rather than issued by anybody — are the ordinary case: their names, and, for those whose membership is a rule, the fact that nothing records it (§2.16), are properties of the system. Who is in a BUILTIN group is not: it is recorded by the sources, and asked of them (§2.16).

Such an answer is subject to every rule in this section. In particular an authority MUST apply the reserved-character and comparison rules to it, and MUST NOT return an answer for a name it would have refused.

An answer of this kind is not an outage and does not make a reply Unavailable, whatever the state of the configured sources. It is also not a source: an authority MUST NOT report it in the incomplete list of an enumeration (§2.17), and MUST NOT count it as a source having answered for the purpose of §2.18.

2.15.7 Bare in, qualified out #

Every successful reply carries the resolved SID and the canonical qualified name, whatever form the request used (§2.16).

A caller that looked up a bare name can therefore always tell which principal it got, compare two answers for identity, and record an unambiguous name in a log. Ambiguity is permitted in what a caller may ask; it is not permitted in what an authority answers.

Where no realm syntax exists, a canonical qualified name is indistinguishable from a bare one, and the SID alongside it is what carries the unambiguity. A client MUST NOT rely on the two being distinguishable, in either direction: it MUST NOT assume a returned name is unqualified, and MUST NOT treat one that is as a failure to canonicalise.

2.15.8 Shadowing #

Adding a source ahead of another in the search order changes which principal a bare name resolves to. The new principal has a different SID, so every security descriptor naming the old one stops applying to the person now signing in under that name.

This is inherent to a flat namespace and this chapter does not forbid it. An authority SHOULD detect it at logon — where the cost is one additional query per sign-in rather than one per lookup — and SHOULD record that a bare name resolved while another source could also have answered.

An authority MUST NOT fail a logon because it could not complete that check. A source being unreachable is not a reason to refuse a principal whose own source answered.

2.16 Lookup

Peios / Peios technical reference / PGSS / Logon

One request answers every question a name resolver asks.

2.16.1 Lookup #

msg_type = 0x0010. Client to authority.

FieldEncodingLimit
tagu32§2.14
key_typeu8§2.B
namestring256 bytes
sidlength-framed bytes (SID)68 bytes
unix_idu32
kindu8§2.B
fieldsu32§2.B

Exactly one of name, sid and unix_id is meaningful, selected by key_type. An encoder MUST leave the others empty or zero, and a decoder MUST ignore them.

2.16.1.1 key_type #

ValueNameAnswers
1Namegetpwnam, getgrnam
2SidRendering a security descriptor
3UnixIdgetpwuid, getgrgid

UnixId is a key even though no source is ever asked one. An authority converts the number to a source and a relative identifier by the range arithmetic it assigned, and asks that source by relative identifier or by SID.

A source therefore never receives an absolute number here, exactly as it never receives one during a logon. The property that made the authority the only possible answerer (§2.13) is the same property that keeps this side of it confined.

2.16.1.2 kind #

ValueName
0Any
1Principal
2Group

A request for Principal MUST NOT be answered with a group, and a request for Group MUST NOT be answered with a principal. Where the key matches only an object of the other kind, the outcome is NotFound (§2.18).

An authority MUST establish this for itself. Where the object came from a source, the authority MUST check the kind it received against the kind that was asked for, rather than relaying the source's answer and letting the client discover the mismatch.

Note

POSIX keeps users and groups in separate namespaces, so getpwnam and getgrnam can be asked the same string and expect different objects. Carrying the kind on the request is what lets both be served correctly without requiring an authority to forbid the collision.

2.16.1.3 fields #

A bitmask of the attributes the reply should carry. Identity is not among them: every successful reply carries the SID, the canonical qualified name, and the kind actually found, and a client cannot decline them.

BitNameValue encoding
0UNIX_IDu32
1PRIMARY_GROUPreference
2HOMEstring, 4096 bytes
3SHELLstring, 4096 bytes
4DISPLAY_NAMEstring, 256 bytes
5GROUPSarray of references, 128
6MEMBERSarray of references, 256
7CLAIMSarray of claim entries, 64
8ENABLEDu8
9LOGON_TYPESu32
10DESCRIPTIONstring, 1024 bytes

An authority MUST ignore a bit it does not implement, and MUST NOT report it (below). Claim entries use the claim attribute format PCDS §5.9 specifies.

LOGON_TYPES is a bitmask over the logon type values of §2.B: bit n set means the principal may be used for logon type n. Zero means the holder states nothing, not that nothing is permitted — a source that predates the field sends zero, and an authority that read that as "no logon permitted" would lock every principal on every existing system out at once.

An authority substituting a default for zero MUST NOT include Service in it. A principal that has never said how it may be used has certainly not said it may be used for a credential-free service logon (§2.19).

DESCRIPTION says what a group is for, in words for a person to read, on one line. A group with none is answered with an empty string: it has no description, which is an answer. A principal has none, and the field is withheld as Absent (below). An authority answers it for its own well-known groups as well as relaying a source's.

Note

Field bits are the one place in this chapter where an enumeration may grow without a version bump (§2.6). It is safe here, and only here, because the reply says which fields it is answering: an older authority ignoring a newer bit is reported as not implementing it, rather than silently omitting something a client believed it had asked for.

Requesting only what is wanted is not primarily a bandwidth measure — a getpwuid wants nearly every field anyway. It matters for the caller that resolves a dozen SIDs to render a security descriptor and wants only names, and it is the seam at which an authority can restrict individual fields without restricting the socket (§2.14).

2.16.2 LookupReply #

msg_type = 0x8010. Authority to client.

FieldEncodingLimit
tagu32§2.14
outcomeu8§2.B
sidlength-framed bytes (SID)68 bytes
qualified_namestring512 bytes
kind_foundu8§2.B
presentu32§2.B
withheldarray of withheld entries32
valuesarray of length-framed values32

Where outcome is not Found, everything after it MUST be empty or zero, and a client MUST NOT read it.

kind_found MUST be Principal or Group, never Any.

2.16.2.1 present, withheld and values #

values holds one length-framed value for each bit set in present, in ascending bit order. Each is length-framed so that a client can step over a value whose field it does not recognise.

Every bit set in the request is in exactly one of three states:

  • set in present — its value is in values;
  • listed in withheld — with a reason, below;
  • in neither — this authority does not implement the field.

The third state is a statement about the authority, not about the object. An authority that implements a field MUST place every request for it in one of the first two states, whatever happened underneath: a field an authority supports but could not obtain is Absent, Declined, Restricted or TooLarge, never silence. Reporting it as neither would tell the client the authority cannot answer that question at all, and a client is entitled to stop asking.

A withheld entry is a length-framed structure:

FieldEncodingLimit
fieldu32one bit, §2.B
reasonu8§2.B
ReasonMeaning
AbsentThe field has no value.
RestrictedThe caller may not have this field.
DeclinedThe source will not produce it.
TooLargeIt exists and exceeds one reply. Use Enumerate (§2.17).

Distinguishing these is the point of the structure. An empty member list and a source that refuses to enumerate members are the same bytes to a POSIX caller, and an administrator diagnosing a system needs to know which one happened.

2.16.2.2 References #

PRIMARY_GROUP, GROUPS and MEMBERS carry references rather than bare SIDs:

FieldEncodingLimit
sidlength-framed bytes (SID)68 bytes
namestring512 bytes
unix_idu32

An empty name means the authority has no name for that SID; a unix_id of zero means it has no number for it. Both are ordinary answers for a SID belonging to no source on this machine.

Zero is the only encoding of "no number". An authority MUST NOT substitute a POSIX identifier that a caller could mistake for a real one — a projection onto nobody is a rendering decision belonging to whatever produces a passwd record, and putting it on the wire destroys the distinction the client needs in order to make it.

Carrying the name is what keeps the round-trip discipline below intact. A reply of bare SIDs would make a single getgrnam into one request plus one per member.

2.16.3 Memberships #

GROUPS on a principal is the membership question, and it is the direction sources actually hold — the same one a logon uses. An authority MUST answer it whenever it can answer anything about the principal at all.

MEMBERS on a group is the reverse index, and it is not owed the same guarantee.

An authority MUST return MEMBERS as withheld, rather than as an error or an empty list, when it will not or cannot produce it:

  • Declined where the source will not enumerate the group's members.
  • TooLarge where the membership exceeds what one reply can carry.
  • Absent where the group is not one that has recorded members at all.

That last case is not a limitation. A group's membership may be recorded — held by a source, as a local group's is — or it may be a rule an authority applies when it derives a token. Nothing records who belongs to Everyone; an authority adds it to every token it mints. Groups reflecting how a principal signed in are further still from a recorded membership: they are properties of a logon rather than of a principal, and the same principal is in one at a console and not over a network.

An authority MUST report Absent for such a group rather than manufacturing a list, and MUST NOT report Declined, which would suggest an answer exists somewhere.

2.16.3.1 The BUILTIN groups #

The BUILTIN groups (S-1-5-32-…, such as Administrators, Users and Guests) are well-known, and the authority answers for them itself (§2.15), but their membership is recorded, not a rule. "jack is in Administrators" is a record a source keeps on this machine's behalf (PSPU §2.19), as it keeps a local group's.

For MEMBERS on a BUILTIN group, an authority MUST therefore gather the members each source records, from every source in its search order that is permitted to assert memberships outside its domain (PSPU §2.19), and MUST NOT report Absent. A source without that permission records none that a token would carry, and is not asked.

  • Where every such source answered and the list fits one reply, the members are present, in source order.
  • Where it will not fit, MEMBERS is withheld as TooLarge, and the members are walked with Enumerate (§2.17).
  • Where a source in the order could not be reached, or would not enumerate, MEMBERS is withheld as Declined. A list missing one source's members would look complete.

Note

This asymmetry is deliberate and follows the shape of the data. "Which groups is this principal in" is answered by every source cheaply, because it is how memberships are stored. "Who is in this group" needs an index in the other direction, over a set that may be the whole of an organisation — historically the reliable way to make a directory server fall over from a filesystem listing.

2.16.4 One round trip #

An authority MUST be able to answer each of the following in a single request:

Caller wantsRequest
A passwd recordLookup{key_type: UnixId or Name, kind: Principal, fields: UNIX_ID | PRIMARY_GROUP | HOME | SHELL | DISPLAY_NAME}
A group recordLookup{key_type: UnixId or Name, kind: Group, fields: UNIX_ID | MEMBERS}
A principal's groupsLookup{key_type: Name, kind: Principal, fields: GROUPS}
A name for a SIDLookup{key_type: Sid, kind: Any, fields: 0}

This is a design constraint on future revisions as much as a statement about this one. A name resolver is called from every process on the system, synchronously, and a field that cannot be fetched alongside the record it belongs to turns one lookup into several.

2.17 Enumeration

Peios / Peios technical reference / PGSS / Logon

Lookup answers about one object that a caller can already name. Enumeration answers when it cannot: walking every principal on the system, or every member of a group too large for one reply.

2.17.1 Enumerate #

msg_type = 0x0011. Client to authority.

FieldEncodingLimit
tagu32§2.14
kindu8§2.B
fieldsu32§2.B
of_key_typeu8§2.B
of_namestring256 bytes
of_sidlength-framed bytes (SID)68 bytes
of_unix_idu32
cursorlength-framed bytes256 bytes

kind MUST NOT be Any. A caller enumerating is filling a passwd or a group table, and the two are separate.

2.17.1.1 of #

Where of_key_type is zero, the request enumerates every object of kind that the authority knows.

Where it is non-zero, the named object MUST be a group, and the request enumerates that group's members. This is the continuation path for a MEMBERS field withheld as TooLarge (§2.16): the same answer, paged. An authority that withholds MEMBERS as TooLarge MUST serve this mode, since there is otherwise no way to obtain what it said existed.

A group's members are asked of the one source that holds the group. A BUILTIN group is no source's, and its members are walked across every source that may record them, in search order, as the whole table is (§2.16); a source in the order that did not contribute is reported in incomplete. A well-known group whose membership is a rule has no members to walk, and is NotFound here.

Note

Members are a field of Lookup for the common case and a mode of Enumerate for the large one, rather than a message of their own. The field keeps getgrnam to one round trip where the group is small, which is nearly always; this keeps the large case correct without a third request type or a partial member list, which would be worse than no list at all.

2.17.1.2 cursor #

Empty on the first request. On a continuation it MUST be the next returned by the immediately preceding reply.

A cursor is opaque. A client MUST NOT construct, parse or modify one, and MUST NOT present one to a different authority or after reconnecting.

An authority MUST reject a cursor it did not issue, or one it can no longer honour, with Malformed (§2.18). It MUST NOT silently restart the enumeration, and MUST NOT answer with an empty page and an empty next: the first hands the caller a second copy of the beginning under the impression it is continuing, and the second reports a truncated walk as a complete one.

2.17.2 EnumerateReply #

msg_type = 0x8011. Authority to client.

FieldEncodingLimit
tagu32§2.14
outcomeu8§2.B
entriesarray of entries256
nextlength-framed bytes256 bytes
incompletearray of strings32

An entry is a length-framed structure carrying the same fields as a successful LookupReply (§2.16), from sid through values.

An empty next means the enumeration is complete. A non-empty next means there is more, even if entries was empty — an authority may return a short or empty page while working through a source.

A client MUST continue until next is empty. A client MUST NOT infer completion from an empty page.

A client MUST NOT report an enumeration as complete when it stopped for any other reason. A transport failure, a timeout, or an outcome other than Found mid-walk is a truncated enumeration, and a client that presents it to its caller as the end of the list has manufactured an empty system out of an outage — the same error §2.18 forbids on a single lookup, at a scale where nothing records that it happened.

2.17.3 incomplete #

Each string names a source that did not contribute: because it declined to enumerate, or because it could not be reached.

An authority MUST list every such source. A client displaying an enumeration SHOULD say that it is partial, and MUST NOT discard the list without doing so.

Note

A short list that looks complete is the failure this field exists to prevent. An administrator reading an account listing has no way to tell a machine with four principals from a machine whose directory did not answer, and the difference decides whether they are looking at the whole picture.

2.17.4 No completeness guarantee #

An authority MUST NOT be required to enumerate.

A source may hold more principals than a reply, a page, or an administrator's patience can carry, and a source backed by a remote directory may be able to answer any single question while being quite unable to answer all of them. incomplete is the honest outcome, not a degraded one.

A caller MUST NOT treat enumeration as a way to test whether a principal exists. Lookup answers that, exactly, at any scale.

2.18 Outcomes

Peios / Peios technical reference / PGSS / Logon

Every reply on the identity channel carries an outcome.

ValueNameMeaning
1FoundThe request was answered.
2NotFoundNo such object, and every source that could have said so was asked.
3UnavailableA source that could have answered did not.
4RefusedThe caller may not make this request.
5MalformedThe request could not be understood.

Unlike the denial codes of §2.10, these are not a security boundary. NotFound reveals that a name is unused, which is what a name lookup is for.

An EnumerateReply carries an outcome on the same terms. An authority MUST NOT report Found on a page it could not produce.

2.18.1 NotFound and Unavailable #

If any source that could have answered was unavailable, the outcome is Unavailable — even if every source that did answer said no.

This is the most important rule in this part of the chapter, and it is stated normatively rather than left to implementations because it is invisible when wrong.

An authority is expected to cache. A cache that is told NotFound will store an absence, and if that absence was really a source being unreachable, it has memoised an outage as a fact. The account comes back when the source does; the cached answer does not. A principal is then unable to sign in, or a file is shown as owned by a number, for as long as the entry lives — with nothing in the system still recording that anything failed.

Unavailable is not cacheable, and that is the whole of the difference.

An authority MUST NOT report NotFound unless every source in the search order (§2.15) that could have answered was consulted and answered. A source that does not serve lookups at all is not such a source and does not need to be asked; a source that does, and could not be reached, is.

Note

The rule binds even where an authority does not cache, because its clients may. A name resolver is entitled to remember an answer for the length of a process, and it can only do that safely if the two are distinguishable.

2.18.2 Unavailable and the search order #

An authority MUST consult sources in the configured order and MUST stop at the first that answers, so an unreachable source later in the order does not make an answer Unavailable. It was never going to be asked.

An unreachable source earlier in the order does, even if a later one holds a matching name — because the earlier source is the one whose answer would have won.

Note

Returning the later source's principal in that situation would be worse than failing. The name would resolve to a different SID than it does when the system is healthy, so access decisions would be made against the wrong principal precisely while something is broken.

2.18.3 Refused #

Reserved. No field in this version is restricted by default, and an authority that restricts none will never send it.

It exists because the field mask (§2.16) is where restriction belongs, and a request refused in its entirety needs an answer that is not NotFound. An authority restricting a field MUST use the Restricted withheld reason instead, and MUST still answer the rest of the request.

Because it is reserved, Refused MUST NOT be used for anything else. A source that will not answer, a mode an authority has not implemented, or a question it cannot serve are not the caller lacking permission, and reporting them as Refused tells a client to stop asking on behalf of this caller when the answer would be the same for every caller. Those outcomes are Unavailable, Absent, or Malformed as the case requires.

An authority MUST NOT use NotFound in place of Refused or of a restricted field. Concealing a principal's existence from a caller that may not read their shell protects nothing — the SID is already in the file listing that prompted the lookup — and would make an authorization decision indistinguishable from an empty system.

2.18.4 Timing #

The prohibition in §2.10 on distinguishing an unknown principal from a bad credential does not apply here. There is no credential, and NotFound is an ordinary answer that this chapter states plainly.

An authority MUST NOT allow the presence of an answer in a cache to be observable to a caller that would be refused the answer itself. Where no field is restricted, nothing is refused, and the requirement is vacuous.

2.19 Service attestation

Peios / Peios technical reference / PGSS / Logon

msg_type = 0x0020. Client to authority, on /run/logon.sock. A complete request in one message; the authority answers with AccessGranted (§2.9) or AccessDenied (§2.10) and nothing else follows.

FieldEncodingLimit
identitystring128
servicestring256

A service has no human to prompt and no secret to present. This is the message by which the component that launches services obtains a token for one, and the whole of this section is about the conditions under which an authority may answer it.

2.19.1 A service identity MUST NOT have a credential #

An authority MUST NOT hold, store, or accept a credential for a service identity, and MUST NOT offer a credential exchange for one.

This is not a convenience. A machine that could authenticate its own services unattended would have to hold their secrets, so a credential for a service identity implies a store of service credentials on every machine that runs one — a structure that is read by anything which compromises the machine, and which grants whatever those identities were given. The requirement exists to make that structure impossible rather than to make it careful.

What replaces the credential is attestation: the authority satisfies itself about who is asking, and derives everything else.

2.19.2 Why this is not a LogonStart #

A LogonStart (§2.7) followed by no credentials would encode the same request. It is a separate message because the difference is structural rather than cosmetic.

Accepting a credential-free LogonStart creates a path through an authority's conversation handling on which zero credentials succeeds. The only thing then standing between an arbitrary principal and a credential-free token is the authorisation check below being correct — so a defect in that check is a complete authentication bypass rather than a defect.

With a distinct message, the credential-free path is reachable only by a message that carries no principal an authority would accept. The check remains necessary; it stops being the only thing.

An authority MUST reject a LogonStart that completes without a credential exchange for any identity it would accept here, and MUST reject a ServiceAttest naming an identity it would accept there.

2.19.3 Who may send it #

An authority MUST refuse a ServiceAttest unless both of the following hold of the connected peer, each established from the connection itself and never from the message:

  1. The peer is a principal the authority permits to attest — this standard does not fix which, and an implementation SHOULD keep the set as small as one.
  2. The peer is the process the platform designates as its service manager, established by a property of the connection that no other process can present.

Neither is sufficient alone, and an authority that checks only the first has a much weaker rule than it appears to. On a system where platform components share one identity — which is usual — the first condition alone admits every daemon on the machine, so compromising any one of them yields a token for any service identity the authority would mint.

An authority MUST answer a refusal with PermissionDenied.

Note

On Peios the two facts are that the peer's token names SYSTEM, and that the peer is PID 1. The second is sound there for a reason specific to PID 1 — it cannot exit and be replaced while the system runs, and the credentials are captured by the kernel when the connection is made — and that reasoning does not extend to any other process identifier.

2.19.4 What the authority may attest #

An authority MUST NOT mint a token under this message for a principal that has not been designated as usable for a service logon.

The designation is a property of the principal, held wherever that principal is held, and it is what keeps this message from being an oracle that yields a credential-free token for anybody on the machine. An authority that cannot determine whether a principal carries it MUST refuse with AccountRestricted.

Identities the authority defines for itself — the platform's own service identities, which no principal source holds and for which no credential could exist — are designated by construction.

2.19.4.1 Peios virtual service accounts #

On Peios, identity = "Service" (case-insensitive) selects a distinct virtual account whose user SID is the service SID derived from service. It uses the same policy evaluation and Service logon groups as other attestations, without LocalService or SYSTEM membership. The peer checks above precede this selection; no credential-bearing logon is added. LocalService and NetworkService remain available as shared account identities: their user SIDs are S-1-5-19 and S-1-5-20, respectively, while the derived per-service SID is a group. For Service, that per-service SID is both user and group. The derived groups are Everyone, Authenticated Users, Local and Service, plus the kernel’s session-specific logon SID. Local policy determines privileges and integrity for the resulting token.

A refusal here MUST NOT distinguish "no such principal" from "not designated for service logon", for the reason given in §2.10.

2.19.5 service, and what the authority cannot check #

service names the service being launched. The authority uses it to derive the per-service identifier it stamps on the token, which is what keeps two services running under the same identity distinguishable to an access decision.

An authority cannot verify it. Elsewhere in this standard a peer's claims are checked against the connection: an authority establishes who is calling rather than believing what it is told. That is not available here, because the process this request is about does not exist yet — there is no token to interrogate and nothing to compare the name against.

So service is taken on the peer's word. This is a genuine grant of trust and it is not removable: the service manager holds the service definitions, launches the process, and is the only component in a position to know which service it is launching. It is the reason the set of peers permitted to send this message is required to be as narrow as it is.

An authority MUST reject an empty or unusable service, with MalformedRequest. An empty name derives a well-formed identifier that names nothing and that every service making the same mistake would share.

2.19.6 The token #

Answered with AccessGranted (§2.9), carrying the token as a descriptor under the same rules as any other logon, and with the logon type recorded as Service.

The profile structure SHOULD be empty. A service has no home directory, no shell and no display name, and an authority that has nothing to say MUST leave the fields empty rather than invent values.

2.19.6.1 The token is weaker, and MUST be #

A token issued on attestation MUST NOT be usable to authenticate to another system on this authority's behalf.

The evidence behind it does not travel. A credential a principal source verified is evidence a second system could in principle have checked too; a local process's word is evidence on one machine only, and a token forwarded off the machine on the strength of it would let one system's service manager act as an arbitrary account on another.

Where a platform expresses this as a level on the token, the level an attested logon receives MUST be below the level a credentialled logon receives, and MUST bound everything derived from that token.

An authority MUST record, in whatever it records for a credentialled logon, that this logon was attested rather than authenticated. An audit trail that cannot tell the two apart cannot answer which authority vouched for a session.

2.20 Credential Change

Peios / Peios technical reference / PGSS / Logon

A principal changes their own credential by opening a conversation on /run/logon.sock with CredentialChangeStart. The authority asks for what it requires, in the rounds of §2.8, and ends the conversation with CredentialChanged or AccessDenied (§2.10).

client                                   authority
  |                                          |
  |-------- CredentialChangeStart ---------->|   principal = the peer
  |                                          |
  |<--------- CredentialRequest -------------|   the current credential
  |---------- CredentialResponse ----------->|
  |                                          |
  |<--------- CredentialRequest -------------|   the new one
  |---------- CredentialResponse ----------->|
  |                                          |
  |<-- CredentialChanged --------------------|   terminal
  |         or AccessDenied                  |

Adding a credential beside the ones a principal has, such as an SSH public key, or removing one, is the sibling conversation of §2.23.

Everything §2.3 says about the shape of a conversation applies unchanged: one conversation per connection, bounded rounds, exactly one terminal message. The client stays generic here as it does in a logon. It renders the prompts it is given and returns the answers, and it does not know what is being changed or how.

2.20.1 CredentialChangeStart #

msg_type = 0x0030. Client to authority. Opens the conversation; MUST be the first message.

FieldEncodingLimit
supported_credential_typeslength-framed bytes, one u8 per type32

supported_credential_types carries the meaning and the capability rule of the §2.7 field of the same name. It binds the authority, and a decoder MUST drop a value it does not recognise rather than refuse the message.

Unlike the §2.7 field, it is not optional. This message is newer than the rule that made that field optional, so there is no older client whose silence needs a default. An authority MUST refuse a CredentialChangeStart that ends before the field with MalformedRequest.

2.20.2 Whose credential #

The principal whose credential changes is the user of the connected peer's token, established from the socket as §2.4 requires. The message carries no identifier, and an authority MUST NOT change the credential of any other principal in this conversation.

The absence of an identifier is deliberate. A field naming a principal would let anything that can reach the socket ask to change anybody's credential, with only knowledge of that credential in the way. That is a guessing surface over every account the authority holds, open to every principal the socket admits. Taking the principal from the token confines each caller to their own account.

An authority MUST refuse, with AccountRestricted, a change for a principal it holds no changeable credential for. That includes a service identity (§2.19), a platform identity no source holds, and a principal that authenticates with no credential at all.

2.20.3 Proof of the current credential #

A token is not proof enough. An authority MUST establish, within the conversation, that the principal can present their current credential before it changes that credential, and MUST refuse with AuthenticationFailed where they cannot.

A token shows that someone signed in. It does not show that the person at the keyboard now is that person. An unattended terminal holds a token too, and a credential changed from one cannot be recovered by signing in again, because the credential needed to sign in is the one that changed.

AuthenticationFailed keeps the wording rule of §2.10 here: reason MUST NOT say what was wrong with the credential. The caller cannot use the distinction to learn about another account, since only their own is reachable, but a reason that varied would still tell a guesser which part of a guess was right.

2.20.4 The new credential #

What the authority asks for, and what it accepts, is its own policy. An authority that refuses a proposed credential MAY say why in a message of Error severity (§2.8) and ask again, within its round limit. An authority that gives up MUST end the conversation with ConversationLimit.

The new credential is credential material like any other. It is collected and handled under §2.12, and the client neither knows nor cares that it is new.

2.20.5 CredentialChanged #

msg_type = 0x8030. Authority to client. The successful terminal message; nothing follows it.

It has no fields and no ancillary data. A change mints no token and creates no session: the caller already holds a session, and the change leaves it as it was. A client MUST NOT expect a descriptor with this message, and MUST close one if one arrives.

An authority MUST NOT send CredentialChanged until the new credential is the one a subsequent logon will be tested against. A principal told about a change that is not yet durable can lose it, and finds out only when the old credential works and the new one does not.

What a change does to sessions already established is the authority's policy. This chapter specifies none.

2.20.6 Access to the socket #

Every principal permitted to change their own credential needs connect access to /run/logon.sock (§2.5), and most of them may not originate a logon. The socket's descriptor therefore admits both kinds of peer and cannot tell them apart. An authority whose descriptor admits a peer that may not originate logons MUST decide, from each peer's identity (§2.4), whether that peer may originate one. It MUST NOT treat reaching the socket as that permission.

The two kinds of peer SHOULD NOT share one bound on concurrent conversations (§2.5). A principal who can reach the socket to change a password can also open connections and hold them open, and a shared bound they could exhaust would stop the console signing anyone in.

2.20.7 An authority that does not offer it #

An authority MAY decline to offer credential change. One that declines MUST refuse CredentialChangeStart with PermissionDenied, and is conforming.

2.21 Conformance

Peios / Peios technical reference / PGSS / Logon

A conforming implementation MUST satisfy every requirement in this chapter. This section collects them by role.

2.21.1 Authority obligations #

An implementation claiming to be a PGSS Logon authority MUST satisfy all of the following.

2.21.1.1 Channel #

  1. Listen on /run/logon.sock as a SOCK_STREAM Unix domain socket (§2.5).
  2. Control access to it with a security descriptor, not with process integrity and not with POSIX permission bits (§2.4).
  3. Serve conversations concurrently, so that one stalled logon does not block others (§2.5).
  4. Support SCM_RIGHTS on the channel (§2.9).

2.21.1.2 Framing #

  1. Reject any message whose magic is not PGSL, as a whole-connection failure (§2.6).
  2. Reject any message whose version it does not implement, answering UnsupportedVersion where it can still encode a denial (§2.6).
  3. Reject any message declaring more than 65536 bytes, or fewer than a header, without reading the body (§2.6).
  4. Skip to a structure's declared end after reading known fields, rather than assuming its length, and substitute a documented default for an optional trailing field a peer did not write (§2.6).

2.21.1.3 Conversation #

  1. Require LogonStart, CredentialChangeStart, CredentialEnrollStart, SessionEnd or SessionEndQuery as the first message, and reject a conversation opening otherwise (§2.3).
  2. Send exactly one terminal message, nothing after it, and close the connection (§2.3).
  3. Bound the number of rounds, the time spent awaiting an answer, and the time a conversation may remain open, and terminate with ConversationLimit on exhausting any of them (§2.3, §2.5). A bound reached MUST produce a terminal message; closing the connection silently is what the code exists to prevent.
  4. Reject a CredentialResponse it did not solicit (§2.3).
  5. Match answers to prompts by credential_ref, never by position, and treat a missing answer as a failed logon rather than as grounds to tear down the connection (§2.8).

2.21.1.4 Trust #

  1. Establish peer identity from the connected socket's peer token, never from a message body, and never via SO_PEERCRED (§2.4).
  2. Treat logon_type as a proposal and constrain it against the verified peer (§2.4).
  3. Treat identifier, tty and remote_host as unverified claims (§2.4).

2.21.1.5 Derivation #

  1. Perform derivation itself, and never accept a token, privilege set or integrity level from another party (§2.1).
  2. Never fork or exec on the client's behalf; never learn about terminals, environments or session leadership (§2.1).

2.21.1.6 Prompting #

  1. Never send a prompt for a credential type absent from the client's supported_credential_types, and deny the logon instead if it cannot proceed within them (§2.8).
  2. Ensure credential_ref is unique among the prompts of one request (§2.8).

2.21.1.7 Result #

  1. Transfer the token as a file descriptor in the ancillary data of AccessGranted, never by name (§2.9).
  2. Never distinguish an unknown principal from a bad credential — by denial code, by reason text, or by timing (§2.10, §2.12).
  3. Send home and shell as absolute paths, or empty, whatever their provenance; an authority with no value for a profile field MUST leave it empty rather than invent one (§2.9).

2.21.1.8 Credential handling #

  1. Erase credential material, and every buffer it was encoded and decoded through — including an allocation abandoned by a buffer that grew — before that memory is released (§2.12).
  2. Never write credential material to a log, audit record, error message or diagnostic (§2.12).

2.21.1.9 Identity lookup #

  1. Listen on /run/ident.sock as well as /run/logon.sock, as a SOCK_STREAM Unix domain socket (§2.14).
  2. Grant connect access to that socket, by security descriptor, to every principal that runs ordinary programs (§2.14).
  3. Refuse a message of one socket's range received on the other (§2.14).
  4. Echo each request's tag, and never interpret it otherwise (§2.14).
  5. Serve requests concurrently, and never let a slow answer on one connection delay another (§2.14).
  6. Bound the time spent answering a request, and answer Unavailable rather than waiting indefinitely (§2.14).
  7. Ignore ancillary data received on the identity socket (§2.14).

2.21.1.10 Resolution #

  1. Interpret names itself, and never require a client to parse, qualify or case-fold one (§2.15).
  2. Compare names ASCII case-insensitively, establishing the comparison itself rather than delegating it to a source (§2.15).
  3. Refuse a name containing a reserved character, a byte outside 0x20–0x7e, or a leading or trailing space — whether created locally, received in a request, asserted by a source, or carried alongside a SID in a reference (§2.15).
  4. Resolve bare names in a configured order, never in registration order, stopping at the first source that answers (§2.15).
  5. Carry the resolved SID and the canonical qualified name on every successful reply, whatever form the request used (§2.15).

2.21.1.11 Answers #

  1. Never answer a Principal request with a group, or a Group request with a principal, and establish the kind itself rather than relaying a source's (§2.16).
  2. Ignore a field bit it does not implement, and never report it as present (§2.16).
  3. Report each requested field it implements as present or as withheld with a reason, never as neither (§2.16).
  4. Withhold MEMBERS with a reason rather than returning a partial or empty list, where it will not or cannot produce it (§2.16).
  5. Report Absent rather than Declined for a group whose membership is a rule rather than a record, and gather a BUILTIN group's recorded members from the sources rather than reporting it Absent (§2.16).
  6. Encode "no POSIX identifier" as zero, and never substitute a number a caller could mistake for a real one (§2.16).
  7. Answer a passwd record, a group record, a principal's memberships, or a name for a SID, each in one request (§2.16).
  8. Never report NotFound unless every source in the search order that could have answered was consulted and answered (§2.18).
  9. Report Unavailable where a source earlier in the search order could not be reached (§2.18).
  10. Never report NotFound in place of Refused or of a Restricted field, and never report Refused for anything other than a caller that may not make the request (§2.18).
  11. List every source that did not contribute to an enumeration, and never report Found on a page it could not produce (§2.17, §2.18).
  12. Reject a cursor it did not issue, or can no longer honour, with Malformed — never by restarting the walk and never by reporting it complete (§2.17).
  13. Serve member enumeration where it withholds MEMBERS as TooLarge (§2.17).

2.21.1.12 Service attestation #

An authority that implements ServiceAttest MUST satisfy all of the following. An authority that does not implement it MUST refuse the message, and is conforming.

  1. Never hold, store, or accept a credential for a service identity, and never offer a credential exchange for one (§2.19).
  2. Refuse a ServiceAttest unless both the peer's principal and the peer's being the platform's service manager are established from the connection, and never from the message (§2.19).
  3. Answer a peer that satisfies neither condition, or only one, with PermissionDenied (§2.19).
  4. Never mint a token under this message for a principal that has not been designated as usable for a service logon, and refuse with AccountRestricted where it cannot determine whether a principal carries that designation (§2.19).
  5. Never let a refusal distinguish "no such principal" from "not designated for service logon" (§2.19, §2.10).
  6. Reject an empty or unusable service with MalformedRequest (§2.19).
  7. Record the logon type as Service, and leave every profile field empty rather than inventing a value (§2.19, §2.9).
  8. Issue a token that cannot be used to authenticate to another system on the authority's behalf, at a level below the one a credentialled logon receives and bounding everything derived from it (§2.19).
  9. Record that the logon was attested rather than authenticated, wherever it records what vouched for a credentialled logon (§2.19).
  10. Reject a LogonStart that would complete with no credential exchange for an identity it accepts under ServiceAttest, and reject a ServiceAttest naming an identity it accepts under LogonStart (§2.19).

2.21.1.13 Logon type restrictions #

  1. Refuse a logon whose type the principal's holder does not permit, with AccountRestricted, after authentication rather than instead of it (§2.16, §2.10).
  2. Read an unstated LOGON_TYPES as its own default rather than as "nothing permitted", and never include Service in that default (§2.16, §2.19).
  3. Never widen what a principal's holder permits. Local policy may narrow it further; nothing may add to it (§2.16).
  4. Refuse a logon_type it does not recognise, with MalformedRequest (§2.7, §2.10).

2.21.1.14 Credential change #

An authority that implements CredentialChangeStart MUST satisfy all of the following. An authority that does not implement it MUST refuse the message with PermissionDenied, and is conforming. Obligations 10 to 13, 19, 20, 24 and 25 bind a change conversation as they bind a logon.

  1. Change only the credential of the principal the connected peer's token names, established from the socket and never from a message (§2.20, §2.4).
  2. Establish, within the conversation, that the principal can present their current credential before changing it, and refuse with AuthenticationFailed where they cannot (§2.20).
  3. Refuse with AccountRestricted a principal it holds no changeable credential for, including every service identity (§2.20, §2.19).
  4. Send CredentialChanged only once the new credential is the one a subsequent logon will be tested against (§2.20).
  5. Mint no token and create no session for a change, and send no descriptor with CredentialChanged (§2.20).
  6. End with ConversationLimit a change it gives up on for want of an acceptable new credential (§2.20).
  7. Refuse a CredentialChangeStart that ends before supported_credential_types with MalformedRequest (§2.20).
  8. Where the socket's descriptor admits peers that may not originate logons, decide from each peer's identity whether it may originate one, and never treat reaching the socket as that permission (§2.20, §2.4).

2.21.1.15 Ending a session #

An authority that implements SessionEnd and SessionEndQuery MUST satisfy all of the following. An authority that does not implement them MUST refuse both with PermissionDenied, and is conforming. Obligations 5 to 8, 10 and 14 bind these requests as they bind a logon.

  1. Never accept SessionEndQuery as SessionEnd or the reverse, and change nothing in answering SessionEndQuery (§2.22).
  2. Answer SessionEndQuery with SessionEndAllowed exactly where a SessionEnd from the same peer would be permitted, and otherwise with the denial it would draw (§2.22).
  3. Refuse, with PermissionDenied, a peer whose token is restricted, the SYSTEM and Anonymous sessions the kernel creates, and every session of logon type Service, whoever asks (§2.22).
  4. Permit a caller to end a session whose user is the caller, of a logon type a person has, where the caller is a principal of an issued domain (§2.22).
  5. Decide any other request by an access check of the caller's token against a descriptor local policy states — SYSTEM and Administrators alone where it states none, and nobody where it states one that cannot be used (§2.22).
  6. Answer NoSuchSession only to a caller who may end other principals' sessions, and PermissionDenied to any other where the session does not exist (§2.22, §2.10).
  7. End every process whose primary token belongs to the session — asking first, then, after a grace period of at most 10 seconds, without asking — and never a process only because one of its threads impersonates a token of the session (§2.22).
  8. Address each signal by a reference that cannot come to name a different process, never by a process identifier looked up again (§2.22).
  9. Look again after each round, bound the number of rounds, and answer within 30 seconds (§2.22).
  10. Count in remaining every process it could not examine or end, and never fail the request for one (§2.22).
  11. Hold no token of the session longer than it takes to read which session it belongs to, and never end its own process (§2.22).
  12. Where the session is the one the connected peer's token belongs to, send SessionEnded before signalling anything, carrying what it found to end and a remaining of zero (§2.22).
  13. Start nothing in ending a session (§2.1, §2.22).

2.21.1.16 Credential enrolment #

An authority that implements CredentialEnrollStart MUST satisfy all of the following. An authority that does not implement it MUST refuse the message with PermissionDenied, and is conforming. Obligations 10 to 13, 19, 20, 24, 25, 68, 69 and 72 bind an enrolment as they bind a credential change.

  1. Add or remove only the credentials of the principal the connected peer's token names, established from the socket and never from a message (§2.23, §2.4).
  2. For both Add and Remove, establish within the conversation that the principal can present their current credential before changing anything, and refuse with AuthenticationFailed where they cannot (§2.23).
  3. Refuse with AccountRestricted a principal it holds no credential for that it can ask for as proof, including every service identity (§2.23, §2.19).
  4. Refuse an action or credential_type it does not recognise with MalformedRequest, a credential_type it does not enrol with PermissionDenied, and a CredentialEnrollStart that ends before material with MalformedRequest (§2.23).
  5. Refuse material it will not accept with CredentialRejected, check it again when applying the change, and never send CredentialRejected for a failed proof or in answer to any other message (§2.23, §2.B).
  6. Leave unchanged which credentials the principal may sign in with (§2.23).

2.21.2 Client obligations #

There are four client roles, and they are independent. A program may be any of them, several, or none: a logon originator never looks a principal up; a name resolver does the reverse.

An implementation originating logons MUST satisfy obligations 1 to 20. An implementation performing identity lookup MUST satisfy 21 to 27. An implementation changing its own principal's credential MUST satisfy obligations 2 to 12 and 18 to 20, reading CredentialChangeStart wherever they say LogonStart, and 28 and 29. An implementation adding or removing its own principal's credentials MUST satisfy the same, reading CredentialEnrollStart for CredentialChangeStart, and 34 and 35. An implementation ending logon sessions MUST satisfy obligations 4 to 7 and 30 to 33.

2.21.2.1 Conversation #

  1. Send exactly one LogonStart, as the first message (§2.3).
  2. Answer each CredentialRequest with exactly one CredentialResponse (§2.3).
  3. Never send a CredentialResponse that was not solicited (§2.3).
  4. Treat a connection that closes without a terminal message as a failed logon, and not retry automatically (§2.3).

2.21.2.2 Framing #

  1. Reject any message whose magic is not PGSL (§2.6).
  2. Reject any message whose version it does not implement (§2.6).
  3. Skip to a structure's declared end after reading known fields (§2.6).

2.21.2.3 Capabilities #

  1. Never declare in supported_credential_types a credential type it cannot render (§2.7). Declaring fewer than it can render is permitted, and an empty list is how a client asks for a logon requiring no interaction.
  2. Fail the conversation, rather than guess, if it receives a prompt it cannot render (§2.8).

2.21.2.4 Rendering #

  1. Display received messages, in order, before the prompts of the same request (§2.8).
  2. Not interpret or branch on message text, credential_name, or reason (§2.8, §2.10).
  3. Echo credential_ref back unchanged (§2.8).

2.21.2.5 Result #

  1. Read the token descriptor from the ancillary data of AccessGranted, and treat an AccessGranted without one as a failed logon (§2.9).
  2. Close the descriptor if it does not install the token (§2.9).
  3. Have a fallback for every profile field, and treat an empty field, and an absent profile, identically and not as an error (§2.9).
  4. Never execute a relative shell, nor resolve a relative home against its own working directory (§2.9). A shell containing no separator is relative, and MUST NOT be resolved against a search path.
  5. Never treat profile as a directory lookup, nor cache it as an answer about any principal other than the one who just authenticated (§2.9).

2.21.2.6 Credential handling #

  1. Collect a credential only where it can establish that its collection method meets the type's requirements, and never collect a Password with echo enabled (§2.12).
  2. Never silently truncate an answer to fit its own buffer (§2.12).
  3. Erase credential material, and every buffer it was collected and encoded through, before that memory is released, and never write it to a log, error message or diagnostic (§2.12).

2.21.2.7 Identity lookup #

  1. Forward a name unchanged, and never parse, split, qualify or case-fold one (§2.15).
  2. Never reuse a tag while a request bearing it is outstanding, and never assume replies arrive in request order (§2.14).
  3. Treat a cursor as opaque: never construct, parse or modify one, nor present one to a different authority or across a reconnection (§2.17).
  4. Continue an enumeration until next is empty, never infer completion from an empty page, and never present a walk it abandoned for any other reason as a complete one (§2.17).
  5. Surface an enumeration's incomplete list rather than discarding it (§2.17).
  6. Distinguish NotFound from Unavailable, and never cache the second as though it were the first (§2.18).
  7. Never use enumeration to test whether a principal exists (§2.17).

Obligations 24 and 26 bind a client that caches for the lifetime of a single process just as they bind an authority. A resolver that remembers "no such user" through an outage will keep reporting it after the outage ends, and one that reports a failed walk as an empty system does the same thing to every principal at once.

2.21.2.8 Credential change #

  1. Send exactly one CredentialChangeStart, as the first message, and name no principal in it — there is no field for one (§2.20).
  2. Treat CredentialChanged as the only successful outcome, expect no token with it, and close any descriptor that arrives with it (§2.20).

2.21.2.9 Ending a session #

  1. Send exactly one SessionEnd or SessionEndQuery, as the only message, and use SessionEndQuery — never SessionEnd — to learn whether ending a session would be permitted (§2.22).
  2. Wait up to 60 seconds for the answer to SessionEnd before treating it as unanswered, and then not assume either outcome (§2.22).
  3. Treat a non-zero remaining as a session that may still exist, and never read the counts as an outcome where the session was the caller's own (§2.22).
  4. Treat a denial code it does not recognise as a refusal, not as an unreadable answer (§2.B).

2.21.2.10 Credential enrolment #

  1. Send exactly one CredentialEnrollStart, as the first message, with every field, and name no principal in it (§2.23).
  2. Tell CredentialRejected (the material) apart from AuthenticationFailed (the proof) where it reports a refusal, and treat a denial code it does not recognise as a refusal (§2.23, §2.B).

2.21.3 What a client is not required to do #

A client is not required to understand what any credential type means, what any message says, or why a logon was denied. It renders what it is given and returns what it collects.

That is the property the whole conversational shape exists to produce, and a client that starts reasoning about the content of prompts has given it up — it will need changing the next time an authority's policy does.

2.22 Ending a Session

Peios / Peios technical reference / PGSS / Logon

A caller asks the authority to end a logon session with SessionEnd, and asks whether it may with SessionEndQuery. Each is a whole conversation in one message, on /run/logon.sock: the client sends it first and alone, and the authority answers with one terminal message.

client                                   authority
  |                                          |
  |------------ SessionEndQuery ------------>|   may I?
  |<-- SessionEndAllowed | AccessDenied -----|   nothing changed
  |                                          |

client                                   authority
  |                                          |
  |-------------- SessionEnd --------------->|   end it
  |                                          |   (ends its processes)
  |<---- SessionEnded | AccessDenied --------|   terminal

§2.3's rules hold for both: one per connection, the request as the first and only client message, exactly one terminal message, and the connection closed after it.

2.22.1 Why the authority #

The kernel has no call that ends a logon session. A session lasts as long as a token of it does, and is destroyed when the last reference goes (Kernel TRM §3.2.7). Ending a session is therefore ending the processes that run in it, after which the kernel does the rest.

Any sufficiently privileged program could do that walk. It belongs to the authority for two reasons.

One decision, one record. The authority created the session and is the component the standard already trusts to decide who may do what with logons. A desktop's task manager, a principal manager and a command-line tool asking it get the same answer, under one policy, and leave one record of what was ended at whose request.

It can finish. A process in the session may be protected, or carry a descriptor that does not let an administrator end it. A program doing the walk itself can stop halfway and leave half a session. The authority, as the most trusted process outside the kernel, can end what an administrator's tools cannot.

2.22.2 SessionEndQuery #

msg_type = 0x0041. Client to authority. The whole request.

FieldEncodingLimit
logon_session_idu64—

logon_session_id is the session's identifier: the auth_id every token of the session carries.

The authority answers with SessionEndAllowed where a SessionEnd for the same session from the same peer would be permitted now, and otherwise with AccessDenied carrying the denial that SessionEnd would draw. It MUST NOT signal any process, or change anything else, in answering.

It exists so that a program can decide whether to offer the act at all. A window that shows a person a button that can only fail has told them something false.

2.22.3 SessionEnd #

msg_type = 0x0040. Client to authority. The whole request.

FieldEncodingLimit
logon_session_idu64—

The authority ends the session's processes as §2.22 describes below and answers with SessionEnded, or refuses with AccessDenied.

2.22.4 Why the question is a separate message #

The two requests have the same body and differ only in msg_type. They could have been one message with a flag saying "only ask". They are not, for the reason ServiceAttest is not a LogonStart (§2.19): a flag puts the path that ends a session on the same dispatch as the path that must not, separated by one bit. With two messages, a probe cannot end a session by any corruption or confusion of a field — only by being a different message.

An authority MUST NOT accept either message as the other.

2.22.5 Who may end a session #

The caller is the user of the connected peer's token, established from the socket as §2.4 requires. An authority MUST refuse, with PermissionDenied, a peer whose token is restricted: a restricted token does not speak for its user's whole authority.

Unlike CredentialChangeStart (§2.20), these messages name their subject. They have to: the usual caller is an administrator, and the session is somebody else's. What the name buys is decided entirely by who is asking.

An authority MUST apply the following, in this order.

  1. Never the kernel's sessions, never a service's. An authority MUST refuse, with PermissionDenied, the session the kernel creates for SYSTEM, the one it creates for Anonymous, and every session whose logon type is Service — whoever asks, the session's own user and SYSTEM included. Every process on the machine descends from the first and depends on it. A service is stopped through the service manager, which knows how to stop it and whether to start it again; signing it out from under that manager does neither.
  2. Your own session. An authority MUST permit a caller to end a session whose user is the caller, whose logon type is one a person has — one of those §2.B lists, other than Service — and whose user is a principal of an issued domain (S-1-5-21-A-B-C-RID). No grant is needed: signing yourself out is not a privilege. A well-known identity is not a person signing out, whatever its session's type.
  3. Anybody else's. Otherwise, an authority MUST decide by an access check of the caller's token, for a right that means "end a session", against a security descriptor its local policy states.
    • Where local policy states no descriptor, the authority MUST permit SYSTEM and Administrators and nobody else.
    • Where it states one that cannot be used — the wrong type, empty, unreadable, or not a descriptor — the authority MUST permit nobody, and SHOULD record why. Falling back to the default would grant more than the site wrote to anybody the site meant to exclude, and nobody would notice; refusing everybody is noticed at once.
  4. No such session. Where the session does not exist, an authority MUST answer NoSuchSession to a caller rule 3 permits, and PermissionDenied to any other.

The last rule is about disclosure. Which sessions exist — who is signed in, and since when — is not something every principal may list, and a NoSuchSession that differed from a refusal would let anybody who can reach the socket enumerate sessions by number.

PermissionDenied and NoSuchSession are the only denials these messages draw for a decision. MalformedRequest, UnsupportedVersion and Internal keep their usual meanings (§2.10).

Note

On Peios the descriptor is the REG_SZ value SessionEndSecurity on Machine\Generic\Authn\Policy, in SDDL, read on every request. The right is 0x1, and every generic right maps to it. With the value absent the descriptor is O:SYG:SYD:(A;;0x1;;;SY)(A;;0x1;;;BA). The kernel's sessions are 999 (SYSTEM) and 998 (Anonymous).

2.22.6 What the authority ends #

An authority MUST end every process whose primary token belongs to the session, by asking each to terminate, giving it a grace period, and then terminating whatever is left without asking.

It MUST NOT signal a process because one of its threads is impersonating a token of the session. A service that impersonates a person to answer one request is not in that person's session, and ending it would end the service for everybody.

It MUST address each signal to the process whose token it examined, by a reference that cannot come to name a different process — never by a process identifier looked up again, which may have been reused in the meantime.

A process can start another during the grace period, and the new one holds the session too. So after each round an authority MUST look again, and end what it finds in a further round. It MUST bound the number of rounds, and MUST bound the grace period. Both bounds are implementation-defined, but the grace period MUST NOT exceed 10 seconds, and the whole request MUST be answered within 30 seconds — half of the 60 a client waits (§2.21), so that a slow authority and a patient client never meet at the same instant.

A process the authority cannot examine, or cannot signal, is not a failure of the request. It is counted in remaining, and the authority carries on with the rest.

An authority MUST NOT end its own process, and MUST NOT hold a token of the session for longer than it takes to read which session the token belongs to. Each token held is a reference that keeps the session alive.

An authority SHOULD record each SessionEnd it acts on — who asked, which session and whose it was, and the counts it answered — wherever it records logons.

This is the one thing the authority does to processes. It starts none, and §2.1's prohibition stands.

Note

Mainline's authority finds the session in /sys/kernel/security/kacs/sessions, walks /proc, opens each process by pidfd_open and reads its primary token through kacs_open_process_token on that pidfd, so the signal it later sends by pidfd_send_signal reaches the process whose token was read. It sends SIGTERM, waits up to 5 seconds for everything signalled to exit, sends SIGKILL to what is left, waits up to 1 second, and walks again — at most three rounds, so at most about 18 seconds.

2.22.7 SessionEnded #

msg_type = 0x8040. Authority to client. The successful terminal message of SessionEnd; nothing follows it.

FieldEncodingLimit
endedu32—
remainingu32—

ended is the number of processes of the session the authority signalled that its last look no longer found.

remaining is the number of processes its last look did find still holding the session, plus those it could not examine. Not zero means the session may still exist, and a client SHOULD say so rather than report the person signed out.

Both fields are mandatory. The message is newer than the rule that lets a trailing field be absent, so there is no older authority whose silence needs a default.

The counts are of processes, not of references. A session outlives its processes wherever a reference the authority cannot see survives them — see below — and such a reference is counted nowhere.

2.22.8 SessionEndAllowed #

msg_type = 0x8041. Authority to client. The successful terminal message of SessionEndQuery; nothing follows it. It has no fields.

2.22.9 When the caller is in the session #

A caller may end the session it is running in, and a person signing themselves out usually does. Ending it ends the caller, and the authority cannot answer a connection whose other end is gone.

So where the session being ended is the one the connected peer's token belongs to, an authority MUST send SessionEnded before it signals anything. Its ended is then the number of processes it found to end, and remaining is zero: the counts say what the authority is about to do, not what it achieved. It then ends the session exactly as it would any other.

A client in that position MUST NOT read the counts as an outcome, and SHOULD expect to be ended shortly after it reads them.

The connection is itself a reference to the caller's token, held by the authority's end of the socket, so the session is not destroyed until the authority has closed it.

2.22.10 What ending a session cannot reach #

The kernel destroys a session when its last reference goes, and some references are not processes running in the session:

  • A token descriptor held elsewhere. A token can be passed between processes as a descriptor (Kernel TRM §3.2.7). A process outside the session holding one keeps the session alive after every process in it has ended, and nothing here finds it.
  • An impersonating thread. By the rule above, a thread of another process impersonating a token of the session is not ended. The session lasts until that thread reverts.
  • A process not yet reaped. A process that has exited still holds its token until its parent reaps it. It counts in remaining, and ending its parent, where that is in the session, usually releases it.

An authority is not required to find these, and a client MUST NOT assume that remaining of zero means the session is gone.

2.22.11 Access to the socket #

Every principal permitted to end its own session needs connect access to /run/logon.sock. That is the same population §2.20 admits, and as there, reaching the socket permits nothing by itself.

2.22.12 An authority that does not offer it #

An authority MAY decline to offer ending sessions. One that declines MUST refuse both SessionEnd and SessionEndQuery with PermissionDenied, and is conforming.

2.23 Credential Enrolment

Peios / Peios technical reference / PGSS / Logon

A principal adds a credential of their own beside the ones they have, or removes one, by opening a conversation on /run/logon.sock with CredentialEnrollStart. The only credential this section defines for enrolment is an SSH public key (§2.D). The authority asks for the principal's current credential, in the rounds of §2.8, and ends the conversation with CredentialChanged or AccessDenied (§2.10).

client                                   authority
  |                                          |
  |-------- CredentialEnrollStart ---------->|   principal = the peer
  |                                          |   material: the key, or which
  |<--------- CredentialRequest -------------|   the current credential
  |---------- CredentialResponse ----------->|
  |                                          |
  |<-- CredentialChanged --------------------|   terminal
  |         or AccessDenied                  |

It is the sibling of a credential change (§2.20), and everything that section says about the shape of the conversation, whose credential is affected, and the terminal holds here unless this section says otherwise. The difference is where the material travels: a change collects the new credential in a round, while an enrolment carries it in the opening message. That is possible because the material is public: an SSH public key, or the fingerprint of one.

2.23.1 CredentialEnrollStart #

msg_type = 0x0031. Client to authority. Opens the conversation; MUST be the first message.

FieldEncodingLimit
supported_credential_typeslength-framed bytes, one u8 per type32
actionu8§2.B
credential_typeu8§2.B
materialstring16384

Every field is mandatory. The message is newer than the rule that lets a trailing field be absent, so there is no older client whose silence needs a default, and a truncated message read with one could perform an action nobody asked for. An authority MUST refuse a CredentialEnrollStart that ends before material with MalformedRequest.

2.23.1.1 supported_credential_types #

The credential types the client can render, for the proof the authority will ask for. It carries the meaning and the capability rule of the §2.7 field of the same name, and a decoder MUST drop a value it does not recognise rather than refuse the message.

2.23.1.2 action #

1, Add: enrol material as a new credential of the principal. 2, Remove: remove the credential material identifies. The values are instructions rather than statements of capability, so a decoder MUST refuse one it does not recognise with MalformedRequest.

2.23.1.3 credential_type #

The kind of credential being added or removed, from the credential types of §2.B. Only SshPublicKey is defined for enrolment. An authority MUST refuse any other with PermissionDenied.

2.23.1.4 material #

For Add of an SshPublicKey, one line of an OpenSSH public key file, such as ~/.ssh/id_ed25519.pub: the key type, the base64 key and an optional comment. The comment MAY become the key's label.

For Remove of an SshPublicKey, the key's fingerprint as ssh-keygen -l writes it: SHA256: and the unpadded base64 of the SHA-256 of the key blob (§2.D).

2.23.2 Whose credential #

The principal whose credentials change is the user of the connected peer's token, as in §2.20, and for the same reason: a field naming a principal would let anything that reaches the socket ask to add a key to anybody's account. The message carries no identifier, and an authority MUST NOT enrol a credential for, or remove one from, any other principal in this conversation.

An authority MUST refuse, with AccountRestricted, a principal for whom it holds no credential it can ask for as proof (below). That includes a service identity (§2.19), a platform identity no source holds, a principal that authenticates with no credential at all, and a principal that authenticates only with the kind of credential being enrolled. The reason SHOULD tell the principal that an administrator can make the change for them.

2.23.3 Proof of the current credential #

Both actions require it. An authority MUST establish, within the conversation, that the principal can present their current credential before it adds or removes anything, and MUST refuse with AuthenticationFailed where they cannot. The rule and its wording requirement are §2.20's.

A token shows that someone signed in, not that the person at the keyboard now is that person. A key added from an unattended terminal is a way back into the account for whoever added it, and outlives every session; a key removed from one locks its owner out of every machine that trusts it. Neither can be undone by signing in again.

The proof is a credential the principal already has and that is not the one being enrolled. A principal who signs in only with SSH keys cannot prove themselves with a key over this socket, since there is no transport to bind the proof to (§2.D); such a principal is refused as above.

2.23.4 Checking the material #

The authority decides what it accepts: which key types and sizes, how many keys a principal may hold, and whether a key may be held twice. It SHOULD check the material before it asks for the current credential, so that a principal is not asked for a password to be told their key is unreadable, and it MUST check it again when it applies the change, since the principal's credentials may have changed between the two.

An authority MUST refuse material it will not accept with CredentialRejected, and SHOULD say why in reason: unreadable, an unsupported type, a key the principal already has, one more than the principal may have, or, for Remove, no key of the principal's with that fingerprint. CredentialRejected answers only this message (§2.B). It MUST NOT be used where the proof failed: that is AuthenticationFailed, and the two are separate so that a client can tell "your key" from "your password".

Enrolment does not change which credentials a principal may sign in with. Whether an enrolled key is accepted at sign-in is the authority's policy for that principal, as it is for a key an administrator adds.

2.23.5 CredentialChanged #

The successful terminal message is §2.20's CredentialChanged, with every rule that section states for it: no fields and no descriptor, and not sent until the change is the one a subsequent logon will be tested against. For Remove, that means the key no longer authenticates the principal at their next sign-in.

Sessions already established are left as they were. Removing a key does not end a session that was signed in with it; ending one is §2.22's.

2.23.6 Access to the socket #

The socket admits the peers §2.20 admits, for the same reason, and §2.20's rules on deciding who may originate a logon and on keeping such peers off the originators' bound on conversations apply unchanged.

2.23.7 An authority that does not offer it #

An authority MAY decline to offer enrolment, or to offer it for some principals. One that declines MUST refuse CredentialEnrollStart with PermissionDenied, and is conforming.

Appendix 2.A Message Reference

Peios / Peios technical reference / PGSS / Logon

2.A.1 Messages #

2.A.1.1 On /run/logon.sock #

msg_typeMessageDirectionDefined in
0x0001LogonStartclient → authority§2.7
0x0002CredentialResponseclient → authority§2.8
0x8001CredentialRequestauthority → client§2.8
0x8002AccessGrantedauthority → client§2.9
0x8003AccessDeniedauthority → client§2.10
0x0020ServiceAttestclient → authority§2.19
0x0030CredentialChangeStartclient → authority§2.20
0x8030CredentialChangedauthority → client§2.20, §2.23
0x0031CredentialEnrollStartclient → authority§2.23
0x0040SessionEndclient → authority§2.22
0x0041SessionEndQueryclient → authority§2.22
0x8040SessionEndedauthority → client§2.22
0x8041SessionEndAllowedauthority → client§2.22

2.A.1.2 On /run/ident.sock #

msg_typeMessageDirectionDefined in
0x0010Lookupclient → authority§2.16
0x0011Enumerateclient → authority§2.17
0x8010LookupReplyauthority → client§2.16
0x8011EnumerateReplyauthority → client§2.17

The high bit marks a message sent by the authority (§2.6). The two ranges are disjoint, and a message of one range MUST be refused on the other socket (§2.14).

2.A.2 Protocol constants #

ConstantValueDefined in
Logon socket path/run/logon.sock§2.5
Identity socket path/run/ident.sock§2.14
MagicPGSL (50 47 53 4c)§2.6
Version1§2.6
Header size12 bytes§2.6
Maximum message size65536 bytes§2.6
Longest grace before a forced end10 seconds§2.22
Longest a SessionEnd may take to answer30 seconds§2.22
Least a client waits for that answer60 seconds§2.21

2.A.3 Field limits #

FieldMaximumDefined in
identifier1024 bytes§2.7
tty128 bytes§2.7
remote_host256 bytes§2.7
supported_credential_types32 entries§2.7
messages8 entries§2.8
prompts16 entries§2.8
answers16 entries§2.8
text512 bytes§2.8
credential_name128 bytes§2.8
data32768 bytes§2.8
home4096 bytes§2.9
shell4096 bytes§2.9
display_name256 bytes§2.9
reason512 bytes§2.10
material16384 bytes§2.23
identity128 bytes§2.19
service256 bytes§2.19
name (lookup key)256 bytes§2.16
sid68 bytes§2.16
qualified_name512 bytes§2.16
withheld32 entries§2.16
values32 entries§2.16
reference name512 bytes§2.16
HOME, SHELL4096 bytes§2.16
DISPLAY_NAME256 bytes§2.16
GROUPS128 entries§2.16
MEMBERS256 entries§2.16
CLAIMS64 entries§2.16
DESCRIPTION1024 bytes§2.16
of_name256 bytes§2.17
cursor, next256 bytes§2.17
entries256 entries§2.17
incomplete32 entries§2.17
incomplete source name32 bytes§2.17

The 68-byte SID maximum is a property of the SID encoding rather than of this chapter: an eight-byte prelude plus the fifteen sub-authorities the one-byte count admits. It is specified in PCDS.

An encoder MUST refuse to produce a field exceeding its maximum; a decoder MUST reject one it receives (§2.6).

Appendix 2.B Enumerations

Peios / Peios technical reference / PGSS / Logon

Adding a value to any enumeration here is a breaking change requiring a version bump — see §2.6, which states the four exceptions and where each is stated in full.

2.B.1 Logon types #

Carried in LogonStart.logon_type (§2.7) as a u8. Semantics are defined by KACS and described in the Peios Kernel TRM; this table is for reference.

ValueName
2Interactive
3Network
4Batch
5Service
8NetworkCleartext
9NewCredentials
10RemoteInteractive

The gaps are deliberate: the numbering follows KACS, and values it does not define are not available here.

A value MAY be added here without a version bump, for the reason §2.7 gives.

2.B.2 Identifier types #

Carried in LogonStart.identifier_type (§2.7) as a u8.

ValueNameidentifier holds
1UsernameA principal name

2.B.3 Credential types #

Carried in Prompt.credential_type (§2.8) and in LogonStart.supported_credential_types (§2.7) as a u8. See §2.11 for when a new one is warranted.

ValueNameCollection
1PasswordA line of text, not echoed
2SshPublicKeyBound SSH candidate or signature proof; §2.D

2.B.4 Message severities #

Carried in Message.severity (§2.8) as a u8.

ValueName
0Info
1Error

2.B.5 Enrolment actions #

Carried in CredentialEnrollStart.action (§2.23) as a u8.

ValueNamematerial holds
1AddThe credential to enrol: for SshPublicKey, one line of an OpenSSH public key file
2RemoveWhich credential to remove: for SshPublicKey, its SHA256: fingerprint

CredentialEnrollStart.credential_type takes a value from the credential types above; only SshPublicKey is defined for enrolment.

2.B.6 Denial codes #

Carried in AccessDenied.denial (§2.10) as a u32.

ValueNameMeaning
1MalformedRequestThe message could not be understood.
2UnsupportedVersionThe protocol version is not implemented.
3PermissionDeniedThe peer may not make this request at all.
4AuthenticationFailedThe principal is unknown, or the credential is wrong. Deliberately one code — see §2.10.
5LogonTypeNotPermittedThe peer may not request this kind of session.
6AccountRestrictedThe principal exists and authenticated, but policy refuses this logon; or, in a credential change, the principal has no credential that can be changed (§2.20).
7AuthorityUnavailableThe authority cannot reach what it needs to decide.
8ConversationLimitToo many rounds, or too long without an answer.
9InternalThe authority failed for a reason it will not describe.
10NoSuchSessionThere is no live logon session by that identifier. Answers only SessionEnd and SessionEndQuery (§2.22).
11CredentialRejectedThe credential offered for enrolment, or named for removal, was refused: unreadable, unsupported, already held, one too many, or not held. Answers only CredentialEnrollStart (§2.23).

A denial code MAY be added without a version bump where it only ever answers messages newer than it is — messages no peer that predates the code can send — because such a peer is then never sent it. NoSuchSession and CredentialRejected were added under this rule. A client of the newer messages SHOULD treat a code it does not recognise as a refusal, reporting the code and reason, rather than as an unreadable answer; it is the one place in this chapter where a client may meet a denial newer than itself.

2.B.7 Key types #

Carried in Lookup.key_type (§2.16) and Enumerate.of_key_type (§2.17) as a u8. Zero in of_key_type means the field is unused.

ValueNameKey is in
1Namename
2Sidsid
3UnixIdunix_id

2.B.8 Object kinds #

Carried in Lookup.kind, LookupReply.kind_found and Enumerate.kind (§2.16, §2.17) as a u8.

ValueName
0Any
1Principal
2Group

Any is not valid in kind_found or in Enumerate.kind.

2.B.9 Fields #

Carried in Lookup.fields, Enumerate.fields and LookupReply.present (§2.16) as a u32 bitmask, and in a withheld entry's field as a single bit.

BitNameValue encoding
0UNIX_IDu32
1PRIMARY_GROUPreference
2HOMEstring
3SHELLstring
4DISPLAY_NAMEstring
5GROUPSarray of references
6MEMBERSarray of references
7CLAIMSarray of claim entries
8ENABLEDu8
9LOGON_TYPESu32
10DESCRIPTIONstring

A bit MAY be added without a version bump, because a reply states which fields it answered and an authority MUST ignore a bit it does not implement (§2.16).

2.B.10 Lookup outcomes #

Carried in LookupReply.outcome and EnumerateReply.outcome (§2.18) as a u8.

ValueName
1Found
2NotFound
3Unavailable
4Refused
5Malformed

2.B.11 Withheld reasons #

Carried in a withheld entry's reason (§2.16) as a u8.

ValueNameMeaning
1AbsentThe field has no value.
2RestrictedThe caller may not have this field.
3DeclinedThe source will not produce it.
4TooLargeIt exists and exceeds one reply; use Enumerate (§2.17).

Appendix 2.C Prior Art

Peios / Peios technical reference / PGSS / Logon

2.C.1 Windows LSA #

The closest predecessor is the Windows Local Security Authority and its LsaLogonUser interface. What is taken and what is deliberately left is worth stating, because the resemblance is close enough that the differences matter.

Taken. The separation of authentication from derivation — establishing who someone is, and then constructing a token for them, are different acts by different rules. The idea that a logon produces both a token and a session, and that the session records which authority vouched for it. The vocabulary of logon types.

Diverged. LSA's authentication packages are DLLs loaded into the LSA process, so a defect in any package is a defect in the most privileged process on the system. Nothing in PGSS Logon admits an in-process extension point; an authority that federates does so across a process boundary of its own choosing.

Rejected. The challenge-response family (NTLM and successors). Challenge-response requires the verifier to store something a response can be recomputed from, which is password-equivalent material — the pass-the-hash failure mode, where stealing the store is as good as knowing the passwords. This chapter requires the opposite property: what is stored MUST NOT be usable to authenticate. See §2.11.

2.C.2 PAM #

Pluggable Authentication Modules supplies the conversation shape: an authority that asks for what it needs, a client that renders prompts without understanding them, and several rounds where policy requires them. That shape is why adding a credential type is a change to authorities and not to every client, and it is adopted wholesale.

What is not adopted is PAM's stacking, in which a credential is offered to each module in turn until one accepts. Trying each source with the password hands every source the credentials of every other source's users, including on typos. Where an authority federates, resolution MUST select the answering party before any credential is collected.

PAM is also an in-process module system, and the objection under Diverged applies to it equally.

2.C.3 POSIX name resolution #

The identity-lookup half of this chapter answers the questions getpwnam, getpwuid, getgrnam, getgrgid and getgrouplist ask, and its field mask, reference encoding and one-round-trip requirement are shaped by what those calls need in one go.

What is not adopted is the passwd record itself. GECOS is not carried (§2.9), the flat name:uid:gid tuple is replaced by a SID plus a set of requested fields, and the reserved-character rules (§2.15) exist precisely because a name that reaches those callers ends up in a colon-and-comma-separated line that nothing else validates.

Nor is the assumption that an absence is authoritative. A world-readable file cannot be unreachable, so POSIX has no vocabulary for "the source that would have known did not answer"; §2.18 exists because a federated authority does, and reporting that as "no such user" memoises an outage as a fact.

2.C.4 Design influences #

Descriptor-passing over ambient authority. The token is transferred as a file descriptor rather than named, so that possession of the conversation is what confers it. There is no window in which a minted token exists under a name another process could reach for.

One conversation per connection. The connection is the conversation's identity, so no correlation identifier exists to be forged or confused. This is deliberately unlike the identity channel of §2.14, and unlike PSI (PSPU §2.7), both of which multiplex because their connections are long-lived.

Capability declaration over negotiation. A client states what it can render and the authority works within it, rather than the two agreeing a version or a profile. That is what lets a credential type be added to authorities without a flag day, and it is why the capability list is the one enumeration whose unknown values are dropped rather than refused (§2.7).

Appendix 2.D SSH Public-Key Authentication

Peios / Peios technical reference / PGSS / Logon

The SSH public-key extension is implemented in authd/lpsd and the Peios OpenSSH port. PEI-67 tracks its release qualification. PGSS/PSI version 1 and the unpublished lpsd store layout were replaced together without a version bump or migration. Rebuild consumers together and explicitly reprovision old development stores; lpsd never resets an unreadable store automatically.

An SSH public key is a credential of a principal. The principal source stores the authorized key and verifies proof of possession. The logon authority constrains the originator, applies policy, derives the token, and grants the logon. SSH does not introduce a second account authority.

The first source implementation is lpsd. No .ssh/authorized_keys lookup is performed. Client private keys and server host keys are not principal credential records; their storage is outside this extension.

2.D.1 Responsibilities and trust #

ComponentResponsibility
SSH clientProduces the standard SSH authentication signature using its private key
SSH transport and monitorEstablishes and retains the live transport binding; mediates credential collection
authdVerifies the local originator, relays the credential exchange, enforces logon policy, and grants a token
lpsdOwns local public-key credentials, checks their eligibility, verifies signatures, and asserts the principal

A network-facing process does not receive authority to assert a principal or obtain arbitrary logon tokens. The protected SSH monitor owns the PGSS connection. A user session receives its resulting token, not the PGSS connection, proof buffers, or monitor control channel.

Public-key verification does not replace disabled-account checks, logon type restrictions, originator restrictions, or normal token derivation. A valid signature alone never creates a session.

2.D.2 Principal storage and policy #

lpsd extends each principal with an explicit authentication policy and a bounded list of SSH public-key records. The existing principal SID remains the account identity; names, key comments, and fingerprints do not replace it.

Each key record contains:

FieldRepresentation
Record IDRandom 128-bit identifier, stable until deletion
Public keyValidated, canonical SSH public-key blob; at most 8192 bytes
LabelUTF-8 text, at most 128 bytes; no control characters
Created timeUTC Unix seconds, recorded by lpsd

The fingerprint is derived as SHA-256 of the canonical SSH public-key blob and displayed in OpenSSH's SHA256: base64 form without padding. It is not separately stored as an authority. Labels are display metadata, not executable authorized_keys options. There are at most 32 keys per principal; duplicate key material within a principal is rejected. The same key may be deliberately enrolled on different principals, but a proof for one username does not authenticate another.

The initial algorithm set is plain Ed25519 and RSA with rsa-sha2-256 or rsa-sha2-512 signatures. RSA moduli are 3072–8192 bits. RSA key blobs use the SSH ssh-rsa key format; that does not enable SHA-1 signatures. DSA, SHA-1 signatures, certificates, security-key variants, and additional key families are rejected until their semantics are specified. Malformed keys, noncanonical encodings, trailing data, and unsupported types are rejected at enrollment and at authentication. A maintained SSH key/crypto implementation performs decoding and verification; no new cryptographic primitive is introduced here.

Authentication policy has an explicit mode:

  • Credentials: a nonempty set of permitted methods, initially Password and/or SshPublicKey. One permitted method is sufficient.
  • NoCredential: authentication is intentionally unnecessary.
  • Denied: no new authentication is permitted by this source.

The principal's separate enabled flag is checked first. In Credentials mode, a permitted method without corresponding usable credential material fails; it does not select another mode. In particular, deleting the last key on a key-only principal leaves that principal unable to authenticate. NoCredential mode is never inferred from an empty verifier or key list.

Keys are enrolled in two ways. An administrator enrolls them through lpsd's protected administrative interface: the lps key add, lps key list, and lps key remove operations operate on one principal and use its existing administration authorization checks. A principal enrolls and removes their own through PGSS Logon's CredentialEnrollStart (§2.23), described under Self-service enrollment below. Neither way silently changes the principal's authentication policy. An explicit policy operation selects password-only, key-only, either credential, NoCredential, or Denied. NoCredential and Denied cannot be combined with credential methods.

Mutations are atomically persisted before success is acknowledged, using the store's existing rollback-on-write-failure behavior. They invalidate affected authentication state. Enrollment accepts a plain OpenSSH public key line, including an optional label, but rejects private-key input and authorized_keys options rather than silently ignoring them. Credential inventory is not exposed through unauthenticated identity lookup.

Compound authentication such as password AND key is a separate extension.

2.D.3 Self-service enrollment #

A principal adds or removes their own SSH public keys with CredentialEnrollStart on /run/logon.sock (§2.23). The account is the user of the caller's token; the message names nobody. Possessing a session token does not by itself authorize adding a persistent credential, so both adding and removing re-prove the principal's current password inside the conversation, and a wrong one ends it with AuthenticationFailed and the fixed Authentication failed. wording.

authd routes the conversation to the source that holds the principal as PSI's EnrollCredential (PSPU §2.23), and only to a source declaring the ENROLLS_CREDENTIALS capability; a source that does not is sent nothing, and the caller is refused PermissionDenied. lpsd declares it, and:

  • resolves the principal from the SID authd vouched for, with the rules a password change uses. A principal with no password to prove — policy key or none, or a password policy with no password set — is refused AccountRestricted, with words telling them to ask an administrator, before anything is asked;
  • checks an added key line, or a removal's fingerprint, by the same store paths lps key add and lps key remove use — the same import, algorithm and size rules, duplicate rejection, 32-key limit and last-administrator rule — before asking for the password, and again when it applies the change. Material it refuses is CredentialRejected, with the store's reason;
  • names a key for removal by its SHA256: fingerprint rather than the record ID lps key remove takes, because the fingerprint is what a person can read from their own .pub file. The key's comment becomes its label;
  • persists the change with the administrative write's rollback, sends authd the same change notification an administrative write sends, and only then answers CredentialChanged.

Adding a key does not let it sign the principal in unless their policy is key or either, which only an administrator sets. A principal whose policy is key cannot use this path at all, since they have no password to prove themselves with; removing the last key of such an account is therefore never reachable here.

2.D.4 Store replacement and revocation #

The unpublished store layout is replaced in place, retaining its existing format-version marker. The reader and writer implement only the replacement layout; there is no old-store importer, dual reader, or downgrade path. Development images and test stores are explicitly reprovisioned from updated inputs rather than carried forward from an earlier build.

Provisioning writes an explicit policy for every principal. Password-bearing fixtures use Credentials/Password; intentionally passwordless fixtures, including the live image account, explicitly use NoCredential. Key-only fixtures use Credentials/SshPublicKey with enrolled keys. An empty verifier or key list never selects NoCredential automatically.

Unreadable, truncated, or malformed state still fails closed. Clean replacement does not mean that lpsd silently deletes a store or creates new identities on a decode failure. Reprovisioning is an explicit development/build operation.

lpsd keeps a per-principal credential generation in pending conversations. Immediately before issuing an assertion it rechecks the principal is enabled, the method remains permitted, the selected key is still enrolled, and the generation has not changed. A changed generation terminates the attempt; the client may start again. A positive candidate-key probe is never cached as authorization for a later signature.

The assertion is the authentication decision's ordering point: removals committed before it take effect for that attempt; removals committed after it affect subsequent attempts. Already issued tokens and sessions are not terminated by deleting a key. Revoking an existing session remains an explicit session operation.

2.D.5 Coordinated protocol replacement #

SshPublicKey is credential type 2; Password remains type 1. PGSS Logon and PSI retain their existing header version 1, magic, framing conventions, and message-size ceilings. Each implements one codec for the revised layout. There is no version negotiation, translation between old and new codecs, or automatic fallback to the previous layout or a weaker credential.

This deliberately replaces unpublished contracts. The implementation updates the existing enum-extension/version wording and message definitions together with the code. authd, lpsd, login, GXWI, peinit, utilities, other active consumers, and their dependency pins are rebuilt as needed for the revised shared types. Old development binaries and stores are not a supported combination with the replacement. Existing header-version and malformed-message checks remain; they are not a compatibility mechanism for two layouts sharing version 1.

LogonStart contains required_credential_type, a u8: 0 leaves the source's ordinary policy in control, 1 requires Password, and 2 requires SshPublicKey. A nonzero value must appear in supported_credential_types. PSI Authenticate carries the same requirement; its Assertion reports authenticated_credential_type (0 means no credential). authd rejects a result that fails a nonzero requirement. The SSH monitor selects 1 or 2 for its current method, so even an intentionally NoCredential principal cannot produce an unearned successful SSH password/key authentication. Rebuilt console and graphical clients retain their intended behavior by explicitly declaring their capabilities and credential requirement.

The new fields follow the existing fields within their enclosing length-framed structure. LogonStart appends required_credential_type, then has_ssh_binding (u8, exactly 0 or 1), then the binding structure when present. PSI Authenticate carries this extended LogonStart. Assertion appends authenticated_credential_type. Prompt appends parameters. These tails are mandatory even when their values are zero or empty; truncation is an error, not permission to infer permissive defaults. Binding fields are encoded in the order listed below: 16 raw connection-ID bytes, a byte string session identifier, and a UTF-8 username. A binding is required for type 2 and forbidden on attempts that do not advertise that type. Unknown method values, invalid presence flags, and type-2 assertions in a Password-only exchange are rejected.

PGSS and PSI share the revised credential bodies. The authority validates and relays them without a legacy translation layer. The SSH frontend advertises only the method of the current SSH authentication attempt, so a separate password attempt remains possible only when policy allows it.

A client advertising only Password is never sent type 2. A key-only account reached through such a client is denied, not prompted for an invented password and not granted without credentials. Identity, service-attestation, and credential-change operations retain their intended behavior in the coordinated build. This extension does not enable SSH key changes through the password-change operation.

The new credential type is offered only to a client that declares it. The authority also checks that the originator is allowed to provide SSH transport bindings. Ordinary password collectors do not acquire that authority by adding type 2 to their capability list.

2.D.6 Binding the proof to a connection #

Standard SSH signs the initial transport session identifier together with the authentication request. It does not sign a fresh arbitrary challenge invented by PGSS. The client-facing exchange remains RFC 4252 public-key authentication; no Peios client extension is required.

For a key attempt, LogonStart includes a typed SshTransportBinding structure. Its fields are a monitor-generated 16-byte connection ID, the initial SSH session identifier (16–64 bytes), and the exact SSH username (1–256 UTF-8 bytes). The username equals LogonStart's Username identifier byte for byte. Other identifier types are rejected for this credential. The SSH service is fixed to ssh-connection and the method to publickey for this first implementation. The binding is copied into PSI Authenticate from authd's validated conversation state, not from an Answer.

The monitor's connection ID is a correlation value, not proof or a bearer capability. authd accepts binding claims only from a specifically authorized service originator verified through its kernel socket-peer token. The implementation requires an unrestricted SYSTEM peer with the enabled, non-deny-only service SID derived from the fixed service name sshd, in addition to the ordinary originator/logon-type checks. This is an SSH-specific gate, not a general service-group policy union. The network child and logged-on user must lack that service authority and access to its PGSS descriptors.

The monitor obtains the initial session identifier from trusted key-exchange state and pins it for the connection, including across rekeys. It does not accept an arbitrary hash supplied through its authentication IPC. The trusted monitor creates a fresh 16-byte server KEXINIT cookie. Before signing the initial exchange hash it validates the bounded structured transcript, requires that cookie and its own host public key, and recomputes the SHA-256 or SHA-512 exchange hash. It supports ML-KEM/X25519, sntrup761/X25519 and Curve25519 exchanges. Simply remembering the first hash a child asks the host key to sign is not accepted as proof of freshness against a compromised child.

This is an explicit trust boundary: lpsd verifies the signature, while the SSH transport component vouches for the live connection. lpsd cannot independently prove network freshness from a caller-supplied transcript. A compromised authorized transport component is outside the guarantee; an ordinary local process or compromised network parser is not.

All candidate offers on one SSH connection retain the same binding. The monitor serializes attempts, fixes the target username for that connection, and rejects username changes. Disconnect, monitor exit, loss of authd, or successful authentication makes pending attempts unusable. A connection receives at most one granted logon token. The monitor does not let a child open an independent PGSS conversation using a previously accepted proof.

2.D.7 Credential exchange #

Prompt adds a length-framed parameters byte string, at most 4096 bytes. It is empty for Password. For SshPublicKey it contains a length-framed structure containing a disposition byte: 0 initially, 1 for the previous candidate being acceptable, and 2 for the previous candidate being rejected. It describes only that candidate in that conversation; it never asserts identity or grants access. There is one outstanding prompt, with a fresh credential_ref for each round.

An SshPublicKey Answer.data contains a length-framed structure:

FieldEncoding and limit
Operationu8: 0 Candidate, 1 Proof
Signature algorithmUTF-8 string, 1–64 bytes
Public keyByte string, 1–8192 bytes
SignatureByte string, 0–8192 bytes; empty exactly for Candidate

The outer structure uses PGSS little-endian lengths. The public-key blob and signature blob retain SSH's own encoding, including its big-endian lengths. Unknown operations fail closed. Total answer data remains below the existing 32768-byte ceiling; the parser additionally rejects trailing fields outside this layout. No signed-data blob or replacement session identifier is accepted in an answer.

For Candidate, lpsd checks whether that key and algorithm would be eligible for the named principal and returns another SshPublicKey prompt with the corresponding disposition. The monitor maps this to SSH's key-acceptable reply or authentication failure. Unknown principals, disabled principals, disallowed methods and unregistered keys all produce the same negative result. Only one submission is in flight; the monitor consumes a disposition before sending the next answer, abandons the PGSS exchange if the SSH client switches methods, and never reports a probe response as logon success. The positive result discloses eligibility of an already-presented key, as standard SSH probing does; it does not enumerate keys.

For Proof, lpsd repeats eligibility checks and verifies the signature. A client may send Proof without a preceding Candidate. Candidate success never eliminates verification or the final account-state check. A bad proof ends the PGSS attempt with a generic authentication failure. Trying another key uses the next bounded SSH attempt; no proof is silently reinterpreted as a different credential.

lpsd reconstructs the signed bytes exactly as RFC 4252 specifies: the validated initial session identifier, message number 50, exact username, ssh-connection, publickey, boolean true, algorithm and key blob. It checks the algorithm agrees with the key and the signature's internal algorithm identifier. There is no “verify these arbitrary bytes” API. Case-insensitive principal lookup may resolve the username, but the signed username is never case-normalized before signature verification.

When proof succeeds, lpsd emits the ordinary PSI assertion. The source does not mint a token or choose additional privileges based on the key. authd's AccessGranted is the only result permitting SSH authentication success and session startup. Password and key are alternatives in the initial policy model; partial success for multifactor authentication is not implemented or advertised.

2.D.8 Bounds, logging and failure behavior #

One SSH connection has at most 16 public-key submissions total, counting Candidate and Proof, and at most 6 failed signed/password attempts. Probes consume the submission budget but do not by themselves trigger persistent account lockout. The whole authentication exchange has a 120-second deadline, without resets for probes or switching methods. Existing tighter system limits still apply. Unknown users take the same bounded failure path; no guarantee of exact constant-time public-key lookup is made.

The authority logs the originator, logon type and result; the source logs the selected key fingerprint on success, and granted sessions carry their principal/session identifiers. Neither passwords, complete proofs nor private material appear in logs. PGSS wire credentials use the existing self-wiping buffers. Parsed public keys and SSH signatures are short-lived public cryptographic data; neither is a password-equivalent secret or reusable bearer token.

Cryptographic verification failure, unsupported algorithm, revoked key, disabled principal and disallowed method produce generic authentication failure to the remote client. Detailed operational reasons are available only through privileged local diagnostics. Invalid framing and state transitions terminate the relevant conversation; they never become a passwordless attempt.

2.D.9 Qualification requirements #

Required regression coverage includes:

  • Successful key-only and password-only logons, and either-method policy; unsupported method and credential-free downgrade refusal.
  • Rebuilt console/GXWI password logons and explicitly provisioned passwordless accounts, including fresh provisioning, restart, and failed store writes.
  • Candidate acceptance without any assertion, direct Proof, multiple-key clients, budget exhaustion, cancellation, and identity-change refusal.
  • Wrong signature, mismatched algorithm, wrong username/service/key, malformed nested lengths, unsupported versions, and cross-protocol input.
  • Key removal, policy change and account disablement between Candidate and Proof and immediately before assertion; deletion of the last key.
  • Captured proof replay on another SSH connection, an ordinary local PGSS connection, a new monitor, and after authd restart. Include a hostile network child proposing an old key-exchange hash, not just an honest client connecting twice.
  • No PGSS or monitor descriptors, originator service authority, or elevated launch privileges inherited by the principal's shell.
  • The final shell's actual primary token carries the intended principal, logon session, groups and policy, verified against kernel state.

2.D.10 Service operation #

The package dev.peios.openssh provides the Peios OpenSSH server and ordinary SSH command-line tools. Its source repository owns the port and Pekit recipe; there are no downstream source patches in the package catalogue.

All SSH logons use RemoteInteractive, including commands without a PTY and SFTP. Password authentication requires an actual password; an intentionally NoCredential account cannot log in through SSH. TCP, Unix-socket, agent, X11 and tunnel forwarding are disabled in this first port.

The sshd-service.reg and sshd-network.reg vendor seeds are separate image opt-ins. The Experimental edition enables both. make boot in dist/release forwards localhost TCP 2222 to the guest's TCP 22. The service runs as SYSTEM with only SeChangeNotifyPrivilege, SeAssignPrimaryTokenPrivilege and SeTcbPrivilege. The network seed permits inbound TCP 22 and reserves that port for the sshd service SID. The network child loses its privileges and service-group authority, receives an Everyone restricting SID, and retains upstream chroot and seccomp protection. The session child installs the granted principal token before accepting the post-authentication network state.

Defaults are /usr/etc/ssh/sshd_config; administrator overrides may use /lcl/etc/ssh/sshd_config. The port and password authentication are not chosen there. They are machine policy, Machine\Software\OpenSSH Port and PasswordAuthentication (both REG_DWORD, seeded 22 and 1), which start-sshd reads at each start and passes to sshd as -p and -o PasswordAuthentication=; -p makes sshd ignore every Port line. The port, the service SID's reservation of it and the ssh firewall rule's DstPort.Equal must agree, so the package's ssh-settings moves all three in one registry transaction and then restarts the service; see Signing in over SSH. The service generates an Ed25519 host key on first start in protected persistent /var/state/sshd, validates and reuses it on subsequent starts, and fails on an invalid existing key. It never rotates a key automatically. Client private keys remain with the client.

Provision a key-only account while disabled to avoid a temporary NoCredential window:

lps add alice --no-password --disabled
lps key add alice /path/to/alice.pub laptop
lps policy alice key
lps enable alice
lps key list alice

Use lps policy alice either to allow either an enrolled key or an existing password. Administrative key removal uses the record ID printed by lps key list: lps key remove alice RECORD_ID. Self-service removal names the key by fingerprint instead (§2.23). Changing material does not change policy.

2.D.11 References #

  • RFC 4252, section 7: candidate-key queries, direct signed requests, and exact signed fields.
  • RFC 8332: RSA SHA-2 signature algorithm names and reuse of the RSA public-key encoding.
  • OpenSSH Portable monitor: upstream privilege-separation integration reference; it does not by itself establish Peios originator authorization or this contract's trusted-transport freshness requirement.

3.1 Scope and Roles

Peios / Peios technical reference / PGSS / MSIP

This chapter specifies PGSS MSIP, the Multi-Surface Interaction Protocol: the protocol by which a daemon or long-running operation drives one or more generic user-interface surfaces — showing information, reporting progress, and requesting input — without knowing or caring how any of them renders.

Two roles participate.

The daemon owns the work and the decisions. It offers one or more conversation kinds on a socket, issues the conversation's pages, decides what every answer means, and decides what page follows. Only the daemon knows the shape of the conversation.

The surface renders. It draws the elements it is sent, collects the person's input, and returns answers without interpreting them. A TUI, a graphical installer, a web page and an automated agent are all surfaces, and the daemon cannot tell them apart except by what they declare they can render.

The person at the surface is not a party to the conversation, in the same sense as the principal of a logon (§2.1).

Both roles are publicly implementable. A third party MAY ship a new surface for an existing daemon, a new daemon driven by existing surfaces, or both, and interoperate with the other half unchanged. That substitution is the point of the protocol: a daemon that offers a conversational interface to generic surfaces MUST offer it as specified here, precisely so that surfaces need not be written per daemon.

This chapter covers:

  • the shape of a conversation: who opens it, who drives it, how many surfaces may attach, and how concurrent answers are arbitrated (§3.3)
  • what each role must establish for itself rather than believe from a message, and the access control governing conversations (§3.4)
  • message framing and the rules under which the format may be extended (§3.5)
  • how a connection binds to a conversation (§3.6)
  • turns, elements, answers, and mid-turn state (§3.7 to §3.10)
  • how a conversation ends, and what a protocol error is (§3.11)
  • the obligations binding on each role (§3.12)

This chapter does not cover:

  • What any conversation is about. The pages of a particular daemon — an installer's disk selection, a first-boot flow's account page — are that daemon's specification, not this chapter's.
  • How a surface renders any element. Rendering is deliberately unspecified; §3.8 states the little a surface owes the daemon.
  • Unattended operation. A machine-driven answering agent is a surface like any other; the policy by which it answers is outside MSIP.

3.2 Terminology

Peios / Peios technical reference / PGSS / MSIP

Conversation. One instance of a daemon driving one flow: a single installation, one first-boot setup, one authorisation request. A conversation belongs to the daemon, outlives any particular connection, and is identified by an unguessable identifier the daemon assigns.

Kind. The name of a flow a daemon offers — install, oobe. A daemon lists its kinds at greeting time; a surface opens a conversation by kind.

Turn. One page of the conversation. A turn is an ordered list of elements issued as a unit, identified by its seq — a number the daemon increments for every turn of a conversation. The seq is the only identity a surface cites. A turn MAY also carry an id, a stable template identity for surfaces that want to special-case a known page: two turns with the same id MUST present the same set of element refs and types, though their state may differ. An id is not unique within a conversation and is never cited in a message.

Element. One item on a page: a piece of text, an input, a progress report, an action. An element has a type from the negotiated set (§3.6) and a ref.

Ref. The semantic identity of an element: what is being asked, not where it appears. A ref is unique within its turn, stable across turns and across runs of the same daemon, and documented by the daemon's own specification. A ref recurs freely — a question re-asked after a validation failure, or revisited because the person went back, keeps its ref. Nothing about the sequence of turns is stable; only refs and their answer types are.

Action. An element a surface presents as something to press rather than something to fill in. Actions carry no value; an answer names at most one, and the daemon alone knows what it means (§3.9).

Attach. Joining a connection to an existing conversation. Any number of connections may be attached to one conversation at once; every turn and update is broadcast to all of them.

Class. A presentation hint, on turns and on elements: a list of tokens a surface MAY use to style what it renders. Classes promise nothing and are never cited in a message.

3.3 The Conversation

Peios / Peios technical reference / PGSS / MSIP

3.3.1 A surface opens; the daemon drives #

Every conversation is opened by a surface (§3.6). Within it, the daemon speaks first and always: it issues every turn, and a surface never sends anything but an answer to the turn that is outstanding.

The invariant is about who opens, not who has the need. A daemon that wants to put a question to whoever is around — a passphrase prompt at boot, an authorisation request — is served by a surface that opened an agent-style conversation earlier and sat waiting in it. The daemon still drives; the surface still merely opened.

3.3.2 Broadcast, and the first valid answer #

A conversation is not a session with one surface. Every attached connection receives every TURN and every UPDATE, and any of them may answer the outstanding turn.

Arbitration is by seq:

  • The first valid answer to the outstanding turn is applied. The daemon then either issues the next turn — which is the only acknowledgement of acceptance — or ends the conversation.
  • An answer that fails validation does not close the turn. The daemon broadcasts the failure as element errors (§3.10) and the turn remains open to every surface, including the one that failed.
  • An answer to any other seq is stale, and MUST be ignored silently. No reply is owed: the broadcast that made it stale is already on the wire to its sender, and reaching the loser is the transport's job, not arbitration's.

Two people on two surfaces can race each other's intent. That is accepted: access control (§3.4) already admitted both, and the situation is no different from two administrators sharing a shell.

3.3.3 Persistence #

A conversation outlives its connections. A surface that crashes, a terminal that disconnects, a web page that is closed — none of these end the conversation; the daemon carries on, and a new connection may attach and continue where the person left off.

A surface that attaches mid-conversation receives the outstanding turn in its current state — the turn as issued, with every update since folded in (§3.10). It does not receive history. There is deliberately no transcript-replay mechanism in this protocol.

3.3.4 Lifecycle #

A conversation exists from the moment the daemon creates it (§3.6) until the daemon broadcasts END (§3.11), after which its identifier is meaningless and MUST NOT appear in a listing.

A daemon SHOULD bound the life of an abandoned conversation — one with no attached surfaces and no progress — by policy of its own: expiry after an interval, a cancel decision, or indefinite patience are all legitimate. This chapter requires only that whatever happens is reported as an END to anyone attached when it happens.

How many conversations of a kind may exist at once is daemon policy, not protocol. A daemon enforcing a singleton — one ongoing installation, say — refuses nothing: it answers a START for a kind that is already running by attaching the caller to the existing conversation, and says so (§3.6).

3.4 Trust and Access Control

Peios / Peios technical reference / PGSS / MSIP

3.4.1 The asymmetry #

MSIP exists to separate privilege from presentation. The daemon typically holds the authority the work requires; the surface renders, and holds none. The protocol is designed so that nothing a surface sends can be more than a claim, and nothing a daemon sends obliges a surface to do more than draw.

3.4.2 What the daemon must not trust #

Everything a surface sends is input from an untrusted peer. Specifically, a daemon:

  • MUST NOT treat any answer as authorisation. Whether the peer may do what the answer asks is established by the access decision of §3.4, made from the connection's verified peer identity — never from the content of a message.
  • MUST NOT vary its behaviour on Hello.surface (§3.6). It is an observability string.
  • MUST validate every answer against the outstanding turn (§3.9) rather than assume a well-behaved surface.

3.4.3 What the surface must not trust #

A surface renders what it is sent, but:

  • MUST NOT render a secret element's value back to the person beyond the masked input it collects (§3.8), whatever the daemon sends.
  • MUST NOT interpret informational text, and MUST NOT vary its behaviour on the content of names, help text, messages, or classes. Text is for the person; behaviour follows structure only.
  • MUST fail a page it cannot render rather than guess. The negotiation of §3.6 makes this unreachable in a conforming pair; the rule is what a surface does when it is reached anyway.

3.4.4 Who may connect, and who may attach #

Access to the socket is governed by a security descriptor on the socket path, like any object. Offering separate conversation kinds to separately-trusted callers on one socket is the daemon's problem; where the difference matters, the daemon MUST check the connection's verified peer identity per kind at START, and per conversation at ATTACH and LIST.

A conversation's identifier is unguessable (§3.6), but secrecy of the identifier is not the control: the access check at ATTACH is. A LISTING MUST contain only conversations the caller could attach to.

3.4.5 Secrets #

An element with secret set collects material that must not escape:

  • A surface MUST collect it without echo, MUST NOT log it, and MUST NOT retain it beyond the answer that carries it.
  • A daemon MUST NOT place a secret value in any element state, in any UPDATE, or in any error text — the answer is the only message that ever carries it. A late joiner therefore never receives another surface's secret, because the outstanding turn it is sent contains only the ask.
  • Neither role logs a secret value under any verbosity.

3.5 Message Framing

Peios / Peios technical reference / PGSS / MSIP

3.5.1 Header #

MSIP reuses the header layout of §2.6 with its own magic. Every message begins with a 12-byte header:

OffsetSizeFieldValue
04magicMSIP (4d 53 49 50)
42version1
62msg_typeSee §3.A
84total_lenHeader plus body, in bytes

The rules of §2.6 apply unchanged to the magic (checked on every message; wrong magic is an immediate whole-connection failure), to the version (an unimplemented version MUST be refused; here, by closing the connection, optionally after an Error where one can still be encoded), and to total_len (self-delimiting; a decoder MUST reject a declaration exceeding the limit without reading the body, and one smaller than the header).

The high bit of msg_type marks a message sent by the daemon; surface messages have it clear. As in §2.6, this is a readability property — a peer MUST validate the type it received against what it expected, not merely against the direction bit.

A message MUST NOT exceed 1 MiB in total. The limit is larger than the logon channel's because log elements legitimately carry bulk; it is a limit all the same.

3.5.2 Body #

The body is one JSON object, UTF-8 encoded, as defined by RFC 8259. Which object each message type carries is specified in §3.6 to §3.11 and tabulated in §3.A. The message type lives in the header only; the body does not repeat it.

A decoder MUST reject a body that is not valid UTF-8, not valid JSON, or not an object, as a protocol error (§3.11).

3.5.3 Extensibility #

The JSON body extends by different rules than the binary codec, and they are binding on anyone revising this chapter:

  • A decoder MUST ignore an object key it does not know. New fields are added as new keys, and MUST be optional with a safe default, because older peers will not send them and will not read them.
  • An absent optional field and a field carrying its documented default MUST NOT be distinguished.
  • A peer MUST treat an unknown value of an enumerated field — Refused.reason, Error.code, End.outcome — as a protocol error. Adding a value to an enumeration is a breaking change and requires a version bump.

Element types and element state are the deliberate exception, as supported_credential_types is to Logon (§2.6): the set a surface renders is a statement of capability negotiated at HELLO, a daemon MUST NOT send an element type the surface did not declare, and a type's state keys are defined by the element type (§3.B), not by the message format. Adding an element type is therefore not a version bump — an old surface simply does not declare it.

3.6 Binding

Peios / Peios technical reference / PGSS / MSIP

3.6.1 One conversation per connection #

A connection carries at most one conversation, for its whole life. There is no multiplexing: a surface that wants two conversations opens two connections, and closing the connection is how a surface detaches. What a connection may send depends only on which of three states it is in: expecting-hello, unbound, or bound.

3.6.2 Greeting #

The first message on a connection MUST be the surface's Hello:

FieldMeaning
element_typesThe element types this surface can render.
surfaceOptional observability string ("install-tui/0.1").

element_types is a statement of capability. The daemon MUST NOT send this connection an element whose type is absent from it, and MUST refuse a binding whose conversation needs more than the surface declared (unsupported_elements) — refusing at bind time is what makes an unrenderable page unreachable, rather than a failure mid-flow. surface is for logs; the daemon MUST NOT vary behaviour on it.

The daemon replies Welcome, carrying the conversation kinds it offers and its own optional observability string.

3.6.3 The unbound connection #

After WELCOME the connection is unbound. It may send exactly three things:

  • List — the daemon replies Listing: the conversations this caller could attach to, each with its identifier, kind, and current seq. A daemon with nothing listable replies with an empty listing.
  • Start — open a conversation of a kind.
  • Attach — join the conversation named by identifier.

Anything else on an unbound connection is a protocol error, as is a second binding attempt on a bound one.

3.6.4 Binding #

A successful Start or Attach binds the connection and is answered by Bound, carrying the conversation's identifier, its current seq, and attached — true when the caller joined a conversation that already existed. Immediately after a Bound, if a turn is outstanding, the daemon MUST send it to the new connection in its current state (§3.3).

A daemon MAY answer a Start by attaching: a daemon whose policy admits one conversation of a kind answers a second Start with Bound { attached: true } for the existing one. The surface learns what happened from attached and nothing else changes.

A conversation's identifier is assigned by the daemon at creation: 32 lowercase hexadecimal characters encoding 128 bits that MUST come from a cryptographically secure source. It names the conversation in Attach, Bound and Listing, and nowhere else.

A binding that is refused is answered by Refused and leaves the connection unbound:

reasonMeaning
access_deniedThe access check of §3.4 failed.
unknown_kindStart named a kind this daemon does not offer, or Attach named a conversation that does not exist or has ended.
unsupported_elementsThe conversation needs element types the surface did not declare.
unavailableThe daemon offers this kind but could not open a conversation.

A daemon SHOULD prefer unknown_kind over access_denied for a conversation the caller may not know exists, and MUST NOT use Refused for anything but a binding attempt.

unavailable is the daemon's own failure and nothing to do with the surface: the kind exists, the caller may have it, and the surface can render it. A daemon SHOULD say what went wrong in message. Without this reason such a daemon can only hang up, and a surface cannot tell a broken daemon from a slow one — it waits on a turn that will never come, showing nothing.

3.7 Turns

Peios / Peios technical reference / PGSS / MSIP

3.7.1 Issuing #

The daemon advances the conversation by broadcasting Turn:

FieldMeaning
seqThis turn's sequence number.
idOptional template identity (§3.2).
nameOptional display title.
classOptional presentation hints.
elementsThe page, in order.

seq starts at 1 and increments by exactly 1 per turn of a conversation. Issuing a turn implicitly closes the previous one: answers to it become stale (§3.3). A daemon MUST NOT reuse or rewind a seq within a conversation.

The elements are an ordered list, and the order is the daemon's one presentational statement: it MUST order elements helpfully, and a surface SHOULD present them in that order — but MAY depart from it, and a daemon MUST NOT depend on the order being visible. Everything else about layout belongs to the surface. This protocol deliberately has no vocabulary for columns, grouping, emphasis or geometry, and anyone extending it SHOULD refuse the temptation: an element says what is being asked, never how it looks.

3.7.2 Turns that expect no answer #

A turn with no enabled input elements and no enabled actions expects no answer: the daemon advances past it on its own — typically a progress page, updated in place (§3.10) until the work completes and the next turn or the END follows. A surface MUST NOT be required to acknowledge such a turn, and the daemon owes it nothing before moving on.

A turn that does expect an answer stays outstanding until a valid answer is applied (§3.9) or the daemon supersedes it — the daemon remains free to issue a new turn at any time, because the flow is its own.

3.8 Elements

Peios / Peios technical reference / PGSS / MSIP

3.8.1 Common fields #

Every element, of every type, is a JSON object with these fields:

FieldDefaultMeaning
ref—Semantic identity (§3.2). Unique within the turn.
type—An element type from the negotiated set.
nameabsentDisplay label.
class[]Presentation hints.
helpabsentLonger explanatory text.
requiredfalseAn answer that validates must supply a value.
defaultabsentValue assumed when the answer omits this ref.
secretfalse§3.4's handling rules apply.
enabledtrueDisabled elements render but cannot be answered.
errorabsentValidation failure text, set by UPDATE (§3.10).

All further keys are state, defined per element type (§3.B).

name and help are for the person. A surface MAY substitute its own text — translations keyed on the element's ref or the turn's id — and the daemon-supplied text is the fallback; this is the protocol's entire localisation mechanism, and it is why refs and template ids are stable.

3.8.2 Disabled elements #

An element with enabled: false is present but inert: a surface SHOULD render it as visibly unavailable (with its help explaining why), MUST NOT collect input for it, and an answer carrying a value for it is a protocol error. Disabled elements exist so a flow can show what it will someday ask — a capability the daemon does not yet have loses none of its place on the page by being unimplemented.

3.8.3 Inputs, outputs, and actions #

Element types divide three ways, and the division is what a surface may rely on:

  • Output types (text, progress, log) inform. They are never answered, are exempt from required, and exist to be updated in place.
  • Input types (string, boolean, select) collect a typed value, returned in the answer under the element's ref.
  • action is pressed, not filled. Actions carry no value and no unattended meaning beyond flow: an automated surface that can answer every input on a page from its own sources presses the page's primary action and needs to understand nothing else.

3.9 Answers

Peios / Peios technical reference / PGSS / MSIP

3.9.1 Shape #

A surface answers the outstanding turn with Answer:

FieldMeaning
seqThe turn being answered.
actionThe action pressed, by ref. At most one.
valuesInput values, keyed by ref.

values MUST name only enabled input elements of the turn named by seq; a value for an unknown ref, a disabled element, an output element or an action is a protocol error. A value's JSON type must match the element type's answer encoding (§3.B).

3.9.2 Actions #

If the turn has any enabled action, the answer MUST name exactly one of them; if it has none, the answer MUST omit action — the implicit submit. Naming anything that is not an enabled action of the turn is a protocol error.

What an action means — next, back, cancel, begin — is the daemon's business alone. A surface presents actions and reports which was pressed; it MUST NOT ascribe navigation semantics of its own.

3.9.3 Validation #

An answer validates unless the action it names carries validate: false (§3.B) — the escape that lets Back and Cancel leave a half-filled page. When an answer validates:

  • every enabled, required input element MUST have a value in values;
  • an omitted optional input with a default is treated as answering with that default.

The daemon then applies its own domain validation — is that disk still present, is that name well-formed — which this chapter does not constrain.

3.9.4 The three fates #

An answer is exactly one of:

  • Stale — seq is not the outstanding turn. Ignored silently (§3.3).
  • Applied — structurally sound and valid. The acknowledgement is whatever the daemon broadcasts next: the following turn, or END. The winning answer itself is never echoed to anyone.
  • Failed validation — structurally sound, but required values are missing or domain validation failed. The daemon broadcasts an UPDATE setting error on the offending elements; the turn stays open for every surface (§3.3).

A structurally unsound answer — the protocol errors above — is none of these: it is answered by Error on the offending connection only (§3.11), and the turn is unaffected.

3.10 Updates

Peios / Peios technical reference / PGSS / MSIP

The daemon changes the outstanding page without a new turn by broadcasting Update:

FieldMeaning
seqThe outstanding turn.
fullReplacement or patch.
elementsSee below.

A sparse update (full absent or false) carries patches: objects naming an existing element by ref plus the fields that change — a progress value, appended log lines, an error, an enabled flip, a refreshed choices. A patch key set to null removes that field. A patch MUST NOT name a ref absent from the turn, and MUST NOT carry a secret element's collected value (§3.4).

A full update (full: true) carries complete elements and replaces the turn's element list. It is the only way the set of refs may change mid-turn. Changing the set is legal but SHOULD be rare — a page that reshapes itself is usually two turns — and a surface's obligation is only to re-render from scratch, which is why a full update must be complete.

Updates are how progress happens: a working page is one turn with no inputs (§3.7), updated as the work advances, so that every attached surface — and any that attach midway — shows the same state.

3.10.1 Folding #

The daemon MUST fold every update into its stored copy of the outstanding turn, and the turn it sends a late joiner (§3.6) is that folded state. An update is therefore transient on the wire but never lost: attaching and having-been-attached see the same page.

A surface receiving an update for a seq it does not hold MUST ignore it — the TURN that supersedes it is already on the wire. A sparse update naming a ref the surface does not know is a daemon defect; the surface SHOULD fail the conversation rather than guess.

3.11 Ending and Errors

Peios / Peios technical reference / PGSS / MSIP

3.11.1 End #

A conversation ends exactly one way: the daemon broadcasts End.

FieldMeaning
seqThe conversation's final seq.
outcomecomplete, cancelled, or failed.
messageOptional text for the person.

complete is the flow's own success. cancelled reports a decision — a person pressed the action the daemon interprets as abandonment, or daemon policy expired an abandoned conversation (§3.3). failed reports that the work cannot continue. In every case the daemon SHOULD have said more in the preceding turns; message is a last line, not a report.

After END the conversation is over: its identifier MUST NOT appear in a listing, an Attach naming it is refused unknown_kind, and the daemon SHOULD close the connections that were attached. A surface receiving END renders the outcome and is done.

3.11.2 Error #

Error reports a protocol violation on one connection, and nothing else — it is never a flow outcome, never validation, never staleness.

codeViolation
malformedUndecodable body, or a field breaking this chapter's rules.
unboundA message requiring a binding, on an unbound connection — or a second binding attempt on a bound one.
unknown_refAn answer naming a ref the turn does not have.
bad_valueA value on an action/disabled/output element, a wrongly-typed value, or an action rule of §3.9 broken.
unexpected_typeA message type this peer never receives, or one invalid in the connection's state.

An Error is advisory: the sender MAY close the connection after it, and MUST close it when the violation makes further decoding unsafe. Errors are per-connection; the conversation, and every other surface, are unaffected.

3.12 Conformance

Peios / Peios technical reference / PGSS / MSIP

3.12.1 A conforming daemon #

  • Offers its conversations on a socket whose access control is a security descriptor, and checks the connection's verified peer identity at START, ATTACH and LIST (§3.4).
  • Assigns conversation identifiers of 128 cryptographically random bits, and treats the ATTACH access check — not identifier secrecy — as the control (§3.6).
  • Never sends an element whose type the connection's HELLO did not declare, and refuses a binding the surface cannot render, unsupported_elements, at bind time (§3.6).
  • Broadcasts every TURN, UPDATE and END to every attached connection, and sends a new attachment the outstanding turn in its folded state (§3.3, §3.10).
  • Applies the first valid answer; keeps the turn open on validation failure, broadcasting the errors; ignores stale answers silently; answers protocol violations with Error on the offending connection only (§3.9, §3.11).
  • Increments seq by one per turn and never rewinds it (§3.7).
  • Keeps every secret value out of element state, updates, errors, listings and logs (§3.4).
  • Does not vary behaviour on Hello.surface, or on anything else this chapter marks presentational (§3.4).
  • Documents, in its own specification, its conversation kinds, their refs and answer types, and its multiplicity policy per kind.

3.12.2 A conforming surface #

  • Sends HELLO first, declaring exactly the element types it can render, and fails rather than guesses if it is nevertheless sent something it cannot render (§3.4, §3.6).
  • Renders the elements of the outstanding turn, honouring enabled, secret and the input/output/action division; collects no input for disabled or output elements (§3.8).
  • Answers only the outstanding seq, names exactly one enabled action when the turn offers any and none when it offers none, and sends values only for enabled input elements (§3.9).
  • Applies updates by folding, ignores updates for turns it does not hold, and re-renders from scratch on a full update (§3.10).
  • Treats names, help, messages and classes as text for the person, never as instructions to itself (§3.4).
  • Keeps secrets unechoed, unlogged and unretained (§3.4).

Appendix 3.A Message Reference

Peios / Peios technical reference / PGSS / MSIP

Message type values. Surface-sent types have the high bit clear; daemon-sent types have it set (0x8000).

MessageDirectionmsg_type
Hellosurface → daemon0x0001
Listsurface → daemon0x0002
Startsurface → daemon0x0003
Attachsurface → daemon0x0004
Answersurface → daemon0x0005
Welcomedaemon → surface0x8001
Listingdaemon → surface0x8002
Bounddaemon → surface0x8003
Refuseddaemon → surface0x8004
Turndaemon → surface0x8005
Updatedaemon → surface0x8006
Errordaemon → surface0x8007
Enddaemon → surface0x8008

Field tables. A field marked opt may be absent; its default is given in the section that specifies the message.

Hello (§3.6): element_types (array of string), surface (opt string).

Welcome (§3.6): kinds (array of string), daemon (opt string).

List (§3.6): no fields.

Listing (§3.6): conversations — array of {conversation, kind, seq}.

Start (§3.6): kind (string).

Attach (§3.6): conversation (string, 32 lowercase hex).

Bound (§3.6): conversation (string), seq (number), attached (opt bool, default false).

Refused (§3.6): reason (access_denied | unknown_kind | unsupported_elements | unavailable), message (opt string).

Turn (§3.7): seq (number), id (opt string), name (opt string), class (opt array of string), elements (array of element objects, §3.8).

Update (§3.10): seq (number), full (opt bool, default false), elements (array of objects: patches when sparse, complete elements when full).

Answer (§3.9): seq (number), action (opt string), values (opt object, ref → value).

Error (§3.11): code (malformed | unbound | unknown_ref | bad_value | unexpected_type), message (opt string).

End (§3.11): seq (number), outcome (complete | cancelled | failed), message (opt string).

Appendix 3.B Element Types

Peios / Peios technical reference / PGSS / MSIP

The initial set. Each type lists its state fields (beyond the common fields of §3.8) and, for input types, the JSON encoding of its answer value. The set grows by capability negotiation, not by version bump (§3.5); a new type is specified by adding it here.

3.B.1 text — output #

Prose for the person to read.

StateMeaning
textThe content.
detailWhat the content says, as structure. Optional; see Detail, at the end of this chapter.

3.B.2 string — input #

One line of text; with secret, a password collector.

StateMeaning
min, maxLength bounds, in characters.
patternAnchored regular expression the value must match.
placeholderGhost text; not a default.

Answer value: JSON string.

3.B.3 boolean — input #

A yes-or-no.

Answer value: JSON true/false.

3.B.4 select — input #

Choose from what the daemon offers.

StateMeaning
choicesArray of {value, name, help?}, in display order.
multipleZero or more choices rather than exactly one.

Answer value: one choice's value; with multiple, an array of them. Values are compared as exact JSON values.

3.B.5 table — input #

Choose among rows that have more to say than a name: a select whose choices are records. Disks, network interfaces, existing installations — anything a person compares on several facts before picking one.

StateMeaning
columnsArray of {key, name, align?}, in display order. align is left (default) or right; numbers and sizes read better right-aligned.
rowsArray of {value, cells, enabled?, note?, detail?}, in display order. cells maps a column key to display text; a key a row omits renders empty. enabled defaults to true; a false row is listed but is not an acceptable answer. note is a short remark shown with the row — typically why it is disabled. detail is what the row stands for, as structure; see Detail, at the end of this chapter.
emptyText shown in place of the rows when there are none.

Answer value: one enabled row's value. Values are compared as exact JSON values.

A surface that cannot fit every column SHOULD drop columns from the right rather than truncate every cell, so the daemon's column order is also its priority order.

3.B.6 progress — output #

How far along the work is.

StateMeaning
valueProgress so far.
maxThe whole; absent means indeterminate.

3.B.7 log — output #

Accumulating lines of detail.

StateMeaning
linesThe lines so far.
appendIn a sparse patch only: lines to add.

append exists so progress detail need not resend the backlog; the daemon folds appended lines into lines (§3.10), so a late joiner receives the accumulated state.

3.B.8 action — pressed #

StateMeaning
validateDefault true; false skips §3.9 validation.
primaryThe action an unattended answerer presses. At most one per turn.
destructivePressing this destroys something; a surface SHOULD make it hard to press by accident.

Answer value: none, ever. The pressed action travels in Answer.action.

3.B.9 Detail #

A table row and a text element may each carry a detail: an object of further facts about what the words stand for, as structure rather than as text. A row's cells say "931.5 GiB" and nothing of what is on the disk; its detail gives the size in bytes and the partitions. A sentence says the whole disk will be erased, Windows included; its detail gives the disk it is about.

It exists for a surface that knows the page by its template id (§3.2) and draws it as more than its elements. Its keys are the daemon's, documented with the daemon's refs; this chapter defines none. A surface that does not know them MUST ignore detail, and a daemon MUST NOT put there anything a person needs in order to answer: a row's cells and note, and a text's words, remain everything they say to a surface that draws them as they are.

Appendix 3.C Prior Art

Peios / Peios technical reference / PGSS / MSIP

3.C.1 PAM, via PGSS Logon #

The conversation shape — a privileged party that asks for what it needs, an untrusted renderer that collects without understanding, and capability negotiation so nothing unrenderable is ever sent — is PAM's, adopted into this document once already by Logon (§2). Logon's credential exchange is in this sense a specialisation of MSIP: a single-kind, single-surface conversation whose elements are prompts. The two chapters remain separate protocols on separate sockets — a logon conversation is one connection with no attachment, no persistence and no broadcast, and gains from that austerity (§2.3) — but a reader will recognise the shape, and divergence between the two should be deliberate.

3.C.2 Debconf #

Debconf solved this chapter's exact problem — one engine, many frontends — and proved the central discipline: a tiny, semantic vocabulary of question types, with rendering wholly owned by the frontend. It also demonstrates the failure mode this chapter's §3.7 legislates against: its templates grew presentation-shaped extensions the moment a frontend could exploit them. What MSIP does not adopt is the shared question database and its state machine; a conversation is owned by its daemon, not by a global store.

3.C.3 Subiquity #

Ubuntu's installer is the closest modern relative: a privileged server driving replaceable clients (TUI, web) over a wire protocol, with attach-and-resume. It validates the process model — and warns about the API: its per-screen endpoints make each page a bespoke contract, so every new page changes both sides. MSIP's turn/element model is the generalisation that keeps the wire contract fixed while pages change.

3.C.4 Desktop agent protocols #

polkit's authentication agents and systemd's ask-password watchers are the daemon-has-a-need pattern: a session registers a renderer, and privileged software asks through it. §3.3's agent-style conversation is that pattern expressed without a second protocol — the surface still opens; the daemon still drives.

3.C.5 Design influences #

Broadcast with first-valid-answer arbitration is what a conversation that outlives connections forces: once two surfaces can watch, choosing a "driving" surface means takeover semantics and detach protocols; arbitration by seq needs neither, and turn sequence numbers were already required for staleness.

Refs versus seqs — semantic identity apart from sequence — is the split that keeps MSIP a conversation rather than a form language. A stable question list would be a schema; stable refs on an unpredictable path is a dialogue.

4.1 Scope and Roles

Peios / Peios technical reference / PGSS / Icons

This chapter specifies PGSS Icons: how a program, a file, a directory or a kind of thing on a Peios system is given an icon, and where the icon comes from.

The chapter rests on one decision. An icon is a name, not a picture. Every party that has something to say about what a thing looks like says a name, and a name becomes a picture in one place only: the theme chain (§4.4). A program carries its name in its own file; a file's owner may set one on it; a file's own name and its type each yield one. Whoever draws the thing looks the name up, and a theme that provides the same name decides how it is drawn.

Two roles participate.

The provider puts icons where they can be found, and says which one a thing has. A package that installs an icon into a theme is a provider. So is a program that carries the name of its icon, and so is a person who sets a name on a file they own.

The consumer draws. Given a thing, it finds the name by the rules of §4.5 and the picture by the rules of §4.4, and draws what it finds. A launcher, a taskbar, a file explorer and a compositor drawing window frames are all consumers, and a provider cannot tell them apart.

Both roles are publicly implementable. A third party MAY ship a theme, a program carrying an icon, or a consumer, and interoperate with the rest unchanged. A consumer that draws icons for things it did not make MUST find them as specified here, precisely so that a provider need say what a thing looks like once, to everyone.

This chapter covers:

  • what a name is, and which names mean what (§4.3)
  • what a theme is, where themes are, and how a name becomes a file (§4.4)
  • how a consumer finds the name for a file: the sources it consults and the order it consults them in (§4.5)
  • how a file's type yields a name (§4.6)
  • the names every system has (§4.A)

This chapter does not cover:

  • What a program is to a desktop: its title, what it opens, how it is started. That is PGSS Applications (§5), which gives a program an icon by name under §4.5.
  • How a consumer determines a file's type. §4.6 takes the type as given.
  • How a consumer draws a picture, at what size, or in what colour. Rendering is the consumer's.
  • Pictures that are not icons: a thumbnail of a photograph, a preview of a document. A consumer that shows those shows them instead of an icon, by rules of its own.

4.2 Terminology

Peios / Peios technical reference / PGSS / Icons

Icon. A picture that stands for a thing: a program, a file, a kind of file, a directory. In this chapter an icon is always referred to by its identifier, and the picture is whatever the theme chain gives that identifier.

Identifier. The name of an icon: a short string in the form of §4.3. generic, inode-directory and dev.peios.gxwi.hello are identifiers.

Theme. A directory holding one picture per identifier it provides, in the form of §4.4. A theme provides an identifier if the directory holds a file named for it.

Base theme. The theme every system has, on which every other stands. It is where a program's own icon is installed, and it provides every identifier of §4.A.

Chain. The ordered list of themes a consumer looks in. The chain is the system's, not the consumer's: it is set as §4.4 says, and it always ends in the base theme.

Subject. The thing a consumer wants an icon for: a path on the filesystem, usually. §4.5 gives the rules for a path; a specification that gives icons to other kinds of subject says how they yield an identifier.

Source. One place an identifier for a subject may come from: the file's extended attribute, the section a program carries, the file's name, the file's type. §4.5 lists the sources and the order they are consulted in.

Terms defined in PCDS (security descriptor) and in the Peios Kernel TRM (extended attribute) are used with the same meaning and are not redefined. Media type is used as RFC 6838 defines it.

4.3 Identifiers

Peios / Peios technical reference / PGSS / Icons

An identifier is a string of 1 to 255 bytes, each a lowercase ASCII letter, an ASCII digit, ., _ or -, whose first byte is a letter or a digit:

identifier = first *254 rest
first      = %x61-7A / %x30-39
rest       = first / "." / "_" / "-"

A consumer MUST treat a string of any other form as no identifier: it is not looked up, and the source it came from is passed over as though it had said nothing (§4.5). This is what keeps a name from naming a path: no identifier contains /, and none begins with ..

Identifiers are compared byte for byte. There is no case folding, because there is no case.

4.3.1 Meaningful forms #

Some forms of identifier are given meaning by this chapter, and a provider MUST NOT use them for anything else:

FormMeaningDefined in
genericThe icon of last resort§4.5
directory-<name>A directory called <name>§4.5
file-<name>A file called <name>§4.5
<type>-<subtype>A file of that media type§4.6
<type>A file of any subtype of that media type§4.6

The set is closed. A future version of this chapter MAY add a form; a provider MUST NOT invent one.

4.3.2 A program's own #

A program's own icon is named for the program, in the reverse-DNS form its package is named in (PSPU §5.3): dev.peios.gxwi.hello. The package that installs the program installs the icon into the base theme under that name, and the program carries the name (§4.5).

A provider MUST NOT install an icon under a name that is another provider's, and a package manager MAY refuse a package that would. The reverse-DNS form makes that a matter of who owns the domain.

Note

The standard names of §4.A, the forms above and reverse-DNS names cannot collide: a reverse-DNS name has a dot in its first label position where the others have a hyphen or nothing. A theme can therefore provide all three kinds in one flat directory.

4.4 Themes

Peios / Peios technical reference / PGSS / Icons

A theme is a directory under /usr/share/icons/, named for the theme, holding one file per identifier it provides:

/usr/share/icons/<theme>/<identifier>.svg

A theme's name has the form of an identifier (§4.3). A theme is flat: a consumer MUST NOT look in subdirectories of a theme, and a provider MUST NOT put anything there that a consumer is meant to find.

4.4.1 Format #

A theme's file is an SVG document. This version of the chapter recognises no other format: a file with another extension is not an icon, whatever it holds.

An icon is drawn at whatever size the consumer needs, so a provider SHOULD draw for a square canvas and SHOULD make sure the picture reads at 16 pixels. A consumer draws the document as an image and MUST NOT run any script in it.

Note

Vector icons are what let a theme be one flat directory: there is no ladder of sizes to arrange, and no index is needed to find the right rung. A raster format, if a later version admits one, is an extension of the file's name and not of the theme's shape.

4.4.2 The base theme #

The base theme is the directory /usr/share/icons/base/. Every system MUST have it, and it MUST provide every identifier listed in §4.A.

The base theme is where a provider installs a program's own icon (§4.3). Another theme MAY provide the same identifier, and is then consulted first.

4.4.3 The chain #

The chain is the ordered list of themes a consumer looks in. It is set in the registry, so that every consumer on the system looks in the same places:

KeyValueTypeMeaning
Machine\Generic\IconsThemesREG_MULTI_SZTheme names, first consulted first

A consumer MUST append the base theme to whatever the value names, and MUST ignore the base theme where the value names it, so that the chain always ends in the base theme and nowhere else. An entry that is not in the form of an identifier, or names a directory that is not there, is passed over. The value absent, or the key absent, is a chain of the base theme alone.

A consumer reads the chain when it starts and MAY read it again. A change to the chain takes effect for a consumer when it next reads it.

4.4.4 Lookup #

A consumer looks an identifier up by trying each theme in the chain, in order, for a file named for the identifier, and takes the first it finds. The lookup of an identifier no theme provides fails; §4.5 says what a consumer does then.

A consumer MUST check the identifier's form before it looks it up (§4.3). The form admits no / and no leading ., so a well-formed identifier can name nothing outside the theme's directory.

4.4.5 Caching #

Nothing in this chapter requires a consumer to keep any state between lookups, and the flat form of a theme is chosen so that none is needed. A consumer that nevertheless remembers a lookup MUST notice when the theme's directory or the chain changes, or MUST say, in its own documentation, how stale a remembered result may be.

4.5 From a File to an Identifier

Peios / Peios technical reference / PGSS / Icons

Given a path, a consumer finds the identifier to draw by consulting the sources below in order, and taking the first identifier that the chain provides (§4.4). A source that yields no identifier, or one the chain does not provide, is passed over and the next is consulted. If every source is passed over, the identifier is generic, which the base theme provides (§4.A).

A consumer MUST consult the sources in this order and MUST NOT skip one.

4.5.1 A governing specification #

A specification that says what a kind of file means to a desktop MAY also say what icon it has. A consumer that implements such a specification applies it first, and if it yields an identifier the chain provides, that is the icon.

Such a specification MUST yield an identifier, to be looked up under §4.4 like any other, and MUST NOT yield a picture.

Note

This is the step by which a declaration that a program is an application, with a title and the types it opens, gives the program an icon. That declaration is PGSS Applications' (§5.5); this chapter only leaves it the first word.

4.5.2 The owner's word #

A file or directory MAY carry the extended attribute user.pgss.icon, whose value is an identifier. The attribute is the word of whoever owns the file, and is consulted second.

A consumer MUST treat a value that is not in the form of an identifier as no value. A consumer MAY treat a value ending in a NUL byte as though the NUL were absent.

Because the attribute is in the user namespace, whoever can write the file can set it, and it can be set on regular files and directories only. A file that cannot carry an attribute is drawn by the sources that follow.

4.5.3 The program's word #

A regular file that is an ELF object MAY carry a section named .pgss.icon, whose contents are an identifier, optionally followed by a NUL byte and nothing else. The section is the word of whoever built the program, and is consulted third.

A consumer finds the section by the object's section header table, by name, and takes the first section of that name. A consumer MUST NOT execute or load the object to find it, and MUST bound what it reads: a header that points outside the file, a section header table of implausible size, or a section longer than an identifier could be, is no section.

A file that is not an ELF object, or is one without the section, is drawn by the sources that follow.

Note

The section holds a name and not a picture on purpose. A program that carried its own SVG could not be restyled by a theme, and a consumer listing a directory of programs would be handed a picture by every one of them. A name is a few bytes, and it is the theme's to draw.

4.5.4 The name #

The fourth source is the file's own name. For a directory the candidate is directory- followed by the name; for anything else it is file- followed by the name; in both the name is converted to lowercase ASCII first. If the result is not in the form of an identifier, which is so for any name with a space or a non-ASCII character in it, the source yields nothing.

So a directory called Documents is drawn as directory-documents if the chain provides it, and a file called Makefile as file-makefile. Which such names a theme provides is the theme's business; a consumer MUST NOT special-case any.

4.5.5 The type #

The fifth source is the file's type, which yields identifiers as §4.6 says.

4.5.6 Symbolic links #

A consumer that shows a symbolic link as the thing it points to consults the sources for the target. A consumer that shows it as a link consults them for the link itself, and a link, being unable to carry an attribute, is then drawn by its name or its type. A link whose target does not exist is drawn as generic.

4.5.7 What an icon is not #

An icon is what a thing looks like, and is chosen by parties that can say anything. A consumer MUST NOT infer from a subject's icon what the subject is, what it will do, or who made it. A file drawn as a program is not thereby a program.

4.6 File Types

Peios / Peios technical reference / PGSS / Icons

A file's type is a media type, text/x-python, image/png, inode/directory. How a consumer determines it is outside this chapter: by the file's name, by its contents, by a table of its own, or by asking something else. This chapter says only what a type yields once known.

4.6.1 From a type to identifiers #

A media type yields two identifiers, tried in order:

  1. The whole type, <type>/<subtype>, with / replaced by -.
  2. The top-level type alone.

Any character in either that is not permitted in an identifier is replaced by -, and the result is lowercase. So image/svg+xml yields image-svg-xml and then image, and text/x-python yields text-x-python and then text. The second lets a theme draw every kind of text alike and still draw Python apart when it cares to.

A consumer MUST try both, in that order, and pass on to generic (§4.5) when the chain provides neither.

4.6.2 Types every consumer determines #

Whatever else it knows, a consumer MUST determine these:

The file isType
A directoryinode/directory
A regular file that may be executed, and nothing better is knownapplication/x-executable

"May be executed" means what it means to the system the consumer runs on; on Peios, that the file's security descriptor grants the consumer execute access.

4.6.3 Determining a type #

A consumer MAY determine a type from the file's name alone, and a consumer listing a directory usually should: reading every file to sniff its contents is the cost that a name is meant to spare. A consumer that reads contents MUST bound what it reads.

Note

There is no system-wide table of types in this version. A consumer ships its own, which is enough for icons: a type a consumer does not know is drawn as generic, and nothing is misdrawn. A shared table is a matter for whichever chapter first needs two consumers to agree on what a file is, which is more than what it looks like.

4.7 Extension

Peios / Peios technical reference / PGSS / Icons

4.7.1 Additive changes #

The following are additive, and a consumer or provider built to this version behaves correctly in their presence:

  • A new identifier in §4.A. A theme that lacks it is a theme that does not provide it, which every consumer already handles.
  • A new theme in the chain. A consumer built to this version looks in it like any other.
  • A new source in §4.5, consulted after the type. A consumer built to this version draws generic where a newer one would draw the new source's icon.
  • A new governing specification, consulted first under §4.5.

4.7.2 Changes requiring a new version #

  • A new file format in a theme (§4.4). A consumer built to this version does not find the file, so the identifier is not provided, so every subject that relies on it is misdrawn as something further down the chain.
  • A new meaningful form of identifier (§4.3), since the set is closed.
  • A change to the order of §4.5, or a source inserted before the type. Two consumers on one system would then disagree about what a file looks like.
  • A change to the form of an identifier.

4.7.3 Reserved #

The pgss.icon name, in the user extended-attribute namespace and as the suffix of a section name, is this chapter's. A specification that wants a file to carry something else MUST use another name.

4.8 Conformance

Peios / Peios technical reference / PGSS / Icons

4.8.1 A conforming consumer #

  • Treats as an identifier only a string in the form of §4.3, and looks up nothing else.
  • Looks in the themes of the chain in order, in /usr/share/icons/ and in no subdirectory of a theme, for <identifier>.svg, and takes the first (§4.4).
  • Reads the chain from Machine\Generic\Icons Themes, appends the base theme, and ignores the base theme anywhere else in the value (§4.4).
  • Draws an SVG as an image and runs nothing in it (§4.4).
  • Notices a change to a theme or the chain, or documents how stale a remembered lookup may be (§4.4).
  • For a path, consults a governing specification, then user.pgss.icon, then .pgss.icon, then the name, then the type, in that order, and draws the first identifier the chain provides, or generic (§4.5).
  • Finds .pgss.icon by the section header table, without executing or loading the object, and within bounds (§4.5).
  • Derives the name source's identifier by lowercase ASCII conversion and prefix alone, and special-cases no name (§4.5).
  • Yields from a type the whole type and then the top-level type, and determines at least a directory and an executable (§4.6).
  • Infers nothing about a subject from its icon (§4.5).

4.8.2 A conforming provider #

  • Installs a theme as a flat directory of <identifier>.svg under /usr/share/icons/<theme>/, with the theme's name in the form of an identifier (§4.4).
  • Uses a meaningful form of identifier for its meaning and no other, and a reverse-DNS name for a program's own icon (§4.3).
  • Installs a program's own icon into the base theme, under a name within a domain it owns (§4.3).
  • Carries a program's icon as an identifier in .pgss.icon, or sets one in user.pgss.icon, and never a picture (§4.5).

4.8.3 A conforming system #

  • Has the base theme at /usr/share/icons/base/, providing every identifier of §4.A (§4.4).

4.8.4 What conformance does not require #

Conformance does not require a consumer to determine any file type beyond a directory and an executable, to show thumbnails, to cache anything, or to draw an icon at any particular size. It does not require a provider to install more than the base theme.

Appendix 4.A Standard Identifiers

Peios / Peios technical reference / PGSS / Icons

The base theme MUST provide every identifier below (§4.4). Another theme MAY provide any of them. The set is closed for this version; a later version MAY add to it (§4.7).

4.A.1 Last resort #

IdentifierDrawn for
genericAnything no other source gives an icon (§4.5)

4.A.2 Types #

The identifiers the two types every consumer determines yield (§4.6), and the top-level types a consumer is most likely to determine from a name.

IdentifierDrawn for
inode-directoryA directory
application-x-executableA program
applicationA file of any other application/ type
textA file of any text/ type
imageA file of any image/ type
audioA file of any audio/ type
videoA file of any video/ type

4.A.3 Named directories #

The directories a person is most likely to keep, by the names they are most likely to have (§4.5).

IdentifierDrawn for
directory-documentsA directory called Documents
directory-downloadsA directory called Downloads
directory-picturesA directory called Pictures
directory-musicA directory called Music
directory-videosA directory called Videos

Appendix 4.B Prior Art

Peios / Peios technical reference / PGSS / Icons

4.B.1 The freedesktop icon theme and naming specifications #

This chapter is, in outline, freedesktop's: an icon is a name, a theme is a directory that gives names pictures, themes stand in a chain that ends in hicolor, and a standard vocabulary lets one theme restyle a whole desktop. What it drops is the size ladder and the index that the ladder made necessary: with one vector file per name a theme is flat, and there is no icon-theme.cache to rebuild. It also drops the Icon= line of a desktop file as the only way a program gets a name, in favour of the program carrying it.

The name source (§4.5) is not freedesktop's, which gives Documents and Downloads their icons by configured path rather than by name. By name is simpler and needs no configuration, and the editor icon themes that colour a src directory apart from a test one show what it is for.

4.B.2 Windows #

Windows puts the picture in the program: a PE file carries icon resources, a shortcut or a registry DefaultIcon value points at path,index, and the shell extracts what it finds. That gives a raw executable an icon anywhere it is copied, which this chapter keeps by having the program carry a name. It also makes every consumer a parser of executables and every listing an extraction, which is why the shell caches, and why this chapter has the program carry a name and not a picture.

MSIX moved a packaged program's icons out of the binary into image files named in the package manifest, which is the shape this chapter gives a program's own icon: a file installed under a name the program declares.

4.B.3 Design influences #

One resolver. Every source yields a name, and a name becomes a picture in one place, so that a theme restyles programs as well as folders, and so that two consumers on one system cannot disagree about what a file looks like without one of them being wrong.

The owner's word above the program's. user.pgss.icon is consulted before .pgss.icon because an attribute is set on a particular file by whoever has it, and a section is set on every copy by whoever built it. The particular overrides the general, as a custom folder icon overrides the folder icon.

A hint, not a claim. Anyone who owns a file can make it look like anything, as anyone can on any other system. The chapter says so once (§4.5) so that no consumer is built to trust it.

5.1 Scope and Roles

Peios / Peios technical reference / PGSS / Applications

This chapter specifies PGSS Applications: what a program is to a desktop on a Peios system. Its title, the icon it has, how it is started, and what kinds of file it opens.

The chapter rests on one decision. An application is a declaration, not a place. A program is not an application because of the directory it is installed in or the name it is installed under, and a desktop does not find applications by looking at programs. A package that ships an application also installs one small file that says what it is, and the directory of those files is the catalogue (§5.4). Presence is the catalogue: nothing runs at install, nothing registers, and nothing indexes.

Two roles participate.

The provider declares. A package that installs a program and its declaration is a provider.

The consumer reads the declarations and acts on them. A launcher that lists what can be started, a dock or taskbar that names a running program, a file explorer that offers what opens a file, and a compositor that starts what a launcher picks are all consumers, and a provider cannot tell them apart.

Both roles are publicly implementable. A third party MAY ship an application, or a consumer, and interoperate with the rest unchanged. A consumer that lists, names or starts applications MUST find them as specified here, precisely so that a provider need say what a program is once, to everyone.

This chapter covers:

  • what a declaration is, where it is, and what it says (§5.3)
  • what the catalogue is, and how a consumer reads it (§5.4)
  • how a consumer finds the declaration for a program it has met, running or on disk, and so the program's title and icon (§5.5)
  • how a consumer starts an application, bare or with a file (§5.6)
  • which application opens a type of file when several could: the choices a person and the machine make, where they are kept, and how a consumer finds the default from them (§5.7)

This chapter does not cover:

  • How a program that offers to set choices presents them. §5.7 says where a choice is kept and who may write it; a settings application's shape is its own.
  • Declarations of a person's own, or of a system's local layer, beyond what the catalogue's directory admits. This version has one catalogue.
  • How a consumer determines a file's type. §5.6 takes the type as given, as PGSS Icons §4.6 does.
  • What a program does once started, or how a consumer confines it. The environment a consumer starts a program in is the consumer's.
  • What a program looks like. That is PGSS Icons (§4); this chapter gives a program an icon by name, under §4.5, and says nothing about pictures.

5.2 Terminology

Peios / Peios technical reference / PGSS / Applications

Application. A program as a desktop knows it: something with a title that can be started from a launcher or opened with. In this chapter an application is always what a declaration says, and nothing is an application without one.

Declaration. The file that says what an application is, in the form of §5.3. One file declares one application.

Id. The name of an application: the reverse-DNS string the declaration's file is named for, in the form of an icon identifier (PGSS Icons §4.3). dev.peios.gexora is an id.

Catalogue. The directory holding every declaration on the system, in the form of §5.4. A consumer that wants to know what applications there are reads it.

Program. The file a declaration says to run: an absolute path. The program is what a consumer executes, and what a consumer matches a running process against (§5.5).

Title. What an application is called wherever a consumer shows it to a person.

Listed. Whether a launcher shows the application among what can be started. An application that is not listed is still an application: it can be opened with, and a running one is still named by its declaration.

Media type pattern. Either a media type, image/png, or a top-level type and *, image/*, which stands for every subtype of it. A declaration says what an application opens in these.

Candidate. An application that says it opens a file's media type, and so may open the file (§5.6).

Choice. A media type pattern and the id of the application chosen to open it, kept in the registry by a person for themselves or for the machine as a whole (§5.7).

Default. The application a consumer opens a file with when it picks one: the candidate the choices give the file's type, by the order of §5.7. A type no usable choice is for has no default.

Terms defined in PGSS Icons (identifier, chain, consumer as one who draws) are used with the same meaning and are not redefined, and nor are those of the registry (key, value, REG_SZ, CurrentUser\), which are the Peios Kernel TRM's, or security descriptor, which is PCDS's. Media type is used as RFC 6838 defines it. TOML is the format at toml.io, version 1.0.

5.3 The Declaration

Peios / Peios technical reference / PGSS / Applications

A declaration is a file in the catalogue's directory (§5.4), named for the application's id:

/usr/share/apps/<id>.toml

The file's name is the declaration's id. Nothing in the file repeats it, so the two cannot disagree.

5.3.1 The id #

An id has the form of an icon identifier (PGSS Icons §4.3): 1 to 255 bytes of lowercase ASCII letters, digits, ., _ and -, beginning with a letter or a digit. A consumer MUST treat a file whose name, before .toml, is of any other form as no declaration.

An id is reverse-DNS, within a domain the provider owns, in the form a package is named in (PSPU §5.3): the package's own name, or a name beneath it when one package ships more than one application. A provider MUST NOT install a declaration under an id in another provider's domain, and a package manager MAY refuse a package that would.

Note

The id doubles as the default name of the application's icon. PGSS Icons §4.3 has a program's own icon named for its package in the same form, so the common case, one package, one program, one icon, needs no icon line and no second name.

5.3.2 The file #

The file is a TOML document whose top-level table has these keys:

KeyTypeMeaning
titlestringWhat the application is called. One line, not empty. Required.
programstringThe file to run: an absolute path. Required.
argumentsarray of stringsWhat the program is always started with, in order, before anything else. Empty when absent.
iconstringThe identifier of its icon (PGSS Icons §4.3). The id when absent.
descriptionstringOne line on what it is for. None when absent.
opensarray of stringsThe media type patterns it opens. Empty when absent.
listedbooleanWhether a launcher lists it. true when absent.

An example, complete for a file explorer:

title = "Gexora"
description = "Browse the files on this system"
program = "/usr/bin/gexora"
opens = ["inode/directory"]

A provider MUST write a declaration that a TOML 1.0 parser accepts, and MUST NOT rely on any key not in the table above being read.

5.3.3 What a consumer makes of it #

A consumer MUST ignore a key it does not know. That is how a later version of this chapter adds to a declaration (§5.8) without breaking a consumer built to this one.

A declaration is unusable, and a consumer MUST treat it as no declaration, when any of these holds:

  • the file is not a TOML document a TOML 1.0 parser accepts
  • title is absent, is not a string, is empty or is more than one line
  • program is absent, is not a string, or is not an absolute path
  • a key in the table is present with a value of another type

A consumer SHOULD say on its own log which file was unusable and why, so that a broken package can be found, and MUST go on to the rest of the catalogue.

A declaration that is merely odd is set right, not thrown away:

  • an icon that is not in the form of an identifier is treated as absent, and the icon is the id
  • an entry in opens that is not a media type pattern is left out
  • leading and trailing whitespace on title, description and icon is not part of the value

arguments is passed as given, byte for byte, with no whitespace splitting, no quoting and no expansion (§5.6). A declaration is data, and no part of it is a command line.

Note

The program is an absolute path and not a name looked up along a PATH on purpose. A launcher runs what a package installed, and a file of the same name earlier on somebody's path is not it.

5.4 The Catalogue

Peios / Peios technical reference / PGSS / Applications

The catalogue is the directory /usr/share/apps/. Every system MUST have it, and it MUST be readable by anyone who can log on to a desktop: every consumer reads it as the person it runs for.

The catalogue is flat. A consumer MUST NOT look in subdirectories of it, and a provider MUST NOT put anything there that a consumer is meant to find. A file in it whose name does not end in .toml, or whose name before .toml is not in the form of an id (§5.3), is not a declaration and MUST be passed over.

5.4.1 Presence is the catalogue #

A declaration is in the catalogue when its file is in the directory, and out of it when the file is gone. Nothing else is required of a provider: no command at install, no index to rebuild, no notice to any consumer. A package that installs a declaration has shipped an application, and one that removes it has withdrawn it.

A consumer MUST NOT require a provider to do anything beyond installing the file.

5.4.2 Reading it #

A consumer that needs the list reads the directory, and reads each declaration by the rules of §5.3. A consumer that needs one application by id MAY read that file alone, at <id>.toml, after checking the id's form: the form admits no / and no leading ., so a well-formed id can name nothing outside the directory.

A consumer SHOULD leave out of any list a declaration whose program is not a regular file. Nothing is there to start, and a package that is half installed or half removed should not be offered.

A consumer MAY order what it lists however it likes; a provider MUST NOT rely on any order.

5.4.3 Caching #

Nothing in this chapter requires a consumer to keep any state between reads, and a catalogue of a few hundred files is meant to be read whenever a launcher opens. A consumer that nevertheless remembers what it read MUST notice when the directory changes, or MUST say, in its own documentation, how stale a remembered result may be.

Note

This is the shape of PGSS Icons' theme (§4.4): a flat directory that is its own index. The desktop-file databases that other systems rebuild after every install exist because those catalogues could be anywhere and hold anything; a catalogue that is one directory of small files needs none.

5.5 From a Program to its Declaration

Peios / Peios technical reference / PGSS / Applications

A consumer often meets a program before it meets a declaration: a compositor sees a process connect, a taskbar is told of its windows, a file explorer lists an executable. This section says how such a consumer finds the declaration, if there is one, and what it gets from it.

5.5.1 Matching #

A program matches a declaration when the program's file and the declaration's program are the same file. Two paths name the same file when they resolve, through every symbolic link, to the same path, or when they refer to the same inode on the same device. The second is for a system that presents one file at more than one path, as Peios' merged views do (/bin over /usr/bin).

A consumer MUST match by file and MUST NOT match by name: a file called gexora anywhere is not thereby Gexora.

For a running process, the program's file is the executable the process is running, which on Peios is reached through /proc/<pid>/exe. A consumer SHOULD match through the process rather than through the path the process was started by, since the path may since have been replaced.

When more than one declaration names the same program, the consumer takes the one whose id sorts first, byte by byte. A provider SHOULD NOT declare one program twice.

5.5.2 What the match gives #

A program that matches a declaration is that application, and a consumer showing it to a person SHOULD show the declaration's title where it names the program as a whole, such as under a dock's icon, whatever its windows are called. A window keeps its own title.

The declaration gives the program its icon. Under PGSS Icons §4.5, this chapter is a governing specification, and the identifier it yields for a program is the declaration's icon. A consumer that implements both chapters consults it first: if the chain provides the identifier, that is the icon, and if not, the consumer goes on to the sources §4.5 lists after it. A program that matches no declaration is drawn by those sources alone.

A consumer MUST NOT infer from a match anything the declaration does not say. In particular, a declaration says nothing about who built the program or whether it is trusted; it says what a package chose to call it.

5.6 Starting and Opening

Peios / Peios technical reference / PGSS / Applications

5.6.1 Starting #

To start an application, a consumer executes its program with its arguments, in order, as the process's arguments after the program's own name. Each argument is passed as one argument, byte for byte. A consumer MUST NOT pass the program or the arguments through a shell, MUST NOT split, join, quote or expand them, and MUST NOT look the program up along a path: it is absolute.

What else the process is started with, its environment, its working directory, its standard streams, what it is confined by, is the consumer's. A compositor puts its display in the environment so that the program can be an app; a consumer of another kind does whatever its kind does.

A consumer MAY let a person add arguments of their own, as a launcher does with the words typed after the application's name. They follow the declaration's.

5.6.2 Opening #

To open a file with an application, a consumer starts it as above with one more argument last: the file's path, absolute. A file's path is never put anywhere but last, and there is no placeholder syntax in this version: a declaration whose arguments contains %f or {file} has an argument that is those characters.

An application whose opens contains a pattern that a file's media type matches is a candidate to open the file. A media type matches type/subtype when it is equal to it, ignoring case, and matches type/* when its top-level type is type. How the consumer knows the file's type is its own, as in PGSS Icons §4.6.

A consumer that opens a file offers the candidates, or picks one of them; which it does is the consumer's. A consumer that picks MUST pick the file's default when its type has one (§5.7), and picks by a rule of its own only when it has none. A consumer that offers SHOULD offer the default first, or otherwise show which it is. A consumer MUST NOT read a default from anywhere §5.7 does not name.

An application with no opens is a candidate for nothing, and is started bare.

5.6.3 From the catalogue, not from the request #

A consumer often starts an application because something asked it to, and the something is not always trustworthy: a page in a browser, a message from another process. A consumer that starts an application on a request that names it by id MUST take the program and the arguments from the catalogue as it stands at that moment, and MUST NOT take a program or arguments from the request. The request says which application; the declaration says what runs.

A request that names an id the catalogue has no usable declaration for starts nothing.

Note

This is why a declaration is data and never a command line, and why the program is matched by file (§5.5) and named by absolute path (§5.3). A consumer that follows this section cannot be made to run anything a package did not install, whatever it is asked.

5.7 Defaults

Peios / Peios technical reference / PGSS / Applications

A person may choose which application opens a type of file, and so may whoever administers the machine, for everyone on it. Each such choice is one value in the registry. The default for a media type is the application the choices give it, by the rules of this section, and it is what a consumer that picks among the candidates (§5.6) opens a file with.

5.7.1 Where choices are kept #

There are two places, each a registry key:

KeyWhose choices
CurrentUser\Generic\Applications\DefaultsThe person's. The kernel routes CurrentUser\ to the caller's own Users\<SID>\, so each person has their own.
Machine\Generic\Applications\DefaultsThe machine's: everyone's on it, for whatever they have not chosen themselves.

Each value of either key is one choice:

PartIs
NameA media type pattern (§5.2): the type the choice is for, image/png, or a top-level type and *, image/*.
TypeREG_SZ.
DataThe id (§5.3) of the application chosen, in UTF-8, with or without a terminating NUL.

The registry compares value names ignoring case, as media types are compared (§5.6), so one key holds at most one choice for a type however it is spelt. A writer SHOULD write the name in lowercase. A consumer reads the name ignoring case, and the data with leading and trailing whitespace removed.

A value whose name is not a media type pattern, whose type is not REG_SZ, or whose data is not in the form of an id is no choice. A consumer passes over it, SHOULD say on its own log which value it passed over and why, and goes on to the rest of the key.

A key that is absent has no choices, and neither has one the consumer may not read. Neither is an error. Anything in either key other than its values, such as a subkey, is reserved (§5.8) and a consumer MUST ignore it.

The person's choices are those of the person the file is opened for. A consumer that runs as that person reads them through CurrentUser\. A consumer that runs as anyone else MUST NOT read its own CurrentUser\ for them, since that is its own key and not the person's; it reads the person's Users\<SID>\Generic\Applications\Defaults if it may, and otherwise uses the machine's choices alone.

5.7.2 Finding the default #

The default for a file whose media type is type/subtype is found by trying these choices in order and taking the first that is usable:

  1. the person's choice named type/subtype
  2. the person's choice named type/*
  3. the machine's choice named type/subtype
  4. the machine's choice named type/*

A choice is usable for the file when the catalogue has a usable declaration for the id it names (§5.3, §5.4), and that application is a candidate to open the file (§5.6). A choice that is not usable is passed over and the next is tried. It is never an error: an application that has been removed, or that no longer says it opens the type, is simply no longer anybody's default, and nothing need be done about the choice that named it. A consumer MAY say on its own log that it passed one over.

When no choice is usable, the type has no default, and a consumer that picks uses its own rule among the candidates (§5.6).

A choice selects among the candidates and never adds one. A consumer MUST NOT open a file with an application that is not a candidate for it, whatever a choice says.

Note

The person's choices come before the machine's whichever is the more particular. A person who chooses one application for image/* means every image, including the types the machine has chosen something else for; the machine's choices are what a person has not decided, not what overrules them.

A consumer reads the choices when it picks, or remembers them under the rule of §5.4 for a remembered catalogue: it MUST notice when either key changes, or MUST say, in its own documentation, how stale a remembered choice may be.

5.7.3 Setting them #

A person sets their own choices by writing values in their own key, and clears one by deleting its value. Whoever may write the machine's key sets the machine's in the same way. A writer creates each key of the path that is not there yet, one level at a time: the registry creates no key on the way to another.

Who may write each key is that key's security descriptor, and nothing else. A system MUST let every person who can log on to a desktop read the machine's key, and SHOULD let only administrators write it, as a Peios system's Machine hive does from its root: SYSTEM and Administrators have full control, and Authenticated Users read. A person's own key is theirs by the descriptor of their part of the Users hive.

A program that offers to set choices, such as a settings application, MUST find out whether it may write a key by asking the registry: by opening the key for writing values, or, where the key is not there yet, the nearest key above it that is for creating subkeys. It MUST NOT infer the answer from the groups the person is in, since the descriptor, and not membership, is what the registry checks.

A writer SHOULD offer, for a type, only the applications that are candidates for it, and for a pattern type/*, only those that say they open type/*. It is not an error to write anything else: a choice that is not usable is passed over when it is read.

Note

Any program running as a person can change that person's choices, as it can change anything else of theirs. This section does not try to prevent it, since a program running as the person could as easily do whatever it would have done by being the default. What it does prevent is a choice opening a file with something that is not a candidate, or with something a package did not install.

5.8 Extension

Peios / Peios technical reference / PGSS / Applications

5.8.1 Additive changes #

The following are additive, and a consumer or provider built to this version behaves correctly in their presence:

  • A new key in a declaration (§5.3). A consumer built to this version ignores it, and a declaration that carries it is usable by that consumer as far as the keys it knows go. A new key MUST be optional, and its absence MUST mean what this version's consumers do.
  • A new kind of consumer. A provider has nothing to do for it.
  • A new declaration in the catalogue, and a new provider.
  • A second place for declarations, consulted after the catalogue, such as a person's own. A consumer built to this version does not see it, and shows the system's applications where a newer one would show more.
  • A subkey of a key of §5.7, or a new key beside Defaults under either Applications key. A consumer built to this version ignores them, and finds the same defaults as before.

5.8.2 Changes requiring a new version #

  • A change to the catalogue's directory, or a subdirectory a consumer is meant to look in (§5.4). A consumer built to this version does not find what is there.
  • A new file format or extension for a declaration (§5.3).
  • A meaning for any sequence of characters in arguments, such as a placeholder for a file. A consumer built to this version passes it through as it is, so an application that relied on it would be started wrongly.
  • A place for the file's path other than last (§5.6).
  • A change to what makes a declaration unusable (§5.3), or to the match rule (§5.5). Two consumers on one system would then disagree about what is installed, or what a program is.
  • A change to where choices are kept, to the form of a choice, or to the order they are tried in (§5.7), including a place for choices consulted between the person's and the machine's, such as a group's. Two consumers on one system would then open one file with different applications.

5.8.3 Reserved #

The directory /usr/share/apps/ is this chapter's, and so is the .toml extension within it. The keys named in §5.3 are this chapter's: a specification or a provider that wants a declaration to carry something else MUST use another key, and SHOULD prefix it with a domain it owns.

The registry keys Machine\Generic\Applications and CurrentUser\Generic\Applications, and everything in them, are this chapter's. Nothing else may keep anything there.

5.9 Conformance

Peios / Peios technical reference / PGSS / Applications

5.9.1 A conforming consumer #

  • Reads declarations from /usr/share/apps/ and no subdirectory of it, from files named <id>.toml with the id in the form of §5.3, and passes over any other file (§5.4).
  • Requires nothing of a provider beyond the file's presence (§5.4).
  • Reads a declaration by the rules of §5.3: takes the keys it knows, ignores the rest, treats an unusable declaration as none and goes on, and sets an odd one right as §5.3 says.
  • Checks an id's form before making it a path (§5.4).
  • Notices a change to the catalogue, or documents how stale a remembered read may be (§5.4).
  • Matches a program to a declaration by file, never by name, and through the process for a running one (§5.5).
  • Yields the declaration's icon as the identifier for the program under PGSS Icons §4.5, and goes on to §4.5's other sources when the chain does not provide it (§5.5).
  • Starts an application by executing its program with its arguments as given, without a shell, splitting, quoting, expansion or path lookup, and with a file's path last (§5.6).
  • Takes the program and arguments from the catalogue, never from a request that names an id (§5.6).
  • When it picks among the candidates, picks the default where the type has one, and offers it first, or shows which it is, when it offers them (§5.6).
  • Finds the default from the person's choices and then the machine's, each for the exact type and then for its top-level type and *, and passes over a choice that is malformed or not usable without treating it as an error (§5.7).
  • Reads the person's choices as the person the file is opened for, and never through a CurrentUser\ of its own on their behalf (§5.7).
  • Opens a file with nothing that is not a candidate for it, whatever a choice says, and reads a default from nowhere but §5.7's keys (§5.6, §5.7).
  • When it offers to set choices, finds out whether it may by asking the registry, never by the person's groups (§5.7).
  • Infers nothing about a program from a match beyond what the declaration says (§5.5).

5.9.2 A conforming provider #

  • Installs a declaration as /usr/share/apps/<id>.toml, with the id in the form of §5.3 and within a domain it owns.
  • Writes a TOML 1.0 document with a one-line title and an absolute program, and relies on no key outside the table of §5.3.
  • Does not rely on any order of listing, on any consumer's reading a key it does not know, or on any placeholder in arguments (§5.3, §5.6).
  • Does not declare one program twice (§5.5).

5.9.3 A conforming system #

  • Has the catalogue at /usr/share/apps/, readable by anyone who can log on to a desktop (§5.4).
  • Lets anyone who can log on to a desktop read Machine\Generic\Applications\Defaults where it is present, and SHOULD let only administrators write it (§5.7).

5.9.4 What conformance does not require #

Conformance does not require a consumer to list applications in any order, to show a description, to let a person add arguments, to cache anything, to determine any file's type, or to offer to set choices. It does not require a provider to declare every program it installs: a program with no declaration is a program, and is still drawn and run as one.

Appendix 5.A Prior Art

Peios / Peios technical reference / PGSS / Applications

5.A.1 The freedesktop desktop entry specification #

This chapter is, in outline, a desktop entry: one small file per application, installed by its package into a shared directory, with a name, an icon, an executable and the MIME types it handles. title is Name, icon is Icon, opens is MimeType, and listed = false is NoDisplay=true without the double negative.

What it drops is Exec. A desktop entry's Exec is a command line with its own quoting rules and a set of field codes, %f, %F, %u, %U, %i, %c, %k, that every consumer must parse and expand, and that have been the source of most of the specification's errata and several of its security advisories. This chapter has program, an absolute path, and arguments, an array that is passed as it is, with the file last. There is nothing to parse and nothing to expand.

It also drops Categories, Keywords, Actions, TryExec, Terminal, StartupNotify and the localised forms of every key, along with update-desktop-database and the mimeinfo.cache it builds. A first version needs a title, a program, an icon and the types it opens, and a directory that is its own index.

5.A.2 Windows #

Windows has three answers to what an application is, and this chapter takes something from each. A Start Menu shortcut is a file in a shared directory whose presence is the listing, which is the shape of §5.4. App Paths in the registry lets a name resolve to an absolute path without a PATH lookup, which is why program is absolute. Registered applications and Default Programs let a program say what it opens, which is opens. The default Windows keeps beside it, a machine's in HKEY_LOCAL_MACHINE and a person's own UserChoice under HKEY_CURRENT_USER, is the shape of §5.7: a person's choice before the machine's, each a registry value.

What this chapter does not take is the registry as the place for declarations. Peios has a registry and does not use it for them, because a package installs files, and a file is present or it is not: there is no second thing to keep consistent with the first. A choice is another kind of thing. It is not installed by a package but made by a person, it belongs to someone, and who may change it is a question of access control. Keeping who-may-change-what is what the registry is for, and CurrentUser\ gives each person their own place without a file in their home directory for every consumer to find.

Windows protects UserChoice with a hash, so that a program cannot quietly make itself a person's default. §5.7 does not: a program running as a person can already do whatever it would have done by being the default, so the hash protects little that the person's own token does not already give away. What a choice can never do here is add a candidate, or run anything a package did not install.

5.A.3 freedesktop MIME applications #

The freedesktop MIME applications specification keeps defaults in mimeapps.list files, a person's in their configuration directory before the system's, under [Default Applications], with a list of applications per type to try in order. §5.7 keeps the order, the person's before the system's, and takes one application per choice rather than a list: a choice that is not usable falls through to the machine's, which is the second entry such a list exists for. It drops the [Added Associations] and [Removed Associations] sections, which let a person make an application a candidate for a type it does not declare, or stop it being one; here a candidate is what a declaration says, and nothing a person keeps can change it.

5.A.4 MSIX #

An MSIX manifest declares an application's entry point, display name and visual assets in one document the package ships, and the package manager reads it at install. That is the shape of a declaration, kept as a file the package installs rather than something the package manager extracts, so that presence is enough.

5.A.5 Atrium #

Atrium was Peios' previous web shell and has been completely replaced by GXWI. Its historical application catalogue influenced this chapter's "presence is the catalogue" rule: installing a declaration is enough; nothing registers at install time.

This chapter carries forward TOML declarations and names derived from the installed file, rather than a separate id key. The former Atrium model loaded a web document from an application directory; PGSS Applications declares a program to run and an icon name for PGSS Icons to resolve.

5.A.6 Design influences #

A declaration, not a place. An application is what a file says it is, and not where a binary sits. A program can be a command and an application at once, a wrapper or a script can be an application, and nothing about a program's path has to change for it to become one.

Matched by file, named by path. A running process is matched to its declaration by the file it runs, and never by its name, so that no program can borrow another's title by being called the same thing. A declaration names its program absolutely for the same reason.

Data, never a command line. The one thing every consumer does with a declaration is start a process from it, often because something untrustworthy asked. Passing the program and arguments through exactly as installed is what makes that safe, and is why there are no placeholders, no shell and no lookup.

One directory that is its own index. As with PGSS Icons' themes, the flat directory is chosen so that no state has to be built, rebuilt or kept consistent, and so that a package's install and remove are the whole of the provider's work.

A choice selects; it never adds. What may open a file is what declarations say, and a person's or the machine's choice only says which of those comes first. A choice that no longer fits is passed over rather than repaired, so that removing an application, or a package changing what it opens, needs nobody to tidy the registry after it.

6.1 Scope and Roles

Peios / Peios technical reference / PGSS / Events

This chapter specifies PGSS Events: what an event on a Peios system is called, what its payload carries and how, when it is written at all, and how the vocabulary is catalogued.

The chapter rests on one decision. A name is policy. Every dot in an event type is a place an administrator can attach a security descriptor, and every field in a payload is an object an access check can grant or deny. Two spellings of one value are two objects, and a descriptor that denies one does not deny the other. So the names in this chapter are fixed with the same care as an access mask, and each value has exactly one of them.

Two roles participate.

RoleObligation
EmitterWrites events. Everything an event is named, carries and encodes is an emitter obligation, as is deciding whether to write it at all (§6.9) and describing it in a fragment (§6.10).
ConsumerReads events, or the catalogue that describes them. A store, a viewer, a forwarder and a policy editor are all consumers. Every rule about interpreting a name or a value binds the consumer.

A kernel subsystem is an emitter, and so is a daemon, a service and an application. The emitter role does not depend on the path an event takes to the stream: an event a daemon writes through the emission system calls and an event the kernel writes on a daemon's behalf (§6.7) are both held to this chapter.

Both roles are publicly implementable. A third party MAY ship an emitter or a consumer and interoperate with the rest unchanged. That is the reason the vocabulary is specified rather than left to each component: a consumer that searches for a principal searches every emitter's events with one query, and an emitter that names its fields as specified is found by every consumer without either knowing the other.

This chapter covers:

  • the form of an event type, and who may use which root (§6.3)
  • the form of a field path, the roles and domains a path leads with, and the names under which a consumer exposes the record header (§6.4)
  • the encoding of every kind of value (§6.5)
  • how an event records success and failure (§6.6)
  • how access decisions are recorded, including those a userspace component makes about objects it guards (§6.7)
  • what is an event rather than a log line or a metric, and the tier each event declares (§6.8)
  • the emission policy that decides whether an event type is written at all (§6.9)
  • the catalogue of event and field definitions, and the rules a definition must pass (§6.10)

This chapter does not cover:

  • The record format, the header layout and the ring-buffer protocol a consumer follows to drain events. These are PSPK §2.
  • How a process or kernel subsystem writes an event to the stream. The emission interfaces are described in the Peios Kernel TRM, and the libraries that wrap them in the SDK documentation.
  • Storing, retaining and querying events, and per-field read access. The query language is PSPU §3; one implementation's storage and retention are described in the eventd TRM.
  • The meaning of each event and field. That is reference material, generated from the catalogue (§6.10) into the Events reference book.
  • Log lines and metric samples, which are PSPU §3's. This chapter says only how to tell them from events (§6.8).
  • How the kernel's access-check interface accepts and copies an audit context. This chapter says what a component passes (§6.7); the interface is described in the Peios Kernel TRM.

6.2 Terminology

Peios / Peios technical reference / PGSS / Events

Event type. The dotted name an event is written under, in the form of §6.3. kacs.audit.access.checked is an event type.

Segment. One dot-separated part of an event type or of a field path.

Root. The leading segments of an event type that name its owner: a platform root, or a package name (§6.3).

Platform root. A short root reserved for one platform component, listed in §6.A.

Field. One value in an event, named by its path. A field is either in the payload or, for the header fields of §6.4, in the record header.

Path. The dotted name of a field. In a payload, each segment is a map key and the path is the chain of maps leading to the value (§6.4).

Role. The leading segment of a path that names a participant in the event: subject, object, source, destination or emitter (§6.4).

Participant. A party to an event that a role names: who acted, what was acted on, where something came from or went to, and who wrote the record.

Domain. The leading segment of a path that is not a role. A domain names a property of the event itself, such as outcome, or a thing the event happened within, such as a ring buffer (§6.4).

Thing. The segment after a role that says what kind of participant it is: token, process, file, key.

Variant. A field that is another reading of a value with its own field: the value before a change, the value asked for, the value expected. A variant is named by a qualifier (§6.4).

Fragment. A file describing events and fields, in the form of §6.10. The catalogue is every fragment installed on a system.

Tier. How much an event matters to an operator, declared per event type: essential, standard, verbose or debug (§6.8).

Essential. The tier of an event that is never switched off (§6.8).

Emission policy. The registry tree that switches event types on and off (§6.9).

Gating. What decides whether one occurrence of an enabled event type is recorded, where something other than the emission policy decides it: a SACL, a token's audit policy. Declared per event type in its fragment.

Asserted field. A field whose value, on a kernel-originated record, may have been supplied by userspace rather than observed by the kernel (§6.7).

Terms defined in PSPK §2 — event, header, payload, origin class, sequence — are used here with the same meaning and are not redefined, and nor are those of PCDS (SID, GUID, LUID, security descriptor, SACL, ACE), those of the registry (key, value, REG_DWORD, Machine\), which are the Peios Kernel TRM's, or package name, which is PSPU §5.3's. MessagePack is the format of the MessagePack specification, version 2.0.

6.2.1 Notation #

6.2.1.1 Field types #

A field's type is written storage.semantic: how the value is stored on the wire, then what it means. bin.sid is a binary SID, uint.time a point in time carried as an unsigned integer. One name carries both, so a reader knows the wire form and the rendering without a lookup. A type followed by [] is an array of that type. The types are listed in §6.5.

6.2.1.2 Pseudocode #

The pseudocode in §6.9 uses the corpus conventions of the Conventions book §2.3, and in addition:

NotationMeaning
and, or, notBoolean operators
for each x in listIteration in list order
absentNo value: a key or registry value that does not exist
returnEnds the procedure with the value given

6.3 Event Types

Peios / Peios technical reference / PGSS / Events

An event type reads left to right as who wrote it, what it is about, and what happened:

<root>.<noun>[.<noun>…].<verb>

kacs.audit.access.checked, peinit.job.started and org.jellyfin.server.playback.started are event types.

6.3.1 Form #

An event type MUST have at least three segments: a root, at least one noun, and a verb.

Every segment other than those of a package-name root (below) MUST match:

[a-z][a-z0-9]*(-[a-z0-9]+)*

That is, lowercase ASCII, kebab-case within a segment, and no underscores, capitals or empty segments. The event type MUST NOT contain /.

6.3.2 Roots #

The root names the owner of the event type.

A platform component has a platform root of one segment, listed in §6.A. A platform root names a component, not a package: several components shipped in one package each have their own root. An emitter MUST NOT use a platform root it is not listed against.

Every other emitter roots its event types at its own package name (PSPU §5.3), split into segments at its periods. A package named org.jellyfin.server writes org.jellyfin.server.playback.started. This holds for first-party applications as much as for third parties. An emitter MUST NOT root an event type at a package name that is not its own.

A platform root MUST NOT be a top-level domain in the DNS root zone, so that no platform root can be mistaken for the first segment of a package name. netd is a valid platform root; net is not.

Note

A package name may contain characters the segment grammar above does not admit — a leading digit, or +. Its segments are used as they are. A query naming such a type has to quote it, because the query language's unquoted identifiers start with a letter and exclude + (PSPU §3.19).

A consumer can derive an event type's owner from its name: a platform root through §6.A, and a package-name root as the longest installed package name its leading segments spell. A consumer MUST NOT treat the root as evidence of who wrote the event. The record header is that evidence (§6.4): nothing in this version prevents an emitter from writing under a root it does not own.

Note

Enforcing root ownership needs a way to prove which package a running program belongs to, which Peios does not yet have. It is left to a later attested-events mechanism rather than approximated now. Until then, review of a package submitted to the Peios package repository includes checking that its events use its own root.

6.3.3 Nouns #

After the root come one or more nouns, from general to specific.

An emitter SHOULD add a noun segment only where someone would plausibly write a policy on the set of event types beneath it. A middle segment that groups nothing anyone would grant, deny or switch off together costs every name a segment and buys nothing; tidiness alone is not a reason. The exception is a segment needed so that two event types do not collide.

Depth is preferred to compounds: lcs.audit.backup.started, not lcs.audit.backup-started. A compound word within a segment is for a single concept that has no useful parent, such as copied-up.

6.3.4 The verb #

The last segment is a past-tense verb saying what happened: created, started, ended, checked, used, refused.

The verb SHOULD be neutral where a neutral verb still says what happened, and MUST NOT be so neutral that it does not. installed is right for a package installation even when the installation failed, because the failure is carried in outcome.success (§6.6) and any consumer can show it. transaction.ended is wrong: it does not say what the transaction was.

An action that can succeed or fail is one event type (§6.6). An event type that is only ever written on failure MAY say so in its verb, as stratafs.mutation.refused does.

Distinct steps of a lifecycle are distinct event types: peinit.job.created, peinit.job.started and peinit.job.ended are three things that happen, not three outcomes of one.

6.3.5 Every dot is a policy anchor #

A consumer that controls read access per event type resolves an event's descriptor by trying the full type, then each shorter dotted prefix (PSPU §3.28). A descriptor on kacs.audit therefore covers the audit trail without the rest of KACS, and the emission policy is keyed the same way (§6.9). This is why the noun rule above is about policy, and why the segments are fixed rather than left to taste.

Note

A form with a slash, dev.peios.peinit/job.created, was considered and rejected. In log origins a slash separates a producer from a part of it below policy granularity, and the descriptor resolver stops at the first slash. Every event type would then resolve only at its package, and the slash is not an identifier character in the query language, so every query would need quotes.

6.4 Fields

Peios / Peios technical reference / PGSS / Events

A field is named for the part it plays in the event, then for what it is, then for which property of it the value is:

<role>.<thing>[.<thing>…].<attribute>      subject.token.sid
<domain>[.<…>].<attribute>                 outcome.success

6.4.1 Nesting is the name #

A payload is a MessagePack map with string keys. A field's path is the chain of keys leading to its value: subject.token.sid is the value at {subject: {token: {sid: …}}}. An emitter MUST encode a path as nested maps, one map per segment, and MUST NOT write a key containing .; such a key is a different and unreachable field (PSPU §3.22).

Every segment of a path MUST match the segment grammar of §6.3, including the segments of a package name that a third party's own fields sit beneath (below). A package whose name has a segment the grammar does not admit cannot define fields of its own.

A path is either a value or a map, never both. Once any event carries a value at a path, no field may be defined beneath that path, and a path defined as a map MUST NOT carry a value in any event. The shape of a subtree is therefore decided before its first field is defined, and the variant rule below exists so that it rarely has to be.

6.4.2 Roles #

A path that describes a participant leads with its role. The roles are a closed set.

RoleParticipant
subjectWhose authority the action used: the principal that acted
objectWhat the action was done to
sourceWhere something came from: the origin of a copy, of traffic, of data a backend supplied
destinationWhere it went
emitterWho was running when the record was written

After the role comes the thing, and then the attribute: subject.token.sid, object.file.path, source.stratum.index.

The emitter and the subject are different participants. The emitter is whoever wrote the record; the subject is whoever acted. They are the same on most events, and differ on every event one component writes about another: an access check a daemon requests for a client has the daemon as emitter and the client as subject. A consumer searching for what a principal did MUST search subject.*, not emitter.*.

An event about an action MUST carry subject.token.sid, the SID of the principal that acted, whether that principal is the caller whose token the action ran under or the emitter acting on its own authority. The header carries only token GUIDs (below), never a SID, so the SID is a payload field in every case.

6.4.3 Domains #

A path that does not describe a participant leads with a domain, then the attribute: outcome.success, access.granted.

A domain names either a property of the event itself, or a thing the event happened within without acting on it: a ring buffer, a connection-tracking flow, a transaction. A thing the event acts on is a participant and takes a role (object.mount.path); a thing it happens within leads with its own name (buffer.fill, flow.state).

The generic roots are shared by every emitter and defined in the platform fragment (§6.10):

RootHolds
subject, object, source, destination, emitterThe participants, and their common things: token, process, file, key, session
eventThe record's own header values (below)
accessRights asked for, granted and audited
outcomeWhether the action succeeded, and why not (§6.6)
triggerWhatever caused the record to exist: an audit ACE, a blocking layer, a causing file
operationWhat was attempted and how
configA configuration value reported on
policyA rule set in force
transactionA transaction an operation belonged to
fieldsProperties of the record's fields (§6.7)

A platform component MAY define a domain of its own for things only it has, such as a ring buffer or a service graph, and owns it (§6.10). Any other emitter's own fields go beneath its package name (below), because a domain is one segment wide and two vendors' choices would collide.

6.4.4 The header #

The record header (PSPK §2) is stamped by the kernel and cannot be forged by the emitter. A consumer that exposes header values by name MUST expose them under these field paths:

Header field (PSPK §2)Field pathType
typeevent.typestr
timestampevent.timeuint.time
sequenceevent.sequenceuint
cpu_idevent.cpuuint
—event.boot.guidbin.guid
origin_classemitter.classuint.enum
effective_token_guidemitter.token.guidbin.guid
true_token_guidemitter.true-token.guidbin.guid
process_guidemitter.process.guidbin.guid

event.boot.guid is not in the record: it is the boot the consumer read the record in, which the consumer supplies. emitter.class is a uint.enum whose numbers are PSPK §2's origin classes, which is why it is not a string (§6.5).

An emitter MUST NOT write any of these paths in a payload. Details of the emitting process that the header does not carry — its PID, name and executable — are payload fields under emitter.process.

6.4.5 Things and their relations #

A thing related to a participant nests under it: the parent of a process is object.process.parent.pid, the target of a registry symlink is object.key.target.guid, the token a job runs as is object.job.token.sid.

Two things of one kind that appear in one event are told apart by direction where there is one: source and destination. Where there is no direction, the second is named by its relation to the first, as above. A path MUST NOT use other, peer or a similar segment to tell two participants apart.

key as a thing means a registry key and nothing else. A cryptographic key is a signing-key, a handle is a handle.

capability as a thing or attribute means a confinement capability SID. A Linux capability is linux.cap, which is context for a privilege decision and never the decision itself.

6.4.6 Many values #

Many values of one field go in an array under a plural name: subject.token.groups. Arrays that describe the same items are parallel and share an index: subject.token.group-attributes[i] describes subject.token.groups[i].

6.4.7 Variants #

A variant of a value is a sibling of it, named with a qualifier suffix, and the plain name always holds the value in force:

PathValue
policy.generationThe generation now in force
policy.generation-previousThe generation it replaced
object.key.pathThe key's resolved path
object.key.path-requestedThe path as the caller wrote it

A qualifier MAY also sit on a thing, where the variant is a whole thing: source.stratum-previous.path.

The following qualifiers MAY be applied to any defined field or thing without that variant being defined separately: -previous, -requested, -expected, -effective, -limit and -count. This set is closed in this version. Any other qualifier is part of a field's name, and the field MUST be defined like any other (§6.10).

Note

Variants are siblings rather than children because of the value-or-map rule. If before and after were last segments, policy.generation.previous could not exist beside a policy.generation that other events already carry as a value. A sibling also sorts next to the value it qualifies.

6.4.8 Standard attributes #

These attributes MAY be attached to any defined thing or domain without being defined separately: count, limit, length, size, depth, capacity, fill, duration, timeout, attempts, offset, index, sequence, generation, digest, name, path, id, guid, sid, type and state. Each has the type its name implies in §6.5. This set is closed in this version.

6.4.9 Who may define a path #

Every path is defined exactly once across the catalogue (§6.10).

The generic roots of the table above, and their common fields, are defined in the platform fragment. A component defines the subtrees of things only it knows about — StrataFS defines source.stratum.* — and any domain of its own. Any emitter MAY carry a field defined by another fragment without defining it.

A field that no generic path or platform subtree covers, and that an emitter that is not a platform component needs, sits beneath its own package name below the role: object.org.jellyfin.server.media.title. Such a field SHOULD be rare, and an emitter SHOULD use a defined field wherever one has the meaning it needs.

An emitter MUST NOT write a field that the catalogue does not define, other than a variant or standard attribute permitted above.

6.4.10 Names are access control #

A consumer that controls read access per field treats each path as an object in an access check (PSPU §3.28). Because the path is nested, a grant on subject can cover every identity field beneath it, including fields defined later. Because each value has one path, a descriptor that denies a value cannot be evaded by writing the value under a second name. That is the security reason for the one-definition rule, and why the catalogue rejects a duplicate (§6.10).

6.5 Values

Peios / Peios technical reference / PGSS / Events

Every value has one wire form, and the field's type names it. A payload is one MessagePack map with string keys (PSPK §2).

6.5.1 Types #

ValueWire formType
PrincipalA binary SID (PCDS)bin.sid
GUIDThe 16-byte binary GUID (PCDS)bin.guid
LUIDUnsigned integeruint.luid
ACEThe binary ACE (PCDS)bin.ace
Point in timeUnsigned integer, nanoseconds since the Unix epochuint.time
DurationUnsigned integer, nanosecondsuint.duration
Size in bytesUnsigned integeruint.bytes
Access maskUnsigned integeruint.mask
FlagsUnsigned integeruint.flags
Integrity levelUnsigned integer, the integrity RIDuint.integrity
Error numberSigned integer, negativeint.errno
BooleanMessagePack booleanbool
EnumerationString, kebab-casestr.enum
Numeric enumerationUnsigned integeruint.enum
PathStringstr.path
IP addressString, in the textual form for its familystr.ip
DigestBinarybin
TextStringstr
Other integerUnsigned or signed integeruint, int

An emitter MUST encode a value in the wire form of its field's type. A consumer MUST NOT accept a different form as the same value: a SID written as an SDDL string is not a bin.sid.

Enumerations are symbolic. An enumeration is a kebab-case string naming the value, wrong-type rather than 2. A uint.enum is permitted only where the numbers are fixed by another specification or an external standard — an impersonation level, an address family — and a consumer renders it by name. Every enumeration declares its values in its fragment, or names the reason-code family it draws them from, and states whether the set is closed (§6.10). A consumer that meets a value of an open enumeration it does not know MUST treat it as a value rather than an error, and SHOULD show it as written.

Masks need their kind. A uint.mask is decoded against the table for the kind of thing it applies to, which the same record names in object.kind. An event that carries a uint.mask field MUST carry object.kind.

6.5.2 What an emitter does not write #

  • An absent value. A field with no value is omitted: the key is not written. An emitter MUST NOT write nil, an empty string or zero to mean "none".
  • The header again. An emitter MUST NOT repeat in its payload a value the header already carries: the time, the sequence, or the emitter's token and process GUIDs (§6.4).
  • A name beside a SID. A principal is carried as its SID only. An emitter MUST NOT carry a principal's name in a field; resolving a SID to a name is a consumer's convenience, done when the record is read.
  • Units in a name. The type carries the unit. A duration is operation.duration, never operation.duration-ns.
  • Debugging output. A string value MUST NOT be the output of a language's debug formatter. Its format is not stable and no consumer can rely on it.

Note

Names are kept out of events because a name is not stable and a SID is. A principal renamed after the event would otherwise appear under its old name in every record written before the change, and a search by the new name would miss them.

6.5.3 Departures from the shared conventions #

This chapter departs from the data conventions of the Conventions book §3.3 on two points:

  • Timestamps are unsigned integers counting nanoseconds since the Unix epoch, not RFC 3339 strings. The record header already carries time this way (PSPK §2), and one form for every time in a record lets a consumer compare them directly.
  • Digests are carried as binary, not lowercase hexadecimal. A consumer renders them as hexadecimal for display.

6.6 Outcomes

Peios / Peios technical reference / PGSS / Events

An action that can succeed or fail is one event type, and its result is carried in outcome.*. "Everything that failed" is then one query across every emitter, and no descriptor can show an action's successes while hiding its failures.

An emitter MUST NOT define separate event types for the success and the failure of one action. A type that only ever records failure is permitted (§6.3).

6.6.1 The outcome fields #

FieldTypeCarries
outcome.successboolWhether the action did what was asked
outcome.reasonstr.enumWhy it failed, or why a success was unusual
outcome.errnoint.errnoThe error number it failed with
outcome.detailstrText for a person to read

An event whose action can fail MUST carry outcome.success.

outcome.reason is symbolic. Its values are drawn from a named reason-code family, or from an enumeration the event declares in its fragment (§6.10). Where a kernel reason code exists for the decision, the value is that code's name in kebab-case with its family prefix removed, so that an event and a trace of the same decision describe it in the same terms.

outcome.errno is the negative error number the operation returned, -13 for EACCES. It is omitted on success. A consumer renders it by name.

outcome.detail is optional free text for a person. Nothing parses it, and an emitter MUST NOT carry anything in it that a consumer would need to filter on: that belongs in outcome.reason or in a field of its own.

Where a decision has more than two results, so that a boolean cannot express it, outcome.verdict carries the result as an enumeration.

Note

Before this chapter, the reason for one failure was written as failure_reason, failure_cause, reason, error, detail, parse_error, finalization_detail, result and message, and a descriptor denying one of them denied none of the others. The four fields above replace all nine.

6.7 Access Decisions

Peios / Peios technical reference / PGSS / Events

Every access decision is recorded the same way: by the kernel's access audit, under the SACL of the object decided on. That holds for a decision a userspace component makes about objects it guards itself — a service manager's services, an event store's namespaces — as much as for a file. "Who was refused what" is then one query, and a SACL governs a daemon's objects as it governs a file.

6.7.1 A component that guards objects #

An emitter that decides access to objects of its own:

  • MUST make each decision through the kernel's access check, against the object's security descriptor;
  • MUST pass the object's identity as the check's audit context (below);
  • MUST NOT write an event of its own recording the decision, whether it granted or refused. It MAY write a log line about a refusal for its own debugging; that line is not the audit record.

6.7.2 The audit context #

The audit context is one MessagePack map:

KeyValue
kindThe kind of object, a kebab-case string: service, job
<kind>Optional. A map of the object's identifying fields, under a key equal to the value of kind
{kind: "service", service: {name: "jellyfin"}}

The map MUST contain kind, MAY contain the map named by it, and MUST contain nothing else. The kernel rejects a context that is not exactly this shape: kind must be a string matching the segment grammar of §6.3, the map under the kind's key must not be empty, and every key in that map must match the same grammar, because each becomes a segment of object.<kind>.<key>. A context with no body map is accepted; an empty body map is not. Each field in that map MUST be a field the component's fragment defines beneath object.<kind>, and MUST be marked asserted there (§6.10).

The kernel's record of the decision then carries object.kind and the object's fields as object.service.name and so on, beside the subject, the access requested and granted, and the trigger, exactly as for any other object. How the kernel's access-check interface accepts and copies the context is described in the Peios Kernel TRM.

6.7.3 Asserted fields #

The kernel cannot verify that a descriptor belongs to the service a component names. A field the kernel copies from userspace is therefore the component's claim: it is the identity of what the component actually enforced, but the kernel did not observe it.

A kernel-originated record that carries any asserted value MUST carry fields.attestation.userspace with the value true. A record that carries none MUST omit the field. A record written by userspace never carries it, because the origin class in its header (§6.4) already says that the whole payload is the emitter's word.

A consumer MUST NOT present an asserted field as observed by the kernel.

6.7.4 The emitter and the subject #

On a decision a component requests for a client, the record's header names the component, as emitter.*, and the payload's subject.* names the client whose access was checked (§6.4). The component's process details are emitter.process.*.

6.7.5 Default SACLs #

A component that guards objects SHOULD give each a default SACL with an audit ACE that records failed access by Everyone, so that a refusal is recorded without configuration. An administrator can narrow or remove it per object like any other SACL. The exception is a kind of object so numerous that recording every refusal would bury the record, which is why files and registry keys carry no default SACL.

A component that stores a descriptor where writing the stored value replaces the SACL without the privilege that normally guards a SACL — for example, a whole descriptor kept as one registry value — MUST allow that store to be written only by principals trusted to change audit policy. A tool that edits such a descriptor's DACL MUST keep its SACL intact.

Note

Two other ways of recording refusals were considered. Adding a SACL to every descriptor just before the check would make the record's trigger.ace name an ACE that is not in the object's descriptor, could not be changed per object, and would be done differently by every component. Turning on failure auditing in every token's audit policy would record every refusal on every object, files included; that remains the tool for auditing everything one principal does.

6.8 Events, Logs and Tiers

Peios / Peios technical reference / PGSS / Events

6.8.1 Event, log or metric #

An event is a discrete occurrence that someone may search for, alert on, or attribute to a principal, one record at a time. It carries an identity the kernel stamped (PSPK §2).

A log line is narrative for the people who maintain a component. Its value is in reading the sequence, not in querying single records.

A metric is a sampled quantity, where the trend matters and no single sample does.

An emitter MUST decide between them by two tests, in order:

  1. Does an operator care? Events and metrics are for operators; logs are for developers. If an operator would not act on it, it is a log line, and the second test does not apply.
  2. Does one instance matter on its own? If it does, it is an event, however frequent it is. If only the trend matters, it is a metric.

An emitter MUST NOT carry log text in an event. A message a person reads belongs in outcome.detail (§6.6) where it explains the outcome, and in the log otherwise.

Note

An operator should not need to read logs in all but the rarest cases. If they do, something that should have been an event was written as a log line.

6.8.2 Tiers #

Every event type declares one tier in its fragment (§6.10). The tiers are a closed set.

TierMeaningOn unless the policy says otherwise
essentialExtremely important and relatively rare, or governed by its own configurationAlways; cannot be switched off
standardWhat an administrator reviews: lifecycle, configuration, failuresYes
verboseDetail for an active investigationNo
debugFor the component's developersNo

A tier sets only the default. The emission policy switches a type on or off whatever its tier, except an essential type, which it never switches off (§6.9). A shipped default policy MUST NOT switch a debug type on.

6.8.3 Essential #

An event type is essential for one of two reasons:

  • It is extremely important and relatively rare. A logon session ending, or a configuration value rejected.
  • Its emission is already governed by its own configuration. The kernel's access audit is the example: a SACL, or a token's audit policy, decides which occurrences are recorded. So is any event whose own configuration, such as a REPORT directive, decides whether it is written.

The second reason follows a principle that holds across Peios: one switch per thing. Something already governed by its own configuration does not also need an emission-policy switch before it works. Making an administrator turn on the same thing in two places is what this rules out.

An emitter MUST NOT declare an event type essential for any other reason.

Note

Nothing in a running system stops a third party declaring an event essential, and nothing tries to. Whether an essential event meets one of the two reasons is one of the checks made when a package is submitted to the Peios package repository.

6.9 Emission Policy

Peios / Peios technical reference / PGSS / Events

An event type that is switched off is never written. Each emitter decides, before it writes an event, whether its type is on, from a policy kept in the registry. A consumer decides what to keep of what it reads; it does not decide what is written.

The component that writes a record decides, whatever path the record takes. A component that writes records of its own without going through the kernel's event stream, straight into a store for example, is their emitter for the purposes of this section, and applies the policy to every type it writes that is not essential.

6.9.1 The policy tree #

The policy is the registry key Machine\Generic\Events and the keys beneath it. There is one key per segment of an event type, so the policy for an event type is found on the path that spells its segments beneath Events, one key each, in order: the policy for kacs.audit.access.checked is found by walking Events\kacs\audit\access\checked.

A package-name root (§6.3) is split at its periods like the rest of the type, so a package's events sit beneath one key per segment of its name: org.jellyfin.server.playback.started is found by walking Events\org\jellyfin\server\playback\started, and Events\org\jellyfin is the key that covers every package named beneath org.jellyfin. A segment is used as it is, including one a package name gives that the segment grammar of §6.3 does not admit.

The registry compares key and value names ignoring case, so the walk does too.

Any key on such a path MAY hold a value named Enabled. A REG_DWORD of 1 switches the event types beneath it on, and 0 switches them off. An Enabled value of any other type, or a REG_DWORD holding any other number, is ignored, as though it were absent.

Nothing else in the tree has a meaning. A key on no event type's path, and any value other than Enabled, decides nothing.

6.9.2 Resolving a type #

Whether an event type is on is decided by this procedure. The deepest Enabled value on the type's path wins, and the type's tier decides when there is none.

enabled(event_type, tier) → bool
    if tier == essential
        return true                       // never consults the policy
    setting = absent
    key = Machine\Generic\Events          // absent if it does not exist
    for each segment in segments(event_type)
        if key == absent
            break
        setting = read_enabled(key, setting)
        key = subkey(key, segment)        // absent if it does not exist
    if key != absent
        setting = read_enabled(key, setting)
    if setting != absent
        return setting == 1
    return tier == standard               // standard on; verbose and debug
                                          // off; essential returned above

read_enabled(key, current) → setting
    value = value(key, "Enabled")         // absent if it does not exist
    if value is REG_DWORD and (value == 0 or value == 1)
        return value
    return current                        // ignored: keep what was found

Each rule of the procedure holds on its own:

  • The Enabled value on the deepest key that has one decides. A value on Events itself applies to every type the keys beneath it do not decide.
  • The key named by the whole type, its last segment included, is read like the keys above it.
  • Where a key on the path does not exist, the keys beneath it do not either, and the deepest value found above it decides.
  • A key the emitter may not read ends the walk as a missing one does.
  • With no Enabled value on the path, a standard type is on, and a verbose or debug type is off (§6.8).

An emitter MUST NOT write an event whose type this procedure finds off.

An emitter MUST write an essential event without consulting the policy, whatever the tree holds.

An emitter SHOULD make the decision before it builds the event's payload, so that a type that is off costs nothing beyond the decision.

6.9.2.1 Example #

With no value on Events, Events\kacs holding Enabled = 0, and Events\kacs\audit holding Enabled = 1:

Event typeTierDecided byOn
lcs.config.value.rejectedstandardits tierYes
kacs.caap.staging.divergedstandardEvents\kacsNo
kacs.audit.handle.usedstandardEvents\kacs\auditYes
kacs.audit.access.checkedessentialits tier, alwaysYes

With Enabled = 0 on Events as well, lcs.config.value.rejected is off and the other three are as before. A verbose or debug type in the place of lcs.config.value.rejected would be off in both trees, and in the place of kacs.audit.handle.used on in both.

6.9.3 Gating, SACLs and audit policy #

For an event type with gating (§6.2), the policy and the gating decide together, by AND: the type is written for an occurrence only if the policy has it on and its gating asks for that occurrence.

Neither widens the other. Switching a type on does not record an occurrence that a SACL, or a token's audit policy, does not ask for; and a SACL that asks for an occurrence does not record it while the policy has its type off. The policy decides whether a kind of event exists at all. The gating decides which occurrences of it are recorded, and only the emitter can evaluate it, because only the emitter can see the object.

An essential type's gating alone decides, because the policy never switches it off (§6.8). The kernel's access audit is the case this is for: kacs.audit.access.checked is essential, so whether an access decision is recorded is the SACL's and the audit policy's choice alone, and an administrator never has to switch the same thing on in two places.

6.9.4 Noticing a change #

An emitter MAY cache the policy in-process.

An emitter MUST apply a change to the policy to every decision it makes one second or more after the change was committed, whether or not it caches. An emitter SHOULD apply it to the first decision it makes after the commit, which an emitter that watches the tree can do.

A watch on the subtree of Events sees every change. An emitter that writes under few roots MAY instead watch only the keys on its own types' paths. A key that does not exist cannot be watched, so an emitter watching for one to be created watches the nearest key above it that does exist.

A change is not retroactive. An event written before it stays written, and an occurrence decided before it stays decided.

6.9.5 When the policy cannot be read #

An emitter that cannot read Machine\Generic\Events — before the registry has a source for the Machine hive, where the key does not exist, or where it may not read it — MUST decide by tier alone, as the procedure does when no Enabled value is found.

It MUST NOT hold an event back to wait for the policy to become readable.

Once the policy can be read, an emitter MUST apply it to every decision it makes one second or more after it became readable.

Note

The kernel writes events from early in boot, before any registry source has registered. For that window its decisions are tier defaults: a verbose type the policy switches on is not written, and a standard type the policy switches off is. An operator who needs a boot-time event whatever the policy says has the essential tier, if the event meets one of its two reasons (§6.8), and nothing else.

6.9.6 Who may change it #

Each key's security descriptor decides who may change the policy beneath it, and inheritance carries that down the tree. This is why the policy has one key per segment rather than one flat key per pattern: a grant on Events\org\jellyfin lets whoever runs Jellyfin switch its events, and reaches nothing else.

A conforming system:

  • lets every emitter read Machine\Generic\Events and every key beneath it;
  • lets only administrators write Machine\Generic\Events itself, and the keys of platform roots (§6.A);
  • SHOULD give Machine\Generic\Events a SACL that audits writes, so that a change to what is recorded is itself recorded.

Note

Leaving the decision to the consumer was considered and rejected. The kernel's event stream drops its oldest events when a ring is full, whatever they are, so events nobody wanted would push the audit trail out of the ring. They would also cost an encoding on hot paths, and every reader of the ring would still see them.

6.10 The Catalogue

Peios / Peios technical reference / PGSS / Events

The catalogue is the single source of every event type and field definition. Everything else that lists them — reference documentation, a viewer's field picker, a policy editor — is generated from it or checked against it.

6.10.1 Fragments #

An emitter MUST describe every event type it writes, and every field it defines, in a fragment: a UTF-8 text file installed in /usr/share/evman/ with the extension .evman. A package installs one fragment per component it ships, named for the component.

The platform fragment, /usr/share/evman/kernel.evman, defines the generic roots of §6.4 and their common fields. A conforming system has it.

A consumer that reads the catalogue reads every .evman file in /usr/share/evman/, and no subdirectory.

6.10.2 Format #

A fragment is a sequence of records. Each record begins with an anchor line of three hyphens, a space, the record kind, a space and the name it defines:

--- field subject.token.sid
--- group subject
--- event kacs.audit.access.checked

After the anchor come header lines of the form key: value, one per line, then a blank line, then prose: the record's description. The prose's first sentence is a complete summary of it. Inline markup in prose is limited to **bold** and `code`.

6.10.2.1 Field records #

KeyRequiredValue
typeYesThe field's type (§6.5)
valuesFor an enumerationThe values, separated by |, or the name of the reason-code family they are drawn from
closedFor an enumerationtrue if no value may be added without a new version of the field
assertedNotrue if the value may be supplied by userspace on a kernel-originated record (§6.7)
carriedNoheader for a header field (§6.4); absent for a payload field

6.10.2.2 Group records #

A group names a set of fields that always travel together, so that events can include the set rather than list it. Its header lines are include: lines, one field path each.

6.10.2.3 Event records #

KeyRequiredValue
tierYesThe event type's tier (§6.8)
gatingIf anyWhat decides which occurrences are recorded
cardinalityNoHow many records one occurrence produces, where that is not one

After the prose come the fields the event carries, one per line:

include: subject
field: object.kind              required
field: object.file.path         when object.kind == file
field: access.granted           required
  The mask this check granted.

include: names a group. field: names a field, followed by when it is present: required, optional, or when and a condition on another field of the record. An indented paragraph after a field: line is a gloss: what the field means in this event. A gloss MAY narrow the field's definition and MUST NOT contradict it.

Where a field's definition leaves its values to the event, as outcome.reason does (§6.6), the event declares them with indented values: and closed: lines directly after the field: line, in the form a field record uses:

field: outcome.reason           when outcome.success == false
  values: disk-full | source-unreadable | cancelled
  closed: false
  Why the snapshot was not written.

6.10.3 Rules #

Every fragment MUST pass these rules, checked across the whole catalogue:

  1. Every path a field: or include: line names is defined.
  2. No path is defined by two fragments, and no path is defined twice in one.
  3. No path is defined both as a value and as a map (§6.4).
  4. Every event type a fragment defines begins with a root that fragment owns: a platform root listed against its component in §6.A, or its package's name.
  5. A platform component's fragment defines fields only in the subtrees and domains of things it alone knows about (§6.4). Any other fragment defines fields only beneath its own package name below a role (§6.4).
  6. Every name matches the grammar of §6.3 and §6.4, and every event type's last segment is a verb in the past tense.
  7. Every enumeration declares its values or names its reason-code family, and says whether it is closed.
  8. Every event type declares a tier, and an event type with gating declares it.
  9. An event type that carries a uint.mask field carries object.kind (§6.5).

A tool that reads the catalogue SHOULD report every rule a fragment breaks. A consumer that acts on the catalogue — one that decides access, builds a field tree for a descriptor, or validates payloads against it — MUST NOT treat a fragment that breaks rule 1, 2 or 3 as defining anything. A tool that only displays the catalogue MAY show such a fragment's records, provided it warns that the fragment breaks those rules.

Note

Rule 2 is what stops one value acquiring three spellings again. Before the catalogue existed nothing could have caught it, and the result was a field that a descriptor could deny under one name and leak under another (§6.4).

6.11 Extension

Peios / Peios technical reference / PGSS / Events

6.11.1 Additive changes #

The following are additive. An emitter and a consumer built to this version behave correctly in their presence:

  • A new event type, under a root its emitter owns.
  • A new field, defined once in the catalogue. A consumer that does not know a field still reads, stores and queries it by its path.
  • A new value of an open enumeration, which a consumer built to this version treats as a value rather than an error (§6.5).
  • A new platform root in §6.A, provided it is not already in use as a package name's first segment and is not a top-level domain.
  • A new domain of a platform component's own (§6.4).
  • A new key in a fragment record. A consumer that does not know a key ignores it.

6.11.2 Changes requiring a new version #

  • Renaming an event type or a field, or moving a field to another path. Every descriptor and every query written against the old name stops matching.
  • Changing a field's type or wire form (§6.5).
  • A new value of a closed enumeration.
  • Defining a field beneath a path that is a value, or giving a value to a path that is a map (§6.4).
  • A change to the roles, the qualifiers or the standard attributes of §6.4, or to the tiers of §6.8.
  • A change to the policy tree's location or its resolution (§6.9).

6.11.3 Reserved #

The roots in §6.A are reserved to the components listed against them. The roles and generic roots of §6.4 are this chapter's, and only the platform fragment defines fields directly beneath them. Every other fragment is bound by rule 5 of §6.10.

/usr/share/evman/ is this chapter's, and so is the .evman extension within it. Machine\Generic\Events and every key and value beneath it are this chapter's: nothing else may keep anything there.

6.12 Conformance

Peios / Peios technical reference / PGSS / Events

6.12.1 A conforming emitter #

  • Writes event types of at least three segments, kebab-case, ending in a past-tense verb, under a platform root listed against it or under its own package name (§6.3).
  • Writes one event type per action, with the result in outcome.*, and carries outcome.success wherever the action can fail (§6.6).
  • Encodes every path as nested maps, never as a key containing ., and writes only defined fields and the variants and standard attributes §6.4 permits (§6.4, §6.10).
  • Writes nothing at a header path, and does not repeat a header value in its payload (§6.4, §6.5).
  • Encodes each value in its type's wire form, omits a field that has no value, carries principals as SIDs without names, and writes no debugging output (§6.5).
  • Carries object.kind wherever it carries an access mask (§6.5).
  • Makes access decisions about objects it guards through the kernel's access check, passes the object's identity as the audit context, and writes no access-decision event of its own (§6.7).
  • Keeps a stored descriptor's SACL intact and its store writable only by those trusted with audit policy (§6.7).
  • Writes events, not log text, and declares each type's tier, choosing essential only for one of the two reasons of §6.8.
  • Writes no event whose type the emission policy finds off, and an event of a gated type only where its gating also asks for it; writes essential events without consulting the policy; applies a change to it within one second; and falls back to tier defaults, without holding events back, when it cannot read it (§6.9).
  • Installs a fragment describing every event type it writes and every field it defines, which passes the rules of §6.10.

6.12.2 A conforming consumer #

  • Exposes header values under the paths of §6.4.
  • Does not treat an event type's root as evidence of who wrote it (§6.3), and does not present an asserted field as observed by the kernel (§6.7).
  • Does not accept a value in a wire form other than its type's (§6.5).
  • Treats an unknown value of an open enumeration as a value, not an error (§6.5).
  • Reads the catalogue from every .evman file in /usr/share/evman/ and no subdirectory, and treats a fragment breaking rule 1, 2 or 3 as defining nothing (§6.10).

6.12.3 A conforming system #

  • Has the platform fragment at /usr/share/evman/kernel.evman (§6.10).
  • Lets every emitter read Machine\Generic\Events, lets only administrators write it and the keys of platform roots, and SHOULD audit writes to it (§6.9).

6.12.4 What conformance does not require #

Conformance does not require an emitter to write any particular event, to cache the emission policy, or to define fields beyond those it writes. It does not require a consumer to store, index or query events, to read the catalogue at all, or to resolve SIDs to names. It does not require either role to know about any event type or field other than the ones it writes or reads: an unknown event type is still an event, and an unknown field is still a value at a path.

Appendix 6.A Platform Roots

Peios / Peios technical reference / PGSS / Events

The platform roots (§6.3). Every other emitter roots its event types at its package name. This list is open: a root is added by appending it under §6.11, and is never reassigned.

RootComponentPackage
kacsKernel Access Control Subsystemdev.peios.kernel
lcsThe kernel's registry (configuration store)dev.peios.kernel
kmesKernel Mediated Event Subsystemdev.peios.kernel
stratafsStrataFSdev.peios.kernel
ntfeThe kernel's network-policy enginedev.peios.kernel
peinitThe service managerdev.peios.peinit
peipkgThe package managerdev.peios.peipkg
eventdThe event storedev.peios.eventd
authdThe authentication daemondev.peios.authd
lpsdThe local principal sourcedev.peios.authd-lpsd
timedThe time daemondev.peios.timed
netdThe network daemondev.peios.netd
trustdThe trust daemondev.peios.trustd

These roots are reserved for components that write no events yet, and MUST NOT be used by anything else:

RootComponent
resolvdThe name-resolution daemon
udThe user daemon
loregdThe log store

Note

A root names a component rather than a package because one package ships several components with separate concerns: the kernel package ships five, and an administrator who wants the access audit without the network-policy reports needs kacs and ntfe to be separate policy anchors.

Appendix 6.B Prior Art

Peios / Peios technical reference / PGSS / Events

This appendix is informative.

6.B.1 Windows event logging and auditing #

Peios's access model is Windows's, and so is the shape of its audit: SACLs with audit ACEs decide which accesses are recorded, and a token's audit policy can force recording for one principal. Windows audit policy also sits above SACLs, so that a category switched off suppresses even records a SACL asks for. §6.9 keeps that relation for a gated event type that is not essential, and drops it for the access audit itself, which is essential so that a SACL alone decides (§6.8).

This chapter departs from Windows in its names. Windows identifies events by numeric ID within a provider, and its payload fields are named per event; the same value is SubjectUserSid in one event and TargetSid in another. Here the name is the policy anchor, so every event type is a dotted name and every value has one path.

6.B.2 Elastic Common Schema #

The Elastic Common Schema names fields by dotted, nested paths and shares a field set across sources, so that one query finds a host or a user wherever it appears. This chapter takes the same approach.

It departs in leading with the participant's role rather than its kind: subject.token.sid, where the schema leads with the kind of thing. A consumer searching for a principal in any role searches across roles either way, and leading with the role makes subject a single policy anchor for "may see who did it".

6.B.3 OpenTelemetry semantic conventions #

OpenTelemetry names attributes by dotted paths and keeps a registry of them with stability levels, so that a name is defined once and referenced everywhere. The catalogue of §6.10 plays the registry's part, and its rule that no path is defined twice is the same discipline, checked mechanically.

It departs in fixing the type and wire form of every value, rather than leaving encoding to each exporter, because the event stream is consumed directly and a SID or a time written two ways is two values to every query.

1.1 Scope

Peios / Peios technical reference / PSPK / Introduction

This document defines the Peios System Protocols Kernel (PSPK): the protocols spoken across the kernel boundary, between a kernel subsystem and a userspace process that serves it.

A contract belongs in this document when both of these hold:

  • one party is a kernel subsystem and the other is a userspace process; and
  • the userspace side is a public, implementable role — a third party can write a program that fills it.

The two parties need not be in conversation. A live protocol has a kernel subsystem and a process exchanging messages; a format has one side producing an artifact that the other consumes, perhaps long afterwards and on a different machine. Both are specified here, because both are contracts a third party has to satisfy exactly.

The second condition is what separates a PSPK protocol from a system-call surface. A system call is an interface the kernel offers to any program that asks. A PSPK protocol is a contract the kernel depends on some program to fulfil: the kernel is the party asking, and the userspace process is authoritative for the answer.

For each protocol, this document covers:

  • the channel, and how a userspace party attaches to it and is recognised
  • message or artifact framing, encoding, and the rules under which the format may be extended
  • the requests the kernel issues, the responses it expects, and their ordering
  • what the kernel-side party validates for itself rather than believing from a response
  • behaviour on failure, refusal, and disconnection
  • the conformance requirements for the userspace role

This document does not cover:

  • The behaviour and data model of the kernel subsystem itself — defined in that subsystem's specification
  • System-call and ioctl surfaces — defined in that subsystem's specification
  • The binary structures these protocols carry — defined in PCDS
  • Standards a system MUST implement to be Peios — defined in PGSS
  • Protocols between userspace components — defined in PSPU
  • How a userspace implementation stores its data or computes its answers — its own design

1.1.1 Trust across the boundary #

A PSPK protocol crosses a trust boundary in the direction that matters most: a kernel subsystem is asking a lower-privileged process for something it will then act on. Every specification in this document therefore states explicitly which parts of a response the kernel establishes for itself and which it takes on the userspace party's word.

1.1.2 Relationship to PGSS #

A protocol in this document is not a conformance requirement in the sense PGSS defines. It is the interface a particular Peios kernel subsystem uses to reach the processes that serve it; a system built from different kernel subsystems is still Peios. These protocols are specified because they are public even so — a third party writing an implementation of the userspace role needs the contract written down.

1.2 Conventions

Peios / Peios technical reference / PSPK / Introduction

The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY in this document are to be interpreted as described in RFC 2119. Text set off as a note is informative, not normative.

Everything else — roles, byte order, sizes, layout tables, notation, strings, timestamps, citation, and the external standards this anthology depends on — is defined in the Conventions book and is not restated here. PSPK departs from none of it.

Where a chapter needs a convention of its own, that chapter states it.

2.1 Scope and Roles

Peios / Peios technical reference / PSPK / KMES Event Stream

This chapter specifies the contract between the Kernel Mediated Event Subsystem and the userspace processes that consume the events it produces.

Two roles participate.

The producer is KMES, a subsystem of the Peios kernel. It constructs events, stamps them with metadata that a consumer cannot forge, places them in per-CPU shared memory ring buffers, and notifies sleeping consumers. There is one producer.

The consumer is a userspace process that maps one or more ring buffers and reads events from them. The consumer role is publicly implementable: any process holding the required privilege MAY attach, and more than one consumer MAY attach to the same buffer at the same time. A conforming consumer is the subject of the requirements in this chapter.

This chapter covers:

  • the binary layout of an event, which a consumer MUST parse
  • the layout of a mapped ring buffer and the meaning of each metadata field
  • how a consumer attaches, maps, and discovers the buffer set
  • the protocol a consumer follows to drain events, to detect and account for loss, and to sleep and be woken
  • the protocol a consumer follows when the producer replaces a buffer
  • the memory ordering both roles rely on

This chapter does not cover:

  • how KMES constructs, stamps, buffers, or overwrites events — the producer's internals are described in the Peios Kernel TRM
  • the emission interfaces, by which a process or kernel subsystem produces an event rather than consuming one
  • event type vocabulary, payload schemas and the encoding of payload values — these are PGSS §6
  • persistence, indexing and querying — the query language is PSPU §3, and storage is the concern of the event storage service

2.1.1 Producing versus consuming #

Emission is not part of this contract. A process emits events by calling the KMES emission system calls, which are an ordinary kernel interface offered to any caller that holds the privilege — the kernel computes the result and the caller reads it. Consumption is different: the kernel deposits bytes in shared memory and depends on an independently written program to interpret them correctly, to sequence its reads against concurrent writes, and to notice when it has fallen behind. That program's obligations have to be written down, which is why they are here.

2.1.2 What the kernel establishes for itself #

A consumer maps one page that it can write: the consumer metadata page. Everything the kernel reads from that page is advisory.

KMES reads exactly one field from consumer-writable memory, the need_wake flag, and treats any nonzero value as set. The flag can only cause KMES to perform a wake that was not needed or to skip one that was; it cannot affect the contents of the data region, the producer metadata, the sequence numbering, or another consumer's view of any of these. A consumer that corrupts the page — deliberately or otherwise — degrades notification for consumers sharing that buffer and nothing else.

Consumers MUST NOT rely on KMES validating anything else they write, because KMES reads nothing else.

The reverse direction is stronger. The producer metadata page and the data region are mapped read-only, and no privilege, capability, or token grants a consumer write access to them. Every identity stamp in an event header is captured by the kernel from kernel state at the moment of the write; an emitting process cannot set, influence, or suppress it. A consumer MAY therefore treat the identity fields of a delivered event as authoritative.

2.1.3 Privilege and the trust model #

Attaching requires SeSecurityPrivilege, which is a very high-trust privilege. Direct ring buffer access is not the ordinary way to consume events: it grants an unfiltered view of every event on the system, with no per-event access control. Ordinary consumers obtain events from the event storage service, which enforces per-event access control on top of this interface.

Because the consumer metadata page is shared by every consumer attached to a buffer, a consumer holding SeSecurityPrivilege can suppress notification for the others attached to that buffer. This is accepted rather than defended against: the privilege required to attach at all is higher than the privilege this would subvert.

2.2 Event Format

Peios / Peios technical reference / PSPK / KMES Event Stream

An event is an indivisible record: a packed binary header followed immediately by a payload. Header and payload are always stored, delivered, and consumed as one contiguous byte sequence, and neither is meaningful alone.

2.2.1 Header layout #

The header fields are laid out sequentially with no padding and no alignment gaps. All multi-byte integers are little-endian.

OffsetSizeTypeFieldDescription
04u32event_sizeTotal size of the event, header plus payload, in bytes.
44u32header_sizeSize of the header in bytes.
88u64timestampWall clock time at emission, in nanoseconds since the Unix epoch.
168u64sequencePer-CPU, per-boot monotonic sequence number.
242u16cpu_idThe CPU on which the event was emitted, identifying the ring buffer that carries it.
261u8origin_classThe emission path that produced the event.
2716GUIDeffective_token_guidGUID of the effective token of the emitting thread. Null GUID if unavailable.
4316GUIDtrue_token_guidGUID of the emitting process's primary token. Null GUID if unavailable.
5916GUIDprocess_guidGUID of the emitting process. Null GUID if unavailable.
752u16type_lenLength of the event type string in bytes.
77type_len[u8]typeEvent type string, UTF-8, not null-terminated.

GUIDs use the binary format defined in PCDS and are opaque 16 bytes to this contract. The null GUID is sixteen zero bytes and means the field is not applicable or was not available.

header_size is 77 + type_len in this version of the format. A consumer MUST use header_size to locate the payload and MUST NOT compute the payload offset from 77 + type_len or from any other constant, so that a future header extension does not break it. All fields before type are at fixed offsets and will remain so.

The payload occupies the bytes from header_size to event_size. The next event in a ring buffer begins event_size bytes after the start of the current one, with no alignment padding between events.

2.2.2 Payload #

The payload is exactly one MessagePack value, and its structure is defined by the emitter. KMES does not interpret it.

A consumer MUST NOT assume a payload is present: an event emitted by a kernel subsystem MAY have event_size == header_size, meaning a header and no payload at all. Events emitted through the system calls always carry a payload, because an empty byte sequence is not a valid MessagePack value and is rejected at the syscall boundary.

Note that MessagePack encodes its own length prefixes big-endian, whereas every integer in the event header is little-endian. Both appear in one event.

2.2.3 Event types #

The event type is an arbitrary UTF-8 string. KMES imposes no structure, namespace, or naming convention on it, and applies no case folding or normalisation. Consumers MUST compare event types as raw byte sequences.

2.2.4 Origin class #

ValueOrigin
0Userspace, via system call
1KMES
2KACS
3LCS
4NTFE

Values 5–255 are unassigned. A consumer MUST tolerate an unrecognised origin class rather than rejecting the event, so that a kernel subsystem added later does not break it.

Events with origin class 0 were emitted through the system call interface, and their origin class is set by the kernel, not by the caller. A userspace emitter cannot claim to be a kernel subsystem.

2.2.5 Identity fields #

The three identity GUIDs are captured by the kernel at the moment the event is written to the ring buffer, not when the emitting call began.

  • effective_token_guid is the token governing the emitting thread's access rights. If the thread was impersonating, this is the impersonation token; otherwise it equals true_token_guid.
  • true_token_guid is the emitting process's primary token, regardless of impersonation.
  • process_guid identifies the emitting process. It is assigned when the process is created and does not change across exec.

Any of the three MAY be the null GUID, meaning the kernel had no identity to record — emission before the access control subsystem initialised, or from a context with no associated process such as a kernel worker thread. A consumer MUST treat a null identity as "unattributed" and MUST NOT treat it as a valid GUID value that could match a real token or process.

2.2.6 Ordering #

Events from different CPUs are ordered by timestamp. Events with identical timestamps from different CPUs were genuinely concurrent and have no defined relative order.

Within a single CPU, sequence is the ordering primitive. It is monotonic across wall clock discontinuities, which timestamp is not: a clock adjustment can move timestamps backwards, and a consumer that requires monotonic ordering within a CPU MUST use sequence. Events with identical timestamps on the same CPU are ordered by sequence.

Each CPU numbers independently and there is no global sequence. The counter starts at zero when the kernel module loads and is incremented before a value is taken, so the first event on a CPU carries sequence number 1 and sequence 0 is never assigned. The pair (cpu_id, sequence) uniquely identifies an event within one boot.

A gap in the sequence for a given CPU means events were lost — either overwritten before the consumer read them, or dropped by the kernel before they reached the buffer. Sequence numbers are continuous across a buffer replacement, so a generation change does not itself produce a gap.

2.3 Attaching and Mapping

Peios / Peios technical reference / PSPK / KMES Event Stream

2.3.1 Attaching #

A consumer attaches to one per-CPU ring buffer at a time by calling kmes_attach with a logical CPU index and a pointer to a u64 that receives the buffer's capacity. The call returns a file descriptor.

The caller MUST hold SeSecurityPrivilege, enabled; the call fails with EPERM otherwise.

The CPU index uses the same numbering as the cpu_id field in the ring buffer metadata and in event headers. Indexes run from 0 to one below the slot count, and the set of buffers is fixed when KMES initialises and does not change while the system runs.

The slot count is not the number of buffers. Slots are indexed by logical CPU id, so a slot within the range holds no buffer when that CPU is not possible, and the two quantities differ on any system whose possible-CPU mask is sparse. An index at or beyond the slot count fails with EINVAL, and so does an index inside it whose slot holds no buffer; the two are not distinguishable from the return value.

A consumer discovers the slot count by calling kmes_attach with the CPU index KMES_ATTACH_QUERY_SLOTS. The call writes the slot count through the capacity pointer, returns 0, and opens no descriptor. It is gated on SeSecurityPrivilege exactly as an attach is.

A consumer MUST enumerate by walking every index from 0 to one below the slot count, and MUST treat EINVAL as "this slot holds no buffer" and continue. A consumer MUST NOT treat the first EINVAL as the end of the set: doing so silently abandons every buffer above the first hole, whose events then accumulate and are overwritten with no consumer able to reach them.

A consumer SHOULD attach to every buffer in the set: a buffer with no consumer still receives events, and those events are lost when it wraps.

A consumer MAY call kmes_attach more than once for the same CPU and receives a distinct file descriptor each time. All descriptors for one CPU refer to the same buffer, and therefore to the same producer metadata, consumer metadata, and data region. Multiple consumers MAY attach to one buffer concurrently. Each maintains its own read position, in its own memory; the kernel does not track consumer read positions, does not know how many consumers exist, and does not know how far behind any of them is. The consumer metadata page is shared per buffer and is not a per-consumer read-position store.

The descriptor supports exactly two operations: mmap() and close(). Closing it invalidates the mapping.

2.3.2 Mapping #

The consumer maps the whole region in a single call:

mmap(NULL, 8192 + 2 * capacity, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0)

The mapping request MUST use MAP_SHARED, MUST pass an offset of zero, and MUST pass a length of exactly 8192 + 2 * capacity, where capacity is the value kmes_attach returned. Any other combination fails with EINVAL. The consumer does not map the regions separately.

The mapped region has three parts:

OffsetSizeRegionConsumer access
04096Producer metadata pageRead-only
40964096Consumer metadata pageRead-write
81922 × capacityData regionRead-only

Per-page permissions are enforced by the kernel regardless of the PROT flags requested: the producer metadata page and the data region are mapped read-only whatever the consumer asks for, and no privilege raises that. The consumer metadata page is writable by ordinary stores.

capacity is always a power of two, so a position's offset within the data region is position & (capacity - 1).

2.3.3 The double mapping #

The data region occupies 2 × capacity bytes of address space backed by capacity bytes of memory: the same pages appear twice, consecutively. An event that crosses the end of the buffer is therefore readable as one contiguous byte sequence starting at its wrapped offset, and a consumer MUST NOT implement wrap handling of its own. Reading an event at offset position & (capacity - 1) is always correct, even when the event extends past capacity.

A consumer MUST NOT read past event_size bytes from the start of an event. The data region is not scrubbed when events are overwritten, so the bytes beyond an event are unrelated remnants of older events.

2.3.4 Producer metadata page #

The producer metadata page is written by KMES and read by consumers. Fields are separated onto 64-byte cache lines by update frequency, so that the position fields KMES writes on every event do not invalidate the line holding the fields it never writes.

2.3.4.1 Bytes 0–63: identification #

OffsetSizeTypeFieldDescription
08[u8; 8]magic4B 4D 45 53 52 49 4E 47, KMESRING in ASCII. Compared byte by byte, not as an integer.
84u32versionRing buffer format version. This version is 1.
122u16cpu_idThe CPU this buffer belongs to.
142u16reserved0Reserved, zero.
168u64capacityData region capacity in bytes. A power of two.
248u64data_offsetOffset from the start of the mapping to the data region. 8192.
328u64generationBuffer generation. Starts at 1 for the first buffer on each CPU and increases by one each time the buffer is replaced.
4024--reserved1Reserved, zero.

A consumer MUST verify magic and version before trusting any other field in the mapping.

A consumer MAY cache magic, version, cpu_id, capacity, and data_offset for the lifetime of the mapping; these do not change once the buffer exists. A consumer MUST NOT cache generation: it shares this cache line but is written when the buffer is superseded, and re-reading it is how a consumer learns that it must re-attach.

2.3.4.2 Bytes 64–127: positions #

OffsetSizeTypeFieldDescription
648u64write_posMonotonically increasing byte offset at which the next event will be written. Never wraps.
728u64tail_posByte offset of the oldest surviving event. Advanced by KMES as events are overwritten.
8048--reserved2Reserved, zero.

Both are absolute byte offsets that increase without bound; the corresponding data region offset is the value masked with capacity - 1. A u64 byte offset does not overflow in any practical deployment — at a sustained gigabyte per second it would take over five hundred years — and consumers MUST NOT implement wrap handling for these counters.

2.3.4.3 Bytes 128–191: notification #

OffsetSizeTypeFieldDescription
1284u32futex_counterIncremented by KMES when it wakes sleeping consumers.
13260--reserved3Reserved, zero.

The counter is 32-bit because the Linux futex operates on 32-bit integers, and it is incremented only when need_wake is set.

2.3.5 Consumer metadata page #

OffsetSizeTypeFieldDescription
40961u8need_wakeSet by a consumer that is about to sleep. Read by KMES after writing an event; any nonzero value counts as set.
40974095--reserved4Reserved.

This page is shared by every consumer attached to the buffer. A consumer MUST NOT store per-consumer state on it, and in particular MUST NOT store its read position there.

Consumers MUST NOT write to any offset in this page other than need_wake. Reserved bytes are reserved for future extension of this contract.

2.4 Consumer Protocol

Peios / Peios technical reference / PSPK / KMES Event Stream

A consumer typically dedicates one thread to each buffer. Each thread independently drains its buffer, sleeps when the buffer is empty, and re-attaches when the buffer is replaced. The protocol uses no locks and, while events are available, no system calls.

Each consumer keeps its own read_pos in its own memory. On first attaching to a buffer, a consumer SHOULD set read_pos to the buffer's current tail_pos, which starts it at the oldest surviving event.

2.4.1 Draining #

  1. Load write_pos with acquire ordering. If write_pos == read_pos, no events are available: go to the notification wait.
  2. Load tail_pos with acquire ordering. If read_pos < tail_pos, the events at the read position have been overwritten and the consumer has been lapped: set read_pos = tail_pos. The skipped span is lost, and will show up as a sequence gap.
  3. Save the current tail_pos as saved_tail.
  4. Read the event at data region offset read_pos & (capacity - 1).
  5. Re-read tail_pos. If it has advanced past saved_tail and read_pos < tail_pos, the event was overwritten while it was being read. The bytes just read MUST be discarded: go to step 2.
  6. Check that event_size > 0 and event_size >= header_size. If either fails, the bytes are not a valid event; the consumer SHOULD set read_pos = tail_pos and go to step 2. A consumer MUST perform this check: an event_size of zero would otherwise make the drain loop spin forever.
  7. Process the event. Advance read_pos by the event's event_size. Go to step 1.

A consumer MUST NOT read beyond an event's event_size boundary.

Steps 3 and 5 are what make a lock-free read safe against a producer that is overwriting the region being read. A consumer that omits the re-read can process a torn event assembled from two different events' bytes.

2.4.2 Detecting loss #

A consumer SHOULD track the last sequence number it processed for each CPU. A gap means events were lost, whether because they were overwritten before being read or because the kernel dropped them before they reached the buffer. The size of the gap is the number of events lost.

Loss is a normal condition under load, not an error: the buffer preserves recent events at the cost of old ones. A consumer SHOULD report loss rather than treating it as fatal.

2.4.3 Notification wait #

When a buffer is empty:

  1. Store 1 to need_wake with release ordering.
  2. Re-load write_pos with acquire ordering. If events arrived between the drain loop finding the buffer empty and this store, clear need_wake to 0 and return to the drain loop. This re-check is REQUIRED: without it, an event written in that window would find need_wake still clear, and the consumer would sleep with events waiting.
  3. Read futex_counter.
  4. Optionally spin, re-checking write_pos. If events arrive during the spin, clear need_wake to 0 and return to the drain loop. The spin duration is the consumer's choice, and a consumer MAY omit this step entirely.
  5. Call futex_wait(&futex_counter, last_seen_value), where last_seen_value is the value read in step 3. The kernel puts the thread to sleep only if futex_counter still holds that value, so a wake that arrived in the meantime is not missed.
  6. On waking, clear need_wake to 0 and return to the drain loop.

The futex address is the futex_counter field in the mapped producer metadata page. This is a shared futex, keyed by the page's backing inode, and a consumer MUST wait on it as such: a wait issued with FUTEX_PRIVATE_FLAG will never be woken.

Clearing need_wake to 0 in steps 2, 4, and 6 MAY be a relaxed store. If KMES reads a stale set value after the consumer has cleared it, it performs a wake on a thread that is already awake, which is harmless.

Under sustained load a consumer never reaches the notification wait: it stays in the drain loop, need_wake stays 0, and the producer's notification cost is a single byte read per event.

2.4.4 Buffer replacement #

KMES replaces every buffer when its configured capacity changes. Replacement preserves as many surviving events as the new capacity allows and keeps sequence numbering continuous, but it invalidates positions: the events are re-compacted from position 0 in the new buffer, so the consumer's read_pos means nothing there.

After each drain cycle — the buffer emptied, or a batch limit reached — a consumer SHOULD read generation. If it differs from the value last seen:

  1. Record the sequence number of the last event successfully processed from this buffer.
  2. Finish draining the old buffer up to its write_pos, which is now frozen: KMES has stopped writing to it. A consumer MUST complete this drain before switching, or it loses every event written between its read position and the switchover.
  3. Call kmes_attach for the same CPU to obtain a descriptor for the replacement buffer, and map it.
  4. Read the new buffer's capacity, write_pos, and tail_pos.
  5. Scan the new buffer for the first event whose sequence number is greater than the recorded one, and set read_pos to that event's position. A consumer MUST locate its position by sequence number and MUST NOT carry read_pos across.
  6. Close the old descriptor and unmap the old buffer.
  7. Resume draining from the new buffer.

The old buffer's pages remain valid for as long as any consumer keeps them mapped, so a consumer is never racing to finish before the memory disappears.

If the new capacity is large enough to hold everything that survived in the old buffer, no events are lost across the replacement. If it is smaller, the oldest surviving events are discarded until the remainder fits — the same overwrite semantics applied against the smaller capacity — so loss is bounded to the oldest part of the buffer and appears as a sequence gap.

A consumer sleeping on the old buffer is woken when the replacement happens, provided its need_wake was set, so it observes the generation change rather than sleeping indefinitely on a buffer that will never receive another event.

2.4.5 Memory ordering #

OperationOrderingPurpose
Producer stores tail_posreleaseThe advanced tail is visible before the data that replaces the events it skipped past.
Producer stores write_posreleaseComplete event data is visible before the position that makes it reachable.
Producer stores futex_counterreleaseA consumer waking from the futex observes all prior writes.
Consumer stores need_wake = 1releaseThe producer observes the flag before the consumer waits.
Consumer stores need_wake = 0relaxedA stale read causes only a spurious wake.
Consumer loads write_pos in the drain loopacquirePairs with the producer's release.
Consumer loads write_pos after setting need_wakeacquireCloses the window between finding the buffer empty and announcing the sleep.
Consumer loads tail_posacquirePairs with the producer's release.

For a given buffer there are exactly two kinds of party: one producer, which is the kernel on the owning CPU, and any number of consumers. There is no multi-producer contention to account for.

On x86-64 the producer's release stores compile to plain stores, because the architecture does not reorder stores with other stores. Consumers MUST NOT rely on that: the ordering above is required for correctness on weaker architectures, and a consumer written without it is incorrect on those machines whether or not it is observed to fail on x86-64.

3.1 Scope and Roles

Peios / Peios technical reference / PSPK / Binary Signing and PIP

This chapter specifies how a binary is signed so that the Peios kernel will accept it, and what the trust level a signature confers means.

Two roles participate.

The signer is a userspace program holding a private key. It computes a content hash over a file, signs it, and attaches the signature to that file. The signer role is publicly implementable: a third party building software for Peios, or an organisation operating its own trust tier, MUST be able to produce an acceptable signature from this chapter alone.

The verifier is the Peios kernel. It carries public keys, checks signatures at execution, at library load and when it reads a file for its own use (device firmware; §3.4), and derives a process's Process Integrity Protection identity from whichever key verified. The verifier role is not publicly implementable and is not specified here; this chapter constrains it only where the signer needs guarantees about what it will do.

This chapter covers:

  • the signature blob's encoding
  • where a signature is stored, and the order in which storage locations are consulted
  • exactly which bytes are covered by the signature
  • the signature algorithm, its parameters, and the absence of domain separation
  • how a key is selected during verification, and how a trust tier follows from it
  • the meaning of a PIP identity, and what a signer is asserting by requesting one
  • the guarantees a signer may rely on, and the ones it may not

This chapter does not cover:

  • how the kernel parses, hashes, or verifies — described in the Peios Kernel TRM
  • how PIP is enforced between processes or against objects — likewise the TRM's concern
  • key generation, custody, rotation, or distribution policy
  • the process mitigations that compose with PIP, which are set by a process launcher and are unrelated to signing

3.1.1 Why signing is a contract and not an implementation detail #

A binary's trust level is not something a process can request. There is no runtime interface that confers PIP, no inherited grant from a parent, and no flag at process creation. The only input is the signature on the file being executed, and the only authority is the key that verifies it.

That makes the signature the entire boundary. A signer that produces a byte-for-byte correct blob over the correct bytes obtains a trust tier; one that gets any of it wrong obtains none, silently, because an unverifiable binary executes with no protection rather than failing to execute. There is no error to observe and no diagnostic to read.

A specification is therefore the only thing standing between a signer and a silent, total loss of the property it was trying to obtain.

3.1.2 What the kernel establishes for itself #

The verifier trusts nothing in the artifact except the signature's arithmetic.

The trust tier is not carried in the signature, the file, or any metadata a signer controls. It is a property of the key that verified, looked up in a table compiled into the kernel image. A signer cannot encode, request, or influence the tier it receives; presenting a signature made with a key the kernel does not carry is indistinguishable from presenting no signature at all.

The signature covers the file's content, and the verifier re-derives the content hash itself over a stable size snapshot rather than trusting any length or digest recorded in the artifact.

A verified file is pinned against in-place modification for as long as its inode lives, so a signer MUST NOT assume it can update signed content in place. Replacement by a new inode is the only supported update path.

3.1.3 Relationship to PGSS #

Binary signing is not a conformance requirement in the sense PGSS defines. A system that verifies no signatures, or verifies them against different keys, is still Peios; PIP is additive protection rather than a property every Peios system exhibits.

It is specified here because the contract is public even so. A third party that wants its software to run at a trust tier — or that wants to operate a tier of its own — needs the format written down exactly, and needs to know which of the verifier's behaviours it may depend on.

3.2 Signature Format

Peios / Peios technical reference / PSPK / Binary Signing and PIP

3.2.1 The blob #

A signature is a fixed 3310-byte blob, identical wherever it is stored:

OffsetSizeField
01Version. MUST be 0x01.
13309Raw ML-DSA-65 signature.

Total 3310 bytes exactly. There is no length field, no algorithm identifier, no key identifier, no timestamp and no padding. A blob of any other length MUST be rejected, and so MUST a version byte other than 0x01.

Signers MUST NOT emit any other version. A verifier encountering one MUST treat the file as unsigned rather than attempting a fallback interpretation.

3.2.2 Storage #

Two locations are defined. A verifier MUST consult them in this order.

3.2.2.1 ELF section #

An ELF binary SHOULD carry its signature in a section named exactly .peios.sig. The name comparison covers all eleven bytes including the terminating NUL, so a longer name having .peios.sig as a prefix MUST NOT match.

The section's type MUST be SHT_PROGBITS and its size MUST be exactly 3310. The range [sh_offset, sh_offset + sh_size) MUST lie entirely within the file.

The containing file MUST be ELFCLASS64, MUST be ELFDATA2LSB, and MUST carry EV_CURRENT in e_ident[EI_VERSION]. e_shentsize MUST equal 64. e_shstrndx MUST NOT be SHN_UNDEF and MUST be less than e_shnum. The section header table and the section-name string table MUST both lie entirely within the file.

No alignment or flag requirement applies. sh_addralign, sh_flags, sh_addr, sh_link and sh_info are not inspected, and the section's index and position are unconstrained. Where several sections share the name, the one at the lowest index is used.

A 32-bit or big-endian ELF cannot carry a signature at all: it fails the structural requirements above, and a verifier MUST NOT fall back to the extended attribute for it.

3.2.2.2 Extended attribute #

Any file MAY carry its signature in the extended attribute security.peios.sig. The value MUST be exactly 3310 bytes; any other size MUST be treated as unsigned.

This is the only location available to non-ELF files, and it is available to ELF files that carry no .peios.sig section header.

An extended attribute is a property of an inode, not of the file's bytes, so a signer cannot ship one: it produces the blob and something with the right to set security.* attributes on the destination filesystem applies it when the file is created. On Peios that is the package consumer, from a detached-signature payload entry named <file>.peios.sig (PSPU §5.16). A signer that emits the blob under that name beside the file it signs, and does not touch the file, has done its whole job for this placement.

3.2.2.3 Lookup order and commitment #

A verifier MUST determine the storage location as follows.

  1. Read the first four bytes. A file shorter than four bytes, or whose first four bytes are not \x7fELF, is not ELF: go to step 3.
  2. Parse the ELF structures and scan for a section named .peios.sig. Once such a section header is found, the ELF path is committed: the extended attribute MUST NOT be consulted, whatever happens next. A wrong type, a wrong size, an out-of-range offset, a read failure, a bad version byte or a failed verification all yield "unsigned". A structural failure encountered while parsing MUST commit the path in the same way. The single exception is e_shnum == 0, which does not commit.
  3. Read security.peios.sig. If present and exactly 3310 bytes, use it.
  4. Otherwise the file is unsigned.

The commitment rule is a security requirement rather than an optimisation. Without it, an attacker able to write a malformed ELF section could force fallback to whichever location they more easily controlled.

Where both locations are populated, the ELF section wins. A signer SHOULD NOT populate both.

3.2.3 What is signed #

The message is a 32-byte SHA-256 content hash. Which bytes it covers depends on where the signature is stored, and a signer MUST use the form matching its chosen location.

ELF section source. The hash covers the file with the section's contents replaced by zeros:

SHA-256( file[0 .. sh_offset)
       || 0x00 × sh_size
       || file[sh_offset + sh_size .. file_size) )

Only the section contents are zeroed. The Elf64_Shdr entry describing the section is hashed verbatim, as are the ELF header, the program headers, the section-name string table and every other byte of the file. The section header metadata is therefore integrity-protected along with everything else, which is what stops an attacker relocating or resizing the signature section without invalidating the signature.

This has a direct consequence for how a signer MUST work. The complete file layout — including sh_offset, sh_size, sh_name and the position of the section header table — MUST be final before the hash is computed. The practical sequence is to reserve a 3310-byte .peios.sig section filled with zeros, finalise the layout, hash the file as it then stands, sign the hash, and write the blob into the reserved bytes without touching anything else. Because the reserved region is already zero, the file as hashed and the file as shipped differ only in those 3310 bytes.

Extended attribute source. The hash covers the entire file with no exclusions:

SHA-256( file[0 .. file_size) )

This applies to ELF files reaching the attribute path as well as to non-ELF ones. The ELF-zeroed form is used only when the ELF section is the signature source.

In both cases file_size is a snapshot taken at the start of verification. A verifier MUST re-check the size before returning and MUST discard the result if it changed.

3.2.4 Algorithm #

The signature is ML-DSA-65 as specified in FIPS 204. The public key is 1952 bytes and the signature is 3309 bytes.

Signing is:

ML-DSA.Sign(private_key, content_hash, ctx = "")

and verification is the corresponding ML-DSA.Verify.

This is pure ML-DSA — FIPS 204 Algorithm 2 and Algorithm 3 — and not HashML-DSA, the pre-hashing variant. The message happens to be a 32-byte SHA-256 hash, which pure ML-DSA signs directly. A signer MUST NOT use the pre-hashing variant; a signature produced that way will not verify.

The context string MUST be empty. Signers MUST NOT set a context. Verifiers cannot express one, so a signature produced under a non-empty context simply fails to verify rather than being detected and reported.

It follows that ML-DSA's context field is not available for domain separation between binary signatures and any other Peios signature system. Separation MUST come from using distinct keys.

A signer MUST supply the public key as the raw 1952-byte key, not an SPKI-wrapped DER encoding. From OpenSSL 3.5 or later, the raw key is the trailing 1952 bytes of the DER public key.

3.2.5 Key selection and trust tiers #

The blob carries no key identifier, so a verifier selects a key by exhaustive trial: it tries each key in its table in order and takes the first that verifies.

The trust tier is a property of which key verified — a pip_type and a pip_trust, both unsigned integers — and never of anything the signer encoded. A signer cannot express intent about the tier it wants. It obtains whichever tier the verifier associates with the key it used, and if the verifier carries no matching key the file is simply unsigned.

Consequently:

  • A signer that wants software to run at a tier MUST have its public key present in the verifier's table. Getting a key into that table is a deployment question, not a format question.
  • A signer MUST NOT assume its signature is portable across systems carrying different key tables. The same file may be trusted on one and untrusted on another, with no observable difference in the file.
  • Verification cost is linear in the number of keys, so a verifier MAY reasonably carry few.

3.3 The PIP Contract

Peios / Peios technical reference / PSPK / Binary Signing and PIP

A verified signature confers a PIP identity: a pip_type and a pip_trust, both 32-bit unsigned integers, taken from the key that verified. An unsigned, unverifiable or unrecognised binary confers type 0 and trust 0, which means no protection.

This chapter states what that identity means to a party outside the kernel — what a signer is asserting by obtaining one, what an object owner is asserting by labelling an object, and what may and may not be relied upon.

3.3.1 Dominance #

All PIP enforcement reduces to one comparison:

dominates(caller, target):
    if target.pip_type == 0:
        return true
    return caller.pip_type  >= target.pip_type
       and caller.pip_trust >= target.pip_trust

Both axes are compared numerically. Neither is a closed enumeration: a value carries no meaning beyond its ordering.

Three type values are conventional — 0 for None, 512 for Protected, 1024 for Isolated — but a specification MUST NOT assume they are the only ones, and a party evaluating dominance MUST compare numerically rather than switching on known values.

An unprotected target is dominated by everyone. This is what keeps ordinary processes universally accessible whatever trust values a caller carries, and it means PIP restricts access to protected things rather than granting access to trusted callers.

Dominance is binary. A caller either dominates or does not; there is no partial ordering and no per-operation granularity in PIP itself.

3.3.2 What a signer asserts #

Obtaining a tier is an assertion about the binary, not about what it will do. Specifically, a signer with a key at some tier asserts that the signed file is fit to run at that tier, that its contents are what the signer intended, and that the signer accepts the file being treated as trusted by every dominance comparison on every system carrying that key.

A signer MUST NOT treat a tier as a capability grant. A high tier confers no privilege, no access right and no identity. It protects the process from lower-tier processes and permits it to reach higher-protected objects; it grants nothing on its own.

A signer SHOULD understand that a tier is inherited by children at fork and re-derived at their exec. A protected process that execs an unsigned binary loses protection entirely — protection follows the binary, not the lineage.

3.3.3 What an object owner asserts #

An object opts into PIP protection by carrying a process trust label in its SACL, whose SID has the form S-1-19-{type}-{trust} — the Process Trust authority with exactly two sub-authorities. A SID of any other shape makes the descriptor malformed, and an evaluator MUST reject it rather than guess.

The label's access mask names exactly the rights a non-dominant caller may still receive. A dominant caller is unrestricted by the label.

Two properties matter to anyone authoring one.

There is no default. An object with no trust label is unrestricted by PIP, reachable by any process whatever its identity. Protection is opt-in per object.

Privileges do not compensate. PIP revokes rights that privileges granted, including ACCESS_SYSTEM_SECURITY. There is no relabel equivalent, no administrative override, and no privilege that substitutes for insufficient trust. An object owner labelling an object may rely on this: a non-dominant caller cannot reach the object's SACL to remove the label, which is what makes the protection self-sustaining rather than trivially removable.

3.3.4 What may be relied upon #

A party may rely on the following.

A tier is derived from the signature alone. No parent process, no privilege, no runtime interface and no environment can confer, elevate, or forge one — a compromised process running as SYSTEM cannot grant PIP to an unsigned binary.

Impersonation does not alter it. PIP is read from per-process state rather than from a token, so a service impersonating a client still evaluates its own tier, and a process impersonating a token created for a protected process gains nothing.

A verified file is pinned against in-place modification for as long as its inode remains live. Ordinary, positioned and append writes, truncation by descriptor or pathname, every fallocate mode, and content-mutating and unrecognised ioctls are all refused on it, as is mutation or removal of the signature attribute.

A binary the kernel execs on its own behalf carries at least PeiosTcb trust. Where the kernel spawns a userspace helper for its own purposes rather than at a process's request — resolving a module name, and any comparable kernel-initiated exec — the implementation MUST refuse the exec unless the binary's pip_trust is at least the PeiosTcb level. A party may therefore rely on such a helper being TCB-signed, and on the exec failing rather than proceeding at a lower tier.

The refusal MUST apply equally when no tier could be derived at all. "Could not establish trust" and "is not trusted" reach the same outcome here, so that the requirement cannot be evaded by preventing the derivation from running.

3.3.5 What may not be relied upon #

A party MUST NOT rely on the following.

Execution is not gated, with one exception. A bad, tampered or absent signature costs a binary its tier; it does not prevent execution. PIP determines trust level, not permission to run. Permission to run is the file's own security descriptor.

The exception is the kernel-initiated exec described above, where a tier below PeiosTcb refuses the exec outright. It is confined to that case for a reason: everywhere else there is a requesting process whose own authority bounds what the exec can do, so an untrusted binary can be allowed to run and simply carry no tier. A kernel-initiated exec has no such process behind it — the kernel is acting on its own behalf, at its own authority — so there is no lesser authority to fall back to and nothing to bound the result. A party MUST NOT generalise the exception to ordinary execs.

Absence of a tier is not observable as an error. There is no diagnostic distinguishing "this binary is unsigned", "this signature is malformed" and "this key is not in the table". All three produce a process at type 0 and trust 0 with no indication.

There is no revocation. A signed binary later found to be malicious cannot be invalidated. There is no hash blocklist and no key-scoped revocation. The remedies are removing the file or replacing the verifier's key table.

A tier is not a container boundary. PIP operates inside the kernel's trust boundary. Kernel compromise voids it, DMA-capable hardware bypasses it, and it offers nothing equivalent to hypervisor-based isolation.

Library trust is compared, not merely required. Where a process enables library signature verification, a library has to dominate the loading process, so raising a process's tier narrows the set of libraries it can load. A signer distributing libraries alongside a high-tier program has to sign them at a tier that dominates it.

Scripts take their interpreter's tier. A script executed through a #! line contributes nothing; the tier comes from the interpreter binary. A signer MUST NOT expect signing a script to affect anything, and an object owner MUST NOT treat "runs at a high tier" as evidence that the code being run was signed.

3.4 Files the Kernel Loads

Peios / Peios technical reference / PSPK / Binary Signing and PIP

Executing a binary is not the only way file contents become code. When a driver asks the kernel for device firmware, the kernel reads a file from the filesystem and hands its bytes to hardware that can act on host memory. A tampered firmware blob is at least as complete a compromise as an unsigned binary running at the highest tier, and it happens without an exec for the verifier to intercept. This section extends the signature of §3.2 to those files.

3.4.1 What is covered #

The verifier checks a signature on every file it reads through the firmware loader — the mechanism a driver uses to request a named blob, whether whole or in pieces. Files the kernel reads for other purposes (a module, a policy file, a kexec image) have their own integrity mechanisms and are not covered here.

Firmware is not executed by a process, so it obtains no PIP identity. The signature is used for one decision only: whether the bytes reach the device.

3.4.2 What a signer produces #

A firmware file is never ELF. The signer therefore produces the extended-attribute form of §3.2 — the 3310-byte blob over SHA-256 of the entire file as it sits on disk — and delivers it as the detached-signature payload entry of PSPU §5.16, <file>.peios.sig beside the file in the package. The package consumer derives security.peios.sig from it when the file is created.

Because the hash covers the on-disk bytes, a compressed blob is signed in its compressed form. The kernel verifies before it decompresses, which is the side of that boundary a verifier has to be on: the decompressor is code that runs on untrusted input, and it must not run on bytes that have not yet been checked.

A signer MUST NOT sign a symlink. The verifier reads through links and checks the target's attribute; a signature on the link itself is never consulted.

3.4.3 What the verifier requires #

The verifier resolves the signature by the lookup of §3.2 and selects a key by the trial of §3.2. It then requires that the key's tier be the one that denotes the kernel's own trusted computing base (PeiosTcb). A verified signature from a key at any other tier is treated exactly as no signature: firmware runs with kernel privilege on the far side of a bus, and no lower tier is fit to supply it.

A firmware file that is unsigned, unverifiable, or signed at an insufficient tier is refused: the loader reports failure to the requesting driver, which fails as it would for a missing file. The verifier MUST NOT fall back to loading the file unsigned.

A verifier MAY provide a boot-time policy that reports rather than refuses such a file, so that a system can be brought up with an unsigned firmware set and the gaps found before refusal is turned on. A verifier in that mode MUST record every file it would have refused, and MUST NOT make the mode selectable at run time.

3.4.4 What a signer may rely on #

  • The bytes verified are the bytes delivered to the device. The verifier checks the buffer the loader will hand over, not a separate read of the file, so a file changed between the two is not a concern the signer has to design around.
  • The check applies to every path the loader resolves, including paths an administrator has added to its search list. A signer cannot be shadowed by an unsigned file earlier in that list.
  • The check does not apply to firmware pushed into a device by means other than the loader — a device's own update interface, for example. Those are the responsibility of the driver that exposes them.

4.1 Scope and Roles

Peios / Peios technical reference / PSPK / Registry Source Interface

This chapter specifies the Registry Source Interface (RSI): the protocol between the Peios kernel's registry subsystem and the userspace processes that store registry data for it.

Two roles participate.

The kernel is LCS, the Layered Configuration Subsystem. It owns the registry namespace, the layer model, access control, watches and transactions, and it holds no storage of its own. There is one kernel.

The source is a userspace process that stores the data for one or more hives and answers the kernel's requests about it. The source role is publicly implementable: any process holding the required privilege MAY register as a source, and the kernel is source-agnostic. A conforming source is the subject of the requirements in this chapter.

The kernel is the party asking. A source is authoritative for the bytes it returns and for nothing else.

This chapter covers:

  • the character device, how a source attaches to it and is recognised, and the source slot lifecycle
  • the framing and encoding of requests and responses, and the rules under which each may be extended
  • every operation the kernel issues, its request payload, its response payload, and its meaning
  • the status vocabulary a source answers with
  • what the kernel validates for itself rather than believing
  • the obligations a conforming source MUST satisfy

This chapter does not cover:

  • The registry data model — hives, keys, path entries, values, layers, tombstones, resolution — which is described in the Peios Kernel TRM. A source does not need it.
  • The system-call and ioctl surface the registry offers to ordinary programs, which is that subsystem's own documentation.
  • The registry backup format, which is specified in its own chapter.
  • How a source stores its data, serves concurrent requests, or computes its answers.

4.1.1 What a source is not #

A source stores and returns. It MUST NOT resolve layers, filter results by visibility, evaluate a Security Descriptor, interpret a path beyond the parent and child names it is given, or dispatch a notification. Every such decision belongs to the kernel, and a source that made one would be making it with less information than the kernel has.

A source never learns the identity of the process on whose behalf a request was issued. Requests carry no caller identity, and there is no mechanism by which a source could obtain one.

4.1.2 What the kernel establishes for itself #

The kernel validates that every response is structurally well-formed, that names are valid, that Security Descriptors parse and satisfy the mask rules, that sequence numbers cannot be from the future, and that metadata blocks cover exactly the GUIDs they should. §4.5 lists these.

It cannot validate meaning. A source is inside the trusted computing base precisely because the kernel has no independent copy of what a source returns: a Security Descriptor granting everyone full access to a sensitive key is a valid Security Descriptor, and the kernel will enforce it.

A source is trusted with the correctness of the registry's access control for the hives it backs. That is the whole trust model, and it is why attaching requires the highest privilege the system has.

4.2 The Channel

Peios / Peios technical reference / PSPK / Registry Source Interface

4.2.1 The device #

A source attaches by opening the character device /dev/pkm_registry.

The open() handler evaluates the calling thread's effective token. The caller MUST hold SeTcbPrivilege and it MUST be enabled, not merely present; open() fails EPERM otherwise. An unprivileged process cannot obtain a descriptor to the device at all.

One open descriptor corresponds to one source connection.

4.2.2 Registration #

Before entering the request loop a source MUST register its hives, by issuing the REG_SRC_REGISTER ioctl on the device fd. The argument is a reg_src_register_args:

FieldTypeDescription
hive_countu32Number of hive entries. MUST be non-zero.
_padu32Reserved. MUST be zero.
max_sequenceu64The highest sequence number persisted anywhere in this source's storage. A single value for the whole source, not per hive.
hives_ptru64Userspace address of an array of hive_count reg_src_hive_entry structures.

Each reg_src_hive_entry:

FieldTypeDescription
name_lenu32Length of the hive name in UTF-8 bytes.
_pad0u32Reserved. MUST be zero.
name_ptru64Userspace address of the hive name. Not null-terminated.
root_guidu8[16]The GUID of this hive's root key. MUST NOT be all-zero.
flagsu32RSI_HIVE_PRIVATE (0x01). All other bits reserved and MUST be zero.
_pad1u32Reserved. MUST be zero.
scope_guidu8[16]The private scope identifier. MUST be all-zero unless RSI_HIVE_PRIVATE is set.

Each hive carries its own name pointer; there is no separate array of names.

The kernel validates, and registration fails if any of the following does not hold:

  • every hive name is valid — UTF-8, no null byte, no separator, non-empty, within the configured component length;
  • no hive name is CurrentUser in any casing, which is reserved;
  • the route identity of each hive — its case-folded name paired with its scope — does not collide with one held by an Active source (EEXIST);
  • no root GUID is all-zero, and the root GUIDs within this request are distinct from each other;
  • a hive without RSI_HIVE_PRIVATE carries an all-zero scope_guid;
  • hive_count is within MaxHivesPerSource and the registered source count is within MaxRegisteredSources (ENOSPC);
  • max_sequence is not U64_MAX, since the kernel MUST be able to allocate above it (EOVERFLOW).

max_sequence initialises the kernel's global sequence counter to at least one above it, so that new writes always outrank anything already persisted. A source MUST report it accurately; under-reporting it allows a new write to collide with a stored entry.

4.2.3 Source slots #

A successful registration creates a source slot, the kernel object owning one connection and its hive set. A slot is Active or Down.

Each registered hive has a stable identity: its case-folded name, its visibility, its scope GUID if private, and its root GUID.

A source crash or an fd close marks the slot Down. It does not unregister the source or retire any hive identity. Down slots keep their identities reserved, and collision checks include them. There is no implicit retirement.

While a slot is Down its hives are unavailable; operations needing a round trip fail, key descriptors held by processes remain valid, and watches remain armed.

4.2.4 Resuming a Down slot #

A new process MAY take over a Down slot. It MUST hold SeTcbPrivilege and it MUST register exactly the same hive set: the same number of hives, and for each of them the same case-folded name, the same visibility, the same scope GUID and the same root GUID.

Partial resume is rejected. In particular:

  • A request whose only mismatch is a different root GUID for an otherwise-matching hive fails ESTALE.
  • Other partial or malformed resume attempts fail EINVAL.
  • A collision with an Active slot fails EEXIST, and takes precedence over ESTALE when both would apply.

A source MUST NOT expect to add hives by resuming a Down slot with a larger set; that is a partial-resume failure. New hives require a new slot.

The kernel authenticates a replacement by SeTcbPrivilege, not by process identity. Process identity cannot survive a crash and restart, so nothing records or compares it.

On a successful resume the slot becomes Active and the kernel replays any pending layer deletions before resuming normal traffic.

4.2.5 Before serving anything #

A source MUST purge orphaned key records before completing registration. An orphaned record is a key with no path entry in any layer, left behind by a key that was unlinked but not dropped before the previous shutdown.

If that cleanup cannot be completed, the source MUST fail registration rather than become Active with known orphans. The kernel does not verify this and cannot: it has no independent view of the source's storage.

On first boot against an empty store, a source MUST create a root key record for each hive it backs, generating a GUID for each and giving it an appropriate default Security Descriptor, and MUST persist them. The root GUIDs it then reports in registration are those. Subsequent startups reuse the persisted ones.

4.2.6 The lifetime extension #

A source MAY negotiate the lifetime extension. It tells the source how long each request and transaction may live, and gives it a lease that proves a transaction still has a live kernel-side owner. The RSI request and response frames are unchanged by it. A source that does not negotiate it gets the protocol described in the rest of this book.

All three ioctls are issued on the device fd. Every timestamp is an absolute CLOCK_MONOTONIC time in nanoseconds.

4.2.6.1 Negotiation #

To use the extension, a source MUST issue REG_SRC_NEGOTIATE before REG_SRC_REGISTER. The argument is a reg_src_negotiate_args (16 bytes):

FieldTypeDescription
versionu32REG_SRC_LIFETIME_VERSION, which is 1.
flagsu32Reserved. MUST be zero.
sessionu64MUST be zero on input. On success, the kernel writes a nonzero session identifier, unique to this open fd and never reused by a later fd.

Any other version, nonzero flags or a nonzero input session fails EINVAL. Negotiating after registration, or on a connection that is closing, fails EBUSY.

4.2.6.2 Request context #

After reading a request and before responding to it, a negotiated source MAY issue REG_SRC_REQUEST_CONTEXT with its session and the request's id. The argument is a reg_src_request_context_args (40 bytes):

FieldTypeDescription
sessionu64The session returned by negotiation.
request_idu64The id of a request the source has read and not yet answered.
request_deadline_nsu64Output. When the kernel-side caller stops waiting, counting from when the request was queued.
lifetime_deadline_nsu64Output. The transaction's lifetime ceiling; zero for a transaction owned by one kernel operation, which has no separate ceiling.
lease_deadline_nsu64Output. The current cleanup lease; zero when the request has no live transaction owner.

The context stays available after the kernel-side caller has given up, until the source's response is accepted, so a source can still clean up and answer a request it read late. A request the source has not read, or has already answered, fails ENOENT.

4.2.6.3 Renewing a lease #

REG_SRC_RENEW takes a reg_src_renew_args (32 bytes): session and transaction_id as inputs, and lifetime_deadline_ns and lease_deadline_ns as outputs. On success the lease is extended to at most five seconds from now, and never past the lifetime ceiling.

Renewal succeeds only for an existing transaction that has not ended, whose kernel-side owner (a transaction fd, or a kernel operation) is still live, on this source's own session. An unknown, released, expired or foreign transaction fails ESTALE. A lease that has expired cannot be renewed back to life. Releasing the last owner, or aborting the transaction, ends renewal before the abort message is delivered.

None of the three ioctls uses an in-flight request slot, so a saturated request queue cannot prevent renewal. On a connection that has not negotiated, is not Active, or names another session, request context and renewal fail ESTALE.

4.2.6.4 What a negotiated source does with it #

A source that negotiated:

  • MUST stop holding transaction state (locks, an open storage transaction, a read snapshot) once that transaction's lease has expired or renewal has failed, and MUST treat the transaction as aborted from then on;
  • MUST reject new operations on a transaction after its lifetime ceiling, and MUST serialise its rollback with the transaction's other operations and its commit;
  • MUST report the true outcome of a commit it had already admitted when the lease or lifetime ran out, rather than inferring one from the expiry.

A lease is evidence that the kernel-side owner is alive. It is not an inactivity timeout, and lease expiry does not change how the kernel treats a commit response that arrives after its caller stopped waiting.

4.3 Message Framing

Peios / Peios technical reference / PSPK / Registry Source Interface

The protocol is binary and multiplexed. All multi-byte integers are little-endian.

4.3.1 The request header #

A request is 22 bytes of header followed by an operation-specific payload.

OffsetSizeField
04total_len
48request_id
122op_code
148txn_id

total_len is the whole message including the header.

request_id matches a response to its request. Request ids are allocated by the kernel, are strictly increasing within one connection, and are never reused while that connection lives — including after a request has timed out.

txn_id is the transaction the operation belongs to, or zero for none. When non-zero, the source MUST process the operation inside that transaction's context.

4.3.2 The response header #

A response is 14 bytes of header followed by a payload. It carries no txn_id.

OffsetSizeField
04total_len
48request_id
122op_code

A source MUST copy request_id from the request into its response, and MUST set op_code to the request's operation code with the high bit set — that is, op_code | 0x8000. Every operation has a named response code for this value.

Every response payload begins with a u32 status at offset 14, so the minimum response size is 18 bytes.

4.3.3 Encoding #

A length-prefixed field is a u32 byte count followed by that many bytes. Strings so encoded are UTF-8 and carry no terminator; a terminator byte counted in the length is a null byte and is therefore invalid. Some length-prefixed fields carry binary rather than text — Security Descriptors and value data — and are not UTF-8.

A GUID is 16 raw bytes.

An array is a u32 count followed by that many entries.

A boolean is one byte.

4.3.4 Extension, and its asymmetry #

Requests and responses extend differently, and a source MUST implement both rules.

A request MAY carry trailing fields beyond those a source recognises. A source MUST skip them, using total_len to find the end of the message. This is how the kernel adds an optional field without an RSI version bump.

A response MUST NOT. The kernel rejects any trailing bytes in a response payload as malformed data. A source MUST emit exactly the payload each operation defines, and no more. Extending the protocol in this direction is done with new operations, never by growing an existing payload.

4.3.5 Reading and writing #

The device fd is message-oriented. total_len frames messages, but a successful I/O call never splits or joins one.

read() returns exactly one complete request. If none is queued, a blocking read waits until one is, or until the fd is closing, in which case it returns 0. Under O_NONBLOCK an empty queue returns EAGAIN. If the caller's buffer is too small for the next queued request, read() returns EMSGSIZE and does not consume it.

write() submits exactly one complete response. The buffer length MUST equal the response header's total_len, and MUST be at least the response header size. A successful write is never short.

The following all fail EINVAL and tear the connection down, marking the source Down:

  • a length shorter than the response header;
  • a length that does not equal total_len;
  • an unknown request_id;
  • a response to a request that has not been delivered;
  • a second response to a request already answered;
  • an op_code that is not the request's with the response bit set;
  • a response written on a descriptor that is not the slot's active one.

A source that cannot frame its own messages correctly is not one whose other answers can be relied on. Ordinary per-operation errors are reported through the status vocabulary and do not tear anything down.

poll() reports the fd readable when at least one complete request is queued, writable while the slot is Active, and POLLHUP | POLLERR when the slot is Down or the fd is closing. An fd that is open but not yet registered reports nothing.

4.3.6 Concurrency and timing #

A source MAY process requests in any order and MUST handle multiple in-flight requests without head-of-line blocking. Responses are matched by request_id, not by arrival order.

The kernel bounds in-flight requests per source by MaxConcurrentRSIRequests, default 256.

A source MUST respond to every request it has read, exactly once, even if the kernel-side caller has already given up. The kernel applies a request timeout — RequestTimeoutMs, default 30 seconds, measured from the moment it first tries to reserve an in-flight slot and covering the whole wait — and a source is not disconnected for exceeding it. Late responses are validated and processed exactly like on-time ones.

A timed-out request remains in the kernel's in-flight table, and keeps occupying one of the source's slots, until the source answers or the connection is torn down. A source that accumulates unanswered requests will exhaust its own concurrency budget.

4.4 Operations

Peios / Peios technical reference / PSPK / Registry Source Interface

Eighteen operations. Every one that returns layer-qualified data MUST return all entries across all layers: a source MUST NOT pre-filter, resolve, or omit. The kernel decides what is effective.

Every response payload begins with a u32 status. Payload fields below are listed after it, in wire order, with no padding between them. "Status only" means the payload is the status and nothing else — 18 bytes in total.

OperationCodeResponse
RSI_LOOKUP0x00010x8001
RSI_CREATE_ENTRY0x00020x8002
RSI_HIDE_ENTRY0x00030x8003
RSI_DELETE_ENTRY0x00040x8004
RSI_ENUM_CHILDREN0x00050x8005
RSI_CREATE_KEY0x00100x8010
RSI_READ_KEY0x00110x8011
RSI_WRITE_KEY0x00120x8012
RSI_DROP_KEY0x00130x8013
RSI_QUERY_VALUES0x00200x8020
RSI_SET_VALUE0x00210x8021
RSI_DELETE_VALUE_ENTRY0x00220x8022
RSI_SET_BLANKET_TOMBSTONE0x00230x8023
RSI_BEGIN_TRANSACTION0x00300x8030
RSI_COMMIT_TRANSACTION0x00310x8031
RSI_ABORT_TRANSACTION0x00320x8032
RSI_FLUSH0x00400x8040
RSI_DELETE_LAYER0x00500x8050

Any other operation code is invalid.

4.4.1 Path operations #

4.4.1.1 RSI_LOOKUP #

Look up a child entry under a parent key. This is the path-walking primitive; the kernel issues one per path component.

Request: parent_guid (16), child_name (length-prefixed).

Response: entry_count (u32), then that many path entries:

SizeField
4+nlayer_name
1target_type: 0 = GUID, 1 = HIDDEN
16target_guid
8sequence

then metadata_count (u32), then that many key metadata entries:

SizeField
16guid
4+nsd, binary
1volatile
1symlink
8last_write_time

An empty response — entry_count zero — means the child does not exist in any layer. That is an ordinary answer, not an error.

The metadata block is deduplicated: exactly one entry per distinct GUID referenced by the path entries. A source MUST satisfy all of:

  • every GUID appearing as a target has exactly one metadata entry;
  • no metadata entry is duplicated;
  • no metadata entry is unreferenced;
  • no metadata GUID is all-zero;
  • a HIDDEN entry MUST carry an all-zero target_guid and MUST NOT contribute a metadata entry.

Violating any of these is malformed data.

The kernel resolves symlink targets itself, by issuing a separate RSI_QUERY_VALUES for the key's default value. A source MUST NOT interpret the symlink flag or follow anything.

4.4.1.2 RSI_CREATE_ENTRY #

Create a path entry (parent, child_name, layer) → guid.

Request: parent_guid (16), child_name, layer_name, child_guid (16), sequence (u64).

Response: status only.

Always paired with RSI_CREATE_KEY, which carries the same GUID. The kernel sends RSI_CREATE_ENTRY first, so that a losing race is detected before a key record is created: an RSI_ALREADY_EXISTS here means another writer got the name.

4.4.1.3 RSI_HIDE_ENTRY #

Create a HIDDEN path entry at (parent, child_name, layer).

Request: parent_guid (16), child_name, layer_name, sequence (u64).

Response: status only.

4.4.1.4 RSI_DELETE_ENTRY #

Remove the path entry at (parent, child_name, layer), whether it was a GUID entry or a HIDDEN one.

Request: parent_guid (16), child_name, layer_name.

Response: status only.

4.4.1.5 RSI_ENUM_CHILDREN #

Enumerate every child entry under a parent, across all layers.

Request: parent_guid (16).

Response: child_count (u32), then per child:

SizeField
4+nchild_name
4entry_count
…that many path entries, in the RSI_LOOKUP entry format

followed by one metadata block, in the RSI_LOOKUP metadata format, covering the distinct GUIDs across all children. Emitting it once rather than per child is what makes this cheaper than a lookup per name.

The same deduplication and closure rules apply, evaluated across the whole response.

4.4.2 Key operations #

4.4.2.1 RSI_CREATE_KEY #

Create a key record. The GUID is assigned by the kernel.

Request: guid (16), name, parent_guid (16), sd (binary, length-prefixed), volatile (1), symlink (1).

Response: status only.

The path entry linking the key into the namespace is created separately by RSI_CREATE_ENTRY. For a symlink key, the target is written afterwards as a default REG_LINK value through RSI_SET_VALUE.

A source MUST persist the GUID exactly as given: no rewriting, no remapping, no reassignment. GUIDs are the kernel's identity for keys and the source's primary key for storage.

4.4.2.2 RSI_READ_KEY #

Request: guid (16).

Response: name, parent_guid (16), sd, volatile (1), symlink (1), last_write_time (i64).

4.4.2.3 RSI_WRITE_KEY #

Update a key's mutable fields.

Request: guid (16), field_mask (u32), then the named fields in bit order.

BitFieldEncoding
0sdlength-prefixed
1last_write_timei64

Only fields whose bit is set are present. Any other bit set in field_mask is invalid.

Response: status only.

There is no way to express a change to the GUID, the volatile flag or the symlink flag: those fields are simply absent from the request. A source MUST reject a request attempting to modify an immutable field with RSI_INVALID.

A field_mask of zero is a well-formed existence check that mutates nothing.

4.4.2.4 RSI_DROP_KEY #

Purge everything associated with a GUID: the key record, all value entries across all layers, all path entries, and all blanket tombstones.

Request: guid (16).

Response: status only.

RSI_DROP_KEY MUST be idempotent. If the GUID does not exist — already purged by startup cleanup, say — the source MUST return RSI_OK.

The kernel issues this when the last descriptor to an unnamed key closes, and it issues it without a waiting caller. A source MUST answer it like any other request.

4.4.3 Value operations #

4.4.3.1 RSI_QUERY_VALUES #

Retrieve every layer entry for one value, or for all values on a key.

Request: guid (16), value_name, query_all (1). When query_all is set the value name is empty and every value on the key is returned.

Response: entry_count (u32), then per entry:

SizeField
4+nvalue_name
4+nlayer_name
4type
4+ndata, binary
8sequence

then blanket_count (u32), then per blanket tombstone:

SizeField
4+nlayer_name
8sequence

The blanket list is part of every response, including one for a single named value: the kernel needs it to resolve that name.

A tombstone entry carries type REG_TOMBSTONE (0xFFFF) and zero-length data. A source MUST NOT return a tombstone with data, an undefined value type, or data exceeding the configured maximum value size.

4.4.3.2 RSI_SET_VALUE #

Store a value entry at (guid, value_name, layer), replacing any existing entry for that triple.

Request: guid (16), value_name, layer_name, type (u32), data, sequence (u64), expected_sequence (u64).

Response: status only.

Conditional writes. A source MUST support expected_sequence. Zero means unconditional. Non-zero means the source MUST atomically verify that the current entry at (guid, value_name, layer) carries that sequence number before writing, and MUST return RSI_CAS_FAILED without writing if it does not match or if no entry exists.

The condition is against the layer's own entry, not against any resolved value. A source does not know what is effective and MUST NOT try to work it out.

4.4.3.3 RSI_DELETE_VALUE_ENTRY #

Remove the entry at (guid, value_name, layer), whether it was a value or a tombstone.

Request: guid (16), value_name, layer_name.

Response: status only.

This operation MUST be idempotent: a source MUST return RSI_OK when there was no entry to remove. The kernel does not mask RSI_NOT_FOUND here, so a source returning it makes the caller's delete fail.

4.4.3.4 RSI_SET_BLANKET_TOMBSTONE #

Set or remove a blanket tombstone on (guid, layer).

Request: guid (16), layer_name, set (1), sequence (u64).

Response: status only.

4.4.4 Transaction operations #

4.4.4.1 RSI_BEGIN_TRANSACTION #

Request: txn_id (u64), mode (u32).

The transaction id appears in the payload. The request header's own txn_id is zero for this operation, since the transaction does not yet exist. RSI_COMMIT_TRANSACTION and RSI_ABORT_TRANSACTION carry it in both places.

ModeValueMeaning
RSI_TXN_READ_WRITE0An ordinary transaction.
RSI_TXN_READ_ONLY1A point-in-time read snapshot.

Response: status only.

For RSI_TXN_READ_WRITE, reads tagged with the id MUST observe the transaction's own uncommitted writes, and writes tagged with it MUST be committed atomically by RSI_COMMIT_TRANSACTION.

For RSI_TXN_READ_ONLY, reads tagged with the id MUST observe a stable point-in-time snapshot. The kernel MUST NOT send a mutating operation with a read-only transaction id, and a source that receives one MUST reject it with RSI_INVALID and MUST NOT mutate anything. A read-only transaction is released with RSI_ABORT_TRANSACTION; the kernel MUST NOT send RSI_COMMIT_TRANSACTION for one.

A source whose store cannot support a mode MAY answer RSI_TXN_NOT_SUPPORTED for that mode. The two are independent: a source MAY support read-only snapshots without supporting read-write transactions.

4.4.4.2 RSI_COMMIT_TRANSACTION #

Request: txn_id (u64).

Response: status only.

On success every change in the transaction MUST be durable. On failure every change MUST be rolled back.

4.4.4.3 RSI_ABORT_TRANSACTION #

Request: txn_id (u64).

Response: status only.

Sent when a transaction is closed without committing, on timeout, and to release a read-only snapshot. The kernel MAY send it without a waiting caller.

4.4.5 Layer operations #

4.4.5.1 RSI_DELETE_LAYER #

Remove everything tagged with a layer name.

Request: layer_name.

Response: orphaned_guid_count (u32), then that many GUIDs (16 each).

The source MUST atomically remove all path entries, all value entries and all blanket tombstones whose layer is layer_name, and MUST NOT remove any key record. Orphan cleanup is the kernel's, through RSI_DROP_KEY.

orphaned_guids MUST list exactly the GUIDs that lost their last path entry as a result, with no nil GUID and no duplicates. The kernel tracks them for deferred deletion.

An unknown layer name is not an error. A source with no entries for it MUST return RSI_OK with an empty orphan list — a layer may have entries in one source and none in another.

4.4.6 Maintenance #

4.4.6.1 RSI_FLUSH #

Persist pending writes for one hive to durable storage.

Request: hive_name.

Response: status only, returned when persistence is confirmed.

This is the only operation that identifies its target by hive name rather than by GUID, because flushing is a hive-level act — a WAL checkpoint, say — not a key-level one.

4.5 Conformance

Peios / Peios technical reference / PSPK / Registry Source Interface

A conforming source MUST satisfy every requirement in this chapter. This section collects the obligations that are not tied to one operation, and the status vocabulary.

4.5.1 Status codes #

Every response payload begins with a u32 status. Zero is success.

StatusCodeKernel maps toMeaning
RSI_OK0successThe operation completed.
RSI_NOT_FOUND1ENOENTA key, value or path entry does not exist.
RSI_ALREADY_EXISTS2EEXISTA path entry or key already exists.
RSI_STORAGE_ERROR3EIOA failure in the backing store.
RSI_NOT_EMPTY4ENOTEMPTYA key still has children or values.
RSI_TOO_LARGE5ENOSPCValue data exceeds the maximum size.
RSI_TXN_BUSY6EBUSYA transaction could not take the write lock.
RSI_INVALID7EINVALA malformed request or an invalid field value.
RSI_CAS_FAILED8EAGAINA conditional write's sequence did not match.
RSI_TXN_NOT_SUPPORTED9ENOTSUPThis transaction mode is not supported.

A source MUST NOT return a code outside this vocabulary. One outside it is malformed data.

Source-specific detail is never surfaced to the process that made the registry call. The status is the whole interface, and a source MUST choose the code that most accurately describes what happened.

4.5.2 Obligations #

Respond to everything. A source MUST send exactly one response for every request it has read, even after the kernel-side caller has timed out. A request the source has read and will never answer occupies an in-flight slot until the connection is torn down.

Return complete layer data. When asked for values or path entries, a source MUST return all layer entries. It MUST NOT pre-filter, resolve, or omit any. Layer resolution is the kernel's.

Order enumerations deterministically. The same request against unchanged hive state MUST return the same ordering every time. This applies to the child list of RSI_ENUM_CHILDREN, the value entries and the blanket tombstone list of RSI_QUERY_VALUES, and the path entries of RSI_LOOKUP. Ascending folded name, then layer, then sequence satisfies it.

This is correctness, not tidiness. The kernel exposes enumeration to callers as a dense index walk — position 0, 1, 2 until exhaustion — observing the source's ordering at each step. A source that returns the same set in a different order across those observations makes the walk revisit some entries and never see others, which surfaces as duplicate and silently missing keys. An unordered SQL query or a hash-map iteration does not satisfy this: UNION ALL without ORDER BY and deliberately randomised map iteration both yield different orderings for identical input.

The obligation constrains ordering within one response only. It does not constrain the order in which concurrent requests are processed, and it does not by itself make a multi-step enumeration atomic against concurrent mutation — a caller that needs a stable view across a whole walk uses a read-only transaction.

Preserve GUIDs exactly. A source MUST persist a GUID as given.

Handle concurrency. A source MUST handle multiple in-flight requests without head-of-line blocking, and MAY process them in any order.

Serialise commits. Concurrent read-write commits MUST be serialised, and a commit MUST be atomic. The kernel does no conflict detection: it relies on commits being ordered and atomic, and lets the later write win by sequence number.

Support conditional writes. expected_sequence on RSI_SET_VALUE MUST be honoured atomically.

Protect immutable fields. RSI_WRITE_KEY requests attempting to modify the GUID, the volatile flag or the symlink flag MUST be rejected with RSI_INVALID.

Create hive roots on first boot, and purge orphans before registering. Both are described in §4.2.

4.5.3 What the kernel validates #

A source cannot rely on a malformed response being tolerated. The kernel checks each of the following, and two categories of failure have different consequences.

Malformed data — a structurally valid message with invalid content — fails the request with EIO, emits an audit event naming the source and the class of failure, and leaves the source running, since corruption may be localised. The classes are: an unparseable or mask-invalid Security Descriptor; an invalid layer name, key name or value name; a payload of the wrong shape or with trailing bytes; a metadata block that is incomplete, duplicated, unreferenced or nil; an invalid value type, a tombstone carrying data, or oversized data; a nil or duplicated orphan GUID; a status code outside the vocabulary; and the two sequence rules below.

Malformed protocol — a structurally invalid message, a framing error, an unknown or duplicate request id, an operation code that does not match its request — is treated as a crash. The connection is torn down and the source is marked Down.

4.5.3.1 The two sequence rules #

A source MUST NOT return a layer-qualified entry whose sequence number is greater than or equal to the next number the kernel would allocate. Sources store the numbers the kernel assigns them and cannot legitimately hold a future one. Without this rule a compromised source could fabricate a sequence number and win every resolution tie in its own hives.

A source MUST NOT return duplicate sequence numbers at the same precedence where they would have to be compared to select a winner. The kernel rejects the response rather than choosing arbitrarily. Duplicates that are never compared are not an error.

4.5.4 The trust boundary #

A source is inside the trusted computing base. The kernel has no independent copy of anything a source returns, so a compromised source controls the access-control outcome for its hives entirely: it can return a permissive Security Descriptor for any key and the kernel will enforce it. Structural validation catches malformed data; it cannot catch data that is well-formed and false.

Three consequences follow that an operator should understand.

A source backing the hive that holds layer metadata can fabricate precedence and enabled values, and so decide which layer wins every resolution contest system-wide. The privilege check applied when precedence is written does nothing about a fabricated read.

The same source can return permissive descriptors for layer metadata keys, granting any process write access to any layer.

SeRestorePrivilege implies descriptor control: a restore replaces every Security Descriptor in the subtree it covers.

Sources MUST therefore run with tightly scoped privileges and be protected by descriptors on their service definitions. The kernel emits an audit event for every source data validation failure.

5.1 Scope and Roles

Peios / Peios technical reference / PSPK / Registry Backup Format

This chapter specifies the registry backup format: the byte stream that represents a registry key and everything beneath it, with full layer fidelity.

Two roles participate, and unlike a live protocol they need not exist at the same time.

The writer produces a stream. The Peios kernel's registry subsystem is one writer; so is any third-party tool that constructs a backup for migration, provisioning or archival.

The reader consumes one. The kernel is a reader, and so is any tool that inspects, converts or transforms a backup.

The format is specified rather than described because both roles are publicly implementable, and because a stream outlives the process that wrote it. A backup taken on one machine is restored on another, by a different implementation, perhaps years later.

This chapter covers:

  • the framing common to every record, and the encoding of its fields
  • the versioning fields, and the rules under which the format may be extended
  • every record type and its payload, in wire order
  • the ordering of records within a stream
  • the integrity trailer and exactly what it covers
  • what a reader MUST validate, and what a restore MUST do with a stream it accepts

This chapter does not cover:

  • The registry data model the stream represents — hives, keys, layers, tombstones, resolution — which is described in the Peios Kernel TRM.
  • The Registry Source Interface, which is a separate chapter. The backup format is a kernel-level format; a registry source never sees one.
  • The system calls that produce and consume a stream.
  • The binary layout of a Security Descriptor or a SID, which is defined in PCDS. A backup carries them as opaque byte strings.

5.1.1 Design constraints #

The format is shaped by five requirements, and an implementation of either role has to respect all of them.

Streamable. A stream is written to an arbitrary descriptor — a file, a pipe, a socket — in a single forward pass, and read back the same way. Neither role may require seeking.

Full layer fidelity. Every path entry, value, tombstone and blanket tombstone carries its layer tag. Restoring reconstructs the layered state, not a flattened view of it.

Depth-first pre-order. A key's parent always appears before it, so a reader can create keys top-down without buffering a tree.

Descriptors inline. Each key record carries its own Security Descriptor, with no deduplication and no shared table. Redundancy is left to external compression.

Self-verifying. A trailer carries a record count and a cryptographic checksum, so truncation and corruption are detectable before anything is acted on.

5.2 Stream Structure

Peios / Peios technical reference / PSPK / Registry Backup Format

All multi-byte integers in this format are little-endian, in the record framing and in every payload.

5.2.1 Record framing #

Every record begins with the same six-byte header.

OffsetSizeField
02record_type
24record_len

record_len is the record's total size including this header, so its minimum valid value is 6. The payload follows immediately.

A reader MUST validate record_len >= 6 and MUST verify that the record body can be read in full before acting on the record or skipping it.

RecordCode
HEADER0x01
LAYER0x02
KEY0x03
PATH_ENTRY0x04
VALUE0x05
BLANKET_TOMBSTONE0x06
TRAILER0xFF

5.2.2 Field encoding #

A length-prefixed field is a u32 byte count followed by that many bytes. Names — hive name, layer name, child name, value name — are UTF-8. Three length-prefixed fields are binary, not UTF-8: a LAYER record's owner SID, a KEY record's Security Descriptor, and a VALUE record's data.

A GUID is 16 raw bytes. An all-zero GUID is nil and is valid only where a record's definition says so.

5.2.3 Ordering #

HEADER                         exactly one, first
LAYER                          one per referenced layer, before any key data
  for each key, depth-first pre-order over the merged tree:
    KEY                        the key object
    PATH_ENTRY *               entries owned by this section
    VALUE *                    all layers' values for this key
    BLANKET_TOMBSTONE *        all layers' blankets for this key
TRAILER                        exactly one, last

The merged tree is the union of every layer's namespace. Depth-first pre-order guarantees a key's parent precedes it.

The following are normative:

  • HEADER MUST be the first record and MUST appear exactly once.
  • Every LAYER record MUST precede all key data. A LAYER record after key data has begun is invalid.
  • The root KEY record — the one whose GUID equals HEADER.RootGUID — MUST be the first KEY record in the stream, and MUST appear exactly once.
  • Within a key's section, records MUST appear in the order PATH_ENTRY, then VALUE, then BLANKET_TOMBSTONE. A PATH_ENTRY after a VALUE or BLANKET_TOMBSTONE in the same section is invalid, as is a VALUE after a BLANKET_TOMBSTONE.
  • No PATH_ENTRY, VALUE or BLANKET_TOMBSTONE may appear before the first KEY record.
  • TRAILER MUST be the last record. Any record after it is invalid.

5.2.4 Which section a path entry belongs to #

A PATH_ENTRY naming a key belongs to that key's section: it is one of the incoming entries for the key whose section it is in.

A HIDDEN entry has no key, so it cannot have a section of its own. It belongs to the section of the key that is its parent, alongside that key's other records. A HIDDEN entry masking a name where no key exists in any layer is still valid — it expresses that a layer hides a name, whatever else is or is not there.

A writer MUST NOT emit a GUID-bearing PATH_ENTRY in the root key's section. On restore, the target key's existing name is authoritative and such a record would be discarded. A reader MUST skip one rather than treat it as an error.

A path entry's parent GUID may belong to a key that only has path entries in a different layer. The merged-tree walk handles that; it is not a special case.

5.2.5 Versioning #

HEADER carries two version numbers.

FormatVersion is the version the stream was written with.

MinReaderVersion is the oldest reader that can process the stream correctly. A reader MUST reject a stream whose MinReaderVersion exceeds its own supported version, before acting on any of it.

The current version is 21 in both fields, and readers support 21.

A writer that used only older features SHOULD set a lower MinReaderVersion, so that older readers can restore the stream. A writer MUST raise MinReaderVersion when a new record type is required for a correct restore, so that an older reader refuses the stream rather than restoring an incomplete one.

5.2.6 Extension #

Extension is by new record types only.

Unknown record types MAY appear anywhere between HEADER and TRAILER. When MinReaderVersion permits, a reader MUST skip them, and MUST treat them as inert: they do not begin or end a key section, do not satisfy any required record, do not declare a layer, and do not affect root mapping, sequence remapping or any validation rule. They do count toward TRAILER.RecordCount and they are covered by the checksum.

A record payload MUST be consumed exactly. Trailing bytes inside a record of a known type are invalid, even though record_len would accommodate them. A reader MUST NOT skip unrecognised trailing data within a known record, and a writer MUST NOT add any.

This is deliberately the opposite of the RSI's request convention. A stream is replayed into mutations long after it was written, and a field silently ignored there is data silently lost.

5.3 Records

Peios / Peios technical reference / PSPK / Registry Backup Format

Payload fields are listed in wire order, immediately after the six-byte framing header, with no padding between them.

5.3.1 HEADER — 0x01 #

Exactly one, first in the stream. Fixed portion 44 bytes plus the hive name.

SizeFieldDescription
8MagicThe ASCII bytes PEIOSREG — 50 45 49 4F 53 52 45 47.
4FormatVersionu32.
4MinReaderVersionu32.
8Timestampi64, Unix nanoseconds.
16RootGUIDThe GUID of the key at the root of this backup.
4+nHiveNameThe hive the backup was taken from.

A reader MUST reject a stream whose magic does not match, and MUST reject one whose MinReaderVersion exceeds its own supported version.

HiveName MUST be a valid hive name under the ordinary naming rules.

RootGUID is a stream-local identity for the backup root. On restore it is remapped to the target key (§5.4).

5.3.2 LAYER — 0x02 #

One per layer name that has layer-tagged data anywhere in the stream. All LAYER records precede all key data.

SizeFieldDescription
4+nNameThe layer name.
4Precedenceu32, as observed at backup time.
1Enabledu8. MUST be 0 or 1.
4+nOwnerBinary SID, as observed at backup time.

A LAYER record is a stream manifest entry, not a backup of the layer's definition. It records what the layer looked like when the backup was taken so that a restore can validate the stream against it, and it creates, updates, deletes, enables, disables and authorises nothing.

A layer's definition is backed up only when its metadata subtree is itself inside the exported subtree, in which case it appears as ordinary KEY, PATH_ENTRY and VALUE records like anything else.

A reader MUST validate that every layer name is valid, that folded layer identities are unique within the manifest, that Enabled is 0 or 1, that Owner parses as a SID, and that every layer name appearing in a PATH_ENTRY, VALUE or BLANKET_TOMBSTONE has exactly one corresponding LAYER record.

5.3.3 KEY — 0x03 #

One per distinct key object in the subtree, however many layers name it.

SizeFieldDescription
16GUIDThe key's identity. MUST NOT be nil.
4Flagsu32. Bit 0 volatile, bit 1 symlink.
4SDLengthu32.
nSDThe full Security Descriptor.
8LastWriteTimei64, Unix nanoseconds.

Name and parent GUID are absent. They are derivable from the PATH_ENTRY records in the key's own section, and carrying them separately would let a stream contradict itself.

Undefined bits in Flags MUST be zero. A reader MUST reject a record with any bit outside 0x03 set, rather than ignoring it.

SD MUST parse as a Security Descriptor and MUST have an owner.

5.3.4 PATH_ENTRY — 0x04 #

One per name-to-key mapping per layer.

SizeFieldDescription
16ParentGUIDThe parent key. MUST NOT be nil.
4+nChildNameThe name under that parent.
16ChildGUIDThe key being named, or an all-zero GUID meaning HIDDEN.
4+nLayerNameThe layer this entry belongs to.
8Sequenceu64.

ChildGUID is the only GUID field in the format that may be nil, and a nil one means HIDDEN rather than "no key". No KEY record is emitted for the zero GUID.

5.3.5 VALUE — 0x05 #

One per value entry per layer, tombstones included.

SizeFieldDescription
16KeyGUIDThe key this value belongs to. MUST NOT be nil.
4+nNameThe value name; empty for the default value.
4Typeu32.
4DataLengthu32.
nDataThe value's bytes.
4+nLayerNameThe layer this entry belongs to.
8Sequenceu64.

Type MUST be one of the defined registry value types, or REG_TOMBSTONE (0xFFFF). A tombstone MUST carry zero-length data.

5.3.6 BLANKET_TOMBSTONE — 0x06 #

One per blanket tombstone per layer.

SizeFieldDescription
16KeyGUIDThe key this blanket belongs to. MUST NOT be nil.
4+nLayerNameThe layer.
8Sequenceu64.

5.3.7 TRAILER — 0xFF #

Exactly one, last. Payload 40 bytes, so the whole record is 46.

SizeFieldDescription
8RecordCountu64. Every record in the stream, HEADER and TRAILER included.
32ChecksumSHA-256.

RecordCount MUST be at least 2 — a stream has at minimum a header and a trailer.

5.3.7.1 What the checksum covers #

The checksum is a SHA-256 over the bytes from the start of the HEADER record's framing header through the end of TRAILER.RecordCount, inclusive.

That is: every byte of every preceding record, then the trailer's own six-byte framing header, then the eight bytes of RecordCount. The 32 checksum bytes themselves are not covered, and nothing follows them.

Skipped unknown records are covered, in full, like any other.

A reader MUST verify both RecordCount and Checksum. A stream whose record count does not match, or whose checksum does not verify, MUST be rejected.

5.4 Restoring

Peios / Peios technical reference / PSPK / Registry Backup Format

Restoring is a replace, not a merge. The target key's contents and descendants are removed before the stream's contents are written.

The whole operation — teardown and rebuild together — MUST be atomic. There is no partial-restore mode, and a store that cannot offer atomicity cannot be a restore target.

5.4.1 The target key survives #

A restore is performed against a key that already exists. That key object is not replaced: its GUID, its parent, its name, its volatile flag and its symlink flag remain what they were and MUST NOT be taken from the stream.

What the stream's root KEY record supplies is the mutable part — the Security Descriptor and the last write time — which MUST be written to the target inside the restore.

The root record's immutable flags MUST match the target's. A backup of a volatile key restored onto a non-volatile one, or a symlink onto a non-symlink, MUST be rejected.

5.4.2 Root remapping #

HEADER.RootGUID is stream-local. Every reference to it — a PATH_ENTRY's ParentGUID or ChildGUID, a VALUE or BLANKET_TOMBSTONE's KeyGUID — MUST be remapped to the target key's existing GUID before any validation of parent references and before any record is applied.

The backup root GUID MUST NOT be created as a new key record.

Descendant KEY records keep their backup GUIDs, which are written into the target verbatim.

5.4.3 GUID rules #

  • The stream MUST contain exactly one KEY record whose GUID equals HEADER.RootGUID, and it MUST be the first KEY record.
  • A non-root GUID MUST NOT appear twice in the stream.
  • A non-root GUID MUST NOT equal the restore target's GUID.
  • A non-root GUID that already exists outside the subtree being replaced is a collision and the restore MUST fail. A reader is not required to detect this before beginning; it MAY surface during replay, in which case the atomicity requirement ensures nothing is left behind.

5.4.4 Parent validation #

Before a path entry is applied, its ParentGUID — after root remapping — MUST be either the restore target's GUID or the GUID of a non-root KEY record already processed earlier in the stream. A parent outside the stream's remapped key set MUST cause the restore to fail.

This is what prevents a crafted backup from injecting path entries into arbitrary parts of the existing namespace, outside the subtree being replaced. It is not optional.

A HIDDEN PATH_ENTRY is held to a stricter rule: its remapped ParentGUID MUST equal the GUID of the section it appears in, not merely some already-processed key.

5.4.5 Creating a key #

A KEY record carries no name and no parent, so both come from the section's path entries.

The anchor is the first GUID-bearing PATH_ENTRY in the section, in stream order, whose remapped ChildGUID equals the KEY record's GUID. Its remapped ParentGUID and its ChildName are the parent and name the key is created with.

  • If the section contains no GUID-bearing path entry targeting the KEY record's GUID, the restore MUST fail.
  • If any GUID-bearing PATH_ENTRY in a non-root section targets a different GUID after remapping, the restore MUST fail.
  • HIDDEN entries do not satisfy the anchor requirement. They are parent-owned records for the key being created and are replayed only after it exists.

The key's LastWriteTime MUST be written immediately after it is created, before any of the section's other records are replayed.

Path entries for the root section are handled differently: GUID-bearing ones are not restored, because the target key's existing incoming path entries remain authoritative. HIDDEN entries in the root section are parent-owned records for the restore root and MUST be restored, after parent validation.

5.4.6 Sequence remapping #

The backup's sequence numbers preserve its internal layer-resolution ordering. A restore is a new mutation, and its entries MUST become newer than everything already present while keeping that internal order. A reader MUST NOT write backup sequence numbers through unchanged.

Remapping MUST preserve streamability: it MUST NOT require a seekable input or a pre-scan pass. Before the first layer-qualified record is applied, the reader records an offset — the next sequence number it would allocate — and then, for every restored layer-qualified record:

new_sequence = restore_sequence_offset + backup_sequence

That needs no lookahead: the running maximum is computed during the single pass.

The offset is held stable for the duration of the restore, so that no other sequence-allocating mutation can interleave. Reads are not blocked by it. A restore containing no layer-qualified record at all need not reserve one.

If a remapped value would reach or exceed U64_MAX, the restore MUST fail. The valid remapped range is [offset, U64_MAX); U64_MAX itself is never a valid sequence number.

When the restore reaches any terminal state — success, abort, failure or cancellation — the global counter MUST be advanced past the highest number the restore dispatched, and that advance MUST NOT be rolled back. Numbers a failed restore dispatched become unused gaps, exactly like those of any other failed write.

Dispatch order remains structural stream order. It is the remapped numbers, not the order they were sent in, that preserve the backup's internal layer resolution.

5.4.7 Layers in a restored stream #

LAYER manifest records define nothing (§5.3). If restored entries reference a layer that is not in the live layer table, and the stream does not also restore that layer's metadata subtree as ordinary registry data, those entries become latent unknown-layer entries and are ignored during resolution until real metadata exists. If the metadata subtree is included, it is restored through the ordinary path, and those records are what define the layer.

5.4.8 Privilege #

A restore replaces every Security Descriptor in the subtree it covers, so the privilege to perform one effectively confers descriptor control over everything within its reach.

Before any key record is written, a reader MUST check the layer manifest: if any declared layer has a precedence above 0, or any existing layer with the same folded identity does, the caller MUST hold the privilege that guards high-precedence layers, or the restore MUST be aborted before a single byte is written.

Records in the stream that create or raise persisted layer metadata above precedence 0 are subject to the same check as an ordinary write would be. Neither check is redundant: the first covers what the manifest declares, the second covers what the stream actually writes.

5.4.9 Validation before mutation #

A reader MAY validate the entire stream — including the trailer's record count and checksum — before applying any of it. Doing so is stronger than this specification requires, and it means a corrupt stream is rejected before anything is torn down rather than after. The cost is memory rather than seeking, since the records must be retained across the teardown.

A reader that instead validates as it goes MUST still guarantee that a checksum failure leaves nothing applied, which the atomicity requirement already demands.

1.1 Scope

Peios / Peios technical reference / PSPU / Introduction

This document defines the Peios System Protocols Userspace (PSPU): the protocols and interchange formats by which the foundational userspace components of a Peios system agree with one another.

An interface belongs in this document when both of these hold:

  • it is a contract between userspace parties, at least one of which is a component the system is built from rather than an application running on it; and
  • the interface is public — a third party is expected to implement one side of it.

The parties need not exist at the same moment. A live protocol has two processes in conversation; an interchange format has a producer and a consumer that never meet, and the artifact between them carries the contract. Both are in scope, because what makes something belong here is that two independently written parties must agree on it.

1.1.1 These protocols are not conformance requirements #

A system that does not offer a protocol in this document is still Peios. The components that speak these protocols are one answer to a problem, not the definition of the platform; a system that solves the same problem with different components conforms exactly as well.

They are specified because they are public even so. A third party writing a component to plug into one side of one of these protocols needs the contract written down, and needs it to stay put. What they are not is a bar anyone must clear.

For each interface, this document covers:

  • for a live protocol: the channel, its direction, which party connects to which, message framing and encoding, the messages exchanged, and the shape of a conversation
  • for an interchange format: the layout of the artifact, how it is identified and versioned, and how a consumer validates one it receives
  • how a party announces itself or is identified, and how its counterpart establishes what it is and what it may speak for
  • the rules under which the format may be extended
  • what each party must declare about itself, and what its counterpart validates rather than believes
  • the conformance requirements for each role

This document does not cover:

  • Standards a system MUST implement to be Peios — defined in PGSS
  • Protocols spoken across the kernel boundary — defined in PSPK
  • The binary structures these interfaces carry — defined in PCDS
  • How a component stores its data, reaches the answers it gives, or produces the artifacts it emits — its own design
  • Which counterparts a system is configured to trust, and how that configuration is expressed — the consuming component's own design
  • Administering a component's contents — its own design, unless the component publishes a protocol for other programs to administer it with, as the local principal store does (§10)

The fourth of those is the point of the whole document. A component is asked a question and gives an answer, or is asked for an artifact and produces one; how it arrives there is exactly what different components exist to do differently.

1.1.2 Stability #

Publication here is a commitment that the contract is written down and will not change out from under an implementation. Each specification states its own rules for extending its wire or file format; those rules are the supported way for an interface to grow.

1.2 Conventions

Peios / Peios technical reference / PSPU / Introduction

The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY in this document are to be interpreted as described in RFC 2119. Text set off as a note is informative, not normative.

Everything else — roles, byte order, sizes, layout tables, notation, strings, timestamps, citation, and the external standards this anthology depends on — is defined in the Conventions book and is not restated here. PSPU departs from none of it.

Where a chapter needs a convention of its own, that chapter states it.

2.1 Scope and Roles

Peios / Peios technical reference / PSPU / Principal Source Interface

This chapter specifies the Principal Source Interface (PSI): the protocol by which an authentication authority federates identity to separate processes that hold it.

Two roles participate.

The authority is the process that mints tokens and creates logon sessions. It is the party asking. On PSI it listens; it never dials out (§2.3). It is also the party that speaks PGSS Logon to its clients, and PSI exists so that it can answer them.

The source, in full a principal source, is a process that is authoritative for some set of principals: it verifies their credentials and says who they are. The source role is publicly implementable: any process an authority has been configured to accept MAY register as a source, and a third party writing one for a directory, a hardware token service or an identity provider is the case this chapter is written for. A conforming source is the subject of the source obligations in §2.22.

A source is not a store, necessarily. A local source owns its bytes; a directory-backed source owns nothing and forwards the question. The interface deliberately does not distinguish them, which is why the term is "source" rather than "store".

What a source is emphatically not is a component of the authority. It runs as a separate process, at lower trust, and it cannot mint anything (§2.4).

This chapter covers:

  • the channel, its direction, and why sources connect inward (§2.3, §2.6)
  • message framing, the conversation identifier, and the rules under which the format may be extended (§2.7)
  • registration: how a source announces itself, how the authority establishes what it is, and what domain it may speak for (§2.8 to §2.10)
  • the relayed interrogation, and its relationship to PGSS Logon (§2.5, §2.12)
  • assertion and refusal, the terminal messages of a source conversation (§2.13)
  • querying a source outside a logon, so that an authority can serve PGSS Logon's identity lookup (§2.15, §2.16)
  • what a source must declare about itself before an authority may cache its answers (§2.8, §2.17)
  • scope: what a source may claim about identity, separately about membership, and separately again about POSIX identifiers (§2.18 to §2.20)
  • relaying a principal's change of their own credential to the source that holds it (§2.21)
  • the obligations binding on each role (§2.22)
  • relaying a principal's addition or removal of one of their own credentials, such as an SSH public key, to the source that holds it (§2.23)

This chapter does not cover:

  • The logon protocol itself, specified in PGSS.
  • Tokens, SIDs, sessions and privileges — described in the Peios Kernel TRM, with SIDs, security descriptors and claim attributes specified in PCDS.
  • How a source stores identity, or verifies a credential.
  • Derivation — what a token ends up containing — which is the authority's, applying local policy (PGSS §2.1).
  • Which sources a machine trusts, and how that is configured.
  • Which identifier range a source is given, and how that is configured. The rules the assignment must satisfy are §2.20.
  • Administering a source's contents.

The third of those is the point of the whole interface. A source is asked a question and gives an answer; how it reaches the answer is exactly what different sources exist to do differently.

2.1.1 PSI is not a conformance requirement #

PGSS Logon is a Peios Generic System Standard: a system that does not offer it is not Peios. PSI is not. It is the interface an authority uses to reach the processes that know who exists, and a system running entirely different authentication infrastructure is still Peios.

It is specified because it is a public interface even so. A third party writing a principal source needs the contract written down, and needs it to be stable. What it is not is a bar anyone must clear.

Note

The distinction shows up in what happens when you disagree with each document. Disagreeing with PGSS Logon means shipping something that is not Peios. Disagreeing with this chapter means shipping an authority that federates differently, or does not federate at all, which is a design choice nobody will dispute.

2.2 Terminology

Peios / Peios technical reference / PSPU / Principal Source Interface

Terms defined in the Peios Kernel TRM (token, logon session, privilege), in PCDS (SID, security descriptor, claim attribute) and in PGSS (authority, client, principal, conversation, round, credential material, prompt, derivation) are used here with the same meaning and are not redefined.

Source. A process that is authoritative for some set of principals: it verifies their credentials and says who they are. Called a principal source in full.

Registration. The exchange in which a source announces itself and the authority decides whether to accept it. Precedes any conversation.

Domain. The SID namespace a source is authoritative for. Every principal a source may assert lives under it. See §2.10.

Source conversation. One logon's exchange between the authority and a source, distinguished from other concurrent ones by a conversation identifier. Not to be confused with a PGSS Logon conversation, which is between a client and the authority; one of each exists per logon.

Assertion. A source's terminal message stating who a principal is. The only successful outcome a source can produce.

Originator. The verified identity of the process that requested a logon, as established by the authority from the client's connection. Relayed to the source, which cannot learn it any other way.

Service SID. A SID derived from a service's name, placed in that service's token by the init system, and unforgeable by anything else. How a source's identity is established (§2.9).

Membership scope. The constraint on which groups a source may assert. Separate from identity scope, which constrains whose identity it may assert at all. Sections 2.18 to 2.20 exist because these are different questions with different answers.

Relative identifier. In this chapter, a POSIX identifier as a source states it: an offset within the range the authority assigned that source, never an absolute number (§2.20). Where the SID sense is meant — the last sub-authority of a SID — the text says so.

2.3 Sources Dial In

Peios / Peios technical reference / PSPU / Principal Source Interface

The authority listens. Sources connect to it. The authority never initiates a connection to a source.

This is the most consequential shape decision in this chapter, and it is worth being explicit about what it buys.

2.3.1 Why the direction matters #

The authority holds the privilege to mint tokens. It is the most privileged userspace process on the system. An authority that dialled out would need, in its configuration, a list of paths to connect to — and a process holding that privilege having a configurable list of things to go and talk to is a liability out of proportion to the convenience.

Because sources connect inward, the authority's sockets are accept()-only. It never opens an outbound connection to anything, for any reason.

2.3.2 What follows #

Restart is the source's problem. A source whose connection drops reconnects. The authority does not retry, does not queue, and does not track sources it has not heard from. A source that has gone away is simply not registered.

A source is not required to exist. An authority with no registered sources cannot authenticate anybody, and that is a coherent state rather than an error — it means no identity has been made available to it yet.

Ordering is the init system's problem. The authority must be listening before a source can register, and a service that depends on authentication must start after a source has. Expressing that is a service-ordering question, not a protocol one, and this chapter says nothing about it.

Note

A source SHOULD report itself ready only once its registration has been acknowledged, so that "the source is running" and "the source can authenticate" are the same statement to anything ordered after it. This is what makes the Registered acknowledgement load-bearing despite carrying almost nothing.

2.4 Assert, Never Mint

Peios / Peios technical reference / PSPU / Principal Source Interface

A source says who somebody is. It cannot say anything else, and the protocol is built so that this is structural rather than a rule anyone has to remember.

2.4.1 The success terminal #

PGSS Logon's success terminal is AccessGranted, carrying a session identifier and a token descriptor. If PSI reused it, sources would be minting sessions.

PSI's success terminal is Assertion (§2.13), which carries an identity: a SID, a canonical name, and group memberships. There is no session identifier and no descriptor to attach a token to. A source could not mint one if it wanted to, because there is no message in which to say so.

That is the whole of the mechanism. No capability check, no trust level, no configuration flag — a source cannot mint because the protocol gives it no way to express minting.

2.4.2 What the authority keeps #

Everything else:

  • Derivation. What the token actually contains — its privileges, its integrity level, its derived group memberships, its projected identifiers — is the authority's, applying local policy (PGSS §2.1).
  • Session creation. The logon session, and the record of which source vouched for it.
  • Validation. Every SID a source sends is bytes until the authority has checked it (§2.13).
  • Scope enforcement. What a source is permitted to claim (§2.18 to §2.20).
  • Peer verification. On every connection it accepts.
  • Rate and round limits, and the policing of what a source may ask a client for (§2.12).

2.4.3 Why a compromised source is bounded #

A source that is entirely compromised can lie about the principals in its own domain. It cannot mint a token, cannot elevate anyone's privileges, cannot claim identities outside its domain (§2.18), and — unless configured otherwise — cannot assert memberships outside it either (§2.19).

That bound is the reason for the process boundary. It is not that sources are expected to be malicious; it is that a source is the component parsing credentials from the outside world, and therefore the one most likely to be wrong.

2.5 A Superset of PGSS Logon

Peios / Peios technical reference / PSPU / Principal Source Interface

A principal source is an authentication authority for its slice of the world. The authority that federates is an authority over authorities. Once that is seen, most of PSI writes itself.

2.5.1 The relationship #

PSI's interrogation phase is PGSS Logon's, with identical message bodies. CredentialRequest and CredentialResponse carry exactly the bytes PGSS §2.8 defines, and the authority relays them nearly verbatim in both directions.

The consequences are worth stating plainly:

  • The source decides what to ask for. Not the authority. The authority does not know what credentials a source requires, and does not need to.
  • The authority becomes a relay in the interrogation phase. It is a shorter path than synthesising its own prompts, not a longer one.
  • Adding a credential type is a change to sources, not to the authority and not to clients, which already render what they are given (PGSS §2.3).
  • A source could be tested in isolation by pointing a PGSS Logon client at it, for the interrogation phase at least.

2.5.2 Where they diverge, deliberately #

Three differences, each for a stated reason.

The success terminal. Assertion rather than AccessGranted, so that a source cannot mint. See §2.4. This is the divergence that matters.

Multiplexing. PGSS Logon is one conversation per connection; the connection is the conversation. PSI carries many concurrent logons over one long-lived connection, so its header adds a conversation identifier (§2.7). The alternative — serialising every logon behind one connection — would make any slow logon a system-wide login stall.

Distinct magic. PPSI rather than PGSL. Two protocols this similar sharing a codec is a cross-protocol hazard: a socket plugged into the wrong daemon would partially work, which is far worse than failing outright. The magic makes it a hard error on byte zero.

The full accounting of what is shared, what is added and what differs is §2.C.

2.5.3 "Just relaying" is loose #

The authority is a relay in the interrogation phase only, and even there it is not passive. It polices what a source may ask a client for (§2.12), it validates what a source asserts (§2.13), and it enforces scope (§2.18 to §2.20). Everything before and after the interrogation is entirely its own.

2.6 The Channel

Peios / Peios technical reference / PSPU / Principal Source Interface

2.6.1 Socket #

An authority that federates over PSI MUST listen on a SOCK_STREAM Unix domain socket.

Unlike PGSS Logon's path, this one is not normative. PSI is not a conformance requirement (§2.1), and an authority that offers it may put it where it likes provided its sources are told. Mainline's is /run/psi.sock.

2.6.2 Access control #

The socket SHOULD carry a security descriptor. It is DoS protection and nothing more, and an implementation MUST be written as though it were absent.

The reason is that the socket cannot be the boundary. What establishes a source's identity is the peer's token (§2.9), which is checked on every connection. A descriptor that kept casual traffic away would be a convenience; a descriptor relied upon would be a second, weaker access control that someone will eventually assume is doing the work.

An authority MUST therefore bound the number of unregistered connections it will hold open, and the time it will wait for a registration, independently of any descriptor.

Note

An unauthorised peer is refused at the identity check, which is cheap, and the connection cap bounds what it can occupy in the meantime. That is the whole of what the descriptor's absence costs.

2.6.3 Long-lived connections #

A source's connection persists for the life of the source and carries every logon routed to it.

An authority MUST bound the number of registered sources and the number of concurrent conversations per source. A source MUST bound the conversations it will track, and MUST NOT depend on the authority's bookkeeping to do it — a source that trusted the authority's limit would be trusting a bound it cannot verify.

2.6.4 Failure #

A framing error is fatal to the connection, not to a conversation. Once a message has failed to parse there is no way to know where the next one starts, so both parties MUST tear the connection down rather than attempt resynchronisation.

A failed write is likewise fatal. A partial write desynchronises the stream just as a bad frame does, and treating it as a per-conversation error would leave a corrupt connection in use.

Ordinary semantic failures — an unknown principal, a bad credential, a refused logon — are not connection failures. They are Refusal messages (§2.13) and the connection continues.

2.7 Message Framing

Peios / Peios technical reference / PSPU / Principal Source Interface

2.7.1 Header #

Every message begins with a 20-byte header:

OffsetSizeFieldValue
04magicPPSI (50 50 53 49)
42version1
62msg_typeSee §2.A
84total_lenHeader plus body, in bytes
128conversationSee below

The first twelve bytes are PGSS Logon's header, unchanged and at the same offsets. total_len in particular sits where PGSS §2.6 puts it, which is what lets one transport implementation frame either protocol off a stream.

2.7.2 Magic #

PPSI, checked on every message, fatal to the connection when wrong.

This matters more here than it would for an unrelated protocol, because PSI and PGSS Logon share message bodies (§2.5). A CredentialRequest from one is byte-identical to the other's. Without distinct magic, a socket plugged into the wrong daemon would decode several fields correctly before going wrong — which is the failure mode hardest to diagnose and easiest to miss.

2.7.3 Conversation identifier #

The conversation field distinguishes concurrent logons on one connection.

  • Conversation 0 is reserved for connection-level messages: Register, Registered and Changed (§2.17). It MUST NOT be used for a logon or a query.
  • Logon and query conversations use identifiers from 1 upward, drawn from one space.
  • The authority allocates them. A source MUST NOT invent one, and MUST reply on the identifier it was given.
  • An identifier is unique among live conversations on one connection. An authority MAY reuse one after a conversation has reached a terminal state.

A source MUST reject a message on a conversation it does not know, and MUST NOT treat it as opening a new one. Only Authenticate (§2.11), Query (§2.15) and EnumerateSource (§2.16) open one, and an authority MUST NOT open one with an identifier already live.

Rejecting means declining to act on it. A source MUST NOT reply on a conversation it does not know: an authority MAY have reused the identifier after a terminal state, so a reply could arrive as a second terminal message for a conversation that has already ended. Discarding it, and recording that it happened, is the whole of the obligation.

A source MUST likewise refuse an Authenticate, Query or EnumerateSource arriving on conversation 0, which is reserved.

2.7.4 Size limit #

A message MUST NOT exceed 81920 bytes — larger than PGSS Logon's ceiling, because a PSI message wraps one.

2.7.5 Message direction #

The high bit of msg_type marks a message sent by the source. This follows PGSS Logon's convention that the bit marks the authority for the matter at hand: on this interface the source is the authority for its own principals, and the logon authority is the one asking.

2.7.6 Encoding #

PSI shares its codec with PGSS Logon, and PGSS §2.6's encoding rules apply here unchanged: little-endian multi-byte integers; UTF-8 strings, length-prefixed and never NUL-terminated; length-framed structures and array elements, skipped to their declared end; u32 element counts on arrays, with stated maxima binding on encoder and decoder alike.

2.7.6.1 SIDs on the wire #

SIDs are carried as opaque bytes, in the binary self-relative form PCDS specifies, never as text.

They are opaque to the codec, which has no business knowing what a SID is. They are emphatically not opaque to the authority, which MUST validate every SID it receives before treating it as identity (§2.13). The obligation to check sits in the process that mints tokens, not in the layer that moves bytes.

A SID MUST NOT exceed 68 bytes — the eight-byte prelude plus fifteen sub-authorities, which is the most the encoding's one-byte count admits.

2.7.7 Body extensibility #

The extensibility rules of PGSS §2.6 apply unchanged: fields are appended only, a new field is optional with a safe default, and a new enumeration value is a breaking change requiring a version bump. The one exception is the capability bitmask of §2.8, for the reason given in §2.B.

One PSI-specific application deserves stating. Authenticate (§2.11) nests a whole LogonStart inside its own length frame rather than inlining its fields. The obvious encoding — LogonStart's fields, then PSI's — is wrong: LogonStart belongs to PGSS Logon and grows on PGSS Logon's schedule, so a field appended there would silently displace the field after it. Nesting lets the two evolve independently.

The same reasoning applies to every shared body PSI carries, and §2.C lists them.

2.7.8 Initial unpublished layout replacement #

The coordinated SSH credential extension replaced the unpublished version-1 layout without retaining older codecs. Current LogonStart capability, required-method and binding fields, Prompt parameters, and all current Assertion fields are mandatory, including explicit empty/zero values. Omitted mandatory fields fail decoding. The compatibility rules above apply to future extensions of this replacement contract; they do not require accepting the superseded development layout. See PGSS Logon §2.D.

2.8 Registration

Peios / Peios technical reference / PSPU / Principal Source Interface

A connection opens with registration. Nothing else may precede it.

2.8.1 Register #

msg_type = 0x8001. Source to authority, on conversation 0.

FieldEncodingLimit
source_namestring32 bytes
domainlength-framed bytes (SID)68 bytes
capabilitiesu32§2.B
entry_ttlu32seconds
max_batchu3264

2.8.1.1 source_name #

What the source calls itself. Bounded at 32 bytes because it becomes the authentication-package name on every session the source authenticates — so a token's provenance answers which source vouched for this? rather than merely the authority minted it.

The name is a claim. It MUST be cross-checked against the identity the authority established for itself (§2.9), and it MUST NOT be used for anything else. A mismatch MUST be refused rather than quietly corrected: a service registering under another's name is worth failing on, not normalising.

2.8.1.2 domain #

The SID namespace this source is authoritative for (§2.10).

2.8.1.3 capabilities #

What the source can do beyond authenticating.

BitNameMeaning
0QUERIESAnswers Query (§2.15).
1ENUMERATESAnswers EnumerateSource (§2.16).
2MEMBERSCan produce a group's membership.
3PUSHES_CHANGESSends Changed (§2.17).
4CHANGES_CREDENTIALSAnswers ChangeCredential (§2.21).
5ENROLLS_CREDENTIALSAnswers EnrollCredential (§2.23).

An authority MUST NOT send a message a source did not declare it answers, and MUST NOT set a field bit gating a capability the source did not declare. A source declaring nothing authenticates and does nothing else, which is what a source predating these fields is saying by omission — and is the only reading that keeps such a source working.

A source that declares no QUERIES cannot be asked about its principals outside a logon, so they are unresolvable through PGSS Logon's identity channel: they can sign in and will appear as bare numbers everywhere else. That is a coherent configuration and this chapter permits it, but it is almost never what an administrator intended, and an authority SHOULD report it where one will see it — as it reports a source registering with no identifier range.

This is a declaration of capability, not of willingness. A source declaring QUERIES may still answer Refused to any particular question (§2.15); a source declaring ENUMERATES may still refuse a cursor it can no longer honour (§2.16).

2.8.1.4 entry_ttl #

How long, in seconds, the authority may hold an answer from this source before asking again.

Zero means do not cache. A source declaring neither PUSHES_CHANGES nor a non-zero entry_ttl has said its answers must not be held at all, and an authority MUST honour that — see §2.17, where the reasoning for reading silence that way is set out.

entry_ttl and PUSHES_CHANGES are not exclusive, and a source declaring the second SHOULD declare a non-zero first as well. See §2.17: the TTL is the backstop against a notification that was never sent.

2.8.1.5 max_batch #

The largest number of keys the source will accept in one Query (§2.15). Zero means one.

An authority MUST NOT exceed it, and MUST NOT send more than 64 keys whatever the source declared: an encoder MUST NOT declare more, and a decoder MUST read a larger declaration as 64.

A source MUST still validate what it receives. The field is a hint the authority is required to respect, not a guarantee about what will arrive.

2.8.2 Registered #

msg_type = 0x0001. Authority to source, on conversation 0.

FieldEncoding
unix_id_baseu32
unix_id_countu32

Load-bearing beyond its contents. A source SHOULD report itself ready only once it has received this, so that anything ordered after the source finds a system that can actually authenticate rather than merely a process that exists (§2.3).

2.8.2.1 unix_id_base, unix_id_count #

The POSIX identifier range the authority has assigned this source (§2.20). A base of 0 means no range was assigned, and every principal the source asserts will project as unmapped.

Informational. A source counts within its range and asserts relative identifiers; the authority applies the base. A source MUST NOT apply it — see §2.20, where the reasoning is set out in full.

It is sent so that a source's administration tools can show an operator the identifier a principal will really project to, rather than the relative number the source stores. Without it that arithmetic falls to the operator.

An authority that predates these fields sends neither, and a source MUST read their absence as no range assigned — which is what such an authority means, since it has no ranges to assign.

2.8.3 Rules #

  1. A connection MUST open with Register on conversation 0. An authority MUST refuse any connection that opens otherwise.
  2. An authority MUST bound the time it waits for the opening Register, and close the connection on expiry. A peer that connects and says nothing MUST NOT be able to hold resources indefinitely.
  3. An authority MUST NOT admit two sources under one name at the same time.
  4. An authority MUST send Registered only after the source is routable, so that a logon racing the acknowledgement cannot find a source that is registered but not yet reachable.
  5. A source MUST NOT send any other message before receiving Registered.
  6. An authority SHOULD report, where an administrator will see it, that a source registered with no identifier range — its principals will all project as unmapped, and the cause is a configuration omission rather than anything the source did.

2.9 Establishing What a Source Is

Peios / Peios technical reference / PSPU / Principal Source Interface

The authority MUST establish a connecting source's identity for itself, from the kernel, and MUST NOT take it from anything the source sends.

2.9.1 A source proves nothing #

The mechanism worth recommending is that a source proves nothing at all, because the init system already did.

Where the init system places a service SID in each service's token — a SID derived from the service's name, which only the init system can mint — the authority can:

  1. take its list of permitted source names from its own configuration;
  2. derive the service SID each of those names implies;
  3. read the connecting peer's token and ask which of those SIDs it carries.

The resulting identity is assembled entirely from the authority's configuration and the kernel. Nothing is contributed by the process on the other end. There is no shared secret, nothing to provision, nothing to rotate, and nothing to steal — the derivation is a pure function of a name that only the init system can act on.

Note

Mainline derives S-1-5-80-<SHA-1 of the uppercased UTF-16LE service name>, matching what the init system places in every service token. The derivation exists so that platform services — all running as SYSTEM — remain distinguishable from one another, which is exactly this problem.

2.9.2 What is deliberately not checked #

That the peer is SYSTEM. The service SID subsumes it: the user SID could never distinguish one platform service from another, since they all run as the same principal. Requiring SYSTEM as well would needlessly forbid a future source running under a lesser account, which is a direction worth keeping open.

2.9.3 The allowlist #

An authority MUST NOT accept a source it has not been configured to accept.

An empty configuration MUST mean no source may register, not any source may. An allowlist that fails open is not an allowlist. The visible cost is that a system configured with no sources cannot authenticate anyone, which is the correct way for that mistake to present — loudly, at the first logon attempt, rather than silently at the first compromise.

Note

Mainline's allowlist is a registry key whose subkey names are the permitted source names, so enumerating that key answers "what may assert identity on this machine?" exactly, with no second list anywhere to drift out of step.

2.10 The Domain Claim

Peios / Peios technical reference / PSPU / Principal Source Interface

A source declares the domain it is authoritative for. Every principal it may assert lives under it (§2.18).

2.10.1 It is a claim #

A source generates or is given its own domain, so nothing about the SID can prove the claim is honest. What gives it weight is entirely what the authority does with it.

An authority MUST apply all of the following.

2.10.1.1 1. A domain MUST be declared #

A source that declares no domain MUST be refused. There would be nothing to confine its assertions to, which is the whole purpose of collecting one.

Note

The field is appended to a message that existed before it, so a source built against an older shape decodes as declaring nothing rather than as malformed. That is why the refusal is stated as its own rule: the diagnosis should name the real problem.

2.10.1.2 2. The shape MUST be checked #

A domain MUST be a well-formed, locally-issued domain SID: revision 1, the NT authority (5), the non-unique prefix 21, and three further sub-authorities — S-1-5-21-A-B-C, four sub-authorities in total.

The shape is the entire check, and it is enough. A source cannot claim S-1-5-32 (BUILTIN), or the NT authority's well-known range, or S-1-1-0, because none of them has that shape. There is no list of forbidden domains to keep in step with the SID catalogue — the permitted shape excludes every one of them by construction.

2.10.1.3 3. Domains MUST be disjoint #

No two concurrently registered sources may claim the same domain. Two authorities for one namespace means whichever answers first decides who a name belongs to, and the other's principals become impersonable by the first.

2.10.1.4 4. A source MUST NOT change domain #

A source that re-registers MUST declare what it declared before. An authority MUST refuse a change.

This is the check that survives a source restarting, and it is worth its cost: every other check passes for a source that is killed and comes back compromised. It still holds the right service SID, its new domain is still a claimable shape, and with itself deregistered there is nothing left to collide with.

An authority MAY hold this record only for its own lifetime. Persisting it means the authority writing state, which is a larger commitment than the guarantee justifies.

2.10.1.5 5. An administrator MAY pin #

An authority SHOULD allow an administrator to configure the exact domain a named source must declare, and MUST refuse a source declaring anything else when one is configured.

A configured pin that cannot be parsed MUST NOT be treated as absent. Absence means no pin; an unparseable value means an administrator tried to apply the control and got it wrong, and silently downgrading that to "unconstrained" removes the control at the moment it was being applied. An authority MUST fail towards refusing the source.

2.10.2 What remains uncovered #

With no pin configured, and another source not currently registered, a compromised source could declare that source's domain and assert its identities. Disjointness catches it only while both are registered.

This is stated rather than solved. Closing it requires someone to write the pin down, and an authority cannot invent that authority for itself — writing it automatically would mean the process holding the token-minting privilege also holding a configuration write handle, which is a worse trade than the gap it closes.

2.11 Authenticate

Peios / Peios technical reference / PSPU / Principal Source Interface

msg_type = 0x0002. Authority to source. Opens a conversation.

FieldEncodingLimit
startnested LogonStart, length-framedPGSS §2.7
originatorlength-framed bytes (SID)68 bytes

2.11.1 start #

The client's LogonStart, nested whole (§2.7). Its fields and their meanings are PGSS §2.7's, unchanged — including that identifier is an unverified claim and supported_credential_types binds what may be prompted for.

2.11.2 originator #

The verified identity of the process that requested this logon, taken by the authority from the client's connected socket and never from a message body.

A source cannot learn this for itself: it is not party to the client's connection, and there is nothing it could ask. The authority relays it because a source may legitimately refuse a logon on the strength of it — an account restricted to console logons needs to know what asked — and that decision needs a trustworthy input.

A source MUST treat originator as established fact and MUST NOT treat any other field of this message the same way.

2.11.3 Routing #

Before sending Authenticate, an authority MUST decide which single source answers.

The credential MUST NOT be offered to more than one source. Trying each in turn with the password hands every source the credentials of every other source's users, including on typos — the failure PAM stacking exemplifies (§2.D).

Resolution therefore happens on the identifier, before any credential exists. Asking several sources "do you own this name?" is a resolution step with no secret in it and is permitted; offering them the answer is not.

An authority SHOULD resolve a qualified name to its owning source and MUST NOT fall back to another source when the owning one is unreachable. A name that can fall through lets anyone who can break a network choose which authority answers for a principal.

2.11.4 Conversation limits #

An authority MUST bound the conversations it opens against one source. A source MUST bound what it will track, and MUST refuse beyond its own limit with AuthorityUnavailable (§2.13) rather than dropping the conversation silently.

2.12 The Relayed Interrogation

Peios / Peios technical reference / PSPU / Principal Source Interface

Two messages, both carrying PGSS Logon bodies verbatim. They serve a credential change (§2.21) exactly as they serve an authentication, and everything below applies to both.

2.12.1 CredentialRequest #

msg_type = 0x8002. Source to authority. Body is PGSS §2.8's CredentialRequest, byte-for-byte.

The source decides what to ask for, in what order, and over how many rounds. The authority relays it to the client.

2.12.2 CredentialResponse #

msg_type = 0x0003. Authority to source. Body is PGSS §2.8's CredentialResponse, byte-for-byte.

2.12.3 What the authority polices #

The authority relays, but does not relay anything.

An authority MUST refuse to relay a prompt whose credential type is absent from the client's supported_credential_types. PGSS §2.8 makes this the authority's obligation towards the client, and it holds however the authority reached the prompt — a prompt originating in a source is still the authority's to police.

Relaying it would force the client to hard-fail, and a client that guessed instead might echo a secret to the screen. The authority MUST terminate the logon instead.

An authority MUST also enforce its own round and time limits on the relayed exchange (PGSS §2.3), independently of any the source applies. A source that never terminates a conversation MUST NOT be able to hold a client's logon open indefinitely.

2.12.4 Credential handling #

The obligations of PGSS §2.12 bind both parties on this leg as they do on the client's. Credential material reaching a source has been decoded and re-encoded once more than it would have been without federation, and every buffer it passed through on the way is one the obligation covers.

2.12.5 What the authority does not do #

It does not interpret prompts, rewrite messages, reorder anything, or synthesise a request of its own. A source's prompt reaches the client as the source wrote it, which is the property that makes adding a credential type a change to sources alone.

2.13 Assertion and Refusal

Peios / Peios technical reference / PSPU / Principal Source Interface

Exactly one terminal message ends a source conversation. An authentication ends in Assertion or Refusal. A credential change ends in CredentialChanged or Refusal, never in Assertion (§2.21).

2.13.1 Assertion #

msg_type = 0x8003. Source to authority. The only successful outcome a source can produce.

FieldEncodingLimit
user_sidlength-framed bytes (SID)68 bytes
canonical_namestring256 bytes
groupsarray of group entries128
unix_idu32§2.20
primary_grouplength-framed bytes (SID)68 bytes
profilelength-framed structure (PGSS §2.9)
claimsarray of claim entries64
permitted_logon_typesu32PGSS §2.16
authenticated_credential_typeu8; 0 none, 1 Password, 2 SshPublicKeyPGSS §2.D

Note what is absent: no session, no token, no privileges, no integrity level. A source has no way to express them (§2.4).

All listed fields are mandatory in the coordinated replacement layout. Empty arrays/strings and zero values represent absent assertions; omitted fields are malformed. The authority checks the authenticated method against the client's capabilities and any required method before granting a token.

2.13.1.1 canonical_name #

The source's own spelling of the principal's name. A client may have typed JACK; this is what the principal is actually called.

Carrying it is what makes case-insensitive matching safe: the authority records the canonical form rather than whatever was typed, so a session's records do not vary with a caller's shift key.

A source MUST NOT assert a name that PGSS §2.15 forbids — one carrying a reserved character, a byte outside the printable ASCII range, or a leading or trailing space. The authority MUST refuse one anyway (§2.22), because a name from a source reaches a passwd-format record and an audit line, and by then the damage is the reader's to do.

The obligation is on what a source asserts, not on what it creates. A source that validates a name when an administrator adds it, and not when it reads one back from storage, has enforced nothing against a store it did not itself write.

2.13.1.2 permitted_logon_types #

Which kinds of sign-on this principal may be used for, as the bitmask PGSS §2.16 defines for the LOGON_TYPES field.

A source states the property and MUST NOT act on it. Deciding what to do about a principal's restrictions is derivation, and derivation is the authority's (§2.4) — a source that refused an authentication on these grounds would be making an access decision it has no standing to make, and would do it differently from the authority that also has to.

Zero means the source states nothing, and a source that does not hold the property MUST send zero rather than inventing a value. In particular it MUST NOT send "everything", which would assert that a principal may be used for a credential-free service logon (PGSS §2.19) on no evidence at all.

Sources that state no restriction send zero explicitly.

2.13.1.3 groups #

Each entry is a separate length-framed structure:

FieldEncodingLimit
sidlength-framed bytes (SID)68 bytes
unix_idu32§2.20

A SID and a number. No attributes.

A source asserts which groups a principal belongs to. Whether a group entry is enabled, owner-marked, or deny-only is a decision about how to build a token, and building tokens is the authority's (§2.4). A source saying "this principal is an administrator" is identity; a source saying "and mark that group deny-only" would be reaching into derivation.

Both current group fields are mandatory. The per-entry framing preserves the boundary of any future trailing extension.

A unix_id of 0 means the source does not number this group — the honest answer for a group it does not own. A source naming a well-known group is stating a membership, not claiming authority over what that group projects to; see §2.20.

2.13.1.4 unix_id #

The principal's POSIX identifier, relative to the range the authority assigned this source (§2.20). Zero means the source has no number for this principal.

A source MUST NOT apply its own base. It counts within its range and the authority rebases; a source that added the base itself would have it added twice.

2.13.1.5 primary_group #

Which of the principal's groups projects to the POSIX group id, and becomes the default group of objects the token creates. Empty means the source did not say, and the authority chooses.

It need not appear in groups. The authority is required to place it on the token regardless (§2.22), because a token's primary group must be a group the token carries — so naming a group here is a membership claim, and it is subject to membership scope exactly as a listed group is (§2.19).

That applies to a primary group the source asserted. Where the field is empty and the authority substitutes one of its own, the substituted value is the authority's choice and MUST NOT be tested against the source's membership scope. Testing it would deny every logon from a source that declined to name a primary group, on the strength of a claim that source never made — and an authority's own default is very unlikely to be a sibling of the principal's domain, so the test all but always fails.

Note

A source that made this the one field escaping the scope check would have found a route to BUILTIN\Administrators that the group array denies it. The obligation in §2.22 to apply membership scope to the primary group closes that.

2.13.1.6 profile #

PGSS Logon's profile structure (PGSS §2.9), relayed onward to the client unchanged. It is not identity, it decides no access, and the authority does not interpret it.

The one thing the authority does check is the one PGSS §2.9 requires of it: home and shell, when non-empty, are absolute paths. That obligation binds the authority towards its client whatever the value's provenance, so a relayed profile is not exempt from it.

2.13.1.7 claims #

Named, typed attributes fed to conditional ACE evaluation, in the claim attribute format PCDS §5.9 specifies. Each entry is a separate length-framed structure:

FieldEncodingLimit
namestring255 bytes
flagsu32PCDS §5.9
value_typeu32PCDS §5.9
valuesarray of length-framed values64

A claim is the one field here that is a trusted input to access decisions rather than a statement of identity: a conditional ACE can turn a claim into a grant. Which claim names a source may assert is therefore the same kind of question as which groups it may assert, and belongs with membership scope (§2.19).

An authority MUST reject an assertion carrying a claim it cannot carry to a token — an unsupported value type, a name containing an interior NUL, a value exceeding its limit — rather than dropping the claim. The reasoning is rule 3 below: a dropped claim signs the principal in against a policy nobody stated.

2.13.1.8 What the authority MUST do with an assertion #

  1. Validate every SID with a structural check before treating it as identity. Bytes from another process are bytes until checked, and the check belongs in the process that mints tokens rather than in the codec that moved them (§2.7). This includes SIDs carried inside a claim value.
  2. Enforce identity scope (§2.18), membership scope (§2.19) — including over primary_group — and numeric scope (§2.20).
  3. Fail the logon on a malformed group SID, an unusable claim, an invalid canonical_name, or a unix_id outside the source's range, rather than dropping it. Dropping would sign the principal in with authority the source did not state — a confusing way to be wrong at best, and for a claim, a silent change of the policy that will be applied to them. A source that cannot encode a SID is broken.
  4. Drop a logon SID from the asserted groups, loudly. No source is authoritative for one: the kernel mints them per session, and this session's did not exist when the source answered. A source asserting one is either buggy or reaching for a different session's SID, which would forge membership of somebody else's logon.
  5. Drop duplicates, first mention winning. A source asserting a group the authority also derives is redundant, not wrong.

Rules 4 and 5 drop rather than refuse because the token still ends up correct, and refusing would punish a principal for a source's defect without making anything safer. Rule 3 refuses because the token would not end up correct.

Rules 4 and 5 run before rule 2. A logon SID is by construction outside the principal's domain, so an authority that applied membership scope first would refuse the logon that rule 4 says to survive by dropping. Duplicates are the same shape of problem. The drops are about what a source should never have sent; the scope tests are about what it is permitted to claim, and only what survives the first is subject to the second.

An authority MUST also place primary_group on the token even when the source did not list it among groups, since a token's primary group must be a group the token carries.

2.13.2 Refusal #

msg_type = 0x8004. Source to authority.

FieldEncodingLimit
denialu32PGSS §2.B
reasonstring512 bytes

Reuses PGSS Logon's denial vocabulary rather than inventing a parallel one, so that relaying a refusal outward needs no lossy translation.

A source MUST NOT refuse with NoSuchSession (PGSS §2.B). It answers only a request to end a logon session (PGSS §2.22), which the authority decides alone and never puts to a source, so in a refusal it can only be a mistake. An authority that receives it MUST NOT relay it, and SHOULD end the conversation with Internal instead.

CredentialRejected is the same kind of code: it answers only an enrolment (PGSS §2.23), so a source MUST NOT send it in any other conversation, and an authority that receives it elsewhere MUST NOT relay it (§2.23).

A source MUST NOT distinguish an unknown principal from a bad credential — by code, by reason, or by timing (PGSS §2.10, §2.12). The obligation is the source's here, because the source is where the distinction exists to be leaked.

Note

A principal requiring no credential is a separate matter, and cannot be hidden: a conversation that reaches Assertion with no round at all says that the named principal exists and needs nothing. That is inherent in permitting a zero-round logon rather than a defect in this rule, and it is the reason a passwordless principal is a configuration decision rather than a convenience.

An authority MAY relay reason to the client and MAY replace it. It MUST NOT relay a reason that reveals a distinction the source was required not to make.

2.14 Abandon

Peios / Peios technical reference / PSPU / Principal Source Interface

msg_type = 0x0004. Authority to source. No fields beyond the header.

Tells a source that a conversation will not continue: the client hung up, a limit was reached, or the authority terminated the logon for reasons of its own.

2.14.1 Why it exists #

Without it, a client that disconnects mid-prompt leaves the source holding conversation state forever. A source cannot detect this for itself — it is not party to the client's connection — so the authority has to say.

2.14.2 Rules #

  1. An authority MUST send Abandon for any conversation it opened that will not reach a terminal state, unless the connection itself is being torn down.
  2. A source MUST discard all state for the conversation on receipt, and MUST NOT reply.
  3. Abandon is not a terminal message from the source, and no Assertion or Refusal follows it. If one arrives anyway, the authority MUST ignore it — the conversation is gone, and a late answer to an abandoned question is at best stale.
  4. A source that receives Abandon for a conversation it does not know MUST ignore it. It has already cleaned up, which is the outcome the message wanted.

Note

The natural implementation is to send Abandon from whatever owns the conversation's lifetime, on any path that does not reach a terminal state — including error paths. An authority that sends it only on the tidy path will leak conversations exactly when things are already going wrong.

2.15 Query

Peios / Peios technical reference / PSPU / Principal Source Interface

An authority serving PGSS Logon's identity lookup must be able to ask a source about a principal outside a logon: to render a name for a SID, or a POSIX record for a number.

A query is a conversation like any other. The authority allocates the identifier, Query opens it, and one terminal message closes it.

2.15.1 Query #

msg_type = 0x0005. Authority to source. Opens a conversation.

FieldEncodingLimit
fieldsu32PGSS §2.B
keysarray of key entries64

A key entry is a length-framed structure:

FieldEncodingLimit
key_typeu8§2.B
namestring256 bytes
sidlength-framed bytes (SID)68 bytes
relative_idu32
kindu8PGSS §2.B

Exactly one of name, sid and relative_id is meaningful, selected by key_type. An encoder MUST leave the others empty or zero.

2.15.1.1 Keys are never absolute #

ValueName
1Name
2Sid
3RelativeId

There is no key type carrying an absolute POSIX identifier, and this is the load-bearing property of the message.

PGSS Logon's lookup accepts one, because that is what getpwuid hands a name resolver. The authority resolves it: it locates the range containing the number, subtracts the base, and asks the owning source by relative identifier.

A source is therefore never asked an absolute number, exactly as it never asserts one during a logon (§2.20).

The reason is not that an absolute number would let a source escape its range — it could not, because the authority refuses a relative identifier at or past the count before adding anything to it (§2.20). The reason is that the arithmetic must exist in exactly one place. A source asked an absolute number would have to subtract its own base to answer, which is the operation §2.20 forbids it, and an authority that asked would have taught it that its stored numbers and the system's are the same numbers. Every subsequent bug in that source would be an off-by-a-base.

Note

This is also why the authority is the only party that can answer a getpwuid. See PGSS §2.13.

2.15.1.2 Batching #

keys is an array so that an authority may ask several questions in one exchange. An authority MAY send a single key, and one that always does is conforming.

The array is here from the outset because adding it later would break every source written against a single-key message. A source with a cheap local store gains little; a source backed by a remote directory gains the difference between one query and a hundred.

A source MUST answer every key it is sent, in order, and MUST NOT reorder, merge or omit results. A source that cannot serve a whole batch MUST refuse the conversation rather than answer part of it.

2.15.2 QueryResult #

msg_type = 0x8005. Source to authority. Terminal.

FieldEncodingLimit
resultsarray of result entries64

One result per key, in the order the keys were sent.

A result entry is a length-framed structure:

FieldEncodingLimit
outcomeu8§2.B
sidlength-framed bytes (SID)68 bytes
canonical_namestring256 bytes
kindu8PGSS §2.B
presentu32PGSS §2.B
withheldarray of withheld entries32
valuesarray of length-framed values32

Everything from present onward is PGSS §2.16's structure, unchanged — the same reuse of message bodies as the interrogation phase (§2.5), and for the same reason: an authority relaying a source's answer outward should not have to translate it.

Where outcome is not Found, everything after it MUST be empty or zero.

2.15.2.1 canonical_name, not qualified #

A source returns its own spelling of the name, as it does in an Assertion (§2.13). It does not qualify it.

Qualification names which source answered, and a source cannot know what it is called in another authority's search order. PGSS Logon requires a qualified name on the way out (PGSS §2.15); producing it is the authority's.

2.15.2.2 Identifiers are relative #

Every identifier in a result — a UNIX_ID field, a reference's unix_id — is relative, exactly as in an Assertion (§2.20). A source MUST NOT apply a base, and the authority rebases before the number leaves it.

2.15.2.3 Outcomes #

A source may send only:

ValueNameMeaning
1FoundThe source holds this object.
2NotFoundIt does not.
4RefusedIt holds it and will not say so.

A source MUST NOT send Unavailable: it is answering, so nothing was unavailable to it. The authority produces that outcome when a source does not answer (PGSS §2.18), and a source claiming it would let a working source be recorded as a broken one.

A source MUST NOT send Malformed in a result. A message it cannot parse is a Refusal for the whole conversation (§2.13).

Note

Refused is how a source declines to expose a principal it holds — which §2.22 has always permitted. It is distinct from NotFound because the authority may consult another source on NotFound, and must not on Refused: the object was found, and the answer was no.

A Refused result is about the object, not about the caller, and an authority MUST NOT relay it outward as PGSS Logon's Refused outcome, which is reserved for a caller that may not make the request (PGSS §2.18). What a source declining to expose an object means to a client is the authority's to decide, and it is not "you lack permission".

A source that declared no QUERIES is a third case again. It has not answered and cannot be asked, so an authority MUST NOT record it as having answered NotFound: a source that was never consulted is not evidence that an object does not exist, and PGSS §2.18 forbids reporting NotFound on the strength of one. It contributes nothing to the search, and §2.8 says what an authority should do about the configuration that produced it.

2.15.3 Scope #

An authority MUST apply identity confinement (§2.18), membership scope (§2.19) and numeric scope (§2.20) to a QueryResult exactly as to an Assertion, and MUST validate every SID in one structurally before using it — including the SIDs of references inside a PRIMARY_GROUP, GROUPS or MEMBERS value, not only the sid of the result itself.

"Exactly as to an Assertion" is meant literally, and it is the sentence an implementation is most likely to satisfy by halves. An authority that confines the object a result names, while relaying the group references beside it unchecked, has left the whole of membership scope unenforced on this channel — and a source with no permission to assert a foreign membership can then report one through a name lookup that it could not report through a logon.

A query is not a weaker channel than a logon. A source that could name a principal outside its domain here would be able to make ls -l display another source's principals as its own — and, worse, could then be believed the next time something compared that name to a SID.

2.16 Enumeration

Peios / Peios technical reference / PSPU / Principal Source Interface

Query asks about objects the authority can already name. Enumeration asks a source to produce them: to fill a POSIX passwd or group table, or to page through a group whose membership will not fit in one answer.

A source is never required to enumerate. A source that declines is fully conforming, and this section exists as much to make declining safe as to make enumerating possible.

2.16.1 EnumerateSource #

msg_type = 0x0006. Authority to source. Opens a conversation.

FieldEncodingLimit
kindu8PGSS §2.B
fieldsu32PGSS §2.B
oflength-framed key entry (§2.15)
cursorlength-framed bytes256 bytes

kind MUST NOT be Any.

An empty of enumerates every object of kind the source holds. A non-empty of MUST name a group, and enumerates that group's members.

That group is one the source holds, or a BUILTIN group (S-1-5-32-…) named by its SID. A BUILTIN group is no source's object, but a source permitted to assert memberships outside its domain records who is in it (§2.19), and the authority asks every such source for its members (PGSS §2.16). Such a source MUST answer with the principals it records in the group — by a listed membership or as their primary group — and with an empty Found where it records none.

cursor is empty on the first request, and otherwise carries the next from the source's immediately preceding reply.

2.16.2 EnumerateResult #

msg_type = 0x8006. Source to authority. Terminal.

FieldEncodingLimit
outcomeu8§2.B
entriesarray of result entries (§2.15)256
nextlength-framed bytes256 bytes

An empty next ends the enumeration. A non-empty next means there is more, even where entries is empty.

A source MUST size a page against the smaller of this chapter's message ceiling and PGSS Logon's, because the authority re-encodes what it returns into the latter (§2.A). Neither the 256-entry bound nor the message ceiling here prevents a page nobody can deliver.

A source that will not enumerate replies Refused, with entries and next empty. An authority MUST record it as a source that did not contribute, and MUST NOT retry it for the remainder of that enumeration — across pages as well as within one (PGSS §2.17).

Refused and an empty Found are different answers and MUST NOT be conflated. Found with entries and next both empty says there are none: the group exists and has no recorded members, or the source holds no objects of that kind. Refused says this source is not answering. A source that returns an empty Found where it means the second has told the authority a falsehood it cannot detect, and the authority will go on asking it — the non-retry rule above has nothing to attach to.

The cases most often got wrong, all of which are Refused and not an empty Found: a group whose membership the source will not expose, a key that names an object the source does not hold, a key that names a principal where a group was required, and a cursor the source can no longer honour.

2.16.3 Cursors belong to the source #

A cursor is opaque to the authority. The authority MUST NOT construct, parse or modify one; it relays what it was given.

A source MAY encode anything into a cursor, and MAY refuse one it no longer honours — a store rewritten underneath a half-finished walk is the ordinary case, not an exceptional one. A refused cursor is Refused, and the authority MUST NOT restart the enumeration on the source's behalf.

Note

Restarting would turn a store edit during a getent passwd into an unbounded loop over a source that never finishes. Reporting an incomplete enumeration is the honest outcome, and PGSS §2.17 requires the authority to say so.

A source MUST NOT assume a cursor comes back on the same conversation, or on the same connection, and MUST NOT hold per-cursor state it is unwilling to discard.

The authority's own cursor, the one it hands its client, is its to construct — PGSS §2.17 requires it to reject one it did not issue, which it can only do for a cursor it made. A source's cursor travels inside it, not as it.

2.16.4 Why members are here and not only in Query #

MEMBERS is a field of Query (§2.15), so the common case — a small group, whose members fit alongside the rest of the record — costs one exchange.

A group whose membership will not fit is reported through PGSS Logon's TooLarge (PGSS §2.16), and this is where the caller is sent. Paging a membership through the mechanism that already pages is cheaper than a third message, and considerably cheaper than the alternative of a partial member list, which is a wrong answer rather than a smaller one.

2.16.5 Enumeration is not existence #

An authority MUST NOT use enumeration to determine whether a principal exists, and MUST NOT infer from a source declining to enumerate that the source holds nothing.

A directory-backed source able to answer any single question while quite unable to answer all of them is the expected case, not a degraded one.

2.17 Change Notification

Peios / Peios technical reference / PSPU / Principal Source Interface

An authority answering a name lookup for every process on the system will cache. This is the message that lets it.

2.17.1 Changed #

msg_type = 0x8007. Source to authority, on conversation 0. Unsolicited, and never answered.

FieldEncodingLimit
scopeu8§2.B
sidlength-framed bytes (SID)68 bytes
scopeNameMeaning
1AllEverything this source holds may have changed.
2ObjectThe object named by sid may have changed.

sid is meaningful only for Object, and MUST be within the source's declared domain (§2.18).

Object scope MUST be used for a deletion as well as a change, and for a creation — an authority may be holding a cached NotFound for a name that now exists.

2.17.2 Obligations #

A source declaring PUSHES_CHANGES (§2.8) MUST send Changed before, or at the same time as, the altered answer becomes observable through Query.

Sending it afterwards leaves a window in which the authority's cache and the source disagree while both believe themselves current — which is indistinguishable, from the authority's side, from the notification never arriving.

A source MAY send All where it could have sent Object. Over-invalidation costs a query; under-invalidation costs correctness.

An authority MUST accept Changed at any time after Registered, including while conversations are open on the same connection, and MUST NOT reply to it.

An authority MUST treat the loss of a source's connection as All for that source. It does not know what changed while it was not listening.

A source declaring PUSHES_CHANGES SHOULD declare a non-zero entry_ttl as well (§2.8). The two are not alternatives: the TTL is the backstop against a notification that was never sent, or was sent and failed to write. Declaring PUSHES_CHANGES with a TTL of zero means a single lost notification leaves the authority holding a stale answer until the connection drops — and a source that tolerated a failed Changed write without tearing the connection down would have made that outcome reachable, which is one of the reasons §2.6 makes a failed write fatal.

2.17.3 Sources that do not push #

A source that does not declare PUSHES_CHANGES is conforming, and many cannot: a remote directory has no way to tell this machine that an account was renamed.

Such a source declares entry_ttl instead (§2.8), and an authority MUST NOT hold its answers beyond it.

A source declaring neither has said it cannot support caching, and an authority MUST NOT cache its answers at all. That is the safe reading of silence, and it is what a source predating this message says by omission.

Note

The alternative default — cache anything not explicitly forbidden — would make a source written against an older revision silently serve stale identity, which is the one class of staleness that decides access.

An authority that does not cache at all satisfies this section trivially, and is conforming. The obligations here bind what an authority may hold, not whether it must hold anything.

2.17.4 What this does not carry #

Changed says that something changed. It does not say what it changed to.

Carrying the new value would make this a second, unsolicited path by which a source could assert identity — one arriving outside any conversation, with no key to check it against, and no logon in progress to refuse. An authority that believed it would have accepted an identity assertion it never asked for.

The authority discards what it holds and asks again through Query, where every scope rule in §2.18 to §2.20 applies.

2.18 Identity Confinement

Peios / Peios technical reference / PSPU / Principal Source Interface

A source may assert only principals within its declared domain (§2.10). No configuration lifts this.

An authority MUST refuse an Assertion whose user_sid does not lie within the domain the source registered for, and MUST terminate the logon. It MUST apply the same test to a QueryResult (§2.15) and to the sid of a Changed (§2.17).

2.18.1 Containment #

A principal SID lies within a domain when it is the domain's SID plus exactly one relative identifier.

Exactly one, deliberately. S-1-5-21-A-B-C-1000-1 is not a principal of S-1-5-21-A-B-C, and admitting it would let a source that owns one domain mint names in a nested namespace nobody agreed it owned. A domain SID is likewise not a principal of itself.

2.18.2 Why nothing lifts it #

A source that could assert identities outside its domain could hand out another authority's principals to anyone who satisfied its credential check.

The concrete case: a local source holds no domain credential. If it were unconfined, it could produce a domain administrator's identity for anyone who knew a local password. The domain's own authority would never be consulted and would have no way to know.

Confinement keeps a compromised source at "authority over its own domain" — which is what it already was — rather than "authority over everyone".

This is why identity scope and membership scope (§2.19) are separate settings rather than one. They are different questions, and only one of them has a legitimate exception.

2.19 Membership Scope

Peios / Peios technical reference / PSPU / Principal Source Interface

Group membership is the question with a legitimate exception, and it needs one.

2.19.1 The default #

By default, an authority MUST refuse an Assertion carrying a group outside the domain of the principal being asserted.

A directory vouches for its own users and its own groups, and nothing else. Without this rule, a directory-backed source could declare its users members of BUILTIN\Administrators — making a remote authority the arbiter of who administers this machine.

The test is relative: are the group and the principal in the same domain? It needs no configuration and no knowledge of which domain belongs to whom, which is why membership scope is enforceable before anything else about scope is settled.

2.19.2 The exception #

An authority SHOULD allow a source to be configured as permitted to assert memberships outside the asserted principal's domain.

A local source needs it. Local group membership of any principal is a local decision: "CORP\Domain Admins is in BUILTIN\Administrators" is a record this machine keeps, not something a domain controller asserts at it. Without the exception, a local source could not express the one thing it is most authoritative about.

An authority MAY grant this to more than one source. Nothing about it is exclusive.

2.19.3 Memberships only, never identity #

A source holding this permission remains fully confined on identity (§2.18).

The two are separate because the risks are not symmetric. A source asserting a foreign membership is making a claim about what a principal may do on this machine, which is a local matter and is the local source's business. A source asserting a foreign identity is claiming to be the authority for somebody else's principal, which is never anyone's business but that authority's.

Note

Specifying the permission narrowly — memberships only — from the outset is what allowed identity confinement to be switched on later without changing what the setting means. A permission defined as "this source is trusted" would have had to be redefined, and every existing configuration reinterpreted, the day identity scope arrived.

2.19.4 The primary group is a membership #

primary_group (§2.13) is subject to this section exactly as a listed group is, and an authority MUST apply the test to it.

It would otherwise be a way round: the authority is required to place the primary group on the token whether or not the source listed it, so a source that named BUILTIN\Administrators there and nowhere else would obtain a membership the group array denies it.

An authority that adds an unlisted primary_group to the membership set MUST do so before applying this section, not after. Adding it afterwards reintroduces exactly the route the rule closes.

This binds a primary group the source asserted. A default the authority substituted for an empty field is not the source's claim and MUST NOT be tested against the source's scope (§2.13); an authority that tested its own default would refuse every logon from a source that simply left the field empty.

2.19.5 Claims are the same question, unanswered #

A claim (§2.13) is a trusted input to conditional ACE evaluation, so asserting one can produce a grant just as asserting a membership can. Which claim names a source may assert is therefore the same shape of control as this section — and it is not yet specified.

The gap is stated rather than papered over. An authority federating to a source it does not fully trust should consider claims as it considers foreign memberships, and a future revision is expected to define the control here.

Note

Active Directory splits this in a way worth borrowing: claim type definitions are forest-level configuration while claim values are per-user attributes. The equivalent split — the machine decides which claims exist, the source supplies values for them — is the obvious shape for the control, and is why this is a gap in configuration rather than a flaw in the message format.

2.20 Numeric Scope

Peios / Peios technical reference / PSPU / Principal Source Interface

A source's POSIX identifiers are relative. The authority assigns the range and applies the base; a source is told its range but MUST NOT act on it.

Every unix_id in an Assertion — the principal's and each group's — and every identifier in a QueryResult is an offset within a range the authority assigned that source. The authority adds the base before the number reaches a token or a caller.

This is the numeric counterpart of identity confinement (§2.18). That section stops a source naming principals outside its namespace; this one stops it numbering them outside its namespace.

2.20.1 The range #

An authority MUST assign each source a range, as a base and a count, spanning [base, base + count).

Note

Mainline configures both per source in the registry, as UnixIDBase and UnixIDCount alongside the domain pin (§2.10). How the assignment is expressed is the authority's own design; what it must satisfy is this section.

An authority MUST reserve a band below every source's base for identifiers of its own — well-known SIDs, service SIDs, confinement SIDs — none of which come from any directory. The band has to be generous, because the last of those categories has no bound.

2.20.2 Rebasing #

Given a relative identifier r from a source with range (base, count), the authority computes base + r, and MUST refuse to produce a number at all when:

  • r is 0. Zero is not an identifier; it is how a source says it has no number for something. It MUST NOT become base.
  • r is at or past count. The source has reached outside the range it was given.

A number the authority declines to produce projects as unmapped, which the Peios Kernel TRM §3.10.1 defines.

2.20.3 Refuse, never clamp #

An out-of-range identifier MUST be refused. It MUST NOT be clamped to the top of the range, and it MUST NOT be reduced modulo the count.

Both alternatives look like graceful degradation and are worse than a refusal:

  • Clamping puts two principals on one number, so a filesystem cannot tell them apart.
  • Wrapping lands inside somebody else's range, so one source's principal projects as another source's.

The range is a boundary, not an offset, and the count is what makes it one.

2.20.4 Two numbers a source can never reach #

Because the base sits above the reserved band and a relative identifier cannot escape the count:

  1. uid 0. It belongs to the authority's own table and is not reachable by adding a base to anything a source can send.
  2. Another source's numbers. Whatever a source asserts, arithmetic confines it to its own range.

Neither depends on the source behaving. They hold because of what the source is able to express.

2.20.5 Well-known SIDs are not the source's to number #

An authority MUST use its own identifier for any SID in its reserved band, and MUST ignore whatever unix_id a source sent alongside it.

A source asserting BUILTIN\Administrators is stating a membership. It is not claiming authority over what that group projects to, and honouring a relative identifier there would place a well-known group inside that source's range — where a second source could number something else identically.

2.20.6 The authority tells the source its range #

A source is told its base and count at registration (§2.8). This is informational and exists so an administration tool can show an operator the identifier a principal will really project to, rather than the relative number on disk.

A source MUST NOT apply the base to what it asserts. It is told the range so it can explain itself, not so it can do the arithmetic. A source that applied its own base would have it applied twice.

Note

Keeping the base out of the source's stored records is what makes rebasing a configuration change rather than a data migration. An administrator moving a source's range edits two configuration values; nothing the source persists has to be rewritten, because nothing it persists was ever absolute.

Disclosure costs nothing that matters. What confines a source is not ignorance of the base but the authority's refusal to accept a relative identifier at or past the count — a check the authority performs on every number it rebases, whatever the source knows.

2.20.7 Uniqueness within a source #

SIDs are one namespace; POSIX user and group identifiers are two. A source MUST therefore allocate from a single counter across every kind of object it holds, so that a number issued to a principal is never issued again to a group.

An authority cannot check this — it sees one assertion at a time — so it is stated as an obligation on the source (§2.22) rather than as something enforced.

Note

The simplest way to satisfy it is to make an object's identifier its relative identifier within the domain, which is already unique across principals and groups. That has the incidental benefit that one object has one number rather than two unrelated ones.

2.21 Credential Change

Peios / Peios technical reference / PSPU / Principal Source Interface

A PGSS Logon credential change (PGSS §2.20) reaches the source that holds the principal as a conversation of its own kind. It opens with ChangeCredential, runs the relayed interrogation of §2.12, and ends in CredentialChanged or Refusal.

  authority                              source
    |------------ ChangeCredential ---------->|   conversation N
    |<----------- CredentialRequest ----------|   the current credential
    |------------ CredentialResponse -------->|
    |<----------- CredentialRequest ----------|   the new one
    |------------ CredentialResponse -------->|
    |<------ CredentialChanged | Refusal -----|

It is not an Authenticate that ends differently. A change asserts nobody, so it carries nothing an authority could mint from, and keeping the two apart by message type keeps the path that mints unreachable from this one (§2.4).

2.21.1 ChangeCredential #

msg_type = 0x0007. Authority to source. Opens a conversation.

FieldEncodingLimit
startnested CredentialChangeStart, length-framedPGSS §2.20
principallength-framed bytes (SID)68 bytes

2.21.1.1 start #

The client's CredentialChangeStart, nested whole (§2.7), for the reason Authenticate nests LogonStart (§2.11): it grows on PGSS Logon's schedule, and a field appended there must not displace principal. Its supported_credential_types binds what may be prompted for, exactly as in a logon.

2.21.1.2 principal #

The principal whose credential changes: the user of the client's token, established by the authority from the client's connected socket and never from a message body.

It names both who asked and who is being changed, because PGSS Logon gives a client no way to name anybody else. A source MUST treat it as established fact, MUST NOT change the credential of any other principal in this conversation, and MUST NOT take a principal from anything the client supplies in it.

2.21.1.3 Routing #

An authority MUST send ChangeCredential only to the source authoritative for principal's domain (§2.18), and MUST NOT offer the change to any other source. The rule and its reason are those of §2.11: a credential offered to the wrong source is a credential handed to it.

Where no registered source is authoritative for principal and a configured source is absent, the authority MUST answer the client AuthorityUnavailable rather than refusing the account. The absent source may be the one that holds it, and "this account has nothing to change" would turn an outage into a statement about the account.

An authority MUST NOT send ChangeCredential to a source that did not declare CHANGES_CREDENTIALS (§2.8).

2.21.2 The interrogation #

The rounds are those of §2.12, unchanged: CredentialRequest and CredentialResponse carrying PGSS Logon's bodies, relayed and policed by the authority as they are in a logon. The source decides what to ask for.

A source MUST establish that the principal can present their current credential before changing it (PGSS §2.20), and MUST refuse with AuthenticationFailed where they cannot. The token the authority read principal from is not that proof.

A source that proves the current credential in one round and collects the new one in a later round MUST NOT change the credential if the current credential has itself changed in between. Otherwise an administrator's reset between the two rounds would be overwritten on the strength of the credential it replaced.

2.21.3 CredentialChanged #

msg_type = 0x8008. Source to authority. The only successful outcome of a change conversation. No fields.

A source MUST NOT send it until the new credential is the one it will verify at its next Authenticate for this principal — until the change is durable. The authority relays it to the client as PGSS Logon's CredentialChanged, and PGSS §2.20 forbids reporting a change that a subsequent logon would not see.

2.21.4 Refusal #

A change that does not end in CredentialChanged ends in Refusal (§2.13). A source MUST refuse:

  • with AccountRestricted, a principal it holds no changeable credential for: one that authenticates with no credential, one designated only for service logons, or one that is disabled;
  • with AuthenticationFailed, a current credential that does not verify;
  • with ConversationLimit, a change it gives up on for want of an acceptable new credential.

2.21.5 Terminals out of place #

An authority MUST treat an Assertion in a change conversation, and a CredentialChanged in any other conversation, as a source fault. It MUST end the client's conversation with Internal, MUST NOT mint from the assertion, and MUST NOT report the change.

Abandon (§2.14) applies unchanged. A source discards everything it held for the conversation, including what a completed round established.

2.22 Conformance

Peios / Peios technical reference / PSPU / Principal Source Interface

A conforming implementation MUST satisfy every requirement in this chapter. This section collects them by role.

2.22.1 Authority obligations #

An authority federating over PSI MUST satisfy all of the following.

2.22.1.1 Channel #

  1. Listen; never dial out to a source (§2.3).
  2. Never rely on the socket's descriptor as the access control, and bound unregistered connections and registration time independently of it (§2.6).
  3. Bound registered sources, and conversations per source (§2.6).
  4. Tear down the connection on a framing error or a failed write, rather than attempting resynchronisation (§2.6).

2.22.1.2 Registration #

  1. Require Register on conversation 0 as the first message (§2.8).
  2. Establish the source's identity from the peer's token, never from source_name, and refuse a mismatch rather than correcting it (§2.8, §2.9).
  3. Accept only configured sources, treating an empty configuration as no source may register (§2.9).
  4. Refuse a source that declares no domain (§2.10).
  5. Refuse a domain that is not a well-formed locally-issued domain SID (§2.10).
  6. Refuse a domain another registered source claims (§2.10).
  7. Refuse a source declaring a different domain from the one it declared before (§2.10).
  8. Refuse a source whose declared domain contradicts a configured pin, and treat an unparseable pin as refusing rather than as absent (§2.10).
  9. Send Registered only once the source is routable (§2.8).
  10. Report, where an administrator will see it, a source registering with no identifier range, and a source registering without QUERIES (§2.8).

2.22.1.3 Conversations #

  1. Allocate conversation identifiers, never accepting one from a source, never opening one with an identifier already live, and reserve 0 for registration (§2.7).
  2. Route each logon to exactly one source, resolved before any credential is collected (§2.11).
  3. Never fall back to another source when the owning source is unreachable (§2.11).
  4. Relay the verified originator, taken from the client's socket (§2.11).
  5. Refuse to relay a prompt for a credential type the client did not advertise (§2.12).
  6. Enforce its own round and time limits on the relayed exchange (§2.12).
  7. Send Abandon for any conversation that will not reach a terminal state (§2.14).

2.22.1.4 Assertions #

  1. Validate every SID structurally before treating it as identity, including SIDs carried inside claim values (§2.13).
  2. Drop an asserted logon SID, and drop duplicates first-mention-wins, before applying any scope test (§2.13).
  3. Enforce identity confinement, with no configuration lifting it, on an Assertion, on a QueryResult, and on the sid of a Changed (§2.18).
  4. Enforce membership scope, subject only to a per-source permission covering memberships alone, and apply it to a source-asserted primary_group as well as to listed groups — promoting an unlisted one into the membership set before the test (§2.19, §2.13).
  5. Never apply membership scope to a primary_group it substituted itself for an empty field (§2.13, §2.19).
  6. Fail the logon on a malformed group SID, an unusable claim, a canonical_name PGSS §2.15 forbids, or an out-of-range identifier (§2.13).
  7. Place primary_group on the token even when the source did not list it among groups (§2.13).
  8. Perform derivation itself, and never accept privileges, integrity, or a token from a source (§2.4).
  9. Never relay a refusal reason revealing a distinction the source was required not to make (§2.13).

2.22.1.5 Identifiers #

  1. Apply the source's base to every relative identifier it accepts, and never accept one already rebased (§2.20).
  2. Refuse a relative identifier of 0 or one at or past the source's count, rather than clamping or wrapping it (§2.20).
  3. Reserve a band of identifiers below every source's base for its own, and use its own value for any SID within that band regardless of what the source sent (§2.20).

2.22.1.6 Queries #

  1. Never send a message a source did not declare it answers, and never set a field bit gating a capability it did not declare (§2.8).
  2. Never send more keys in one Query than the source's max_batch, nor more than 64 whatever it declared (§2.8).
  3. Resolve an absolute POSIX identifier to a source and a relative identifier itself, and never send an absolute one to a source (§2.15).
  4. Apply identity confinement, membership scope and numeric scope to a QueryResult exactly as to an Assertion, and validate every SID in one structurally — including those of references inside a value (§2.15).
  5. Rebase every identifier in a result, under the rules of §2.20 (§2.15).
  6. Qualify a source's canonical_name itself; never require a source to (§2.15).
  7. Consult no further source on a Refused result, treat only NotFound as leave to continue, and never relay a source's Refused outward as PGSS Logon's Refused outcome (§2.15).
  8. Never record a source it may not ask — one that declared no QUERIES — as having answered NotFound (§2.15).
  9. Relay cursors opaquely, never construct or modify a source's, and never restart an enumeration on a source's behalf (§2.16).
  10. Record a source that declined or could not be reached, and not retry it for the remainder of that enumeration, across pages as well as within one (§2.16).
  11. Never infer from a source declining to enumerate that it holds nothing (§2.16).

2.22.1.7 Caching #

  1. Not cache a source's answers at all unless it declared PUSHES_CHANGES or a non-zero entry_ttl (§2.8, §2.17).
  2. Not hold an answer beyond a declared entry_ttl (§2.8).
  3. Accept Changed at any time after Registered, and never reply to it (§2.17).
  4. Re-read through Query after an invalidation, and never take a new value from Changed (§2.17).
  5. Treat the loss of a source's connection as All for that source (§2.17).

An authority that holds nothing satisfies 45 to 49 trivially.

2.22.1.8 Credential change #

An authority that relays PGSS Logon's credential change (PGSS §2.20) over PSI MUST also satisfy the following. Obligations 19 to 21 bind a change conversation as they bind an authentication.

  1. Take principal from the client's connected socket, never from a message body (§2.21).
  2. Send ChangeCredential only to the source authoritative for principal's domain, never offer the change to another source, and never send it to a source that did not declare CHANGES_CREDENTIALS (§2.21, §2.8).
  3. Answer the client AuthorityUnavailable, rather than refusing the account, where no registered source is authoritative for principal and a configured source is absent (§2.21).
  4. End the client's conversation with Internal on an Assertion in a change conversation or a CredentialChanged in any other, minting nothing and reporting no change (§2.21).

2.22.1.9 Credential enrolment #

An authority that relays PGSS Logon's enrolment (PGSS §2.23) over PSI MUST also satisfy the following. Obligations 19 to 21 and 50 to 53 bind an enrolment conversation as they bind a change, reading EnrollCredential for ChangeCredential.

  1. Never send EnrollCredential to a source that did not declare ENROLLS_CREDENTIALS, and answer the client PermissionDenied where the source holding the principal did not (§2.23, §2.8).
  2. Relay CredentialRejected only from an enrolment conversation, and end any other conversation carrying it with Internal (§2.23, §2.13).

2.22.2 Source obligations #

A principal source MUST satisfy all of the following.

2.22.2.1 Connection #

  1. Connect to the authority; never listen for it (§2.3).
  2. Open with Register on conversation 0, carrying its name and its domain (§2.8).
  3. Send nothing else before receiving Registered (§2.8).
  4. Report itself ready — to an init system or equivalent — only after Registered (§2.3).
  5. Declare the same domain on every registration, for the life of the machine's configuration (§2.10).
  6. Tear down the connection on a framing error or a failed write, on every path including an unsolicited Changed (§2.6, §2.17).

2.22.2.2 Conversations #

  1. Reply on the conversation identifier it was given, and never invent one (§2.7).
  2. Decline to act on a message on a conversation it does not know, and never treat it as opening one — without replying on it, since the identifier may since have been reused (§2.7).
  3. Refuse an Authenticate, ChangeCredential, Query or EnumerateSource arriving on conversation 0 (§2.7).
  4. Bound the conversations it tracks itself, rather than relying on the authority's limit (§2.6).
  5. Refuse beyond that bound with AuthorityUnavailable, rather than dropping silently (§2.11).
  6. Discard conversation state on Abandon, and not reply (§2.14).

2.22.2.3 Answering #

  1. Send exactly one terminal message per conversation: Assertion or Refusal for an authentication, CredentialChanged or Refusal for a credential change (§2.13, §2.21).
  2. Assert only principals within its declared domain (§2.18).
  3. Assert group SIDs and identifiers only, never attributes (§2.13).
  4. Carry the canonical spelling of the principal's name in canonical_name (§2.13), and never assert a name that PGSS §2.15 forbids — validating what it asserts, not only what it creates.
  5. Never distinguish an unknown principal from a bad credential — by denial code, by reason, or by timing (§2.13).

2.22.2.4 Identifiers #

  1. Assert relative identifiers only, and never apply its own base (§2.20).
  2. Allocate from a single counter across every kind of object it holds, so that no number is issued twice (§2.20).
  3. Send 0 for any object it does not number, including every group it does not own (§2.20).
  4. Never issue an identifier at or past the count it was given (§2.20).

2.22.2.5 Queries #

A source declaring no capabilities (§2.8) is exempt from this section entirely.

  1. Declare only capabilities it implements, and answer every message type it declared (§2.8).
  2. Answer every key of a Query, in order, without reordering, merging or omitting — or refuse the conversation whole (§2.15).
  3. Send Found, NotFound or Refused in a result, and never Unavailable or Malformed (§2.15).
  4. Return its own canonical spelling of a name, unqualified (§2.15).
  5. Return relative identifiers in a result, exactly as in an Assertion (§2.15, §2.20).
  6. Answer only for principals within its declared domain, on a query as on a logon (§2.18).
  7. Answer Refused, never an empty Found, wherever it is declining rather than reporting an absence — including a membership it will not expose, a key naming an object it does not hold, and a key of the wrong kind (§2.16).
  8. Refuse a cursor it can no longer honour, rather than restarting or answering from a changed store (§2.16).
  9. Hold no per-cursor state it is unwilling to discard unasked (§2.16).
  10. Size a page against the smaller of PSI's message ceiling and PGSS Logon's, since the authority must re-encode it into the latter (§2.16, §2.A).
  11. Send Changed before the altered answer becomes observable, if it declared PUSHES_CHANGES (§2.17).
  12. Declare a non-zero entry_ttl if it does not push changes and can tolerate its answers being held, and SHOULD declare one even if it does (§2.8, §2.17).

2.22.2.6 Credentials #

  1. Store verifiers that are not usable as credential material — nothing a challenge could be recomputed from (PGSS §2.11).
  2. Erase credential material, and every buffer it was decoded through, before that memory is released (PGSS §2.12).
  3. Never write credential material to a log, audit record, or diagnostic (PGSS §2.12).

2.22.2.7 Logon type restrictions #

  1. State a principal's permitted_logon_types where it holds the property, and send zero where it does not (§2.13).
  2. Never send "everything" for a principal whose restrictions it does not hold — zero is the honest answer, and "everything" asserts that the principal may be used for a credential-free service logon (§2.13, PGSS §2.19).
  3. Never refuse an authentication on the strength of the restrictions it states. A source states; the authority decides (§2.4, §2.13).
  4. Answer LOGON_TYPES from a Query as stored, including "not stated", and never substitute a default of its own — what silence means is the authority's reading, and two components with their own defaults can disagree (§2.15, PGSS §2.16).

2.22.2.8 Credential change #

A source that declares CHANGES_CREDENTIALS MUST also satisfy the following.

  1. Change only the credential of principal, and never take a principal from anything the client supplied (§2.21).
  2. Establish that the principal can present their current credential before changing it, and refuse with AuthenticationFailed where they cannot (§2.21).
  3. Never change a credential on the strength of a current credential that has changed since it was proved (§2.21).
  4. Refuse with AccountRestricted a principal it holds no changeable credential for (§2.21).
  5. Send CredentialChanged only once the new credential is the one its next Authenticate for that principal will verify (§2.21).

2.22.2.9 Credential enrolment #

A source that declares ENROLLS_CREDENTIALS MUST also satisfy the following.

  1. Add or remove credentials only of principal, and never take a principal from anything the client supplied (§2.23).
  2. For both adding and removing, establish that the principal can present a credential they already have before changing anything, refuse with AuthenticationFailed where they cannot, and refuse with AccountRestricted, before asking, a principal it can ask for no such credential (§2.23).
  3. Check the material again when applying the change, refuse material it will not accept with CredentialRejected, and send that code in no other conversation (§2.23).
  4. Send CredentialChanged only once the change is durable, and, where it declares PUSHES_CHANGES, only after Changed (§2.23, §2.17).

2.22.3 What a source is not required to do #

A source is not required to store anything, to be local, to be persistent, or to know what a token is. It answers one question: given this identifier and whatever it chose to ask for, who is this?

Nor is it required to trust the authority beyond the connection. A source that refuses logons on the strength of originator, or declines to answer for principals it holds but does not wish to expose, is conforming.

2.23 Credential Enrolment

Peios / Peios technical reference / PSPU / Principal Source Interface

A PGSS Logon enrolment (PGSS §2.23) reaches the source that holds the principal as a conversation of its own kind. It opens with EnrollCredential, runs the relayed interrogation of §2.12, and ends in CredentialChanged or Refusal.

  authority                              source
    |------------ EnrollCredential ---------->|   conversation N
    |<----------- CredentialRequest ----------|   the current credential
    |------------ CredentialResponse -------->|
    |<------ CredentialChanged | Refusal -----|

It is the sibling of the credential change (§2.21), and every rule that section states binds here unless this section says otherwise: routing to the source authoritative for the principal, AuthorityUnavailable while a configured source is absent, the principal as established fact, the terminals out of place, and Abandon.

2.23.1 EnrollCredential #

msg_type = 0x0008. Authority to source. Opens a conversation.

FieldEncodingLimit
startnested CredentialEnrollStart, length-framedPGSS §2.23
principallength-framed bytes (SID)68 bytes

start is the client's CredentialEnrollStart, nested whole for the reason ChangeCredential nests its start (§2.21). principal is the user of the client's token, established by the authority from the client's socket, exactly as in ChangeCredential. A source MUST NOT add a credential to, or remove one from, any other principal in this conversation.

2.23.2 The capability #

An authority MUST NOT send EnrollCredential to a source that did not declare ENROLLS_CREDENTIALS (§2.8). Where the source that holds the principal did not declare it, the authority MUST answer the client PermissionDenied: for that principal it does not offer enrolment, and PGSS §2.23 gives that answer to an authority that does not.

ENROLLS_CREDENTIALS and CHANGES_CREDENTIALS are independent. A source may change passwords and not enrol keys, or the reverse.

2.23.3 The interrogation #

The source MUST establish that the principal can present a credential they already have, other than the one being enrolled, before it adds or removes anything, for Add and Remove alike, and MUST refuse with AuthenticationFailed where they cannot. The token the authority read principal from is not that proof. A source that cannot ask for such a credential for this principal — the principal has none, or signs in only with the kind being enrolled — MUST refuse with AccountRestricted before asking anything.

The source decides which credential types it enrols and what material it accepts. It SHOULD check the material before asking for proof, MUST check it again when it applies the change, and MUST refuse material it will not accept with CredentialRejected. A source MUST NOT send CredentialRejected in any conversation but this one, and an authority receiving it in any other MUST end the client's conversation with Internal (§2.13).

2.23.4 CredentialChanged #

The successful terminal is CredentialChanged (§2.21), sent only once the change is durable: an added key is one the source's next Authenticate will accept where the principal's policy allows keys, and a removed key is one it will not.

A source that declares PUSHES_CHANGES MUST send Changed (§2.17) before CredentialChanged, as for any other change to what it holds.

Enrolment does not change which credentials the principal may sign in with; that remains the source's administration.

Appendix 2.A Message Reference

Peios / Peios technical reference / PSPU / Principal Source Interface

2.A.1 Messages #

msg_typeMessageDirectionConversationDefined in
0x8001Registersource → authority0§2.8
0x0001Registeredauthority → source0§2.8
0x0002Authenticateauthority → source1+ (opens)§2.11
0x8002CredentialRequestsource → authority1+§2.12
0x0003CredentialResponseauthority → source1+§2.12
0x8003Assertionsource → authority1+ (terminal)§2.13
0x8004Refusalsource → authority1+ (terminal)§2.13
0x0004Abandonauthority → source1+ (terminal)§2.14
0x0005Queryauthority → source1+ (opens)§2.15
0x8005QueryResultsource → authority1+ (terminal)§2.15
0x0006EnumerateSourceauthority → source1+ (opens)§2.16
0x8006EnumerateResultsource → authority1+ (terminal)§2.16
0x8007Changedsource → authority0§2.17
0x0007ChangeCredentialauthority → source1+ (opens)§2.21
0x8008CredentialChangedsource → authority1+ (terminal)§2.21, §2.23
0x0008EnrollCredentialauthority → source1+ (opens)§2.23

The high bit marks a message sent by the source, which is the authority for its own principals (§2.7).

2.A.2 Protocol constants #

ConstantValueDefined in
Socket paththe implementation's choice§2.6
MagicPPSI (50 50 53 49)§2.7
Version1§2.7
Header size20 bytes§2.7
Maximum message size81920 bytes§2.7
Reserved conversation0§2.7

2.A.3 Field limits #

FieldMaximumDefined in
source_name32 bytes§2.8
domain68 bytes§2.8
max_batch64§2.8
originator68 bytes§2.11
principal68 bytes§2.21, §2.23
user_sid68 bytes§2.13
canonical_name256 bytes§2.13
groups128 entries, each SID 68 bytes§2.13
primary_group68 bytes§2.13
claims64 entries§2.13
claim name255 bytes§2.13
claim values64 per claim§2.13
claim string value1024 bytes§2.13
claim octet value1024 bytes§2.13
claim SID value68 bytes§2.13
reason512 bytes§2.13
keys64 entries§2.15
key name256 bytes§2.15
results64 entries§2.15
withheld32 entries§2.15
values32 entries§2.15
entries256 entries§2.16
cursor, next256 bytes§2.16

68 bytes is the largest a SID can be: an eight-byte prelude plus fifteen sub-authorities (§2.7).

A claim name is bounded at 255 bytes of UTF-8 while PCDS §5.9 bounds it at 255 UTF-16 code units. A string's UTF-16 length never exceeds its UTF-8 byte length, so the byte bound is the stricter of the two and satisfies PCDS without transcoding to find out.

The claim limits are otherwise tighter than PCDS §5.9 permits — it allows 1024 values per claim. These bound the work an authority does decoding a message it has not yet decided to believe, and nothing needs a thousand-valued claim from a principal source.

Fields inside a nested LogonStart, CredentialChangeStart, CredentialRequest, CredentialResponse or profile keep PGSS Logon's limits (PGSS §2.A).

2.A.4 The ceiling that actually binds a page #

None of the entry counts above is the constraint on how much a source may return. An entry that fits a PSI message need not fit the PGSS Logon message the authority must re-encode it into: this chapter's ceiling is 81920 bytes and PGSS Logon's is 65536, and a QueryResult or EnumerateResult entry travels outward inside the smaller one.

A source MUST therefore bound a reply by the smaller of the two ceilings, not by this one, and MUST page rather than fill a PSI message it knows an authority cannot forward. A page that fits here and not there is a page nobody can deliver, and the entry-count bounds do not prevent one — 256 entries of a few hundred bytes each exceeds both.

The margin an implementation leaves for the authority's own framing is its own choice; leaving none is a defect.

Appendix 2.B Enumerations

Peios / Peios technical reference / PSPU / Principal Source Interface

Values PSI defines for itself. Everything else it carries is PGSS Logon's — see PGSS §2.B.

Adding a value to any enumeration here is a breaking change requiring a version bump (§2.7). The two exceptions are the capability bitmask below, and PGSS Logon's field bitmask, which PSI carries unchanged and which may gain bits without one.

2.B.1 Key types #

Carried in a Query key entry's key_type (§2.15) as a u8.

ValueNameKey is in
0noneThe key entry is absent
1Namename
2Sidsid
3RelativeIdrelative_id

Zero is not a key. It is how an EnumerateSource encodes an empty of (§2.16), which is the only place it may appear; an encoder MUST NOT send it in a Query key and a decoder MUST reject one that arrives there.

PGSS Logon's key types (PGSS §2.B) share the first two values and differ in the third, where it carries an absolute POSIX identifier. The values are not interchangeable and the tables are deliberately separate: 3 means a rebased number on one side of the authority and a relative one on the other, which is the whole of §2.20 expressed as a number.

2.B.2 Result outcomes #

Carried in a result entry's outcome (§2.15) and in EnumerateResult.outcome (§2.16) as a u8.

The values are PGSS Logon's (PGSS §2.B). A source may send only these three:

ValueName
1Found
2NotFound
4Refused

Unavailable (3) and Malformed (5) are the authority's to produce and MUST NOT be sent by a source — see §2.15. A decoder MUST reject either arriving from a source, rather than leaving the check to a caller.

2.B.3 Change scopes #

Carried in Changed.scope (§2.17) as a u8.

ValueName
1All
2Object

2.B.4 Capabilities #

Carried in Register.capabilities (§2.8) as a u32 bitmask.

BitName
0QUERIES
1ENUMERATES
2MEMBERS
3PUSHES_CHANGES
4CHANGES_CREDENTIALS
5ENROLLS_CREDENTIALS

Unlike the enumerations above, a bit MAY be added here without a version bump. A source that does not set a bit has not declared the capability, and an authority MUST NOT send a message the source did not declare it answers (§2.8) — so an authority that predates a bit simply never uses it, and a source that predates one never sets it. Both are the safe reading.

MEMBERS gates a field rather than a message: an authority MUST NOT set the MEMBERS field bit of a Query (§2.15) against a source that did not declare it.

2.B.5 Claim value types and flags #

A claim's value_type and flags (§2.13) are PCDS §5.9's, and are not restated here.

They are nonetheless closed on this interface: a decoder MUST reject a value type or a flag bit it does not recognise, rather than carrying it through to an authority that will put it on a token. Adding one is therefore a breaking change to PSI by the rule above, even though the values themselves belong to PCDS.

Appendix 2.C What Is Shared with PGSS Logon

Peios / Peios technical reference / PSPU / Principal Source Interface

PSI is a superset of PGSS Logon (§2.5). This appendix consolidates exactly what is shared, what is added, and what differs — as a checklist for an implementer building both, and as the list to re-examine whenever either specification changes.

2.C.1 Shared unchanged #

ElementPGSSNote
LogonStart body§2.7Nested whole inside Authenticate, never inlined (§2.7)
CredentialRequest body§2.8Byte-for-byte identical
CredentialResponse body§2.8Byte-for-byte identical
profile body§2.9Nested inside Assertion, relayed onward unchanged (§2.13)
Denial codes§2.BReused by Refusal (§2.13)
Lookup result body, present onward§2.16Nested inside a QueryResult entry (§2.15)
Field bitmask§2.BCarried unchanged by Query (§2.15)
Object kinds§2.BCarried unchanged by a key entry (§2.15)
Lookup outcomes§2.BA source may send three of the five (§2.B)
Header layout, first 12 bytes§2.6Same fields at the same offsets
Byte order, string encoding, length framing§2.6See §2.7
Extensibility rules§2.6Append-only; new enum value is breaking
Credential-handling obligations§2.12Bind sources too (§2.22)
Name rules§2.15Bind what a source asserts (§2.13)

An implementation that reimplements any of these rather than sharing one definition has taken on the job of keeping two copies in step. The sharing is the point: a translation layer between two byte-identical formats is a place for them to drift.

2.C.2 Added by PSI #

ElementDefined in
conversation header field§2.7
Register / Registered§2.8
Authenticate, wrapping LogonStart plus originator§2.11
Assertion§2.13
Abandon§2.14
Domain claim and its checks§2.10
POSIX identifiers, and the ranges that confine them§2.13, §2.20
Claims carried from a source§2.13
Query / QueryResult, and batching§2.15
EnumerateSource / EnumerateResult, and cursors§2.16
Changed, and the cache contract§2.17
Source capabilities, TTL and batch limit§2.8
Relative key types, where PGSS Logon's are absolute§2.15, §2.B
Identity, membership and numeric scope§2.18 to §2.20

2.C.3 Differs #

PGSS LogonPSI
MagicPGSLPPSI
Header12 bytes20 bytes
Maximum message6553681920
Conversations per connectiononemany
Connection lifetimeone logonthe source's lifetime
High bit of msg_typeauthority → clientsource → authority
Success terminalAccessGranted + token fdAssertion — no session, no token
Socket pathnormativethe implementation's choice
Key type 3absolute POSIX identifierrelative identifier

The success terminal is what makes minting structurally impossible for a source (§2.4); the socket path is normative in PGSS because it is a conformance bar and not here because PSI is not one (§2.1).

The message ceiling is the row most likely to catch an implementer out, because the larger number is the one that does not bind a reply — see §2.A.

2.C.4 When either specification changes #

A field appended to LogonStart, CredentialRequest, CredentialResponse or profile in PGSS appears here automatically, because the bodies are shared. That is the intended behaviour and needs no change to this chapter.

The profile is the one shared body that travels in the opposite direction to the others: the interrogation bodies pass from the authority outward to the client, while the profile originates at the source and is relayed outward through the authority. It is shared for the same reason regardless — one definition, so the value a source states and the value a client reads cannot drift apart.

A new Denial value, a new CredentialType, or any change to the header's first twelve bytes is a breaking change to both and requires a coordinated version bump. An implementer maintaining both MUST NOT bump one alone.

The one exception is a denial added under PGSS §2.6's rule for codes that only newer messages draw. NoSuchSession is one: it answers only requests a source is never party to, a source never sends it (§2.13), and so no PSI peer of either age meets it.

Appendix 2.D Prior Art

Peios / Peios technical reference / PSPU / Principal Source Interface

2.D.1 What this exists to avoid #

The design is shaped more by rejected approaches than adopted ones.

LSA authentication packages. Windows loads authentication packages as DLLs into the LSA process. A defect in any package is a defect in the most privileged process on the system, and the packages are exactly the components most likely to parse hostile input. PSI puts that boundary at a process, permanently: there is no in-process extension point and no message that could create one.

PAM modules. The same objection, plus stacking — offering a credential to each module in turn until one accepts, which hands every module the credentials of every other module's users. PSI resolves which source answers before any credential is collected (§2.11).

NSS. Name service switch modules answer "who is this?" as a library call in whatever process asked, with no boundary at all. PSI's answer is a message from a process that was separately identified.

2.D.2 What is adopted #

RSI's shape, specified in PSPK. A long-lived connection, sources that dial in and register, multiplexed requests tagged with an identifier, and the authority tearing down a connection it cannot parse. PSI is recognisably the same family, and deliberately so — an implementer who has written a registry source will find little surprising here.

The differences are worth naming, because they follow from what is being federated. A registry source is trusted with the correctness of a subtree and the kernel validates its structure; a principal source is trusted with identity, so the checks it faces are about scope — which principals, which memberships, which numbers — rather than about well-formedness alone. And the kernel assigns a registry source no numeric range, because there is nothing to project.

PGSS Logon's interrogation, wholesale. Rather than inventing a parallel vocabulary for prompts and answers, PSI relays PGSS Logon's messages with identical bodies (§2.5). The gain is not brevity but correctness: there is no translation layer to be lossy, and a source's prompt reaches the client exactly as written.

2.D.3 Design influences #

Sources connect inward. The authority never dials out. See §2.3 — this is the single most consequential shape decision in the chapter.

Assertion, not minting. The success terminal deliberately cannot express a session or a token (§2.4). The separation is structural rather than a rule an implementer must remember.

Domains claimed, not assumed. A source states what it is authoritative for and the authority confines it to that (§2.10, §2.18). The alternative — an authority that trusts whatever a source says about anyone — makes every source as dangerous as the most dangerous one.

Relative numbers. POSIX identifiers are the one thing a source states that has no namespace of its own to be confined by, so the range supplies one (§2.20). Nothing else in the protocol needed inventing; a SID already carries its domain.

3.1 Scope and Roles

Peios / Peios technical reference / PSPU / Observability Interfaces

This chapter specifies the observability interfaces: the three channels by which the programs on a Peios system deposit logs and metrics with an observability service, and by which anything on the system asks that service what it holds.

There are three interfaces and they are specified together because they are one contract from the service's side and because two of the three share their encoding, their validation posture and their loss model:

  • the Log Ingestion Interface, on which a producer submits log records (§3.6 to §3.8)
  • the Metric Ingestion Interface, on which a producer submits metric samples (§3.9 to §3.13)
  • the Query Interface, on which a client asks for stored events, logs and metrics and receives records (§3.14 to §3.28)

Three roles participate.

The collector is the process that accepts ingestion on the two datagram channels, serves the query channel, and holds the data in between. There is one collector. It is the party being asked, on all three interfaces — which is why the obligations in this chapter fall mostly on it, and why the producer and client roles are so thin.

A producer is a process that submits metric samples. Its KACS token is conveyed with every datagram, and the collector verifies that it may publish each metric name (§3.9). Service output reaches the log channel through the service manager instead: the service manager authenticates the service origin by the pipe it owns, then acts as the sole log broker (§3.6).

A client is a process that issues a query and reads the result. A client's identity, unlike a producer's, is established by the collector and determines what it may see (§3.28).

One program is commonly all three at once.

This chapter covers:

  • the shape of the three channels and why two are datagram and one is a stream (§3.3)
  • the loss model, which is the load-bearing decision of the whole ingestion design (§3.4)
  • encoding, timestamps and the timestamp domain (§3.5)
  • the log record, its fields, and exactly which malformations cost the record and which are merely ignored (§3.6 to §3.8)
  • the metric data model, the three metric types, the sample record, and what makes two samples the same time series (§3.9 to §3.13)
  • the query channel, its framing, and the four response messages (§3.14 to §3.17)
  • the query language: its shape, its lexis, its operators, its ordering and grouping semantics, and the three modes (§3.18 to §3.25)
  • cross-type filtering and streaming (§3.26, §3.27)
  • what a client is and is not told about data it may not read (§3.28)
  • how these interfaces may be extended (§3.29)
  • the obligations binding on each role (§3.30)

This chapter does not cover:

  • Event emission. Events reach a collector through KMES, not through any interface here; the consumer side of that is specified in PSPK, and emission is a kernel interface offered to privileged callers.
  • Event type vocabulary and payload schemas, which belong to whichever subsystem emits the event.
  • How a collector stores, indexes, retains or accelerates anything — its own design. The mainline collector's is described in the eventd TRMP.
  • Which principals receive publication rights for individual metric names, and how those rights are administered.
  • Administering a collector's contents.

The third of those is the point of the whole document. A collector is handed records and asked questions; how it gets from one to the other is exactly what different collectors exist to do differently.

3.1.1 These interfaces are not a conformance requirement #

A system that offers none of these is still Peios. Observability is not in the definition of the platform, and a system that ships a different collector, or none, conforms exactly as well.

They are specified because they are public. Every service has its output brokered into logs, every collection agent is a metric producer, and every dashboard, alerting tool and command-line viewer is a query client. All three of those are third-party positions, and all three need a contract that stays put.

Note

The Query Interface reaches events as well as logs and metrics, even though events do not arrive over any interface here. That asymmetry is deliberate: PSPK's ring-buffer interface is the raw transport, requires SeSecurityPrivilege, and applies no per-record access control. The query interface is how everything else reads events, and it is the only way to read them after the ring buffer has moved on.

3.2 Terminology

Peios / Peios technical reference / PSPU / Observability Interfaces

Terms defined in PSPK for the KMES event stream — event, header, payload, stamp, sequence number, origin class — are used here with the same meaning and are not redefined. Terms defined in PCDS — GUID, SID, Security Descriptor, ACL, ACE — likewise.

The following terms are specific to this chapter.

Collector: the process that accepts log and metric ingestion and serves queries. The role, not the program: the mainline collector is eventd, and this chapter never requires that it be.

Producer: a process that submits log records or metric samples.

Client: a process that issues a query.

Log record: one line of output from a program, with the light metadata of §3.7 attached. A log record is text; the collector does not parse it.

Metric sample: one measurement of one quantity at one moment, belonging to a time series (§3.13).

Time series: the sequence of samples sharing a name, a label set, and — for histograms — a set of bucket boundaries. Identity is defined in §3.13.

Boot ID: a GUID identifying one boot of the system. Every record a collector stores carries one, so that records from different boots are never interleaved and per-CPU event sequence numbers, which restart each boot, remain unambiguous. It is assigned outside this interface and is visible to a client only as a queryable field.

Datagram: one message on an ingestion channel, carrying either one record or a batch of them (§3.7, §3.11).

Effective query range: the half-open interval a query examines, [SINCE, UNTIL), with the bounds resolved as §3.19 defines.

Concrete identifier: the event type, log origin or metric name that a stored record actually carries — as distinct from the pattern a query or a Security Descriptor uses to match one. Access control resolves per concrete identifier (§3.28).

3.3 Three Channels

Peios / Peios technical reference / PSPU / Observability Interfaces

A collector listens on three AF_UNIX sockets. Two carry data inward, one carries it out.

ChannelSocket typeDirectionSection
Log ingestionSOCK_DGRAMproducer to collector§3.6
Metric ingestionSOCK_DGRAMproducer to collector§3.9
QuerySOCK_STREAMrequest and response§3.14

The pathnames are configuration and this chapter does not fix them. A collector MUST serve each interface on a distinct socket; it MUST NOT multiplex two of them onto one.

3.3.1 Why ingestion is datagram #

Each submission is an independent message. A datagram either arrives whole or does not arrive, so there is no framing to get wrong, no length prefix to parse, no partial read to reassemble, and no connection state to keep for a producer that submits one line an hour. The record boundary is the datagram boundary.

The property that matters more is that a datagram socket cannot exert backpressure. When the receive queue is full the kernel discards the datagram and the sender proceeds. That is the behaviour §3.4 requires, and choosing a stream socket would make it unachievable: a full stream buffer blocks the writer, which is precisely the outcome this design forbids.

3.3.2 Why the query channel is a stream #

A query result is arbitrarily large, is delivered in several messages, and must not be silently truncated — the opposite requirements. It also needs a caller identity, and a peer token can be obtained from a connected stream socket. Both push the same way.

3.3.3 Separation #

The three channels are separated for the same two reasons.

The first is admission. Each listening socket has its own receive queue. Log volume is orders of magnitude above query volume on any normal system, and a burst of either must not delay the other. Three sockets means three populations of caller that cannot starve one another, whatever load any of them is under.

The second is access control. The service manager alone writes the log channel, while service processes write the metric channel under their own conveyed identities; the set that may read either is different again. Those want different Security Descriptors, and a descriptor is a property of a socket.

The separation is not for isolation. One collector serves all three, so a defect or a hang in any of them reaches the others regardless, and this chapter does not pretend otherwise.

3.3.4 Protecting the channels #

A collector MUST protect each socket with a Security Descriptor.

The log socket descriptor MUST deny FILE_WRITE_DATA to the Service logon group before allowing SYSTEM. The service manager runs as SYSTEM without the Service group; every service it launches, including a SYSTEM service, carries that group. The ordered deny therefore makes the service manager the log broker without making every SYSTEM service one (§3.6).

The metric socket descriptor is coarse admission only. Every metric datagram also carries a KACS sender token and every metric name is authorized for EVENTD_PUBLISH (§3.9). A collector SHOULD let every authenticated caller send by default, so that a publish grant to anyone can be used; an operator MAY narrow it.

The query socket descriptor MUST let every authenticated caller connect. What a caller may read is decided per identifier against the read policy (§3.28), so a socket that admitted fewer callers would only make a policy grant to anyone else impossible to use.

A collector MUST NOT rely on the socket's POSIX mode bits for this. On Peios an access decision is routed through the object's Security Descriptor, not through mode bits, so a mode set on a socket pathname does not restrict anything; and an inode created without a descriptor is denied to every caller, so a collector that binds a socket into a directory carrying no inheritable ACEs produces a socket nothing can reach. A collector MUST establish the descriptor on each socket before it begins accepting on it.

3.4 Loss and Backpressure

Peios / Peios technical reference / PSPU / Observability Interfaces

The single decision that shapes both ingestion interfaces is this: a producer is never slowed down, and never told that a record was lost.

3.4.1 The obligation #

A collector MUST NOT exert backpressure on a producer. A producer MUST NOT stall, block, retry or otherwise change its behaviour because a collector is slow, busy, or absent.

The consequence is accepted openly. When a collector cannot drain an ingestion socket as fast as producers fill it, the kernel discards datagrams. Neither party is notified. A collector MUST NOT report the loss to the producer, because there is no reply message on a datagram channel and adding one would reintroduce the coupling this rule exists to prevent.

3.4.2 Why loss is acceptable here #

A lost log line is an inconvenience. A lost metric sample is a visible gap in a chart. Neither is a failure of the system, and neither is worth the cost of the alternative — which is either blocking the producer or buffering without bound, and the second is only the first with a delay.

Events are the counter-example, and the reason the boundary between events and logs matters. An event may be a security audit record whose absence is itself the finding, so events do not travel on either interface in this chapter: they travel through KMES, where loss is detected, bounded and recorded. A program with data that must not be lost emits an event; a program with output for a human to read writes a log.

Note

This is the practical test for producers deciding where output belongs. If you would want to know that a record went missing, it is not a log and not a metric.

3.4.3 What a collector must not do about it #

A collector MUST NOT emit an event, write a log entry, or perform any other work proportional to the volume of malformed or unwanted input it receives.

Ingestion input is untrusted even though its admission is authenticated (§3.3). A collector that reacted to bad input — by logging it, by counting it in a way a client can observe, or by emitting a diagnostic event — would hand an admitted process an amplification primitive: a cheap malformed datagram producing an expensive durable record. Silence is the defence. In-memory diagnostic counters and rate-limited standard error output are permitted because they do not grow with durable input.

The rule binds only on responses to input. A collector MAY record its own internal conditions, and the mainline collector records several, as synthetic events and as health metrics (eventd TRMP §2.6, §5.7).

3.4.4 Ordering and duplication #

A collector MUST NOT assume that datagrams arrive in the order they were sent, and MUST NOT reorder or deduplicate the records inside one. A producer MUST NOT assume that submitting two datagrams in order causes them to be stored in that order; the timestamp field (§3.7, §3.11) is the only ordering a producer controls.

Records are not deduplicated. A producer that submits the same record twice has produced two records.

3.5 Encoding and Time

Peios / Peios technical reference / PSPU / Observability Interfaces

3.5.1 MessagePack #

Every structured value on all three interfaces — log records, metric samples, query requests, query responses — is encoded as MessagePack. Strings are UTF-8.

The choice is inherited rather than made here: KMES event payloads are a single MessagePack value, so a collector already carries a decoder and a query result can carry an event payload outward without re-encoding it.

A decoder MUST accept any valid MessagePack encoding of a value it is given. In particular a producer MAY use any length-prefix width that can represent the value, and a collector MUST NOT require the shortest.

3.5.2 Canonical MessagePack #

Where this chapter requires a canonical encoding, the value MUST be encoded as follows:

  • Nil and booleans use the fixed singleton encodings.
  • Integers use the shortest encoding that preserves signedness: non-negative values use positive fixint, uint8, uint16, uint32 or uint64; negative values use negative fixint, int8, int16, int32 or int64.
  • Floats are encoded as float64. Finite values use their IEEE-754 binary64 representation; the infinities use the normal binary64 encodings; any NaN is encoded as the single quiet NaN bit pattern 0x7ff8000000000000.
  • Strings, binary values, arrays and maps use the shortest length-prefix form capable of representing the length.
  • Arrays encode each element recursively, in order.
  • Maps encode keys and values recursively, with entries sorted by the canonical encoded key bytes; ties are broken by the canonical encoded value bytes.

Canonical encoding exists so that two values that are equal are also byte-identical, which is what makes them comparable and orderable without decoding. It is required in exactly two places: histogram sample storage, where it makes a sample map a stable value (§3.11), and array comparison in query ordering and grouping (§3.21).

It does not constrain what a producer sends. Ingestion accepts any valid encoding.

3.5.3 Timestamps #

A timestamp is wall-clock time in nanoseconds since the Unix epoch, UTC, as a signed 64-bit value.

The timestamp domain is 0 to 9223372036854775807 inclusive. A value outside it is invalid wherever it appears: as a producer-supplied timestamp (§3.8, §3.12), as a query time literal (§3.19), or as a value in a result record.

The domain has no negative half. A collector MUST reject a negative timestamp rather than storing a time before 1970, and a query whose time arithmetic lands below zero — SINCE 100000d ago, for example — MUST produce an error rather than clamping.

Note

The upper bound is i64::MAX nanoseconds, which is the year 2262. The domain is stated as a closed range rather than "whatever fits" because both parties must agree on where the edge is: a producer that clamps and a collector that rejects would disagree about the same sample.

Wall-clock time is not monotonic. A collector MUST store the timestamp it is given or derives without correcting it, and MUST NOT assume that timestamps within one time series increase (§3.13). A clock step backwards produces records that are out of order with respect to their arrival, and every ordering rule in this chapter is defined to remain total and deterministic when that happens.

3.6 The Log Channel

Peios / Peios technical reference / PSPU / Observability Interfaces

A collector MUST expose an AF_UNIX SOCK_DGRAM socket for log ingestion, protected by a Security Descriptor as §3.3 requires.

3.6.1 The datagram ceiling #

A collector declares a maximum accepted datagram size, the log datagram ceiling. The portable ceiling is 262144 bytes. Every collector MUST accept a whole datagram up to that size, and every portable producer MUST bound its encoded datagrams to that size without discovering collector configuration. A collector MAY accept more, but a producer MUST NOT depend on that when implementing this interface.

A collector MUST receive log datagrams into a buffer of at least its declared ceiling, and MUST discard a datagram the kernel reports as truncated rather than storing the prefix that fitted.

A producer MUST NOT send a log datagram larger than the ceiling. One that does is discarded whole, taking every record in it, and the producer is not told (§3.4).

A sending socket's own buffer can be smaller than the ceiling. A producer that may send a datagram longer than its socket's default send capacity SHOULD first raise SO_SNDBUF enough to accommodate it.

On Linux, a Unix datagram longer than the effective SO_SNDBUF returned by getsockopt minus 32 bytes is refused with EMSGSIZE. Linux doubles the value requested with setsockopt(SO_SNDBUF) for bookkeeping; the effective value must therefore be at least the datagram's length plus 32 bytes. An ordinary unprivileged request is capped at net.core.wmem_max before that doubling. The initial buffer comes from net.core.wmem_default; neither sysctl has a universal value.

For example, with both sysctls set to 212992 bytes, the initial buffer allows datagrams of at most 212960 bytes, below the portable ceiling. Requesting 212992 bytes gives an effective buffer of 425984 bytes and a datagram limit of 425952 bytes. A larger datagram needs a higher wmem_max for an ordinary unprivileged sender. A collector's configured ceiling does not itself enlarge the sender's buffer.

Note

§3.A gives the mainline value and upward-adjustable range for this bound and every other in this chapter.

No capability exchange is needed. The 262144-byte floor is part of the wire contract rather than a tunable assumption shared out of band. Raising a collector's ceiling is a local extension; lowering it below the portable ceiling is non-conforming.

3.6.2 The receive queue is the buffer #

A collector MAY enlarge the socket receive queue, to at most four times the datagram ceiling. It MUST NOT buffer beyond it.

That queue is the only cushion between a producer and the collector's storage. While a collector is committing a batch it is not draining the socket, and datagrams arriving in that window occupy the queue; when the queue fills, they are discarded. This is the designed degradation (§3.4), not a failure to be tuned away — a larger buffer moves the threshold without changing what happens at it, and an unbounded one converts data loss into memory exhaustion.

3.6.3 The service-manager broker #

Every service produces output, including programs that know nothing about the collector, but service processes do not write this socket. The service manager holds each service's standard output and standard error at fork, determines the origin from the pipe it read, and forwards the resulting records (peinit TRM).

The log socket MUST implement the deny-Service, allow-SYSTEM descriptor in §3.3. A collector MUST accept log origins from this broker without a per-record KACS token or AccessCheck. A service can choose the bytes it writes to its pipe but cannot choose another service's origin.

Direct log submission is not part of this interface. A program that wants structured, authenticated observability data emits an event or a metric; it does not bypass the service manager to assert a log origin.

3.7 Log Records

Peios / Peios technical reference / PSPU / Observability Interfaces

A log datagram carries either one record, encoded as a MessagePack map, or several, encoded as a MessagePack array of maps. A collector MUST accept both forms. A producer MAY use either at any time; there is no mode and no negotiation.

3.7.1 Fields #

FieldTypeRequiredMeaning
originstringyesNon-empty name of the program that produced the line.
is_errorboolyesTrue if the line came from standard error, or the producer marked it an error. False otherwise.
messagestringyesThe log text — one line of output. MAY be empty, which is a blank line.
timestampintegernoWhen the line was produced, in the timestamp domain (§3.5). Absent means the collector uses its own clock at receipt.
job_idbinary, 16 bytesnoA GUID in PCDS binary layout correlating this line to one execution of one program.

A collector MUST ignore fields it does not recognise (§3.29).

3.7.2 origin #

origin is what the producer says it is. A collector MUST NOT verify it, because it has no way to: the channel is a datagram socket and carries no peer identity (§3.4). Two producers MAY use the same origin, and one producer MAY use several.

An origin is nonetheless the unit that read access is granted on (§3.28), and a collector matches it against patterns using dot-delimited prefix semantics: the pattern svc matches the origin svc and any origin beginning svc., and matches neither svc_daemon nor svcfoo. It also matches every origin whose part before a slash is svc, such as svc/HealthCheck, because matching considers only that part.

A producer therefore SHOULD choose an origin that names it stably and distinguishably, and SHOULD use dots for hierarchy, because an administrator writing an access rule has nothing else to write it against.

An origin MUST match:

origin    := component | component "/" producer
producer  := component | component "[" [0-9]+ "]"
component := [A-Za-z0-9_][A-Za-z0-9_.-]*

A collector MUST discard a record whose origin does not.

A single component is the identifier grammar of §3.19, widened only to admit a leading digit, and names a program. The optional second component names a producer within that program — a hook, a reload command, a health check, one submitted job — and the bracketed integer distinguishes several of the same kind. A forwarding service manager uses it for exactly that: jellyfin/ExecStartPre[0] is a pre-start hook's output, jellyfin/HealthCheck a health check's, jobs/<guid> a submitted job's. There is at most one slash, and the index may appear only after one.

Everything the grammar still excludes, it excludes on purpose. An origin is not merely a label: it is matched against patterns in which * is the wildcard, so an origin containing * could not be selected exactly and could match a rule its producer was never meant to satisfy; and it is the name an access rule is stored under, so an origin carrying a backslash, a quoting character or whitespace could land somewhere other than where the administrator who wrote the rule believes it is. Constraining the producer is the only point at which either can be prevented.

The slash is safe there only because it is never written into a stored rule. A collector MUST resolve the access rule for an origin from the part before the slash, so that a service's hooks, reloads, health checks and jobs answer to the rule written against the service — and a producer cannot reach a rule of its own by inventing a second component.

A collector MUST count discarded origins and MUST make that count observable to an operator. An unrecognised origin means a producer's vocabulary and a collector's have drifted apart, and every line that producer sends is being dropped; a silent discard makes the most complete kind of log loss the least visible one.

Quoted forms remain valid syntax everywhere an origin may be written (§3.24). A single-component origin beginning with a letter or _ never needs them; every other conforming origin does, because the query language's identifiers admit neither a slash, a bracket nor a leading digit (§3.19). A pattern may need them too, and a collector holding origins stored before this rule applied must still be able to return and select them.

3.7.3 is_error #

is_error is a boolean and deliberately not a severity level.

A forwarding producer can distinguish standard output from standard error and nothing more; inventing five levels out of two file descriptors would be a guess presented as data. A producer with real severity levels either writes them into the message text, where they are text and are searched as text, or emits events, which have types.

A system service SHOULD write informational messages to standard output and warnings and errors to standard error. This keeps informational startup and status messages out of is_error == true queries. Warnings stay in them: they are diagnostics on standard error, and the collector does not infer severity from text. Peios's own system daemons follow this rule. A daemon's best-effort kernel-log mirror, where it has one, is independent of the stream a line is written to.

3.7.4 timestamp #

A producer SHOULD supply the timestamp it captured when the line was produced, not when it submitted it. A producer that batches (§3.8) and omits the field attributes every line in the batch to the moment the collector happened to read it, which discards the timing information the batch was accumulated over.

3.7.5 job_id #

job_id correlates a line to a single execution rather than to a program. A forwarding producer sets it so that the output of one run of a service can be separated from the run before and the run after; a producer with no such notion omits it.

A collector MUST treat it as an opaque 16-byte value. Nothing in this chapter interprets it, and a producer MAY use it for any correlation of its own, provided the value is a GUID.

3.8 Validating a Log Record

Peios / Peios technical reference / PSPU / Observability Interfaces

Every failure on this channel is silent (§3.4). What differs between failures is how much is lost: the whole datagram, one record, or only one field.

3.8.1 The three scopes of failure #

The datagram is discarded when it cannot be resolved into records at all:

  • it is not valid MessagePack
  • it decodes to something that is neither a map nor an array of maps
  • the kernel reported it truncated (§3.6)

One record is discarded, and the others in the same datagram are still processed, when the record itself is unusable:

  • a required field is absent
  • a required field has the wrong type — origin an integer, say
  • origin is the empty string
  • the map contains a duplicate top-level key

One field is ignored, and the record is still stored, when an optional field is unusable:

  • timestamp is not an integer, is negative, or is outside the timestamp domain (§3.5)
  • job_id is not binary, or is binary of a length other than 16

A collector MUST implement all three scopes as stated. In particular it MUST NOT discard a record because an optional field was malformed: a producer with a broken clock or a mangled correlation key still has a log line worth keeping, and the field it got wrong is the field of least value in the record.

3.8.2 Duplicate keys #

A record map carrying the same top-level key twice MUST be discarded.

A collector MUST NOT resolve the duplicate by taking the first or the last. MessagePack decoders differ on which they keep, and the fields here are entirely producer-controlled, so a rule that depended on decoder behaviour would let a producer choose which of two origin values a given collector saw. Discarding is the only answer that is the same everywhere.

3.8.3 Batches #

A batch is validated per record. A malformed record in a batch MUST NOT cost the valid records beside it.

A producer SHOULD batch under sustained load. Batching amortises the syscall over many records, and the ceiling (§3.6) is per datagram, so a batch is also the only way to use the channel's capacity efficiently.

The encoded datagram, batched or not, MUST NOT exceed the ceiling. A producer that batches without bounding the encoded size will eventually build a datagram that is discarded whole — which is the one case where batching loses more than sending singly would have.

3.8.4 What a collector adds #

A collector supplies the boot ID (§3.2) and, when the record omitted timestamp, its own clock reading at receipt. It MUST NOT alter any other field, and MUST store message byte-for-byte as given.

A collector MUST NOT parse message. If the text happens to be JSON, or logfmt, or anything else structured, that is the producer's business: this interface carries lines, and a producer with structured data to record emits an event instead (§3.4).

3.9 The Metric Channel

Peios / Peios technical reference / PSPU / Observability Interfaces

A collector MUST expose an AF_UNIX SOCK_DGRAM socket for metric ingestion, protected by a Security Descriptor as §3.3 requires, separate from the log socket.

Before its first send, a producer MUST enable KACS_SO_PASS_TOKEN on its persistent sending socket. KACS then attaches the producer's effective token to every datagram as KACS_SCM_TOKEN. A collector MUST receive with ancillary space for that token and MUST discard the whole datagram when the token is absent or the data or control message was truncated.

For every valid record, the collector resolves the metric name's publication policy and runs KACS AccessCheck for EVENTD_PUBLISH against the conveyed token. A denied record is discarded silently. Records with other authorized names in the same datagram remain valid. An implementation MAY cache a verdict by stable token identity, token modification identity, metric name and policy generation.

The channel works exactly as the log channel does, for the reasons given there: the 262144-byte portable datagram ceiling, truncated datagrams discarded whole, a receive queue of at most four times the collector's declared ceiling, no backpressure and no notification (§3.6). A collector MUST accept at least the portable ceiling and a portable producer MUST bound its encoded datagrams to it. A producer that may send a datagram longer than its socket's default send capacity SHOULD first raise SO_SNDBUF, as on the log channel (§3.6).

Note

§3.A gives the mainline value and upward-adjustable range for this bound and every other in this chapter.

3.9.1 A sink, not a collector of its own #

The collector is pushed to. It MUST NOT scrape an endpoint, read a kernel interface, or poll anything to obtain metrics; every sample it holds arrived on this socket because a producer sent it.

What gathers the measurements is a separate concern and a separate program. A collection agent that reads system counters and submits them is an ordinary producer here, with no privileged position and no interface of its own.

Note

The push direction follows from the loss model rather than from taste. A pulling collector must reach every producer on a schedule, which makes it responsible for their availability and makes a slow producer the collector's problem. Pushing keeps each producer responsible for its own submissions and keeps a slow or dead producer invisible to everything except the gap it leaves.

3.9.2 Batching #

Batching matters more here than it does for logs. It amortizes both the datagram and conveyed-token costs. A collection sweep produces many samples at once — every CPU core, every disk, every interface — and they share a moment, so a producer SHOULD submit a sweep as one batched datagram rather than as one datagram per sample.

The rules are the log rules: one map or an array of maps, per-record validation, and the encoded datagram bounded by the ceiling (§3.8, §3.12).

3.10 The Metric Data Model

Peios / Peios technical reference / PSPU / Observability Interfaces

A metric is a quantity that varies and is worth watching over time: a utilisation, a queue depth, a running total, a distribution of latencies. Metrics are dense where events and logs are sparse — many measurements of the same thing rather than a record of a thing that happened — and the model reflects that.

A sample is one measurement, of one time series, at one moment. It carries:

  • a name, identifying what is measured
  • a label set, identifying which instance of it
  • a type, fixing how the value is to be read
  • a timestamp
  • a value, whose shape depends on the type

Name and labels together identify the series (§3.13). The type is a property of the series, not of the sample.

3.10.1 Names #

A name MUST match the identifier grammar of §3.19:

[A-Za-z_][A-Za-z0-9_.-]*

A collector MUST discard a record whose name does not, for the same two reasons an origin is constrained (§3.7): names are matched against patterns in which * is the wildcard, and names are what read access is granted on.

Beyond that, naming is convention and a collector MUST NOT enforce any. The conventions in use are a dot-separated hierarchy from general to specific, the unit as the last component, and a cumulative name for a cumulative quantity:

system.cpu.usage
disk.read.bytes
request.duration.seconds
http.requests.total

3.10.2 Labels #

Labels are the dimensions of a measurement: which core, which device, which method. cpu.usage with core="0" and cpu.usage with core="1" are two series, not two samples of one.

Label keys and values MUST be non-empty UTF-8 strings. A key MUST match the identifier grammar above; a value MUST NOT contain = (0x3D) or , (0x2C), which are reserved as delimiters in the collector's canonical representation of a label set (§3.13). A key MUST NOT be repeated within one sample, and MUST NOT be any of the six fixed field names a metric result carries — timestamp, boot_id, name, type, value, overflow — because labels and fixed fields share one flat namespace in a result record (§3.22) and a collision would make the record ambiguous.

A record violating any of these is discarded (§3.12).

Note

Label keys are constrained to the identifier grammar for the reason that matters most in practice: a key that cannot be written in a query cannot be filtered on, grouped by, or granted access to. A label you cannot address is storage you cannot reach.

Label cardinality is the producer's responsibility. Each distinct combination of label values is a distinct series, so labels whose values are unbounded — request identifiers, user-supplied strings, timestamps — produce series without limit, and a collector is required neither to cap them nor to degrade gracefully when they arrive. A producer SHOULD use labels whose value sets are small and known. A dimension that is not bounded belongs in an event payload, where it costs one field, not in a label, where it costs a series.

A collector may bound its total metric storage through retention without limiting series creation. Peios eventd does this by default: it continues accepting new series but removes the oldest samples when the metric store's logical live size exceeds its configured ceiling. That protects the volume; it does not make high-cardinality labels safe, because an active set larger than the series cache can still thrash ingestion.

3.10.3 Types #

The type is fixed when the series is first seen and is immutable. A sample that resolves to an existing series but declares a different type is discarded (§3.12), permanently and without a response to the producer. A producer that changes the type of a metric it already emits has stopped emitting it. The mainline collector counts these conflicts, retains the latest metric name and expected and received types in memory, and may report them through its bounded operator diagnostics (§3.12).

3.10.3.1 Counter #

A value that only increases, and resets to zero when the producer restarts. Used for cumulative quantities: requests served, bytes transmitted, errors encountered.

A counter value MUST be a finite, non-negative binary64 value.

The raw value is rarely what a reader wants; the rate of change is (§3.25). A decrease is read as a restart rather than as a negative change, which is why the type must be declared: the same number sequence means something different for a gauge.

3.10.3.2 Gauge #

A value that may move in either direction. Used for current state: a utilisation, an amount in use, a depth, a temperature.

A gauge value MUST be a finite binary64 value, and MAY be negative.

3.10.3.3 Histogram #

A distribution of observations across buckets the producer chose. Used where the shape matters more than the mean — latencies above all.

A histogram value carries:

  • boundaries: a non-empty array of bucket upper bounds, strictly increasing in the order given
  • counts: one cumulative count per boundary, each being the number of observations less than or equal to that boundary; non-decreasing, and each no greater than the total
  • total_count: the number of observations
  • sum: the finite sum of the observations

Boundaries are part of the series identity (§3.13). A collector MUST NOT sort or reinterpret them; a producer that changes them has started a new series, and SHOULD therefore keep them fixed for the life of a metric.

The final count MAY be less than the total: observations above the highest boundary are the difference between them, and are not otherwise represented. A total of zero is a valid empty sample, in which case every count and the sum MUST be zero.

Note

Observations above the highest boundary are counted but not located. A reader asking for a high percentile of a distribution whose tail overflows gets an explicit overflow result rather than a wrong numeric answer (§3.25), so a producer SHOULD choose a highest boundary above the values it expects to see.

3.10.4 Values are floating point #

Numeric input MAY be a MessagePack integer or a MessagePack float; both are converted to binary64 with round-to-nearest, ties-to-even. Every value a collector stores and every numeric value a query returns is a finite binary64. The null value in an explicit percentile-overflow result is not a numeric metric value (§3.25).

Non-finite values are refused rather than stored: a record whose value converts to NaN or to either infinity is discarded (§3.12). There is no representation for a missing measurement — a producer with nothing to report sends nothing, and the gap is the answer (§3.13).

3.11 Metric Records

Peios / Peios technical reference / PSPU / Observability Interfaces

A metric datagram carries one record, encoded as a MessagePack map, or several, encoded as an array of maps. A collector MUST accept both (§3.9). Each map is one sample of one series.

3.11.1 Fields #

FieldTypeRequiredMeaning
namestringyesThe metric name (§3.10).
labelsmapnoKey-value string pairs. Absent means the series has no labels, which is not the same as a series whose labels are empty — it is the same series.
typestringyesExactly "counter", "gauge" or "histogram", lowercase.
timestampintegernoWhen the measurement was taken, in the timestamp domain (§3.5). Absent means the collector uses its own clock at receipt.
valuevariesyesThe measurement. A number for counter and gauge; a map for histogram.

A collector MUST ignore fields it does not recognise (§3.29).

3.11.2 The histogram value #

For a histogram, value is a map:

FieldTypeMeaning
boundariesarray of numberNon-empty, finite bucket upper bounds, strictly increasing after conversion to binary64, in the order given.
countsarray of integerCumulative count per boundary. Same length as boundaries. Non-decreasing, each no greater than total_count.
total_countintegerNumber of observations.
sumnumberFinite sum of the observations.

Counts and total_count MUST be MessagePack unsigned integers, or non-negative signed integers. boundaries and sum MAY be integers or floats and are converted as §3.10 requires.

3.11.3 type is per record, not per series #

Every record declares its type, including the second and every subsequent sample of a series that already exists.

This is redundant on the wire and deliberately so. A producer holds no state about what a collector already knows, has no way to ask, and MUST NOT be required to establish a series before sampling it: the first sample of a series and the millionth are the same message. The redundancy is what makes a producer stateless, and the cost is one short string per sample.

The collector uses the declaration only on first sight. Afterwards it is a consistency check, and a record that fails it is discarded (§3.10).

3.11.4 Timestamps need not increase #

A collector MUST store a valid sample whose timestamp is older than samples it already holds for that series.

Producers batch, clocks step, and a collection sweep may be submitted out of order or retried. A collector that refused late samples would turn any of those into silent data loss, so it accepts them and defines every ordering it performs over timestamp rather than over arrival (§3.21, §3.25). Two samples of one series MAY share a timestamp; the collector orders them deterministically and a client MUST NOT depend on which comes first.

3.12 Validating a Metric Record

Peios / Peios technical reference / PSPU / Observability Interfaces

Failures on this channel are silent, exactly as on the log channel (§3.4, §3.8). The scopes are the same, with one difference that matters: a metric record has no ignorable field. Every field a metric record carries participates either in the series identity or in the measurement, so there is nothing whose loss leaves a usable record behind. A malformed timestamp costs the log line nothing and costs the sample everything.

3.12.1 The datagram is discarded #

  • it is not valid MessagePack
  • it decodes to something that is neither a map nor an array of maps
  • no KACS sender token arrived
  • the kernel reported its data or ancillary data truncated (§3.9)
  • the collector cannot query the sender token or resolve publication policy

3.12.2 The record is discarded #

A collector MUST discard a record, leaving the rest of its batch untouched, for any of the following.

Structure

  • a required field is absent, or has the wrong type
  • the map contains a duplicate top-level key
  • type is not exactly "counter", "gauge" or "histogram"

Name and labels (§3.10)

  • name is empty or does not match the identifier grammar
  • a label key or value is not a string, or is empty
  • a label key does not match the identifier grammar
  • a label value contains = or ,
  • a label key is repeated within the record
  • a label key is one of timestamp, boot_id, name, type, value, overflow

Timestamp

  • timestamp is present and is not an integer, is negative, or is outside the timestamp domain (§3.5)

Counter and gauge values

  • the value is not a number
  • it converts to a non-finite binary64
  • it is negative and the type is counter

Histogram values

  • the value is not a map, or its map has a duplicate key
  • a field is absent or has the wrong type
  • boundaries is empty
  • a boundary or sum converts to a non-finite binary64
  • counts and boundaries differ in length
  • the converted boundaries are not strictly increasing
  • a count is negative, the counts are not non-decreasing, or a count exceeds total_count
  • total_count is zero and any count or sum is non-zero

Series consistency

  • the record resolves to an existing series whose type differs (§3.10)

Publication policy

  • the conveyed KACS token is denied EVENTD_PUBLISH for name (§3.9)

3.12.3 Why the timestamp rule differs from logs #

On the log channel a malformed timestamp is ignored and the record kept; here it discards the record.

The asymmetry is not an inconsistency. A log line with the wrong time is still the line, and reading it is still worth doing. A sample is a (time, value) pair and nothing else: attaching the collector's receipt time to a measurement taken at an unknown moment does not recover the sample, it fabricates one, and it fabricates one that will be charted next to real ones. Discarding leaves a gap, which is honest (§3.4).

Absence is different from malformation. A record that simply omits timestamp is asserting "now", and the collector's clock is the right answer to that.

3.12.4 Silence, again #

A collector MUST NOT emit an event, write a durable log, or increment anything a query client can observe in response to any failure in this article — with no exception for the series-consistency failure, which is the one that most looks like it deserves one. Local in-memory diagnostic counters and rate-limited standard-error output are permitted by §3.4.

A producer that changes a metric's type is misconfigured, and the misconfiguration is permanent: every sample is discarded for as long as the series exists, and the producer receives no response. Peios eventd increments an in-memory total, retains the latest conflicting metric name and expected and received types, emits at most one coalesced standard-error warning per minute, and includes the total and latest conflict in its SIGQUIT diagnostic dump. None of those signals is part of the query interface or grows in proportion to rejected input.

3.13 Series Identity

Peios / Peios technical reference / PSPU / Observability Interfaces

Two samples belong to the same time series when they agree on:

  • the name, compared exactly, byte for byte; and
  • the label set, compared as an unordered set of key-value pairs, each compared exactly; and
  • for histograms, the bucket boundaries, compared as an ordered sequence of binary64 values.

Nothing else participates. Type does not: a record whose type disagrees resolves to the series and is then discarded for disagreeing (§3.10). Boot ID does not: a series continues across a reboot, and a client that wants one boot's worth filters for it (§3.25). Time does not.

3.13.1 Order does not distinguish a label set #

{core: "0", host: "a"} and {host: "a", core: "0"} are the same series. A collector MUST compare label sets as sets.

To do so it needs a canonical form, and the form in use is the reason label values may not contain = or , (§3.10): pairs are sorted by key in unsigned UTF-8 byte order, each written key=value, and joined with commas. Because neither delimiter can occur inside a key or a value, no escaping is needed and no two distinct label sets can produce the same string.

The byte form itself is the collector's business and this chapter does not require it. What it requires is the property: a label set has exactly one identity, independent of the order the producer wrote it in. The delimiter reservation is stated normatively because it binds the producer, and a producer cannot see the encoding that motivates it.

3.13.2 Absent labels and empty labels #

A record with no labels field, a record with an empty labels map, and a record whose labels were all discarded are the same series: the one with no labels. There is no distinction between "unlabelled" and "labelled with nothing".

3.13.3 Boundaries are identity, not metadata #

Two histogram samples with different boundaries are different series even when name and labels agree.

This is the consequence that surprises producers, and it is unavoidable: cumulative counts against one set of bucket edges cannot be compared with counts against another, so calling them one series would mean computing percentiles across incommensurable distributions. A producer that re-tunes its buckets each collection cycle creates a series each cycle, each holding a single sample and each surviving until retention removes it.

A collector MUST NOT defend against this. It is a producer defect, it is indistinguishable at the interface from legitimately introducing a new metric, and every defence available — capping series, merging near-equal boundary sets, rejecting a second boundary set for a name — would break a correct producer to inconvenience an incorrect one.

3.13.4 Series are created, never announced #

A series comes into existence when its first sample arrives. There is no registration message, no schema, and no way to declare a series in advance or to retire one.

A collector MUST NOT require a series to be known before a sample of it is accepted, and MUST NOT limit how many series exist. A series with no remaining samples ceases to exist when retention removes the last of them; nothing else removes one.

Note

The absence of a cap is a deliberate and load-bearing decision, and it is worth being clear about what it costs. Labels are producer-supplied and are not part of publication authorization (§3.9), so a producer authorized for one metric name can create series beneath that name without limit. Name authorization bounds the namespace, not its label cardinality. Without a byte ceiling, each series persists until the retention window elapses. A collector-level ceiling may remove the oldest samples, and then their orphaned series, earlier; it bounds storage rather than admission and does not change this rule.

3.13.5 Gaps are preserved #

A collector MUST NOT interpolate, backfill, or synthesise a sample that a producer did not send.

A missing sample is a real fact about the system — the producer was down, the datagram was dropped, the sweep was late — and it is a fact the metric is often being watched for. A series with a hole in it is returned with a hole in it, and a client that wants a value across the hole computes one itself.

3.14 The Query Channel

Peios / Peios technical reference / PSPU / Observability Interfaces

A collector MUST expose an AF_UNIX SOCK_STREAM socket for queries, protected by a Security Descriptor as §3.3 requires.

One socket serves all three data types. The mode a query runs in — events, logs or metrics — is determined by parsing the query string (§3.18), never by the transport, so a client needs no connection setup, no mode selection, and no separate endpoint per data type.

3.14.1 One query per connection #

A connection carries exactly one query. A client that wants two concurrent queries opens two connections.

A collector MUST close the connection after the terminal message of a non-streaming query (§3.16), and MUST treat a client disconnect as cancellation of a streaming one.

Note

This is the opposite of the identity-lookup channel in PGSS §2.14, which multiplexes tagged requests over one connection. The reason is the shape of the work rather than a difference in taste: a lookup is small, uniform and answered in one message, so correlating many on one connection saves real cost. A query may run for thirty seconds, may stream indefinitely, and may be cancelled — and every one of those is expressed by the connection itself, with no correlation identifier and no cancellation message to specify.

3.14.2 Identity #

A collector MUST establish the client's identity from the connected socket, before executing anything, by obtaining the peer's token. This is possible here and not on the ingestion channels, and it is the whole reason the query channel is a stream (§3.3).

The token is captured once, at connection time, and is a snapshot. A client whose privileges change while a query runs — and in particular while a streaming query runs, which may be indefinitely — is evaluated throughout against the token it connected with.

If a collector cannot obtain the peer token, it MUST refuse the query. It MUST NOT execute a query for an unidentified caller, and MUST NOT fall back to any other means of identifying one.

3.14.3 No credentials cross this channel #

There is no message with which a client offers a credential and none with which a collector asks for one. Identity is established from the connection and from nothing else.

3.14.4 Concurrency #

A collector MUST bound the number of queries it will execute at once, and MUST reject a query beyond the bound with an error rather than queueing it behind the others.

The streaming bound is the lower of the two and is enforced separately, because a streaming query holds its resources for as long as its client stays connected while an ordinary one holds them for at most a timeout (§3.16).

Note

§3.A gives the mainline value and adjustable range for this bound and every other in this chapter.

Both bounds are global. Because every authenticated caller may connect (§3.3), a collector MUST also bound the queries one caller may have running at once, so that one caller cannot occupy every slot. The caller is the user SID of the token captured at connect. A collector MAY leave SYSTEM's queries uncounted. A query beyond the caller's bound is rejected with an error, like one beyond the global bounds.

Neither bound may affect ingestion: queries and ingestion are separate channels precisely so that exhausting one cannot exhaust the other (§3.3).

3.15 Query Framing

Peios / Peios technical reference / PSPU / Observability Interfaces

Every message in either direction is a length-prefixed MessagePack value:

OffsetSizeFieldValue
04lengthLength of the payload in bytes
4lengthpayloadThe request or response body

length is little-endian, as PSPU §1.2 requires of every integer field in this document. It counts the payload only; the four bytes of the prefix are not included.

There is no magic value and no version field. The channel is a Unix socket at a configured path, so there is no possibility of reaching the wrong service by accident in the way a shared header guards against (PSPU §2.7), and versioning is handled as §3.29 describes.

3.15.1 The two size controls #

A collector declares two independent sizes. The query request ceiling is a hard inbound limit. The query response target is a soft outbound batching target. They are separate because an untrusted client must not be able to force an arbitrarily large allocation merely by declaring a request length, while a record the collector has already accepted must remain possible to return.

Note

§3.A gives the mainline value and adjustable range for both sizes and every other bound in this chapter.

Inbound. A collector MUST refuse a request whose length exceeds the request ceiling. It MUST do so without reading the payload — the point of checking the prefix is to avoid allocating for a request that a malicious or broken client has declared too large — and it MUST send an error response (§3.16) before closing the connection. A collector MUST NOT close on an oversized request silently: a bare close is indistinguishable from a crash, and leaves a client unable to tell that shortening its query is the remedy.

Outbound. A collector SHOULD chunk result records so an ordinary response does not exceed the response target (§3.16). The target is not a ceiling: when one complete record would exceed it, the collector MUST send that record alone in one response rather than split, truncate, skip, or reject it.

3.15.2 Every stored record must remain returnable #

A collector MUST NOT store a record whose encoded result representation cannot fit in the protocol's u32 payload length. This structural framing bound is the only outbound ceiling.

A single result record is never split across messages (§3.16). A collector MUST therefore ensure that its accepted record encodings have a bounded result expansion that fits one frame. It MAY establish that statically from the protocol's record limits; it need not flatten or encode each record on the ingestion path merely to check response size. An implementation MAY encode a large response incrementally after writing its known length; it need not allocate another buffer as large as the record.

The ingestion ceilings (§3.6, §3.9) remain independent configuration. Raising one does not require raising the response target: it merely makes a single-record response above that target more likely. The structural u32 requirement is a property of the wire encodings, not a configurable coupling between those values.

3.15.3 Requests #

A request is a MessagePack map:

FieldTypeRequiredMeaning
querystringyesThe query string (§3.18).

A collector MUST send an error response and close the connection if the payload is not valid MessagePack, is not a map, omits query, gives query a non-string value, or contains a duplicate top-level key. It MUST ignore unrecognised fields that are not duplicates (§3.29).

Unlike the ingestion channels, nothing here is silent. A query client is identified (§3.14), is one of a bounded number, and is asking a question, so telling it what went wrong is neither an amplification vector nor an information leak — with the one exception §3.28 sets out.

3.16 Responses

Peios / Peios technical reference / PSPU / Observability Interfaces

A response is a MessagePack map whose status field names its kind. There are four.

statusCarriesMeaning
"ok"recordsA chunk of result records.
"end"—The non-streaming query is complete.
"watch"—The streaming query's initial result set is complete.
"error"errorThe query failed.

3.16.1 Result messages #

An "ok" message carries records, an array of flat maps (§3.22).

Records are chunked at record boundaries: a successful query sends one or more "ok" messages, ordinarily at or below the response target (§3.15). A collector MUST NOT split one record across two messages. If one complete record exceeds the target, that record occupies an otherwise empty "ok" message by itself. It MUST NOT be truncated, partially sent, skipped, or turned into a query error merely because it exceeds the target.

Each record is self-describing and records in one response MAY carry different sets of keys — event payload fields vary by event type, metric labels vary by series. A client MUST NOT assume a uniform schema across a result set, and MUST NOT infer that a key absent from one record is absent from the data.

A successful query with no matching records sends exactly one "ok" message with an empty records array, then its terminal message. A collector MUST NOT omit it: "no records" and "the query has not yet produced records" are different states and a client must be able to tell them apart.

3.16.2 The two terminal messages #

"end" terminates a non-streaming query. "watch" marks the point in a streaming query where the stored result set ends and live delivery begins (§3.27). A query sends exactly one of them, never both.

Until one has arrived, the query has not succeeded. A collector that fails partway through MUST send "error", and a client that receives "error" before either terminal message MUST discard every "ok" message it received for that query. Partial results are not results: they are an arbitrary prefix of an ordering that was never completed, and a client that kept them would silently under-report.

An "error" after "watch" is different. It terminates the stream, and the records already delivered remain valid — they were complete when they were sent, and the ordering they belonged to had already closed.

3.16.3 Errors #

An "error" message carries error, a human-readable string.

There is no error code and no machine-readable classification. This is a deliberate limit on the interface: an error here is a parse failure, a type mismatch, a timeout, a limit, or a refusal, and a client's response to all of them is the same — show it to whoever wrote the query. A client MUST NOT parse the string, and a collector MAY change the wording of any error at any time.

A collector MUST NOT include in an error message any value the client was not authorized to read (§3.28).

3.16.4 Timeouts #

A collector MUST bound the time a query may take to reach its terminal message.

Note

§3.A gives the mainline value and adjustable range for this bound and every other in this chapter.

The clock starts once the request has been decoded and the caller's token obtained, and it covers everything that follows: parsing, access checks, cross-type pre-computation, execution, merging, aggregation, pagination, projection and transmission. A collector MUST send "end", or for a streaming query "watch", before it expires.

The timeout bounds the initial result set only. Once a streaming query has sent "watch" its watch phase is not time-limited; what bounds it instead is the streaming concurrency limit (§3.14), the distinct-value limit (§3.27), and the client's own ability to keep up (§3.27).

On expiry a collector MUST cancel the query and send "error". Any "ok" messages already sent are discarded by the client under the rule above.

3.16.5 Memory #

A non-aggregating query in the default order (§3.21) needs to hold nothing while it runs: its records can be read in that order and sent as they are read. A collector SHOULD answer it so, and MUST NOT refuse it for the size of its result, however large. A client that wants every record of a large store asks for exactly this.

Other queries must hold something to answer: a sort on another field holds the records it orders, or with TAKE only the best SKIP + TAKE of them, and an aggregation holds its groups. A collector MAY bound the memory queries hold. When a query would exceed the bound, the collector MUST fail it with "error". It MUST NOT truncate the result, drop groups, or answer from part of the data instead.

Note

§3.A gives the mainline bound. The mainline collector applies one bound to every running query together, as it does for concurrency (§3.14), so a query can fail because others hold memory. Retrying it later may succeed.

3.17 Value Encoding

Peios / Peios technical reference / PSPU / Observability Interfaces

Every value in a result record is encoded as follows.

ValueEncoding
IntegerMessagePack integer
FloatMessagePack float64
TimestampMessagePack integer, nanoseconds since the Unix epoch (§3.5)
StringMessagePack string
GUIDMessagePack string, PCDS canonical form
BinaryMessagePack bin
BooleanMessagePack boolean
ArrayMessagePack array
Absent or nullMessagePack nil

A GUID is rendered as a string rather than as sixteen bytes because a result record is read by people as often as by programs, and a raw GUID in a terminal is unreadable. The canonical form is lowercase 8-4-4-4-12 hexadecimal within braces, as PCDS defines it. A query comparing against a GUID accepts either braced or unbraced input, and compares case-insensitively (§3.19); a result always uses the canonical form.

3.17.1 Maps do not appear as values #

An event payload is a MessagePack map, and result records are flat (§3.22). A map in a stored payload is therefore a container to be flattened, not a value to be emitted: its entries become top-level keys of the record, joined by dots, and the map itself never appears.

Arrays are different. An array is emitted as an array value at its flattened path, and a collector MUST NOT traverse into it. Maps nested inside an array are preserved as that array's contents, unflattened and unqueryable.

The asymmetry is deliberate. A map has keys, so its entries have names that can be addressed, granted access to and indexed. An array has positions, and a path like hops.3.address would mean something different in every record — so an array is carried across whole and treated as one value.

Being one value is also why == against an array field compares the whole array. The question usually being asked of one — does it hold this element — is HAS (§3.20).

Binary values in a payload stay binary. A collector MUST NOT render bin as a string, in either direction.

3.17.2 Missing and null are the same value #

A field absent from an event payload or from a metric's label set encodes as nil, exactly as an explicitly null one does, and the two are indistinguishable in a result record.

This is consistent throughout: they compare equal, they sort together, and they group together (§3.20, §3.21). A collector MUST NOT distinguish them anywhere in the query surface, and a client MUST NOT attempt to.

3.18 The Query Language

Peios / Peios technical reference / PSPU / Observability Interfaces

A query is one string. It names a mode, narrows to some data, and says what to do with it.

EVENTS kacs.* SINCE 1h ago WHERE emitter.process.guid == "550e8400-e29b-41d4-a716-446655440000" TAKE 100
LOGS FROM loregd ERROR ONLY CONTAINING "connection refused" SINCE 1d ago
METRIC cpu.usage[core="0"] SINCE 1h ago AVG_OVER 5m

3.18.1 Three modes #

The first token selects the mode, and a collector MUST reject a query whose first token is not one of them.

  • EVENTS searches structured event records, primarily by event type (§3.23).
  • LOGS searches log output, primarily by origin (§3.24).
  • METRIC evaluates measurements, primarily by name and labels (§3.25).

Events and logs are record-oriented: collections you search, returning the records that matched. Metrics are value-oriented: measurements you evaluate, returning numbers computed from samples. The modes differ because the data differs, and forcing all three through one shape would serve none of them.

3.18.2 The primary selector #

Immediately after the mode comes an optional primary selector, specific to the mode: an event type pattern, FROM with one or more log origins, or a metric name with an optional label selector. It narrows the data before anything else runs.

A primary selector MUST NOT be repeated unless its mode defines a list form — LOGS FROM a, b is one selector naming two origins, not two selectors.

3.18.3 Clauses #

Everything after the primary selector is a clause, and clauses MAY appear in any order. EVENTS SINCE 1h ago TAKE 10 and EVENTS TAKE 10 SINCE 1h ago are the same query.

Order of appearance never affects meaning. Execution follows the fixed sequence below regardless of how the string was written, so a collector MUST NOT derive semantics from clause position.

These clauses work identically in all three modes:

ClauseMeaning
SINCE tLower time bound, inclusive.
UNTIL tUpper time bound, exclusive. Defaults to the evaluation time.
WHERE pFilter by a predicate (§3.20).
WHERE METRIC … / WHERE EVENT … / WHERE LOG …Filter by a condition on another data type (§3.26).
SORT f [ASC|DESC], …Order the results (§3.21).
TAKE nReturn at most n.
SKIP nDiscard the first n after ordering.
STREAMDeliver matching records as they arrive (§3.27).

3.18.4 Execution order #

Whatever order the clauses were written in, a collector MUST evaluate them in this sequence:

Phase
1Cross-type conditions, producing time ranges (§3.26)
2The primary selector
3Access control on the primary and cross-type sources (§3.28)
4SINCE and UNTIL
5WHERE, including the ranges from phase 1
6ERROR ONLY and CONTAINING, as WHERE predicates (§3.24)
7Metric transforms (§3.25)
8GROUP
9COUNT BY, TOP N BY, DISTINCT, and the aggregation functions
10Metric window aggregations (§3.25)
11SORT (§3.21)
12SKIP and TAKE
13SELECT (§3.22)

Two positions in that list are load-bearing.

Access control is third, before every filter, aggregate, sort and limit. It is part of the query's logical execution and not a filter applied to the output (§3.28).

SELECT is last. It shapes the output and nothing else; a field it omits is still available to every earlier phase (§3.22).

3.18.5 Repetition #

A clause MUST appear at most once, and a collector MUST reject a repeat as a parse error, with two exceptions:

  • WHERE is repeatable. Multiple WHERE clauses are combined with AND, each treated as a parenthesised group: WHERE a == 1 OR b == 2 followed by WHERE c == 3 means (a == 1 OR b == 2) AND c == 3.
  • SELECT is repeatable where it is valid at all, and is additive: SELECT event.time SELECT event.type names both fields.

Both exist so that a query can be built up in pieces — by a tool appending a filter, or by a person adding one to a query they already have — without rewriting what is already there.

3.18.6 Counts #

TAKE, SKIP and the N of TOP N BY are unsigned decimal integers that MUST fit in 64 bits. A negative, hexadecimal, floating-point or missing count is a parse error.

SKIP defaults to 0. TAKE omitted means no limit. TAKE 0 and TOP 0 BY are valid, and return no records after every earlier phase has run — which is not the same as not running the query, because a TOP 0 BY still counts and a TAKE 0 still enforces access control.

Note

A non-aggregating query without TAKE has no implicit limit, and a broad one over a long range may match millions of records. The timeout (§3.16) is the only backstop.

3.18.7 Case #

Keywords are matched case-insensitively, using ASCII case folding, in grammar positions where a keyword is expected. This document writes them in uppercase by convention only.

Identifiers are case-sensitive, except where the language defines a named alias for a value (§3.23).

A word spelled like a keyword MAY be used where the grammar expects an identifier or a value: LOGS FROM stream selects the origin stream, while LOGS STREAM enables streaming. A collector MUST resolve the ambiguity by grammar position and MUST NOT reserve keywords globally.

3.19 Lexical Rules and Literals

Peios / Peios technical reference / PSPU / Observability Interfaces

A query string is UTF-8. Whitespace separates tokens outside quoted strings and is otherwise insignificant.

3.19.1 Identifiers #

An unquoted identifier is ASCII and matches:

[A-Za-z_][A-Za-z0-9_.-]*

Identifiers name fields, payload paths, metric names, label keys, event type patterns, log origins and value aliases. ., _ and - are permitted inside one; /, :, whitespace, quotes, brackets, parentheses, commas and the comparison operators are not.

This grammar is the same one that constrains a metric name and a metric label key at ingestion (§3.10), which is what makes every stored metric identifier writable here without quoting. A log origin is the one exception: it is constrained to a near relative of this grammar (§3.7) that also admits a leading digit and a producer component such as /ExecStartPre[0], and an origin using either is written as a quoted string.

A value that cannot be written as an identifier MUST be written as a quoted string. Quoted forms are accepted anywhere an identifier is — they are never required for a conforming identifier, but a pattern may need one, and a collector holding identifiers stored under an earlier revision must still be able to select them.

3.19.2 Strings #

A string literal is double-quoted UTF-8. The escapes are \", \\, \n, \r, \t, and \uXXXX for a scalar value in U+0000 to U+FFFF written as four hexadecimal digits.

A collector MUST reject any other backslash escape as a parse error, and MUST reject \uXXXX naming a surrogate code point in U+D800 to U+DFFF. Surrogate pairs are not decoded: a character outside the basic multilingual plane is written directly as UTF-8, not as two escapes.

Note

Refusing surrogates rather than pairing them means there is exactly one way to write every character, and no way to write a sequence that is not valid UTF-8. A language that accepts lone surrogates has to decide what they mean when compared against stored text that cannot contain them.

3.19.3 Binary #

A binary literal is a lowercase x, a double quote, an even number of hexadecimal digits, and a closing quote:

WHERE target_sid == x"010500000000000515000000"

Hexadecimal digits inside are case-insensitive. x"" is valid and is the empty byte string. Whitespace inside the payload, an odd digit count, and any non-hexadecimal character are parse errors.

A binary literal compares only against MessagePack bin values. A collector MUST NOT coerce one to a string or a string to one: x"6162" and "ab" are different values and never compare equal (§3.20).

3.19.4 Integers #

An integer literal is decimal or hexadecimal.

WHERE emitter.class == 2
WHERE access.granted == 0x1F01FF

A decimal integer MAY carry a leading -, in which case it MUST fit in signed 64 bits; without one it MUST fit in unsigned 64 bits. A hexadecimal integer is 0x followed by one or more digits, is always non-negative, and MUST fit in unsigned 64 bits. A leading + is not valid. An out-of-range literal is a parse error.

3.19.5 Floats #

A float literal is a finite decimal number with an optional leading - and either a fractional part or an exponent: 42.0, 0.001, 1e6, -1.25e-3. A token that looks like an integer, such as 42, is an integer literal and not a float.

Float literals are binary64 and MUST be finite. NaN, Infinity, -Infinity and any literal that overflows to infinity are parse errors. A leading + is not valid.

3.19.6 Booleans and null #

true and false are matched case-insensitively with ASCII folding.

NULL, likewise folded, is valid only in IS NULL and IS NOT NULL. A collector MUST reject field == NULL and field != NULL as parse errors rather than evaluating them.

Note

Equality against null is refused rather than defined because every definition of it is a trap. Under three-valued logic it is neither true nor false, which no other operator here does; under two-valued logic it silently disagrees with SQL. IS NULL says what was meant and cannot be misread.

3.19.7 Durations #

A duration is an unsigned decimal integer followed immediately by s, m, h or d — seconds, minutes, hours or days. A zero duration is a parse error.

3.19.8 Times #

LiteralMeaning
<duration> agoThat duration before the evaluation time.
<duration> henceThat duration after it.
todayMidnight of the current day, UTC.
yesterdayMidnight of the previous day, UTC.
YYYY-MM-DDMidnight of that date, UTC.
YYYY-MM-DDTHH:MM:SSThat instant, UTC.

Absolute literals are a fixed UTC subset. Components MUST be zero-padded exactly as shown, the date MUST be a valid Gregorian date, hours are 00–23, minutes and seconds 00–59. Leap seconds are not accepted. Timezone suffixes and fractional seconds are not part of this revision and MUST produce a parse error.

3.19.9 The evaluation time #

A collector MUST capture the evaluation time once, before execution begins, and MUST use that one reading for every ago, every hence, and for an omitted UNTIL, throughout the query — including throughout the watch phase of a streaming query.

A query that read the clock more than once could produce a range whose end preceded its start, or a window that grew while it was being scanned. One reading makes the effective query range a fixed interval for the life of the query.

SINCE is inclusive, UNTIL is exclusive: the effective query range is [SINCE, UNTIL). If SINCE is greater than or equal to UNTIL the query returns no records — which is a successful query with an empty result (§3.16), not an error. A time literal that evaluates outside the timestamp domain MUST produce an error (§3.5).

3.20 Comparison and Logic

Peios / Peios technical reference / PSPU / Observability Interfaces

3.20.1 Operators #

OperatorMeaningOperand types
==Equalany
!=Not equalany
> >= < <=Orderinginteger, float, timestamp
STARTS_WITHPrefixstring
ENDS_WITHSuffixstring
CONTAINSSubstringstring
HASArray contains an elementarray
INMember of a setany
NOT_INNot a memberany
IS NULLAbsent or nullany
IS NOT NULLPresent and not nullany

IN and NOT_IN take a non-empty parenthesised, comma-separated list of literals. An empty list is a parse error.

WHERE origin IN ("loregd", "peinit")
WHERE emitter.class NOT_IN (kacs, lcs)

= is not a comparison operator and MUST produce a parse error, with one exception: inside a metric label selector, where = and == are both equality (§3.25).

3.20.2 HAS asks about an array #

HAS is true when the field holds an array one of whose elements equals the value, compared as == compares it, so a binary literal matches a binary element exactly and a string element folds case.

EVENTS WHERE subject.token.groups HAS x"010200000000000520000000200200"

A payload field MAY hold an array — a token's group SIDs are the canonical example — and == against an array is whole-value equality: an array equals only an array of the same length whose elements are equal in order. That is what an operand of a different type compares against, so == between an array field and a single element is a type mismatch and evaluates false, exactly as §3.20's "types do not coerce" says. HAS is the operator that asks the other question.

HAS applied to a field that is not an array evaluates false for that record, for the same reason an ordering operator applied to a non-numeric value does: a payload field's type varies from record to record. Elements are compared one level deep — an element that is itself an array is compared whole, not searched.

HAS is not a set operator and MUST NOT be confused with IN. IN asks whether a scalar field equals one of several literals; HAS asks whether one array field holds one literal. CONTAINS is substring matching on a string and has nothing to do with either.

A collector MAY evaluate HAS by scanning: array containment need not be indexable, and answering correctly without an index is required where answering quickly is not.

3.20.3 Strings fold case #

Every string comparison — ==, !=, STARTS_WITH, ENDS_WITH, CONTAINS, HAS, IN, NOT_IN — is case-insensitive, using ASCII-only folding: bytes A–Z compare equal to a–z, and every non-ASCII byte compares exactly.

This applies uniformly: to event header fields, to payload fields, to log messages, to metric label values, and to the pattern matching of primary selectors. Integers, floats, GUIDs, timestamps and binary values are unaffected.

Note

Folding is ASCII-only rather than Unicode because full case folding is locale-dependent, version-dependent and expensive, and because the data it would be applied to — identifiers, service names, event types — is ASCII by construction (§3.19). A collector that folded Turkish dotless ı would have to fold it the same way as every other collector, for every Unicode version, forever.

3.20.4 Numbers compare mathematically #

Integers and floats compare by mathematical value, not by casting both to one storage type.

An integer equals a finite float only when the float represents exactly that value. Ordering between an integer and a float MUST be exact, including for integers outside the range binary64 can represent exactly. A collector MUST NOT resolve 9007199254740993 > 9007199254740992.0 by converting the left operand to a float, which would make it false.

3.20.4.1 NaN #

An event payload may carry a NaN (§3.5), though a literal never can (§3.19). Every NaN is one value: it equals every other NaN and nothing else, so x == 5 is false and x != 5 is true for a record whose x is a NaN.

An ordering operator with a NaN on either side evaluates false, as IEEE 754 has it. x < 5 and x >= 5 are both false for that record. Sorting and grouping still need a place for a NaN, and §3.21 gives it one.

3.20.5 Types do not coerce #

Values of different non-numeric types are never equal. The string "1" is not the integer 1, and != between them is true.

An ordering operator applied to a field whose runtime value is non-numeric evaluates false for that record — not an error, because a payload field's type varies from record to record and a query cannot know in advance.

An ordering operator applied to a known fixed field whose declared type cannot be ordered is different: a collector MUST reject the query during parsing or planning rather than executing a predicate that can never match. WHERE message > 5 is a mistake the collector can see, and returning zero records for it would be a wrong answer that looks like a right one.

Binary values compare by exact byte equality under ==, !=, HAS, IN and NOT_IN. Ordering is not defined for binary values, and a predicate applying an ordering operator to a binary literal MUST produce a parse error.

3.20.6 Absent fields #

A field absent from an event payload or from a metric's label set resolves to null (§3.17).

Every comparison against null evaluates false, except IS NULL, which is true, and IS NOT NULL, which is false. In particular WHERE field != "x" does not match records lacking the field: a record with no opinion is not a record with a different opinion.

3.20.7 Combining predicates #

Predicates within one WHERE combine with AND and OR. AND binds tighter than OR. Parentheses override.

Multiple WHERE clauses combine with AND, each parenthesised as a group (§3.18).

There is no NOT. Negation is written with the negative operators — !=, NOT_IN, IS NOT NULL — and a collector MUST reject NOT as a parse error rather than silently treating it as an identifier.

3.21 Ordering, Grouping and Distinct

Peios / Peios technical reference / PSPU / Observability Interfaces

Two rules govern this article, and both exist for the same reason.

Ordering MUST be total and deterministic. For a fixed set of stored records, one query MUST produce one order. Without that, SKIP and TAKE are meaningless: a client paging through results would see records twice and never see others, and would have no way to tell.

Equality here MUST be the query language's, not the storage engine's. A collector that grouped by whatever its database considers equal would group differently depending on how it was built.

3.21.1 SORT #

SORT orders by one or more fields. Each defaults to ascending; ASC may be written, DESC reverses that field.

SORT timestamp DESC
SORT origin ASC, timestamp DESC

If the named fields do not uniquely order two records, a collector MUST append internal tiebreakers until the order is total. The tiebreakers are not query-language fields, are never emitted in a result record, and a client MUST NOT depend on their identity — only on their effect, which is that the order is stable.

When no SORT is present:

  • Events and logs are ordered by timestamp descending, most recent first — the order a person reading a log wants.
  • Metrics are ordered by timestamp ascending, the order a chart wants.

3.21.2 Value ordering #

SORT uses the query language's ordering, not the storage engine's. Missing fields and explicit nulls are equivalent. Ascending order sorts by type first, in this order:

  1. Null
  2. Boolean, false before true
  3. Numeric, integers and floats compared mathematically (§3.20)
  4. String and GUID, ASCII-folded
  5. Binary, unsigned lexicographic
  6. Array

DESC reverses the whole ordering, type order included.

A NaN is numeric and sorts after every other number, the infinities included. Every NaN is one value, so NaNs tie with each other and fall to the tiebreakers. Without a place for it the order would not be total: a NaN is neither less than, greater than nor equal to any number under IEEE 754, and a sort given such a comparison may produce any order at all, or fail.

Strings and GUIDs compare with the same ASCII folding as predicates. Two strings equal under folding are ordered by their original UTF-8 bytes, so that folding never costs totality. Binary values compare as unsigned bytes. Arrays compare by their canonical MessagePack encoding (§3.5).

Maps do not appear as result values (§3.17) and MUST NOT appear as sort keys.

3.21.3 Grouping #

COUNT BY, TOP N BY, GROUP and DISTINCT use query-language equality:

  • missing and null are one group
  • integers and floats that are numerically equal are one group
  • every NaN is one group
  • strings and GUIDs group under ASCII folding
  • binary values group by exact bytes

3.21.4 The canonical representative #

When a group's members are equal under those rules but not byte-identical — "Loregd" and "loregd", or 1 and 1.0 — the value emitted for the group MUST be its canonical representative:

GroupRepresentative
Nullnil
Booleanthe boolean
Numerican integer if every contributing value was an integer; otherwise a float64
String or GUIDthe smallest original UTF-8 byte sequence among the members
Binarythe exact value
Arraythe member with the smallest canonical MessagePack encoding

Choosing the smallest rather than the first makes the representative a property of the set, independent of the order records were read in — which matters because a collector may read them from several places at once and merge (eventd TRMP §6.4).

3.21.5 Ordering of aggregates #

COUNT BY and GROUP … COUNT results are ordered by count descending. Ties are broken by the group key under the value ordering above — for a GROUP of several fields, each key in turn in the order the fields are named — then by the representative's encoded bytes.

GROUP … SUM, AVG, MIN and MAX results MUST be ordered by their group keys ascending under the value ordering above, comparing each key in the order the fields are named. This order applies before SKIP and TAKE. An explicit SORT overrides it; groups tied under that sort retain their group-key order.

TOP N BY is exactly COUNT BY with TAKE N applied after that ordering.

DISTINCT results are ordered by the distinct value under the value ordering, unless an explicit SORT overrides it.

3.22 Fields and Results

Peios / Peios technical reference / PSPU / Observability Interfaces

Every result record is a flat MessagePack map. There is no nesting in a result, in any mode.

Flatness is what makes one set of rules — for access control, for ordering, for grouping, for projection — apply uniformly to a header field, a payload field and a metric label alike. A nested result would need a path language, and a path language would need to be reproduced identically by every SD author and every client.

3.22.1 Event fields #

These names resolve to header fields, by the mapping of PGSS §6.4:

event.time, event.sequence, event.cpu, event.type, event.boot.guid, emitter.class, emitter.token.guid, emitter.true-token.guid, emitter.process.guid

Every other name resolves to a payload field.

3.22.1.1 Header names are reserved #

Header field names are reserved in the query language and in result maps. If a payload carries a value whose flattened path is a header field's name, the header wins.

The colliding payload value is stored unchanged, and is retrievable as part of the raw payload by whatever holds it, but it is not exposed through field resolution, SELECT, WHERE, aggregation, access control or result maps. If the payload carries a map at a header field's path, the map's entire subtree is suppressed in the same way.

Only the paths themselves are reserved. A payload field beside them, such as emitter.process.pid, resolves normally. An emitter MUST NOT write a value at a header path (PGSS §6.4).

Note

The header must win because header fields are the ones the kernel stamped and an emitter could not influence (PSPK §2). If a payload value could shadow emitter.process.guid, an emitter could choose what its own events appeared to come from, and every query filtering on identity would be answerable by the party being investigated.

3.22.1.2 Flattening #

Payload maps are flattened recursively, path segments joined with .: a payload {source: {name: "x"}} exposes the field source.name.

Each map key on a queryable path MUST be a MessagePack string matching:

[A-Za-z_][A-Za-z0-9_-]*

Note that . is not permitted in a segment, though it is permitted in an identifier generally (§3.19) — a key containing a dot could not be distinguished from a path through two maps.

A key that is not a string, contains ., or does not match the grammar is stored unchanged and is not queryable: it does not resolve, does not appear in a result map, and has no field identity for access control. An empty map produces no field at all.

Maps are containers; every non-map value, arrays included, is emitted at its flattened path (§3.17).

If two payload entries flatten to the same path, the first in MessagePack map order wins and later duplicates are suppressed.

3.22.2 Log fields #

timestamp, origin, is_error, message, boot_id, job_id

The set is closed. There are no payload fields and no flattening, and a collector MUST reject any other log field name as a parse error rather than resolving it to null. A log record has a fixed shape, so a name outside it is a mistake the collector can see — unlike an event payload field, which may legitimately be absent from a given record.

is_error is a boolean in the query language, and compares against true/false or against 1/0.

3.22.3 Metric fields #

timestamp, boot_id, name, type, value, overflow

Every other name resolves to a label. Ingestion refuses labels colliding with these six (§3.10), so the flat namespace is unambiguous by construction rather than by a precedence rule.

type is the series type as a string: "counter", "gauge" or "histogram".

overflow is an output-only boolean marker. It is present and true only when a histogram percentile cannot be located below the series' highest boundary (§3.25); otherwise it is absent. Predicates and label selectors therefore see it as null, like any other output not yet computed.

3.22.3.1 Types belong to the query mode #

Header aliases and GUID literal forms apply only where a name is a header in that query mode. A metric label named emitter.class remains a string label: "kacs" is not the event-header class number. Likewise, a metric label named job_id or an event payload field named boot_id does not acquire the log header's GUID type. Ordinary string comparison rules apply, including to bracket selectors and cross-type metric selectors. The actual GUID headers still accept braced and unbraced input (§3.17).

3.22.4 What a record contains #

Event records carry the header fields plus every non-suppressed flattened payload field, as top-level keys.

Log records carry the log fields.

Raw metric sample records carry timestamp, boot_id, name, type, value, and the series' labels as top-level keys. A percentile-overflow result additionally carries overflow.

Aggregated metric results carry name, type and value, plus labels when the result belongs to one label set. They carry boot_id only when the query restricted the samples to exactly one boot by a boot_id equality predicate, in which case the value is that boot ID; a result that could span boots omits it rather than picking one. An aggregate tainted by a percentile overflow additionally carries overflow (§3.25).

Aggregation results in event and log mode carry the group key fields and the aggregate output, with the fixed schemas of §3.23.

3.22.5 SELECT #

SELECT narrows a result record to the named fields. It is valid only for non-aggregating event and log queries.

A collector MUST reject SELECT combined with COUNT BY, TOP N BY, DISTINCT or GROUP, and MUST reject it in metric mode: all of those have fixed output schemas, and a clause that reshapes a fixed schema is a contradiction rather than a refinement.

SELECT is applied last, after every other phase (§3.23). It controls the shape of the output and nothing else: a field not selected is still available to WHERE, to SORT, and to grouping. Narrowing what is displayed MUST NOT narrow what is filtered on.

3.23 Event Queries

Peios / Peios technical reference / PSPU / Observability Interfaces

EVENTS [type_pattern] [clauses…]

3.23.1 The type pattern #

The primary selector is an optional event type pattern, placed immediately after EVENTS.

EVENTS kacs.audit.access.checked   -- exactly that type
EVENTS kacs.*                      -- every type beginning "kacs."
EVENTS *.rejected                  -- every type ending ".rejected"
EVENTS kacs.*.changed              -- kacs.audit.descriptor.changed, kacs.caap.policy.changed, …
EVENTS                             -- every type

* is the only metacharacter, and matches zero or more of any character, dots included. ?, [ and { have no special meaning and a collector MUST NOT treat them as any. Matching folds case, like every string comparison (§3.20).

A pattern with no * is exactly WHERE event.type == "…". A pattern whose only * is trailing is exactly WHERE event.type STARTS_WITH "…". Anything else is a glob.

3.23.2 Origin class aliases #

emitter.class, the origin class, accepts named aliases as well as its integer values:

AliasValue
userspace0
kmes1
kacs2
lcs3
EVENTS WHERE emitter.class == kacs SINCE 1h ago

These are the only aliased values in the language. A collector MUST accept both forms and MUST treat them as identical.

3.23.3 Aggregation #

Grouping equality, canonical representatives and tie ordering are defined in §3.21. Every aggregation below has a fixed output schema, and rejects SELECT (§3.22).

3.23.3.1 COUNT BY #

Counts records grouped by one field, ordered by count descending.

EVENTS SINCE 24h ago COUNT BY event.type

Output: {<field>: representative, count: <unsigned integer>}.

3.23.3.2 TOP N BY #

COUNT BY with a limit — the N most frequent values.

EVENTS SINCE 1h ago TOP 10 BY emitter.process.guid

Output: the COUNT BY schema.

3.23.3.3 DISTINCT #

The distinct values of one field.

EVENTS SINCE 24h ago DISTINCT event.type

Output: {<field>: representative}.

3.23.3.4 GROUP #

Groups by one or more fields, followed by an aggregation function: COUNT, or SUM, AVG, MIN, MAX with a field argument.

EVENTS SINCE 1h ago GROUP emitter.class COUNT
EVENTS SINCE 1h ago GROUP emitter.class, event.type COUNT
EVENTS SINCE 1h ago GROUP event.type AVG operation.duration

Output, for GROUP a, b:

QueryRecord
COUNT{a, b, count}
SUM x{a, b, sum}
AVG x{a, b, avg}
MIN x{a, b, min}
MAX x{a, b, max}

Group-key fields carry canonical representatives (§3.21).

3.23.3.5 What is aggregated #

For SUM, AVG, MIN and MAX, records whose field is null or non-numeric are excluded from the aggregate — not treated as zero. COUNT counts every record regardless. If no record in a group contributes a numeric value, the group's aggregate is null and the group is still present, because COUNT of it is still meaningful.

3.23.3.6 Result types #

  • COUNT returns an unsigned integer.
  • SUM over integers returns an integer when the exact mathematical sum fits in signed or unsigned 64 bits. If it does not, or if any input was a float, it returns a float64. If that would be non-finite, the query MUST fail with an error rather than returning an infinity.
  • AVG returns a float64 whenever at least one numeric value contributed.
  • MIN and MAX return the winning value itself, under exact numeric comparison. When an integer and a float tie, the integer wins.

3.23.4 Ordering #

Without SORT, results are ordered by event.time descending, ties broken as §3.21 requires.

3.23.5 INDEX #

EVENTS INDEX target_sid

INDEX asks the collector to prioritise a field for query acceleration immediately, rather than waiting for it to be observed often enough to be prioritised automatically. It exists for incident response, where the field that suddenly matters has never been queried before.

INDEX is an administrative operation, not a query. It returns no records. A collector MUST check the caller's token against a Security Descriptor governing administration of the collector — one distinct from the read-path descriptors of §3.28 — and MUST refuse a caller that does not hold it. A collector without such a descriptor MUST refuse INDEX outright.

A collector MAY treat INDEX as advisory and MAY decline the request, shed the acceleration later, or do nothing at all. It is a hint about priority; the accelerations a collector maintains are its own business, and a conforming collector that maintains none accepts INDEX and has nothing to do.

There is no command to undo it, because there is nothing to undo: a collector reconsiders its own accelerations continuously and the hint decays with disuse.

Note

The right to issue INDEX MUST NOT be the read right. Accelerating a field costs write throughput on every record thereafter, so INDEX is a way for a caller to degrade the system for everyone, and a caller permitted only to read data has not been permitted to do that.

3.24 Log Queries

Peios / Peios technical reference / PSPU / Observability Interfaces

LOGS [FROM origin[, origin…]] [ERROR ONLY] [CONTAINING "text"] [clauses…]

Log mode has three primary selectors rather than one, all optional and all combinable. Each is sugar for a WHERE predicate, and each exists because it is the thing a person actually types.

3.24.1 FROM #

Selects by origin. Several may be listed, comma-separated.

LOGS FROM loregd
LOGS FROM loregd, peinit
LOGS

FROM is exactly WHERE origin == "…" for one origin and WHERE origin IN ("…", "…") for several.

Origins are written as identifiers (§3.19) or as quoted strings. An origin that names a producer within a service, such as jellyfin/HealthCheck or jobs/<guid>, is not an identifier and is written quoted (§3.7):

LOGS FROM "jellyfin/ExecStartPre[0]"

3.24.2 ERROR ONLY #

Selects lines that came from standard error.

LOGS ERROR ONLY
LOGS FROM loregd SINCE 1h ago ERROR ONLY

It is exactly WHERE is_error == true, and like every clause it may appear anywhere after LOGS without changing the meaning (§3.18).

3.24.3 CONTAINING #

Selects lines whose message contains the given text — a substring match, folding case like every string comparison (§3.20).

LOGS CONTAINING "connection refused"
LOGS FROM loregd CONTAINING "failed to open"

It is exactly WHERE message CONTAINS "…".

CONTAINING is a log-specific keyword because searching text is the primary operation on log data, and the primary operation deserves the shortest spelling. It is a substring scan, not an indexed text search: a collector MUST NOT restrict what it matches, and combining it with SINCE is what keeps it affordable.

3.24.4 Projection and aggregation #

SELECT narrows non-aggregating results to named log fields, and is additive across clauses (§3.22).

COUNT BY, TOP N BY, DISTINCT and GROUP work exactly as in event mode (§3.23), with the same fixed output schemas, the same result types, and the same prohibition on combining them with SELECT.

LOGS SINCE 1h ago COUNT BY origin
LOGS SINCE 1h ago TOP 5 BY origin

3.24.5 Ordering #

Without SORT, results are ordered by timestamp descending, ties broken as §3.21 requires.

3.24.6 No payload fields #

Log mode has a closed field set (§3.22). A collector MUST reject an unknown log field name as a parse error, in a WHERE, a SORT, a SELECT or a grouping clause alike.

This differs from event mode, where an unknown name is a payload field that resolves to null. The difference is that a log record's shape is fixed and known: a name outside it cannot be a field that this record happens to lack, so treating it as null would answer a question the client did not ask.

3.25 Metric Queries

Peios / Peios technical reference / PSPU / Observability Interfaces

METRIC name[label_selector] [transform] [aggregation] [clauses…]

Metric mode evaluates rather than searches. A collector MUST reject SELECT in metric mode: the result schemas are fixed (§3.22).

3.25.1 Selecting series #

The primary selector is a metric name, optionally followed by a label selector in brackets. The name supports * with the same glob semantics as an event type pattern (§3.23).

METRIC cpu.usage
METRIC cpu.*

The brackets — present, absent, or present and empty — decide how multiple matching series are handled, and this is the distinction that governs the rest of the mode.

No brackets — aggregate. Every matching series is combined into one result.

METRIC cpu.usage                    -- average across all cores
METRIC cpu.usage MAX                -- maximum across all cores

Empty brackets — break out. Each series is returned separately.

METRIC cpu.usage[]                  -- latest value per core
METRIC cpu.usage[] SINCE 1h ago     -- a time series per core

Filled brackets — select. Only series matching the label predicates.

METRIC cpu.usage[core="0"]
METRIC cpu.usage[core="0", host="srv1"]
METRIC disk.usage[device STARTS_WITH "sd"]

Label predicates are comma-separated and combined with AND. They use the operators of §3.20, and within a label selector = is accepted as equality alongside ==. Label keys are identifiers; values are identifiers or quoted strings. An absent label resolves to null, so [device IS NULL] selects the series that carry no device label.

3.25.2 Homogeneity #

After the name, the label selector, WHERE predicates and access filtering have been applied, the remaining series MUST be of one type. A collector MUST reject a selection spanning more than one type at execution time, with an error asking for a narrower name or an explicit WHERE type == ….

A selection resolving to zero series returns no records — a successful query with an empty result, not an error.

The rule exists because every function below is defined on one type. A selection mixing counters and gauges has no meaningful rate, and a selection mixing either with histograms has no meaningful value at all.

3.25.3 Function stages #

Function keywords execute in fixed stages regardless of where they were written:

  1. Transform — RATE, DELTA, P50, P95 or P99. Operates within each series independently and produces scalars. At most one per query.
  2. Terminal aggregation — either a scalar aggregation (AVG, MIN, MAX, SUM) or a window aggregation (AVG_OVER, MIN_OVER, MAX_OVER, SUM_OVER). At most one per query; specifying both a scalar and a window aggregation is a parse error.

The pipeline ordinarily operates on scalars throughout. Counter and gauge samples are already scalar; a histogram sample is not scalar until a percentile function has been applied. The sole exceptional result is a tagged percentile overflow, whose propagation is defined below. A collector MUST therefore reject, at execution time when the type is known, a query that resolves to a histogram series without a percentile function, or that applies RATE, DELTA, or any scalar or window aggregation directly to one.

Every numeric output is a finite binary64. If any computation would produce NaN or an infinity, the query MUST fail with an error rather than returning it. The null in a tagged percentile-overflow result is not a numeric output.

3.25.4 Transforms #

3.25.4.1 RATE and DELTA #

DELTA is the change between consecutive samples; RATE is that change per second. Both apply only to counter series, and a collector MUST reject them on a gauge or histogram at execution time.

Both use the same pair construction. Samples of one series are taken in ascending timestamp order, with a deterministic tiebreaker among samples sharing a timestamp. Each consecutive pair (s1, s2) whose s2 falls inside the effective query range, and where s2 is later than s1, produces one scalar at s2's timestamp. The immediately preceding sample before the first in-range one MUST be used as s1 for the first pair, when such a sample exists — without it the first point of every range would be missing, and a chart would show a notch at the start of every window.

The adjusted delta is s2 - s1 when the value rose, and s2 alone when it fell, because a fall means the counter restarted from zero. RATE is that adjusted delta divided by the elapsed seconds. A pair with non-positive elapsed time contributes nothing.

METRIC http.requests.total SINCE 1h ago RATE
METRIC http.requests.total SINCE 1h ago DELTA

3.25.4.2 P50, P95, P99 #

Percentiles of histogram series only; a collector MUST reject them on a counter or gauge at execution time. Each histogram sample yields one value.

Evaluation is nearest-rank over the sample's cumulative counts: for percentile q, compute rank = ceil(q × total_count), and take the first boundary whose cumulative count is at least rank.

A sample with total_count == 0 yields no record. A sample whose rank falls above the final cumulative count — meaning the percentile lies in the overflow region above the highest boundary — yields a record with value null and overflow true. The distribution proves that the percentile exceeds the highest boundary but does not record a finite value for it. overflow is absent from ordinary percentile results.

METRIC request.duration P95
METRIC request.duration[origin="loregd"] SINCE 1h ago P99

Note

The overflow marker is why a producer's highest bucket boundary still matters (§3.10). It prevents a P99 above that boundary from looking like no data, but it cannot recover the value the histogram did not locate. An operator seeing overflow: true is seeing a mis-provisioned histogram.

3.25.5 Scalar aggregations #

AVG, MIN, MAX and SUM reduce scalars to one value. They MUST NOT be applied to a histogram series directly.

What they aggregate over depends on the brackets:

  • Bracketed, so one result per series: over time, within each series.
  • Unbracketed without SINCE: over the latest transformed value of each matching series. The result timestamp is the greatest of the contributing timestamps. A series that cannot produce a value — a RATE with fewer than two samples, say — contributes nothing.
  • Unbracketed with SINCE: valid only when the selector resolves to zero or one series. More than one MUST be rejected with an error asking for a window aggregation.
METRIC cpu.usage AVG
METRIC http.requests.total RATE SUM
METRIC cpu.usage[] SINCE 1d ago AVG
METRIC cpu.usage[core="0"] SINCE 1h ago MIN

The unbracketed default aggregation, when no SINCE and no explicit function is given, is AVG. No implicit scalar aggregation is added when a window aggregation is present.

If nothing contributes to an aggregation, the query returns no record for that output group. Otherwise the output timestamp is the greatest contributing timestamp — for RATE and DELTA, the later sample of the contributing pair.

If any contributing percentile result has overflow true, a scalar aggregation MUST conservatively return value null and overflow true. It MUST NOT ignore the result and compute from the remaining finite values.

3.25.5.1 Why unbracketed plus SINCE needs a window #

A collector MUST NOT synthesise a merged time series from samples that do not share timestamps.

Two series sampled at unrelated moments cannot be averaged point by point without inventing values between the points, and interpolation would make the collector responsible for a number nobody measured. A window aggregation supplies the common time grid explicitly, which is why it is required rather than assumed.

3.25.6 Window aggregations #

AVG_OVER, MIN_OVER, MAX_OVER and SUM_OVER take a duration and produce one value per window. They require SINCE; a collector MUST reject a window aggregation without one as a parse error.

Windows are fixed and aligned to Unix-epoch multiples of the duration — not to the query's start — so that the same window boundaries fall in the same places for every query. The output timestamp is the window start. Windows with nothing in them are omitted rather than emitted as null.

METRIC cpu.usage SINCE 1d ago AVG_OVER 1h
METRIC cpu.usage[] SINCE 1d ago AVG_OVER 5m
METRIC http.requests.total SINCE 1h ago RATE SUM_OVER 5m
METRIC request.duration P95 SINCE 1h ago AVG_OVER 5m

AVG and AVG_OVER are different keywords and a collector MUST NOT treat them as synonyms: AVG produces one value for the range, AVG_OVER one per window.

A collector MAY cache aligned, complete window results as an implementation optimisation. Such a cache MUST be transparent: freshness must be proved from the authoritative samples before reuse, partial windows must retain their ordinary semantics, and a missing, stale or disabled cache MUST produce exactly the same result as direct evaluation.

For raw and percentile-transformed values, a window contains the scalars whose timestamps fall inside it, and the function is applied to those. No interpolation is performed.

If a window contains a percentile result with overflow true, its output MUST also carry value null and overflow true. This propagation applies again when per-series window results are combined.

For RATE and DELTA with a window aggregation, each series first produces at most one scalar per window: the window DELTA is the sum of reset-adjusted deltas for pairs whose later sample is in the window, and the window RATE is that divided by the elapsed seconds those pairs covered. The preceding-sample rule applies to the first pair of each window. The terminal aggregation then combines the per-series window values — so RATE SUM_OVER 5m sums the series' five-minute rates, and RATE AVG_OVER 5m averages them. Where the selector resolves to exactly one series, all four window functions return that series' window value.

Bracketed window queries keep labels in the result rows. Unbracketed ones omit them, unless the selector resolved to exactly one series.

3.25.7 Without SINCE #

With no SINCE, the query returns the latest value.

METRIC cpu.usage[core="0"]
METRIC cpu.usage[]
METRIC cpu.usage

"Latest" is per series, by timestamp with the deterministic tiebreaker. For RATE and DELTA, it is the latest valid consecutive pair with positive elapsed time; a series with no such pair returns nothing.

3.25.8 Boot filtering #

Samples carry boot_id but series continue across boots (§3.13). A query MAY restrict to one boot:

METRIC cpu.usage[] WHERE boot_id == "550e8400-e29b-41d4-a716-446655440000"

A boot-filtered metric query MUST be evaluated from raw samples. A collector MUST NOT serve one from any pre-computed aggregate that is not itself partitioned by boot.

3.25.9 Results #

One record per raw sample; one per valid pair for RATE and DELTA, timestamped at the later sample; one per non-empty histogram sample after a percentile transform, including an explicit overflow record; one per window for window aggregations; one for a scalar aggregation.

A finite histogram result carries only the percentile in value. An overflow result carries value null and overflow true. The boundaries, counts, total and sum are not returned by the query language in this revision, in any mode.

{timestamp: 1714000000000000000, boot_id: "{550e8400-…}", name: "cpu.usage", type: "gauge", core: "0", value: 42.7}
{timestamp: 1714000300000000000, name: "cpu.usage", type: "gauge", core: "0", value: 39.8}

Without SORT, metric results are ordered by timestamp ascending (§3.21) — the opposite of events and logs, because a metric result is read as a series rather than as a list of occurrences.

3.26 Cross-Type Filtering

Peios / Peios technical reference / PSPU / Observability Interfaces

A cross-type filter narrows one data type by a condition on another. It is the only correlation mechanism in the language; there is no join.

EVENTS kacs.* SINCE 1h ago WHERE METRIC cpu.usage[core="0"] > 80
LOGS FROM loregd SINCE 1h ago WHERE EVENT kacs.audit.access.checked EXISTS
METRIC cpu.usage[] SINCE 1h ago WHERE EVENT eventd.store.quarantined EXISTS
EVENTS kacs.* SINCE 1h ago WHERE LOG loregd CONTAINING "error" EXISTS
FormAvailable in
WHERE METRIC …events, logs
WHERE EVENT … EXISTSlogs, metrics
WHERE LOG … EXISTSevents, metrics

3.26.1 How it is evaluated #

A collector MUST evaluate the cross-type condition first, producing the set of time ranges over which it holds, and then apply those ranges as additional timestamp bounds on the primary source.

The condition is evaluated against the referenced data's own resolution — the metric's sample interval, or the density of matching events — and not once per record of the primary source. It is computed once for the query.

3.26.2 Metric conditions #

WHERE METRIC operates on raw scalar samples of counter and gauge series only. Transform, scalar aggregation and window aggregation keywords are not valid in one, and a condition resolving to a histogram series MUST be rejected.

The selector MUST resolve to zero or one series. Zero produces no true ranges. More than one MUST be rejected with an error asking for a bracketed or narrower selector.

Within the effective query range, a sample's value is treated as active over [sample.timestamp, next_sample.timestamp), clipped to the range, and the final sample stays active through the upper bound. A collector MUST include the latest sample before SINCE as the initial state when one exists; without it the condition would be false from the start of every range until the first sample inside it, which for a fifteen-second sampling interval is fifteen seconds of wrongly excluded records. If no earlier sample exists, the condition is false until the first in-range sample.

Samples sharing a timestamp are ordered deterministically; the earlier ones create zero-width intervals and the last at that timestamp is the active value.

This is interpolation of a kind, and it should be understood as such: it assumes the condition held continuously between two samples. A metric that crossed a threshold and crossed back between samples is invisible.

3.26.3 Existence conditions #

WHERE EVENT … EXISTS and WHERE LOG … EXISTS are true when at least one matching record lies near the primary record in time. The event type supports * globbing (§3.23); the log form names an origin and optionally a CONTAINING text.

"Near" is a centred half-open window of a configured width W. With lower = floor(W / 2) and upper = W - lower, the condition is true for a primary timestamp t when a matching record exists with:

timestamp >= t - lower
timestamp <  t + upper

Equivalently, a matching record at e contributes the true range [e - lower, e + upper). When W is odd the extra nanosecond falls on the upper side, so the width is exactly W and never W ± 1.

Note

§3.A gives the mainline width of the existence window and its adjustable range, alongside every other bound in this chapter.

3.26.4 The lookback limit #

A collector MUST bound how far back a cross-type filter may scan.

Note

§3.A gives the mainline value and adjustable range for this bound and every other in this chapter.

If the effective query range exceeds the limit, a collector MUST reject the cross-type filter with an error saying the range is too large, and the error SHOULD suggest narrowing it with SINCE or UNTIL.

A query with a cross-type filter and no SINCE MUST be rejected. An unbounded cross-type scan is never permitted, in any mode, at any configured limit.

The reason is that a cross-type filter reads a second store in full before the first query begins. Its cost is set by the referenced data's density, which the client did not select and cannot see, so a query that looks cheap can scan a hundred times more than it returns.

3.26.5 Cost #

A cross-type filter is efficient when it is selective — narrow true ranges eliminating most of the primary source — and expensive when it is broadly true, which is the case where it also eliminates nothing. A condition that holds across the whole range costs the full scan of both stores and returns exactly what the query would have returned without it.

3.27 Streaming

Peios / Peios technical reference / PSPU / Observability Interfaces

STREAM turns a query into a live tail. It is a flag, may appear anywhere in the string, and takes no argument.

Streaming is available for event and log queries only. A collector MUST reject STREAM in metric mode.

Note

Metric streaming is absent because metric data is sampled on an interval — typically fifteen seconds — and the consumer is a dashboard that polls. Streaming individual samples adds a delivery path with a latency budget far finer than the data it carries.

3.27.1 The shape of a streaming query #

  1. The collector executes the query normally and sends the initial result set as "ok" messages.
  2. It sends "watch" (§3.16). The query is established at this point and not before.
  3. It stays open. As records are committed, it evaluates them against the query and sends those that match.
  4. It continues until the client disconnects, an error terminates it, or the collector shuts down.

There is no "end" message for a streaming query, ever.

3.27.2 What may be streamed #

Raw record queries and DISTINCT queries. A collector MUST reject STREAM combined with COUNT BY, TOP N BY or GROUP as a parse error — those produce one answer about a set, and a set that is still growing has no answer yet.

A collector MUST reject STREAM combined with UNTIL. An upper time bound and an unbounded live tail are contradictory requests.

SINCE is permitted and applies to both phases, resolved against the evaluation time captured at query start (§3.19).

3.27.3 What still applies during the watch phase #

Access control, the primary selector, the SINCE bound and every WHERE predicate — cross-type conditions included — are evaluated against each new record.

SORT, TAKE and SKIP apply to the initial result set only. Streamed records are delivered in commit order and a collector MUST NOT reorder, limit or skip them: there is no total order over records that have not arrived, and applying TAKE to a stream would silently end it.

SELECT applies to streamed records as it does to initial ones.

3.27.4 DISTINCT streaming #

EVENTS kacs.* DISTINCT emitter.process.guid STREAM
LOGS DISTINCT origin STREAM

A DISTINCT stream emits a value the first time it is seen, and never again. The output schema is DISTINCT's fixed one (§3.23) in both phases.

The initial result set is the complete distinct set visible at query start, after access control and every filter. The collector then holds a seen set initialised from it. Each newly committed record that passes access control and the filters is reduced to its value for the field, and emitted only if that value is not already in the seen set under the grouping equality of §3.21; emitted values are then added.

A collector MUST bound the seen set.

Note

§3.A gives the mainline value and adjustable range for this bound and every other in this chapter.

If initialising the set or inserting a value would exceed the bound, the collector MUST terminate the query with an error. It MUST NOT evict: "not seen before" is the entire meaning of the output, and a set that forgets would re-emit values it had already reported, which is worse than stopping.

A collector MUST reject DISTINCT … STREAM combined with SORT, TAKE or SKIP, so that the seen set always corresponds to the complete initial visible set. SELECT is already invalid with DISTINCT (§3.22).

3.27.5 Cross-type conditions during the watch phase #

The pre-computed time ranges of §3.26 describe the past. A collector MUST NOT reuse them for streamed records.

For a metric condition, the selector has already been required to resolve to exactly one series (§3.26). For each committed batch, the collector finds that series' active sample at the batch's latest candidate timestamp under §3.26's interval rules and evaluates the condition against it. If no sample is active there, the condition is false. A false condition filters out the whole batch; a true one leaves the batch to be filtered by the remaining predicates as usual.

For an existence condition, the collector applies §3.26's centred window to each candidate record's own timestamp. These are evaluated per record, not per batch, because a matching record may be near some of a batch and not the rest.

Note

Evaluating the metric condition once per batch rather than once per record is an approximation, and an intentional one. A commit batch spans a fraction of a second while a metric sample spans fifteen, so every record in a batch normally maps to the same sample. At sub-second metric resolution it filters more coarsely than per-record evaluation would, and records near a threshold crossing are included or excluded as a group.

3.27.6 Backpressure #

If a client cannot keep up, the collector MUST drop the query rather than buffer for it.

Backpressure is detected on the socket send buffer: when a result message cannot be sent because the buffer is full, the collector MUST terminate the query immediately and MUST NOT block on the send. It sends an error if the socket will still take one, and closes otherwise.

Streaming MUST NOT slow or block ingestion. A streaming client is the lowest-priority consumer of a collector's time, and a slow one is disconnected rather than accommodated — the same principle as §3.4, applied on the way out.

3.27.7 Latency #

Delivery latency is bounded below by the collector's commit interval for the store concerned, because a record is only streamable once it is committed. A client that needs lower latency than that is not served by this interface: the KMES ring buffer is the lower-latency path and is specified in PSPK.

Note

Streaming is a convenience for interactive tailing and dashboards. Where results must be predictable — cross-type conditions evaluated per batch, a metric threshold sampled coarsely, a disconnect under load — repeated non-streaming queries with a sliding SINCE are more reliable, and for latency-critical consumption the ring buffer bypasses a collector entirely.

3.28 What a Client Cannot See

Peios / Peios technical reference / PSPU / Observability Interfaces

A collector MUST enforce read access on every query, against the token captured when the client connected (§3.14).

How it does so is its own design, and the mechanism the mainline collector uses is described in the eventd TRMP. What this chapter fixes is the part a client can observe: which results it gets, and what it is told about the ones it does not.

3.28.1 The unit of access is the concrete identifier #

Access is resolved per concrete identifier — the event type, log origin or metric name a stored record actually carries (§3.2) — and not per query, per store, or per pattern the query happened to write.

A collector MUST resolve each identifier that a query's data could touch independently. A broad selector authorizes nothing by itself: EVENTS with no pattern, EVENTS kacs.*, LOGS with no FROM, and METRIC cpu.* are all resolved identifier by identifier, and a client permitted to read one matching identifier and not another sees only the first.

Identifiers are matched to rules by dot-delimited prefix, most specific first, falling back to a wildcard default: for kacs.audit.access.checked, a rule for kacs.audit.access.checked, then one for kacs.audit.access, then kacs.audit, then kacs, then the default.

A collector MUST fail closed. If no rule resolves — including because the default is missing — access is denied.

3.28.2 Filtering is silent #

Records and fields removed by access control are removed without comment. A collector MUST NOT indicate in a response that anything was withheld, and a client MUST NOT assume a result set is complete.

The consequences are precise and a client needs all of them:

  • A record whose identifier the client may not read is absent, not redacted.
  • A field the client may not read is absent from the record, and is indistinguishable from a field the record never carried (§3.17).
  • COUNT, COUNT BY, TOP N BY, DISTINCT and every aggregation reflect only authorized records. A count is a count of what the client may see.
  • A cross-type condition referencing data the client may not read evaluates as though no matching data exists (§3.26). It does not fail the query.
  • TAKE and SKIP page over the authorized records only.

3.28.3 Access control runs before everything #

A collector MUST remove unauthorized records from the logical row set before predicates, transforms, grouping, aggregation, sorting, pagination and projection (§3.18).

This is not tidiness. Counting, ordering or paginating over records a client may not read leaks them through the count, through the ordering, and through the gaps in pagination — a client could establish how many records of a type it cannot read exist, and roughly when, without ever seeing one.

A collector MAY reach the result however it likes: pushing the authorization down into its storage engine, or reading candidates and discarding them before aggregating. What it MUST NOT do is produce a different answer from the one filtering-first produces.

3.28.4 Denied fields do not fail the query #

When a query references a field in a predicate, a grouping, a sort or an aggregation, and some matching identifier does not grant that field, the records under that identifier contribute nothing — exactly as if their identifier had been denied outright.

A collector MUST NOT reject the query.

Note

Rejecting would be the more informative behaviour and that is precisely the objection to it. A rejection tells the client that an identifier exists, matches its query, and carries a field it may not read — three facts about data it was not permitted to see, delivered by the mechanism meant to withhold them. A client could enumerate restricted event types by watching which queries are refused. Silence costs the client a result that is narrower than it looks; rejection costs the system the property the whole model rests on.

Authorization for a field is resolved from the field as written, against each concrete identifier, and does not depend on whether any record of that identifier actually carries it. Payload fields vary between records of the same type, so a rule that turned on presence would be undecidable before the scan it was meant to authorize.

3.28.5 What is not a field #

Derived aggregate outputs — count, sum, avg, min, max — are not source fields, have no access identity of their own, and are visible whenever the client is authorized for the records and the source fields they were computed from.

Values internal to preserving query semantics — row identifiers, series identifiers, ordering tiebreakers, series type checks — are likewise not query-language fields (§3.21). A metric result's value is a source field, because it is a raw sample or a scalar derived from raw samples.

3.28.6 Errors say nothing #

A collector MUST NOT include a value the client is not authorized to read in any error message (§3.16), including in errors raised by internal consistency checks.

3.28.7 Streaming #

Access decisions made for the initial result set are reused during the watch phase, but a collector MUST resolve any new concrete identifier that appears in a streamed batch and check it before using the record or its distinct value — a new event type or a new log origin appearing mid-stream has never been authorized.

If a rule changes during a streaming query, a collector MUST re-check subsequent batches against the new rule.

The token does not change. It was captured at connection (§3.14), so a client whose group memberships change mid-stream continues to be evaluated against what it connected with, and a client whose access is revoked keeps receiving records until it disconnects.

3.28.8 What write authentication proves #

Log origin is broker-attested. The service manager chooses it from the pipe being read, and the log socket excludes service processes (§3.6). It proves which service context the manager associated with the line; it does not prove that the line's text is true.

Metric name is publisher-authorized. KACS attests the effective token that sent the datagram and the collector verifies EVENTD_PUBLISH for that name (§3.9). Labels and values remain producer statements. A publisher authorized for http.requests can report any value and any valid label set for that name.

Read authorization is independent. Permission to publish an identifier does not imply permission to query it, and permission to query it does not imply permission to publish it.

3.29 Extension

Peios / Peios technical reference / PSPU / Observability Interfaces

There is no version number on any of the three interfaces. No datagram carries one, no query message carries one, and there is no exchange in which either party could state or discover what the other speaks.

That is a deliberate consequence of the shapes chosen, and it is worth being explicit about, because it means every rule below is the only mechanism available.

Ingestion is one-way over a datagram socket: there is no reply in which a collector could announce a version and no state in which a producer could remember one. The query channel could carry a version — it is a stream, and it has a request message — and does not, because a version field is only useful if a party may then behave differently, and a client cannot usefully vary: it either asks a question the collector understands or does not.

What replaces negotiation is a set of rules under which both sides may change without either being told.

3.29.1 Unknown fields are ignored #

A collector MUST ignore fields it does not recognise in a log record (§3.7), in a metric record (§3.11), and in a query request (§3.15).

This is what allows a field to be added. A producer built against a later revision may send a field this collector has never heard of, and the record is still stored; a producer built against an earlier one omits a field that has since been added, and the record is still stored because everything added is optional.

A field added to any of these three maps MUST therefore be optional, and a collector MUST NOT require one to be present.

3.29.2 Unknown values are refused, not ignored #

The rule does not extend to values.

An unrecognised type in a metric record discards the record (§3.12); a first token that is not a mode fails the query (§3.18); an unrecognised keyword is a parse error. A collector MUST NOT guess at an unrecognised value, and MUST NOT skip a field it recognised but could not interpret.

The asymmetry is the point. An unknown field is something the sender knows about and this collector does not, and ignoring it loses only what was never understood. An unknown value in a known field is the sender saying something specific about this record, and proceeding without understanding it stores something other than what was sent.

3.29.3 Response statuses #

A client MUST treat a response whose status it does not recognise as an error terminating the query, and MUST discard the "ok" messages it has received for that query unless "end" or "watch" had already arrived (§3.16).

A status is the control flow of the response stream, so there is no ignoring one: a client that skipped an unknown status would be waiting for a terminal message that had already been sent, or treating an incomplete result as complete. Failing is the only safe reading.

A collector MUST NOT introduce a new status for a condition that the four existing ones can express.

3.29.4 What may change without notice #

  • New optional fields in a log record, a metric record or a query request.
  • New fields in result records. A client MUST tolerate a key it does not recognise, and MUST NOT reject a record for carrying one.
  • New query keywords, clauses and functions. A client sending one the collector does not know receives a parse error, which is the correct answer.
  • Wording of any error string (§3.16).
  • New event types, log origins and metric names. These are data, not interface; nothing enumerates the valid set of any of them.

3.29.5 What may not change #

  • The meaning of an existing field, in either direction. A field is added or it is left alone.
  • The type of an existing field.
  • The four response statuses, or the rule that exactly one terminal message ends a query.
  • The framing of §3.15, which has no version field and therefore no way to change compatibly.
  • A required field becoming optional, or an optional one becoming required.

3.29.6 Limits are not the interface #

The declared bounds — the datagram ceilings above their portable floor (§3.6, §3.9), the query request ceiling and response target (§3.15), the concurrency and timeout bounds (§3.14, §3.16), the existence window and lookback limit (§3.26) — are configuration, and an administrator may change any of them within its stated range.

A collector MUST behave identically at any value in its supported range. A producer or client MUST NOT infer a bound from having exceeded one, or from not having exceeded one, and MUST NOT depend on the mainline defaults quoted in this chapter.

The 262144-byte log and metric datagram floor is different: it is part of the interface. A collector may raise its declared ceiling but MUST NOT lower it below that floor, and a portable producer does not discover or depend on the raised value (§3.6).

3.30 Conformance

Peios / Peios technical reference / PSPU / Observability Interfaces

A conforming implementation of any role MUST satisfy every requirement in this chapter. This section collects the obligations that are not tied to one message.

3.30.1 A collector #

Serve three separate channels. Two SOCK_DGRAM for ingestion, one SOCK_STREAM for queries, each on its own socket, each protected by a Security Descriptor established before it accepts anything (§3.3).

Never exert backpressure. No producer stalls because of a collector, under any load, in any failure state (§3.4).

Never react to input. No event, no log entry, no client-observable counter, in response to a malformed, unwanted or excessive submission (§3.4).

Authenticate ingestion. Admit only the service-manager broker on the log socket; require a complete KACS token on every metric datagram and authorize every metric name for EVENTD_PUBLISH (§3.3, §3.6, §3.9).

Validate at the stated scope. Datagram, record, or field — as §3.8 and §3.12 set out, and no more broadly. In particular a malformed record MUST NOT cost the valid records batched with it, and a malformed optional field MUST NOT cost a log record.

Store what you were given. A log message byte-for-byte, an event payload unmodified, a timestamp uncorrected, a histogram's boundaries in the order sent (§3.8, §3.10, §3.5).

Preserve gaps. No interpolation, no backfill, no synthesised sample (§3.13).

Identify every query client from the connection, before executing anything, and refuse the query if you cannot (§3.14).

Order totally and deterministically. Every result, for a fixed set of stored records, in one order — so that SKIP and TAKE mean something (§3.21).

Use query-language semantics, not your storage engine's, for every comparison, ordering, grouping and equality test the language defines (§3.20, §3.21).

Enforce access before you compute, per concrete identifier, failing closed, and silently (§3.28).

Bound everything a client can consume: concurrent queries, streaming queries, message size, query time, distinct-stream values, cross-type lookback (§3.14, §3.15, §3.16, §3.26, §3.27).

Behave identically across your configured ranges (§3.29).

3.30.2 A producer #

Send well-formed records and accept that malformed ones vanish without notice (§3.8, §3.12).

Stay within the 262144-byte portable datagram ceiling, batched or not. Do not add discovery or depend on a collector's larger local setting (§3.6).

Choose a stable, conforming metric name. It must match the identifier grammar, name the producer distinguishably and use dots for hierarchy, because publication and read rules are written against it and queries select on it (§3.10).

Convey metric identity. Enable KACS_SO_PASS_TOKEN before sending metrics and retain the sending socket so the captured-token and authorization caches remain effective (§3.9).

Timestamp at production, not at submission (§3.7).

Bound your label cardinality, and keep histogram boundaries fixed for the life of a metric (§3.10, §3.13).

Never assume delivery. No acknowledgement exists, none is coming, and a record that mattered should have been an event (§3.4).

Never change a metric's type. Doing so ends the series silently and permanently (§3.10).

3.30.3 A client #

Tolerate unknown keys in result records, and unknown statuses as errors (§3.29).

Discard partial results. An error before "end" or "watch" means every "ok" message for that query is void (§3.16).

Assume nothing about completeness. Results are silently filtered by access, counts count only what you may see, and an absent field is indistinguishable from a denied one (§3.28).

Interpret provenance narrowly. A log origin is broker-attested and a metric name is publisher-authorized; neither proves the message, value or labels are truthful (§3.28).

Do not parse error strings (§3.16).

Open one connection per query (§3.14).

3.30.4 What this chapter does not require of a collector #

A conforming collector need not accelerate anything, pre-compute anything, shard anything, or retain anything for any particular period. It need not honour INDEX beyond accepting it (§3.23). Its storage, indexing, retention and query planning are entirely its own, and every requirement above is stated about the answer rather than about how the answer is reached.

Appendix 3.A Limits

Peios / Peios technical reference / PSPU / Observability Interfaces

Every bound this chapter requires a collector to enforce, with the value and adjustable range of the mainline collector. The mainline values are informative: a conforming collector chooses its own, and a producer or client MUST NOT depend on any of them (§3.29).

The mainline configuration key names are those of eventd, whose configuration is catalogued in the eventd TRMP §A.

3.A.1 Ingestion #

BoundMainline valueMainline rangeKeySection
Log datagram ceiling262144 B262144 – 1048576MaxLogDatagramBytes§3.6
Metric datagram ceiling262144 B262144 – 1048576MaxMetricDatagramBytes§3.9
Receive queue, either socket≤ 4 × the ceiling——§3.6

3.A.2 Queries #

BoundMainline valueMainline rangeKeySection
Query request ceiling65536 B1024 – 16777216MaxQueryRequestBytes§3.15
Query response target65536 B1024 – 16777216QueryResponseTargetBytes§3.15
Query timeout30000 ms1000 – 300000QueryTimeoutMs§3.16
Concurrent queries1281 – 4096MaxConcurrentQueries§3.14
Concurrent streaming queries641 – 1024MaxStreamingQueries§3.14
Concurrent queries per caller161 – 4096MaxQueriesPerUser§3.14
Memory all running queries hold together268435456 B16777216 – 4294967295MaxQueryHeldBytes§3.16
Values per DISTINCT stream1000001000 – 10000000MaxDistinctStreamValues§3.27

3.A.3 Cross-type filtering #

BoundMainline valueMainline rangeKeySection
Existence window W15000 ms1000 – 300000CrossTypeWindowMs§3.26
Maximum lookback604800 s3600 – 2592000CrossTypeMaxLookbackSeconds§3.26

3.A.4 Fixed by this chapter #

These are not configuration and a collector MUST NOT vary them.

QuantityValueSection
Timestamp domain0 – 9223372036854775807 ns§3.5
GUID field width16 bytes§3.7, §3.11
Message length prefix4 bytes, little-endian§3.15
Maximum response payload2^32 - 1 bytes§3.15
Transforms per queryat most 1§3.25
Terminal aggregations per queryat most 1§3.25
Queries per connectionexactly 1§3.14

The response target is not a ceiling. A complete record larger than the target travels alone, so the configurable ingestion ceilings do not need to be coupled to it (§3.15).

Appendix 3.B Query Language Reference

Peios / Peios technical reference / PSPU / Observability Interfaces

An index of the language, and of where each construct is valid. The normative definitions are in §3.18 to §3.27; nothing here adds a rule.

3.B.1 Shape #

EVENTS [type_pattern]                             [clauses…]
LOGS   [FROM o[, o…]] [ERROR ONLY] [CONTAINING s] [clauses…]
METRIC name[label_selector] [transform] [aggregation] [clauses…]

3.B.2 Clause validity #

ClauseEVENTSLOGSMETRICSection
SINCE / UNTILyesyesyes§3.19
WHEREyesyesyes§3.20
WHERE METRICyesyesno§3.26
WHERE EVENT … EXISTSnoyesyes§3.26
WHERE LOG … EXISTSyesnoyes§3.26
SORTyesyesyes§3.21
TAKE / SKIPyesyesyes§3.18
SELECTnon-aggregating onlynon-aggregating onlyno§3.22
COUNT BY / TOP N BYyesyesno§3.23
DISTINCTyesyesno§3.23
GROUP + functionyesyesno§3.23
STREAMyesyesno§3.27
ERROR ONLY / CONTAININGnoyesno§3.24
INDEXyesnono§3.23

WHERE and SELECT are the only repeatable clauses (§3.18).

3.B.3 Combinations that are rejected #

CombinationRejected atSection
SELECT with COUNT BY, TOP N BY, DISTINCT or GROUPparse§3.22
SELECT in metric modeparse§3.22
STREAM with COUNT BY, TOP N BY or GROUPparse§3.27
STREAM with UNTILparse§3.27
DISTINCT … STREAM with SORT, TAKE or SKIPparse§3.27
Window aggregation without SINCEparse§3.25
Scalar and window aggregation togetherparse§3.25
Two transformsparse§3.25
Cross-type filter without SINCEparse§3.26
= outside a label selectorparse§3.20
== NULL or != NULLparse§3.19
Ordering operator on a binary literalparse§3.20
Ordering operator on a fixed field that cannot be orderedparse or planning§3.20
Unknown log field nameparse§3.24
Effective range beyond the lookback limitplanning§3.26
Selected metric series spanning more than one typeexecution§3.25
RATE or DELTA on a gauge or histogramexecution§3.25
Percentile on a counter or gaugeexecution§3.25
Histogram series with no percentile functionexecution§3.25
Unbracketed metric query with SINCE resolving to several seriesexecution§3.25
Cross-type metric selector resolving to several seriesexecution§3.26
Aggregation producing a non-finite valueexecution§3.23, §3.25
A sort or aggregation needing more memory than the collector allows queriesexecution§3.16

"Parse" failures need no data. "Execution" failures depend on what the store holds, so the same query string may succeed on one system and fail on another.

3.B.4 Metric functions #

KeywordStageValid onProduces
RATEtransformcounterper-second change
DELTAtransformcounterabsolute change
P50 P95 P99transformhistogramone value per sample
AVG MIN MAX SUMscalar aggregationcounter, gaugeone value
AVG_OVER MIN_OVER MAX_OVER SUM_OVERwindow aggregationcounter, gaugeone value per window

Transforms feed aggregations; a query may have at most one of each (§3.25).

3.B.5 Operators #

== != > >= < <= STARTS_WITH ENDS_WITH CONTAINS HAS IN NOT_IN IS NULL IS NOT NULL, combined with AND and OR (§3.20).

CONTAINS is substring on a string; HAS is membership in an array.

There is no NOT and no =.

3.B.6 Literals #

KindFormSection
Identifier[A-Za-z_][A-Za-z0-9_.-]*§3.19
String"…" with \" \\ \n \r \t \uXXXX§3.19
Binaryx"0a1b…", even digit count§3.19
Integerdecimal or 0x…§3.19
Floatfinite, with a fraction or exponent§3.19
Booleantrue, false§3.19
NullNULL, in IS NULL only§3.19
Duration<n>s <n>m <n>h <n>d, non-zero§3.19
Time<duration> ago, <duration> hence, today, yesterday, YYYY-MM-DD, YYYY-MM-DDTHH:MM:SS§3.19
GUID8-4-4-4-12, braced or not§3.19

3.B.7 Fields #

ModeFixed fieldsEverything else
EVENTSevent.time event.sequence event.cpu event.type event.boot.guid emitter.class emitter.token.guid emitter.true-token.guid emitter.process.guida flattened payload path, or null
LOGStimestamp origin is_error message boot_id job_ida parse error
METRICtimestamp boot_id name type value overflowa label, or null

3.B.8 Aliases #

emitter.class accepts userspace (0), kmes (1), kacs (2), lcs (3). These are the only aliased values in the language (§3.23).

3.B.9 Default ordering #

ModeWithout SORT
EVENTSevent.time descending
LOGStimestamp descending
METRICtimestamp ascending
COUNT BY, TOP N BY, GROUP … COUNTcount descending
GROUP … SUM, AVG, MIN, MAXgroup keys ascending in the order named
DISTINCTby the distinct value

All ties are broken to a total order (§3.21).

Appendix 3.C Prior Art

Peios / Peios technical reference / PSPU / Observability Interfaces

The three interfaces here are not novel, and each has a well-known counterpart whose shape informed it. What follows compares the contracts — this appendix is about wire shapes and the obligations they place on either side. The eventd TRMP §1.4 compares the systems.

3.C.1 Log ingestion #

The closest relative is journald's native socket: a Unix datagram socket, world-writable, accepting a self-describing record from any local process, with no acknowledgement and no notification of loss. The agreements are substantive — datagram rather than stream, silent drop under pressure, and a forwarder bridging programs that only know standard output. The identity model differs: this interface admits only the service-manager broker, which assigns the origin from the pipe it read rather than accepting a process's self-assertion.

The differences are three. The record here is MessagePack rather than a line-oriented key-value text format, because the collector already carries a MessagePack decoder for event payloads and a second parser would be a second thing to get wrong. Severity is a boolean rather than a syslog priority, because a forwarder can distinguish two file descriptors and inventing eight levels from two would be a guess presented as data (§3.7). And a batch is a first-class datagram shape rather than a stream of records, which is what lets a forwarder amortise the syscall without giving up the datagram's all-or-nothing property.

Classic syslog over /dev/log is the older relative, and the departure from it is the same one journald made: a record with named fields rather than a formatted line that every consumer re-parses with a regular expression.

3.C.2 Metric ingestion #

The shape is StatsD's: push, datagram, fire-and-forget, no series registration, sender-named series. KACS sender identity and per-name publication authorization are deliberate additions. It is the opposite of Prometheus's, where the collector pulls from endpoints it has been configured to know about.

The choice follows from the loss model rather than from taste (§3.9). A pulling collector must reach every producer on a schedule, which makes it responsible for their availability; pushing keeps a slow or dead producer invisible except for the gap it leaves.

What is taken from the Prometheus data model rather than from StatsD is the identity of a series: a name plus a set of labels, with each distinct label combination a distinct series, and the cardinality warning that comes with it (§3.10). The histogram is Prometheus's cumulative-bucket form, including the property that the top bucket is an overflow whose contents are counted but not located.

Two things are deliberately absent. There is no text exposition format, because nothing scrapes. And there is no summary type — a producer that has already computed its own quantiles cannot submit them, because quantiles do not aggregate and a stored one could not be combined with another (§3.25).

3.C.3 The query interface #

The unusual choice here is having a query language at all.

journald exposes a cursor and a set of field matchers, and computation belongs to the client. The Windows Event Log exposes XPath over an XML representation. Prometheus exposes PromQL, a genuine language, but only for metrics. This interface puts one language over all three data types, with a shared clause vocabulary and per-type modes (§3.18).

The reason is access control. Filtering, grouping and aggregation must happen on the side that knows what the caller may see, because a count computed by a client is a count of what the client was given and a count computed by the collector can be a count of what the client is entitled to (§3.28). A cursor interface pushes the computation across the trust boundary and takes the enforcement point with it.

The framing — a length-prefixed MessagePack request, a sequence of chunked result messages, one terminal message — is unremarkable and deliberately so. What it does not have is more interesting: no version field (§3.29), no error codes (§3.16), no multiplexing (§3.14), and no cursor. A query is one connection, and paging is SKIP and TAKE over a total order (§3.21) rather than an opaque token the collector must keep state for.

3.C.4 Where these interfaces sit #

ConcernWhere it is specified
Event emission and the ring-buffer transportPSPK
Event types and payload schemasthe emitting subsystem's own documentation
Tokens, SIDs and Security DescriptorsPCDS, and the Peios Kernel TRM
Forwarding a service's outputthe peinit TRM
Storage, indexing, retention, query planningthe collector's own design; for the mainline one, the eventd TRMP

4.1 Scope and Roles

Peios / Peios technical reference / PSPU / Service Control and Notification

This chapter defines the two interfaces a Peios service manager offers: the control channel, by which a program manages services, and the notification channel, by which a supervised service reports on itself.

Both are Unix-domain sockets between userspace parties, and both have a publicly implementable side. A monitoring tool, an orchestration agent, a shell utility, or a privileged action broker implements the client side of the control channel. Every supervised service that reports readiness, sends keepalives, or preserves file descriptors across a restart implements the producer side of the notification channel.

4.1.1 The roles #

The manager is the process that supervises services. It listens on both channels. On Peios this is peinit, running as PID 1, but nothing here depends on that beyond the manager being a single process holding both sockets.

A client connects to the control channel to issue commands and read answers. A client is any process; it holds no special relationship with the manager beyond the one its token establishes.

A service is a process the manager started, and speaks the notification channel about itself. A service does not connect to the control channel in that capacity — a program that does both is acting in two roles.

A job the manager started on a submitter's behalf (§7) speaks the notification channel exactly as a service does, and is observed and stopped from the control channel by the job commands of §4.14. The channel by which it is submitted is §7's.

Requirements are stated against the role, not the program.

4.1.2 What this chapter covers #

  • the two channels, their addressing, and how each is reached
  • message framing and encoding on both
  • how a client's identity is established, and how a command is authorised
  • the command set, the response shapes, and the error vocabulary
  • what a command does to a service in each of its states
  • how a service's notification is authenticated, and what a service may say
  • the file-descriptor store
  • the rules under which either channel may be extended
  • the conformance requirements for each role

4.1.3 What this chapter does not cover #

  • How the manager supervises anything. Dependency resolution, restart policy, timers, cgroups, the boot sequence and shutdown are the manager's own design. This chapter defines what a client can ask for and what it is told, not how the answer comes about.
  • How service definitions are expressed. On Peios they are registry keys, administered like any other registry data. That is the service manager's own design.
  • What a service state means. The vocabulary is fixed here (§4.B) because it appears on the wire; what causes a service to be in one of those states is not.
  • Kernel interfaces. Establishing a peer's identity and evaluating an access decision are kernel operations, specified in PSPK and in the kernel's own reference manual.
  • Submitting a job. The channel by which a process asks the manager to run a program under supervision, the identity such a job runs as, and the commands its submitter issues about it are §7. This chapter covers only what a control-channel client sees of such a job.

4.2 Terminology

Peios / Peios technical reference / PSPU / Service Control and Notification

Service. A named unit of execution the manager supervises. Service names are opaque to this chapter except for the character restriction in §4.8.

Job. One process execution. A service that has been restarted has had more than one job. A job submitted on the jobs channel (§7) has no service; it belongs to its submitter.

Operation. A requested state machine action on a service, with an identity and a lifecycle of its own. Lifecycle commands do not act directly; they create operations, and an operation is what a client observes and waits on.

Activation generation. A counter the manager increments each time a service begins starting. It distinguishes one incarnation of a service from the next.

Right. A named permission on a service or on the manager itself, represented as a bit in an access mask and evaluated against a Security Descriptor. §4.7.

Dependent-satisfying state. A service state in which the services that depend on the service may proceed. Which states these are is the manager's design; that a state is or is not one of them is observable through the state vocabulary.

Terminal state. For an operation, one of completed, failed, cancelled, merged or aborted. An operation in a terminal state does not change again.

Frame. One newline-terminated line on the control channel, carrying exactly one JSON object.

Datagram. One message on the notification channel, carrying zero or more KEY=VALUE lines and optionally file descriptors.

4.3 The Two Channels

Peios / Peios technical reference / PSPU / Service Control and Notification

The two channels differ in almost every respect, and the differences are deliberate.

ControlNotification
Socket typeSOCK_STREAMSOCK_DGRAM
Who connectsThe clientNobody; a service sends
AddressingA fixed pathA path given to each service
DirectionRequest and responseOne-way
FramingNewline-delimited JSONKEY=VALUE lines
IdentityThe peer's token, at connectThe sender's kernel-attested PID
AuthorisationAn access check per commandMembership: is the sender this service?
LossNone. A stream, or an errorPossible. A datagram may be dropped
OrderingGuaranteed within a connectionNot guaranteed

4.3.1 Why the notification channel is a datagram socket #

A service reporting on itself must not be able to block the manager, and must not block itself. A stream socket gives both parties a queue that fills, and a service writing into a full queue either blocks — hanging a service on the manager's scheduling — or gets an error it has to handle in the middle of doing something else.

A datagram socket has neither problem. A send either goes or is dropped, and the manager can drain at whatever rate it manages. The cost is that a notification can be lost, which is why nothing in §4.19 is a transaction: every field is either idempotent or a statement of current condition, and a service that needs a lost keepalive to have arrived sends another one.

4.3.2 Why the control channel is a stream socket #

A command has an answer, and a client waiting for one needs to know it did not arrive rather than assuming. It also needs framing: a request can be large, and a response certainly can.

4.3.3 Reaching either socket #

Both sockets are protected by the Security Descriptor on the socket's own inode, and a party that may not reach the socket is refused when it connects or sends, before any content is exchanged.

The manager MUST NOT rely on POSIX mode bits for this. On a Peios system access to a filesystem object is routed through its Security Descriptor, mode bits are not consulted, and a chmod on either socket has no effect whatever.

The manager MUST ensure that each socket, and each directory containing one, carries a Security Descriptor that admits the parties intended to use it. A socket created where nothing inheritable applies acquires no descriptor, and an object with no descriptor is denied to every caller — so a manager that leaves this to chance produces a socket nobody can reach, including principals its own default policy grants access to.

Note

The failure is quiet in both directions and neither direction announces itself. A socket in a permissive place is reachable by anything, and a socket in a bare one is reachable by nothing, and in both cases the manager binds successfully, reports itself ready, and serves no one it meant to.

4.4 The Control Channel

Peios / Peios technical reference / PSPU / Service Control and Notification

The manager MUST listen on a Unix SOCK_STREAM socket at a well-known path. On Peios that path is:

/run/services/peinit/control.sock

The socket MUST exist for as long as the manager is serving, and the manager MUST unlink it when it stops.

The manager MUST create the listening socket and every accepted connection with close-on-exec set, so that no connection descriptor is inherited by a process the manager starts.

4.4.1 A connection #

A client connects, issues one or more commands, and closes. The manager MUST NOT require a client to issue any command before another, and MUST NOT hold state across connections: a connection carries an identity (§4.6) and nothing else.

Requests on one connection MUST be answered in the order they were received. The manager MAY read no further frames from a connection while a response on it is outstanding.

4.4.2 Limits #

The manager MUST enforce three limits, and MUST make their values discoverable to an administrator through the same configuration surface that sets them. The values a Peios service manager uses by default are in §4.A.

Concurrent connections. A connection accepted while the manager is already at its limit MUST be closed at the socket level, without a response. There is no error code for this condition: the manager has declined to enter the protocol at all, and a client MUST treat an immediate close with no response as a refusal rather than as a protocol error.

The limit MUST be applied to each caller, counted by the user SID of the peer's token, as well as to the connections in all: a connection accepted while its caller already holds its limit is closed the same way. The manager MAY exempt the machine's own principal (SYSTEM) from the per-caller limit. A channel that admits every authenticated principal and bounds only the total lets any one of them fill it and shut everyone else out, administrators included.

Request size. A request frame whose content exceeds the limit MUST be answered with REQUEST_TOO_LARGE and the connection MUST then be closed. The limit applies to the frame's content and MUST NOT count the terminating newline, so a request of exactly the limit plus its newline is within bounds.

Idle timeout. A connection with no request outstanding MAY be closed once it has been idle for the configured period. The manager MUST NOT treat a connection as idle while a request on it is outstanding — in particular a connection blocked on a wait=true operation (§4.13) is not idle, however long the operation runs, and MUST be held open until the operation resolves. Such a connection is bounded by the operation's own timeout, not by the idle timeout.

A connection closed for idleness MUST be closed without a response.

4.5 Framing and Encoding

Peios / Peios technical reference / PSPU / Service Control and Notification

4.5.1 Frames #

Every message in both directions is one frame: a single JSON object, serialised compactly, followed by one 0x0A byte. This applies to requests and to responses alike, and the manager MUST terminate every response with a newline.

Framing is byte-oriented and is performed before any JSON is parsed. A 0x0A byte ends the frame wherever it appears, so a raw newline inside what a sender intended as a JSON string does not produce one frame with an embedded newline — it produces two malformed ones. (A raw 0x0A inside a JSON string is not valid JSON in any case; the \n escape sequence is unaffected and is the way to carry a newline in a value.)

The manager MUST NOT emit pretty-printed JSON, and MUST NOT emit more than one object per frame.

4.5.2 Encoding #

Frames are UTF-8. The manager MUST reject a frame that is not well-formed UTF-8 with MALFORMED_REQUEST.

4.5.3 What is malformed #

The manager MUST answer with MALFORMED_REQUEST when a frame:

  • is empty — a bare newline with no content;
  • is not well-formed UTF-8;
  • is not valid JSON;
  • is valid JSON but not an object. An array, a string, a number, true, false and null are all malformed requests.

4.5.4 Closing after an error #

The manager MUST distinguish two classes of failure, because they say different things about the connection.

A frame-level failure means the manager cannot trust the stream's framing any more: it does not know where the next frame begins. MALFORMED_REQUEST for an empty frame and REQUEST_TOO_LARGE are both frame-level. The manager MUST send the error response, discard any buffered input, and close the connection.

A command-level failure means the frame was well-formed and the command in it could not be carried out: unparseable JSON content, an unknown command, missing arguments, a denied access check, an unknown service. The manager MUST send the error response and MUST keep the connection open.

A client MUST NOT assume a connection survives an error response, and MUST be prepared for either.

4.5.5 Timestamps #

Every timestamp field the manager emits MUST be a UTC RFC 3339 string with exactly nine fractional-second digits and the literal offset marker Z:

"2026-06-01T12:34:56.123456789Z"

The manager MUST NOT emit a numeric offset in place of Z, and MUST NOT vary the number of fractional digits.

These are wall-clock instants, presented for a reader. The manager MUST NOT derive elapsed-time decisions — timeouts, retries, ordering — from wall-clock differences, and a client MUST NOT assume that two timestamps in the same response were taken from a clock that did not move between them.

4.6 Peer Identity

Peios / Peios technical reference / PSPU / Service Control and Notification

The manager MUST establish the identity of every client from the kernel. There is no credential exchange in this protocol, and a client MUST NOT be able to assert who it is.

4.6.1 Obtaining the identity #

On accepting a connection, the manager MUST obtain the peer's token through the kernel's peer-identity facility for connected local sockets (the Peios Kernel TRM §3.5). The token is captured by the kernel when the client connects; nothing in this protocol carries it, and a client has no way to supply one.

The token reflects the identity the client was acting under when it connected. A client that is impersonating another principal is therefore captured as the principal it is impersonating, not as its own service identity — which is the intended behaviour: access decisions reflect the identity a client is actually acting under.

4.6.2 When it is captured #

The manager MUST capture the identity once, when the connection is accepted, and MUST use that identity for every command on the connection.

A client MUST NOT expect a change of identity mid-connection to affect authorisation. A client that needs to act under a different identity MUST open a new connection.

Note

One identity per connection is what keeps a command sequence coherent. A job started under one identity cannot be steered by a later command issued under another on the same connection, every command on a connection is attributable to a single principal, and the manager never has to decide which of two identities an in-flight operation belongs to. The rule is a property of this protocol.

4.6.3 Failure #

If the manager cannot obtain the peer's identity, it MUST close the connection without a response. There is no error code, because the manager has no basis on which to decide whether this caller may be told anything at all.

A client MUST treat an immediate close with no response as a refusal. This is the same observable outcome as exceeding the connection limit (§4.4), and a client cannot distinguish the two — deliberately, since distinguishing them would tell an unauthenticated caller about the manager's state.

4.6.4 The manager's identity #

The kernel's facility is symmetric: a client that has connected MAY obtain the manager's identity through the same facility and verify it before sending a command — that the peer is the principal it expects the manager to run as, rather than something else that bound the path. A client MUST NOT treat the control socket's path as proof of the manager's identity; the path is a rendezvous, and the token is what the kernel attests.

4.6.5 The identity is not a UID #

The manager MUST NOT use the peer's UID or GID as an authorisation input. Identity on a Peios system is a token, and the token is what the kernel attests.

4.7 Authorising a Command

Peios / Peios technical reference / PSPU / Service Control and Notification

Every command is authorised against a Security Descriptor, using the peer's token. There is no command the manager performs without a check, and no principal exempt from one.

4.7.1 Rights #

Commands acting on a service are checked against that service's descriptor:

RightBitGrants
SERVICE_QUERY_STATUS0x0001Query the service's state and detail.
SERVICE_START0x0002Start the service.
SERVICE_STOP0x0004Stop the service.
SERVICE_INTERROGATE0x0008Reload the service.
SERVICE_ALL_ACCESS0x000FAll four.

Commands acting on a submitted job are checked against that job's own descriptor, with the rights and generic mapping §7.8 defines: JOB_QUERY (0x0001), JOB_STOP (0x0002), JOB_SIGNAL (0x0004).

Commands acting on the system are checked against the manager's own descriptor:

RightBitGrants
SYSTEM_SHUTDOWN0x0001Initiate a shutdown.
SYSTEM_RELOAD_CONFIG0x0002Re-read the configuration.
SYSTEM_QUERY_STATUS0x0004Ask how the machine booted (boot).

4.7.2 Generic mappings #

The manager MUST use these generic mappings when evaluating a descriptor, so that a descriptor written in generic terms means the same thing to every implementation.

For a service descriptor:

Generic rightMaps to
GENERIC_READSERVICE_QUERY_STATUS
GENERIC_WRITESERVICE_START | SERVICE_STOP | SERVICE_INTERROGATE
GENERIC_EXECUTESERVICE_START | SERVICE_STOP | SERVICE_INTERROGATE
GENERIC_ALLSERVICE_ALL_ACCESS

For the manager's descriptor:

Generic rightMaps to
GENERIC_READSYSTEM_QUERY_STATUS
GENERIC_WRITESYSTEM_RELOAD_CONFIG
GENERIC_EXECUTESYSTEM_SHUTDOWN
GENERIC_ALLSYSTEM_SHUTDOWN | SYSTEM_RELOAD_CONFIG | SYSTEM_QUERY_STATUS

GENERIC_READ maps to the one right on the manager's descriptor that changes nothing, as it maps to SERVICE_QUERY_STATUS on a service.

4.7.3 Per command #

CommandRight required
startSERVICE_START
stopSERVICE_STOP
restartSERVICE_START and SERVICE_STOP
reloadSERVICE_INTERROGATE
resetSERVICE_STOP
statusSERVICE_QUERY_STATUS
listEvaluated per service; see below
operation-statusSERVICE_QUERY_STATUS on the operation's target
job-statusJOB_QUERY on the job
job-listEvaluated per job; see below
job-stopJOB_STOP on the job
shutdownSYSTEM_SHUTDOWN
reload-configSYSTEM_RELOAD_CONFIG
bootSYSTEM_QUERY_STATUS

boot is checked like any other command, although what it reports is not a secret: a descriptor decides who may ask, and §4.15 asks that the default one let every authenticated principal.

reset requires SERVICE_STOP because clearing a terminal state is the tail of stopping something rather than the head of starting it.

4.7.4 The sequence #

  1. If the manager is shutting down, apply §4.15's restriction. The shutdown restriction is evaluated before the access check, so a caller who would have been denied is told the command is invalid for the current state. A client MUST NOT infer anything about its own rights from an INVALID_STATE received during shutdown.
  2. Resolve the target. A command naming no service the manager knows of MUST be answered UNKNOWN_SERVICE. The manager MUST NOT synthesise a descriptor for a service that does not exist.
  3. Evaluate the access check with the peer's token, the target's descriptor, the appropriate generic mapping, and the required right, naming the target in the check's audit context (PGSS §6.7).
  4. On denial, answer ACCESS_DENIED. The attempt is recorded by the access check itself, under the target descriptor's SACL, with the caller, the target and the rights requested and granted; the manager MUST NOT write an access-denied event of its own (PGSS §6.7). A descriptor the manager supplies by default MUST carry a SACL that audits every refusal, so that a denial is not silent unless a descriptor someone wrote says so.
  5. On grant, proceed.

A status, and each job view on this channel, also tells the caller which of these rights it holds on the target, as granted (§4.14). The manager finds them with this same check, asking for MAXIMUM_ALLOWED in step 3 instead of the command's right. That check is a question asked on the caller's behalf, not a command: a right it does not grant is left out of granted, and it is not a denial for step 4.

4.7.5 Filtering rather than denying #

list MUST return only the services the caller may query, and MUST omit the rest rather than denying the command. A caller with no query rights on anything receives an empty list and a successful response.

The response does not say whether services were omitted. What the filtering protects is each service's state, which is what SERVICE_QUERY_STATUS grants, and not whether the service exists: a command naming a service is answered UNKNOWN_SERVICE or by its access check according to whether it exists (step 2 above), and §4.10 does not hide existence either. A client MAY therefore learn from elsewhere which services are defined and show the ones list left out as ones whose state the caller may not see.

job-list MUST be filtered the same way, by JOB_QUERY on each job, and MUST NOT reveal that jobs were omitted.

4.7.6 Not revealing what a caller may not see #

A service's existence is not protected (above). Where a command names an operation the caller may not query, the manager MUST NOT let the answer distinguish "this does not exist" from "you may not see this".

For operation-status this means the authorisation check MUST be evaluated before the operation's existence is reported: a caller lacking SERVICE_QUERY_STATUS on an operation's target MUST receive ACCESS_DENIED whether or not the identifier names a real operation, and MUST NOT receive UNKNOWN_OPERATION for one that exists.

Where the caller's rights cannot be established because the target cannot be resolved, UNKNOWN_OPERATION is the correct answer.

For job-status and job-stop the rule is §7.10's, not this one: a job identifier is unguessable, so a caller that names one was told it, and the manager answers UNKNOWN_JOB for an identifier that names nothing and ACCESS_DENIED for one that names a job the caller may not touch.

4.8 Requests

Peios / Peios technical reference / PSPU / Service Control and Notification

A request is one JSON object.

{"command": "start", "service": "jellyfin", "wait": true}

4.8.1 Fields #

FieldTypeRequiredMeaning
commandstringalwaysThe command to run. §4.11, §4.14, §4.15
servicestringfor service commandsThe target service's name.
waitboolnoWhether to block until the operation resolves. §4.13
typestringfor shutdownpoweroff, reboot or halt.
operation_idstringfor operation-statusThe operation to report on.
job_idstringfor job-status, job-stopThe job.
submitterstringnoFor job-list: only jobs with this submitter SID.
identitystringnoFor job-list: only jobs whose identity is this SID.
logon_sessionintegernoFor job-list: only jobs whose identity is in this logon session.
statestringnoFor job-list: only jobs in this state (§7.B).

command MUST be present and MUST be a string naming a command the manager implements. A request whose command is absent, is not a string, or names no known command MUST be answered INVALID_COMMAND.

service MUST be present and a string for start, stop, restart, reload, reset and status. Its absence, or a non-string value, MUST be answered INVALID_ARGUMENTS.

wait MUST be a boolean when present. A non-boolean MUST be answered INVALID_ARGUMENTS. Its default is per command (§4.13).

type MUST be present and MUST be exactly one of the three values for shutdown. Anything else MUST be answered INVALID_ARGUMENTS.

operation_id MUST be present and a string for operation-status. A value that is not a well-formed identifier MUST be answered INVALID_ARGUMENTS.

job_id MUST be present and a string for job-status and job-stop, under the same rule. The four job-list filters are each optional; one that is present with a malformed value — a SID that does not parse, a state outside §7.B — MUST be answered INVALID_ARGUMENTS rather than matching nothing, and filters that are all present MUST all hold.

4.8.2 Fields that do not apply #

A field the command does not use MUST be ignored, not rejected. A service on a list, or a wait on a status, is accepted and has no effect.

This is what makes the request shape extensible: a client written against a later revision may send a field an earlier manager does not know, and the earlier manager ignores it. §4.21.

4.8.3 Service names #

A service name is 1 to 128 bytes drawn from [A-Za-z0-9._-]. The manager MUST NOT accept a name outside that set, and a client MUST NOT send one.

The restriction exists because service names are used as path components and as configuration key names by managers that store their definitions in a hierarchy. / and : are excluded specifically: the first because it is a separator wherever the name is used as a path component, and the second because it is conventionally reserved for a manager's own synthetic naming.

4.9 Responses

Peios / Peios technical reference / PSPU / Service Control and Notification

Every response carries a status field, which MUST be exactly "ok" or "error". What else it carries depends on which of four shapes it is.

4.9.1 The acknowledgement shape #

Returned by a lifecycle command that created, merged into, queued, cancelled, cleared or executed an operation.

{"status": "ok", "operation_id": "a1b2c3d4-…", "service": "jellyfin",
 "state": "active", "cause": "explicit_start", "warnings": []}
FieldTypeMeaning
operation_idstringThe operation to observe.
servicestringThe target.
statestringThe service's state when the response was formed. §4.B
causestring or nullWhy the service last transitioned. §4.B
warningsarray of stringsHuman-readable warnings. Often empty.
modestringFor a reload only. §4.13

warnings here is an array of strings. The status response uses the same field name for an array of objects (§4.14); a client MUST distinguish them by which command it sent, not by inspecting the array.

4.9.2 The status shape #

Returned by status, and also by a lifecycle command that had no effect — see §4.12. §4.14 gives it in full, including granted, the rights the caller holds on the service, which it carries either way.

4.9.3 The system shape #

Returned by shutdown:

{"status": "ok"}

Nothing else. A shutdown has no operation to observe and no service to report on. reload-config and boot have shapes of their own (§4.15).

4.9.4 The error shape #

{"status": "error", "code": "ACCESS_DENIED",
 "message": "caller lacks SERVICE_START on jellyfin"}

code MUST be one of the values in §4.10. message is human-readable and is not normative: a client MUST NOT parse it, match on it, or branch on its content. Two managers answering the same request with the same code MAY word the message differently.

4.9.5 Nullability #

A field that does not apply to the current state MUST be present and null rather than omitted, except where this chapter says otherwise. A client MUST accept null for any field this chapter marks nullable, and MUST NOT treat a null as an error.

The one exception is mode, which appears only on a reload response. The manager's structured events are not responses and do not follow this rule: their payloads follow PGSS §6.5, under which a value that does not apply is absent, never null.

4.10 Errors

Peios / Peios technical reference / PSPU / Service Control and Notification

The code field of an error response MUST be one of these values. The manager MUST NOT emit any other code, and a client MUST treat a code it does not recognise as an unrecoverable error for that request (§4.21).

CodeMeaningCloses?
MALFORMED_REQUESTThe frame is not a single well-formed JSON object. §4.5On an empty frame
REQUEST_TOO_LARGEThe request exceeds the configured maximum. §4.4Yes
INVALID_COMMANDcommand is absent, not a string, or names no known command.No
INVALID_ARGUMENTSA field the command requires is absent or malformed.No
UNKNOWN_SERVICEThe named service has no definition the manager can act on.No
UNKNOWN_OPERATIONThe operation identifier names nothing the manager holds — it never existed, or its retention has elapsed. §4.14No
UNKNOWN_JOBThe job identifier names nothing the manager holds — it never existed, or its retention has elapsed. §7.7No
ACCESS_DENIEDThe access check denied the requested right. §4.7No
INVALID_STATEThe command is not valid for the service's current state (§4.12), or the manager is shutting down (§4.15).No
OPERATION_TIMEOUTA wait=true request's operation did not reach a terminal state in time. §4.13No
INTERNAL_ERRORThe manager failed while executing the command.No

4.10.1 Distinctions a client can rely on #

UNKNOWN_SERVICE versus ACCESS_DENIED. A caller that may not query a service still receives UNKNOWN_SERVICE for a name that does not exist and ACCESS_DENIED for one that does but which it may not touch. This chapter does not attempt to hide the existence of services: the list filtering (§4.7) keeps a service's state from a caller that may not query it, not the fact that the service exists.

INVALID_STATE versus ACCESS_DENIED during shutdown. During shutdown the state restriction is evaluated first, so a caller who would have been denied receives INVALID_STATE instead. A client MUST NOT infer that it holds a right from receiving INVALID_STATE.

OPERATION_TIMEOUT does not cancel anything. It reports that the client's wait ended, not that the operation did. The operation continues and can still be observed with operation-status.

4.10.2 Codes are not extensible without a version #

The manager MUST NOT introduce a new code without the version negotiation in §4.21. A client written against this revision will not recognise one, and the only safe thing it can do with an unrecognised code is fail the request — so a new code silently converts a handled condition into an unhandled one.

4.11 Lifecycle Commands

Peios / Peios technical reference / PSPU / Service Control and Notification

Five commands move a service through its state machine. None of them acts directly: each creates, merges into, queues or cancels an operation, and the operation is what actually happens.

CommandEffectDefault wait
startStart the service.true
stopStop the service, escalating if it does not exit.true
restartStop then start, under one operation.true
reloadTell the service to re-read its configuration.false
resetClear a terminal state, returning the service to inactive.false

reload defaults to not waiting because a reload's outcome is often advisory, and a client usually wants the identifier rather than the block. reset is synchronous and completes before the response is sent, so waiting on it would mean nothing.

4.11.1 Operations #

The manager MUST return an operation identifier from any lifecycle command that created, merged into, queued, cancelled, cleared or executed an operation. The client uses it to poll (§4.14) or to correlate.

The manager MUST NOT invent an operation solely so that it has an identifier to return. Where a command had no effect, or the service was already in the state asked for, the manager MUST return the status shape instead of an acknowledgement (§4.12).

4.11.2 Merging #

Where an operation of the same type is already in flight for the same service, the manager MUST merge the new request into it and MUST return the existing operation's identifier.

A merged caller therefore receives an identifier that may be older than its own request, whose requested_at precedes the moment it sent the command. This is correct — that is when the work being waited on began — and a client MUST NOT treat an identifier older than its request as an error.

The manager MUST NOT tell the caller that a merge occurred. A merge is not a distinguishable outcome, and a client cannot do anything with the knowledge.

4.11.3 What completion means #

CommandThe operation completes when
startThe service reaches a dependent-satisfying state, or a state indicating its start-time conditions did not apply.
stopThe service is no longer running.
restartThe service reaches its normal successful start target after the restart.
reloadThe reload resolves, whatever its mode.
resetImmediately.

4.11.4 Timeouts #

Every queued or executing operation has a maximum lifetime, derived from the target service's own configured timeouts; a start the service manager is holding on an undecided dependency has none and MUST stay Pending until the dependency is decided.

The lifetime is measured from the operation's creation, including any time it spent queued. From the caller's point of view they have been waiting since they sent the command, and an operation that sat behind another for longer than its lifetime MUST fail rather than begin.

An operation whose lifetime expires while it is still queued MUST fail, and MUST fail its waiters. Expiry of the operation object MUST NOT by itself authorise the manager to act on the service — a stop operation that timed out while waiting its turn does not license signalling the service ahead of that turn.

4.12 Command Outcomes by State

Peios / Peios technical reference / PSPU / Service Control and Notification

A command sent to a service in an unexpected state MUST receive a defined answer. The manager MUST NOT silently do nothing.

inactivestartingactivereloadingstoppingcompletedbackofffailedabandonedskipped
startactmergealreadyalreadyqueueactdeferactinvalidact
stopnoopcancel + actactactmergeclearcancelnoopinvalidnoop
restartactqueueactactqueueactactactinvalidact
reloadinvalidinvalidactmergeinvalidinvalidinvalidinvalidinvalidinvalid
resetnoopinvalidinvalidinvalidinvalidinvalidinvalidclearclearclear
statusokokokokokokokokokok

4.12.1 The outcomes #

act — create an operation and execute it. The manager returns the acknowledgement shape.

merge — an operation of this type is in flight. The command merges into it (§4.11) and the caller receives that operation's identifier.

queue — the operation is created and left pending; it executes once the operation ahead of it completes. The caller receives the new operation's identifier.

defer — an automatic restart is already pending for this service. The manager MUST create a pending start operation, or merge into a deferred one that already exists, and MUST NOT execute it until the existing delay has elapsed. A start MUST NOT shorten a pending restart's delay.

already — the service is in the state the command would take it to and no operation of this type is in flight. The manager MUST return the status shape, not an error and not an acknowledgement.

noop — the command has no effect. The manager MUST return the status shape.

clear — the service returns to inactive. This is a synchronous outcome; the manager returns an acknowledgement.

cancel — abort or cancel the operation in flight, then proceed.

invalid — the command is not valid for this state. The manager MUST answer INVALID_STATE.

ok — status is answered from any state.

4.12.2 The backoff column #

A service in backoff is down with an automatic restart pending, and the four lifecycle commands mean different things there:

  • start defers, as above, and honours the remaining delay.
  • stop cancels both the pending restart and any deferred start, and the service becomes inactive.
  • restart cancels the automatic restart and performs a caller-initiated one.
  • reload and reset are invalid: there is no process to reload and no terminal state to clear.

4.12.3 The abandoned column #

Every lifecycle command except reset is invalid on an abandoned service. reset clears it. Nothing else is meaningful while processes the manager could not terminate are still present.

4.12.4 A service being withdrawn #

A manager MAY keep supervising a service whose definition has been removed while an instance of it is still running. In that condition the manager MUST answer start, restart and reload with UNKNOWN_SERVICE, MUST accept stop, and MUST report the condition in the status shape (§4.14). This holds whatever the service's state.

4.13 Wait Semantics

Peios / Peios technical reference / PSPU / Service Control and Notification

wait decides whether a lifecycle command's response is sent immediately or held until the operation resolves.

With wait: false, the manager MUST respond as soon as it has accepted the operation, with the acknowledgement shape and the service's state at that moment.

With wait: true, the manager MUST hold the connection open and respond when the operation reaches a terminal state, with the same shape and the service's state, cause and warnings observed at that time.

A connection blocked on a wait is not idle (§4.4). The manager MUST NOT close it for idleness however long the operation runs.

4.13.1 When a wait ends #

EndingResponse
The operation reaches a terminal stateThe acknowledgement shape.
The operation's lifetime expiresOPERATION_TIMEOUT.
The operation is no longer held by the managerUNKNOWN_OPERATION.

OPERATION_TIMEOUT ends the client's wait, not the operation. The operation continues, and the client MAY still observe it with operation-status using the identifier it never received — which it does not have. A client that needs to survive a timeout SHOULD issue the command with wait: false, keep the identifier, and poll.

4.13.2 Reload mode #

A response to a reload command MUST carry a mode field saying how the reload resolved:

ValueMeaning
confirmedThe service acknowledged the reload by signalling readiness. The reload demonstrably happened.
advisoryThe manager issued the reload and the service did not acknowledge it. The reload probably happened; nothing confirms it.
failedThe reload did not happen. An external reload command exited non-zero or timed out.

mode MUST be present on every response to a reload, including one sent with wait: false — in which case it MUST be advisory, since nothing has been observed yet.

A client MUST treat these three as an exhaustive set and MUST NOT expect a fourth. A manager MUST NOT introduce one without §4.21.

The distinction between confirmed and advisory is the whole value of the field: a service that implements the reload handshake (§4.19) can be known to have reloaded, and one that does not cannot. failed does not mean the service stopped — a failed reload leaves a running service running.

4.14 Query and Job Commands

Peios / Peios technical reference / PSPU / Service Control and Notification

Three commands read service state and change nothing. Three more observe and stop submitted jobs (§7), and are here because a client of this channel sees jobs alongside services.

4.14.1 status #

Returns everything the manager knows about one service.

{
    "status": "ok",
    "service": "jellyfin",
    "display_name": "Jellyfin",
    "description": "Media server for the living room.",
    "state": "active",
    "cause": "explicit_start",
    "status_text": "Listening on port 8096",
    "progress": null,
    "current_job": {
        "id": "a1b2c3d4-…",
        "type": "service_main",
        "pid": 1234,
        "started_at": "2026-06-01T12:34:56.123456789Z",
        "identity": "jellyfin-svc"
    },
    "current_operation": {
        "id": "e5f6g7h8-…",
        "type": "start",
        "source": "admin"
    },
    "health": "healthy",
    "uptime_seconds": 86400,
    "definition_removed": false,
    "warnings": [],
    "timers": [],
    "granted": ["query_status", "start", "stop"]
}
FieldTypeMeaning
display_namestring or nullThe definition's human-readable name.
descriptionstring or nullThe definition's human-readable description.
statestring§4.B.
causestring or nullWhy the service last transitioned. §4.B.
status_textstring or nullThe most recent status string the service sent (§4.19).
progressobject or nullThe most recent progress the service sent (§4.19), in the form §7.7 gives.
current_jobobject or nullThe current main job, or null if none.
current_operationobject or nullThe current operation, or null if none.
healthstring or nullhealthy, unhealthy, unknown, or null when the service has no health check configured.
uptime_secondsinteger or nullWhole seconds since the current job started. Null when nothing is running.
definition_removedboolTrue while the service's definition has been withdrawn and an instance is still draining (§4.12).
warningsarray of objectsConditions worth an operator's attention.
timersarray of objectsThe service's calendar timer triggers, as the manager has them armed. Empty when it has none.
grantedarray of stringsThe service rights the caller holds on this service. Never null.

current_job carries id, type (§4.B), pid, started_at and identity. pid and started_at are independently nullable. identity is the identity string the manager resolved for the execution, which is not necessarily what the resulting token contains.

current_operation carries id, type and source (§4.B).

The manager MUST clear status_text and progress to null at the start of every activation generation. A status string or a progress figure from a previous incarnation MUST NOT survive a restart and be reported as though it described the current process.

4.14.1.1 What the caller may do #

granted lists the service rights of §4.7 that the caller holds on the service, each by its name lowercased and without SERVICE_: query_status, start, stop, interrogate, in that order, and only those held. It describes the caller who asked, not the service; two callers asking about the same service may be told different things.

The manager MUST compute it with the access check it runs for a command (§4.7) — the caller's token, the service's descriptor, the service generic mapping — asking for MAXIMUM_ALLOWED rather than for one right, and MUST report only the service rights among what that check grants. A client can therefore offer only the commands that will be allowed: while the caller's token and the service's descriptor stay as they were, a command whose required rights (§4.7) all appear in granted passes its access check, and one needing a right that is absent fails it. Whether the command will then do anything is a separate question, which the service's state answers (§4.12), and during a shutdown §4.15's restriction comes first.

This check is a question, not a command. A right the caller lacks is simply absent from granted, and the manager MUST NOT record the check as a denial.

A status returned by a lifecycle command that had no effect (§4.12) carries granted for the caller of that command.

4.14.1.2 Status warnings #

warnings in the status shape is an array of objects, not strings:

{"path": "/sys/fs/cgroup/peinit/jellyfin/health",
 "type": "health",
 "detected_at": "2026-06-01T12:34:56.123456789Z"}
FieldTypeMeaning
pathstringWhat the warning is about.
typestringThe kind of warning. §4.B.
detected_atstringWhen the manager noticed. §4.5.

A client MUST accept a type it does not recognise and MUST NOT discard the warning, since a warning it cannot classify is still one an operator should see.

4.14.1.3 Status timers #

timers has one object for each calendar timer trigger of a service that is not disabled (for peinit, the peinit TRM §9), ordered by schedule:

{"schedule": "*-*-* 02:00:00",
 "scheduled_at": "2026-06-02T02:00:00.000000000Z",
 "fires_at": "2026-06-02T02:07:12.000000000Z",
 "last_fired_at": "2026-06-01T02:03:40.000000000Z",
 "not_armed": null}
FieldTypeMeaning
schedulestringThe calendar expression, as the trigger gives it.
scheduled_atstring or nullThe schedule's next occurrence. §4.5.
fires_atstring or nullWhen the timer will fire: that occurrence, delayed by the jitter drawn for it. Equal to scheduled_at without jitter. §4.5.
last_fired_atstring or nullWhen it last fired. Null when it has not fired since the manager started and no earlier firing is recorded. §4.5.
not_armedstring or nullWhy the manager has not armed it, when it has not: its schedule does not parse, or has no occurrence within the manager's search horizon. Null when armed.

An armed timer has scheduled_at and fires_at, and null not_armed. A timer not armed has only schedule and not_armed.

These are the manager's own figures, not a calculation a client could repeat. The jitter is drawn at random each time the timer is armed, and only the manager knows which schedules it refused, so a client MUST NOT compute a next firing from the definition and present it as when the service will run.

last_fired_at is the firing, not the run: it is when the manager acted on the timer (the peinit TRM §9.2), whatever came of that. For a persistent timer that has not fired since the manager started, it is the timestamp the manager recorded before (the peinit TRM §9.3).

4.14.2 list #

Returns every service the caller may query, with a compact summary.

{
    "status": "ok",
    "services": [
        {"service": "jellyfin", "display_name": "Jellyfin",
         "description": "Media server for the living room.",
         "state": "active", "cause": "explicit_start",
         "health": "healthy", "next_timer_at": null},
        {"service": "logrotate", "display_name": null,
         "description": null, "state": "inactive",
         "cause": "clean_exit", "health": null,
         "next_timer_at": "2026-06-02T00:03:51.000000000Z"}
    ]
}

Exactly seven fields per entry: the name, the definition's nullable display_name and description, the state, cause and health of the status shape, and next_timer_at, the soonest fires_at among the service's armed timers, or null when it has none. Services the caller may not query are omitted (§4.7).

A service whose definition has been withdrawn is listed, and the list entry does not say so. A client that needs to know MUST issue a status.

4.14.3 operation-status #

Returns one operation by identifier.

{
    "status": "ok",
    "operation": {
        "id": "e5f6g7h8-…",
        "type": "start",
        "service": "jellyfin",
        "source": "admin",
        "state": "completed",
        "result": "active",
        "merged_into": null,
        "error": null,
        "requested_at": "2026-06-01T12:34:56.123456789Z",
        "started_at": "2026-06-01T12:34:56.223456789Z",
        "completed_at": "2026-06-01T12:34:58.923456789Z"
    }
}
FieldMeaningPresent when
idThe operation's identifier.Always.
type§4.B.Always.
serviceThe target.Always.
sourceWhy the manager created it. §4.B.Always.
state§4.B.Always.
resultThe resulting service state.completed.
errorWhy it did not complete.failed, cancelled, aborted.
merged_intoThe surviving operation's identifier.merged.
requested_atWhen it was created.Always.
started_atWhen it began executing.Once running.
completed_atWhen it reached a terminal state.Once terminal.

Fields that do not apply to the current state MUST be null.

error carries a reason for all three non-success terminal states, not only for failed. A client MUST NOT read a non-null error as meaning the operation failed — it MUST read state for that. Cancellation and abortion have reasons worth reporting, and a separate field for each would give a client three places to look for one fact.

4.14.4 job-status #

Returns one submitted job by identifier, as the job view of §7.7.

{"command": "job-status", "job_id": "0190a3b2-…"}
{"status": "ok", "job": { … }}

The view is §7.7's, with one field added on this channel: what a submitter sees of its job on the jobs channel is what a client sees of it here, and the client is also told what it may do to the job.

4.14.4.1 What the caller may do to a job #

On this channel every job view — in the response to job-status, job-list and job-stop — carries one field beyond §7.7's:

FieldTypeMeaning
grantedarray of stringsThe job rights the caller holds on this job. Never null.
{"id": "0190a3b2-…", "type": "submitted", "state": "running", …,
 "granted": ["query", "stop"]}

It lists the job rights of §7.8 the caller holds, each by its name lowercased and without JOB_: query, stop, signal, in that order, and only those held. The manager MUST compute it as it computes a status's granted, with the access check it runs for a job command, against the job's own descriptor and the job generic mapping (§7.8), and the same rules follow: it is a question rather than a command, a right the caller lacks is absent rather than denied, and a command whose required right is listed passes its access check.

For a job-stop that waits, the manager MAY compute granted when it accepts the command rather than when it answers. A job's descriptor is fixed at submission (§7.8), so the two could differ only if the caller's token had changed in between.

The jobs channel's view does not carry granted. A submitter is answered there under §7.8's own rules.

4.14.5 job-list #

Returns every submitted job the caller may query, as job views.

{"command": "job-list", "identity": "S-1-5-21-…"}
{"status": "ok", "jobs": [ { … }, { … } ]}

Each entry is the full job view (§7.7), with granted (above). Jobs the caller may not query are omitted (§4.7). The four filters of §4.8 — submitter, identity, logon_session, state — narrow the result before it is filtered by right, and the manager MUST NOT let the response reveal whether the filters or the rights removed an entry.

Terminal jobs still within their retention period are listed; a client that wants only live ones filters by state.

Note

logon_session is what an authority uses to find everything running under a logon it is ending: it lists the jobs in that session and stops each it holds JOB_STOP on. A user-facing view of "my jobs" filters by identity; a broker's view of "the jobs I started" filters by submitter.

4.14.6 job-stop #

Stops one submitted job, with the semantics of the jobs channel's stop (§7.8): the termination signal, the job's stop_timeout, then the kill.

{"command": "job-stop", "job_id": "0190a3b2-…", "wait": true}

wait defaults to true, and the response is the job view, with granted, when the job is terminal; with wait: false it is the view as soon as the stop has been initiated. A job-stop on a terminal job returns the view unchanged, with "status": "ok".

This is the one command on this channel that acts on a job, and it creates no operation: a job has no state machine to contend for (§7.6). There is no job-signal here; signalling is the submitter's business, on its own channel.

4.14.7 Retention #

The manager MUST hold an operation record for at least a grace period after it reaches a terminal state, so that a client polling for the result can retrieve it. The value a Peios service manager uses is in §4.A.

An identifier that never existed, and one whose record has been dropped after its grace period, MUST both be answered UNKNOWN_OPERATION. A client MUST NOT distinguish them, and MUST treat UNKNOWN_OPERATION after a successful acknowledgement as meaning the result is no longer available rather than that the operation never ran.

Job records are retained under §7.7's rule, and UNKNOWN_JOB carries the same two meanings.

4.15 System Commands

Peios / Peios technical reference / PSPU / Service Control and Notification

Three commands are about the manager rather than a service. Two act on it; the third, boot, asks how the machine booted.

4.15.1 shutdown #

{"command": "shutdown", "type": "reboot"}

type MUST be one of:

ValueMeaning
poweroffStop everything and remove power.
rebootStop everything and restart the machine.
haltStop everything and halt, leaving the machine powered.

The response is {"status": "ok"} and nothing else. There is no operation to observe: a shutdown is a mode the manager enters, not an action on a service, and by the time it has finished there is nobody left to tell.

A client MUST NOT expect the connection to survive. The manager MAY close it at any point after the response.

4.15.2 reload-config #

Re-reads the configuration and rebuilds whatever the manager derives from it.

{
    "status": "ok",
    "summary": {
        "added": ["jellyfin"],
        "updated": ["sshd"],
        "restored": [],
        "marked_removed": ["old-migration"],
        "discarded": ["obsolete-timer"]
    },
    "warnings": []
}
FieldTypeMeaning
addedarray of stringsServices that did not exist before.
updatedarray of stringsServices whose definition changed.
restoredarray of stringsServices whose withdrawal was reversed.
marked_removedarray of stringsServices whose definition is gone but which are still running.
discardedarray of stringsServices removed outright.
warningsarray of stringsHuman-readable warnings about the new configuration.

Every member of summary MUST be present, even when empty. A client MUST accept a member of summary it does not recognise, and MUST ignore it (§4.21).

4.15.2.1 It is atomic #

The manager MUST validate the new configuration in full before adopting any of it, and MUST adopt it only if validation succeeds. If validation fails, the manager MUST leave the previous configuration in force and MUST answer INVALID_STATE, reporting what was wrong.

A partially applied configuration is worse than the one already running: the running one at least booted.

4.15.2.2 It does not live-update #

The manager MUST NOT reconfigure a running service. A changed definition takes effect the next time that service starts.

4.15.3 boot #

Reports how the current boot went: which mode the machine booted in and why, how close it is to recovery, and whether this boot has counted as a successful one yet. It takes no arguments and changes nothing.

{"command": "boot"}

A boot mode is how much of the configuration a boot starts. In full mode the manager starts everything configured to start at boot. In safe mode it starts a reduced set: the services the boot cannot do without, which this chapter calls critical services, and those marked as wanted in Safe mode. In recovery mode it starts nothing and offers a maintenance shell instead. Which services are critical, and what each mode starts, is the manager's design.

A boot is confirmed once it has shown it works: every critical service has held a dependent-satisfying state (§4.2) for a grace period without a break. The manager keeps a boot attempt count, the number of boots in a row that started and were never confirmed, and enters recovery when it reaches a threshold rather than boot into the same failure again. Confirming a boot puts the count back to 0.

{
    "status": "ok",
    "boot": {
        "mode": "safe",
        "reason": "safe_mode_downgrade",
        "downgrade": ["critical service in dependency cycle storage -> network"],
        "attempts": 1,
        "max_attempts": 3,
        "confirmed": false,
        "grace_seconds": 30,
        "waiting_on": ["storage"],
        "confirms_at": null,
        "confirm_error": null
    }
}
FieldTypeMeaning
modestringThe mode the machine booted in, after any downgrade. §4.B
reasonstringWhy it is in that mode. §4.B
downgradearray of stringsWhat forced a downgrade, one human-readable finding each. Empty unless reason is safe_mode_downgrade.
attemptsintegerThe boot attempt count as the manager checked it against the threshold at the start of this boot.
max_attemptsintegerThe threshold. 0 means the check is off and the count never sends the machine to recovery.
confirmedboolThis boot has been confirmed, and the count has been put back to 0.
grace_secondsintegerThe grace period, in seconds.
waiting_onarray of stringsThe critical services not yet holding a dependent-satisfying state. Empty once the grace period has ended.
confirms_atstring or nullWhen the boot will be confirmed if every critical service keeps holding, in RFC 3339 UTC. Null while waiting_on is not empty, and once the grace period has ended.
confirm_errorstring or nullWhy the count could not be put back to 0 when the grace period ended. Human-readable.

Every member of boot MUST be present. A client MUST accept a member of boot it does not recognise, and MUST ignore it (§4.21).

attempts counts the boots before this one; this boot is not among them. A machine reaches recovery when a boot begins with attempts at or above max_attempts, so max_attempts - attempts is how many more unconfirmed boots the machine has before recovery.

confirmed MUST be true only once the count has actually been put back to 0. If the grace period ended but the manager could not write the count, confirmed is false and confirm_error says why: the next boot will count this one as a failed attempt, whatever its services did, and a client MUST NOT report the boot as confirmed.

A confirmed boot stays confirmed. A critical service that fails after confirmation is a failure of that service, not of the boot, and does not make confirmed false again.

A manager in recovery mode does not serve the control channel, so a client SHOULD NOT expect to read recovery from boot; the value exists so that a manager which does serve it there has a word for it.

boot is checked against the manager's descriptor for SYSTEM_QUERY_STATUS (§4.7). How a machine booted is not a secret, and a manager's default descriptor SHOULD grant this right to every authenticated principal.

4.15.4 During shutdown #

Once the manager is shutting down, it MUST reject every command except status, list, operation-status, boot, job-status, job-list and job-stop with INVALID_STATE.

The first six are permitted because they change nothing and because a client watching a shutdown proceed has a legitimate reason to keep looking; job-stop because a job the shutdown will reach eventually may be one a client has reason to end now. Everything else — including a second shutdown — is refused: the manager has committed to a course of action and a command that would alter it arrives too late to be honoured consistently.

As §4.7 says, this restriction is evaluated before the access check, so a caller who would have been denied receives INVALID_STATE instead.

4.16 The Notification Channel

Peios / Peios technical reference / PSPU / Service Control and Notification

A service reports on itself over a Unix SOCK_DGRAM socket the manager binds and holds for the lifetime of the system.

4.16.1 Addressing #

The manager MUST make the socket's path available to each service it starts, in the NOTIFY_SOCKET environment variable, set in the service process's environment before exec.

The path is not part of this contract, and a service MUST NOT hardcode one. The manager MAY bind one socket for all services or one per service; a service cannot tell and MUST NOT depend on either.

The manager MUST set NOTIFY_SOCKET unconditionally, for every service it starts, whatever readiness protocol that service uses. A service uses this channel for keepalives, status, timeout extension and the descriptor store as well as for readiness, and a manager that set the variable only for services expected to signal readiness would make the rest unreachable.

The manager MUST NOT allow NOTIFY_SOCKET to be overridden by any configurable environment layer. A service that could override it would silently disable its own supervision.

4.16.2 Direction and delivery #

The channel is one-way. The manager does not reply, and a service MUST NOT wait for one.

Delivery is not guaranteed. A datagram MAY be dropped, by the kernel under load or by the manager. Every field in §4.19 is therefore either idempotent or a statement of a current condition, and a service that needs an effect to have taken hold sends the field again rather than waiting for an acknowledgement that does not exist.

The manager MUST NOT let this channel exert backpressure on a service. A service MUST NOT be able to block by sending, and the manager MUST NOT require a service to slow down.

4.16.3 Bounds #

The manager MUST accept a datagram of at least the size in §4.A, and MUST accept at least the number of file descriptors in §4.A in one datagram's control message.

The manager MUST detect a datagram that exceeded either bound and MUST reject the whole datagram (§4.17). It MUST NOT process a truncated datagram: a truncation can leave a tail that parses as a complete, valid line, which would apply a field the sender did not send.

A service MUST NOT send a datagram exceeding either bound.

4.17 Datagram Framing

Peios / Peios technical reference / PSPU / Service Control and Notification

A datagram carries zero or more lines, separated by 0x0A. Each line is KEY=VALUE.

READY=1
STATUS=Listening on port 8096

A trailing newline on the last line is permitted and is not a line of its own. A trailing 0x0D on any line MUST be stripped before the line is interpreted, so a sender that emits CRLF is understood.

The datagram MUST be well-formed UTF-8.

4.17.1 Applying a datagram #

The manager MUST parse every line before applying any of them, and MUST apply every line of a datagram it accepts, in order.

If any line is malformed, the manager MUST reject the entire datagram and apply nothing from it. Any file descriptors it carried MUST be closed.

Partial application is the failure this rule exists to prevent. A datagram saying RELOADING=1 and something unintelligible has an ambiguous meaning, and applying the half that parsed picks one reading of it silently.

4.17.2 What is malformed #

A line is malformed when it is non-empty and:

  • it contains no =; or
  • its key is empty.

An empty line is not malformed. It is skipped.

A datagram is malformed when it is not well-formed UTF-8, or when it exceeded a bound in §4.16.

4.17.3 Three ways a line can fail to take effect #

These are distinct and a service author needs the distinction:

SituationEffect on the datagramEffect on the line
A malformed lineRejected entirely—
An unrecognised keyApplied normallyIgnored
A recognised key with an unexpected valueApplied normallyIgnored

The second is what makes the field set extensible (§4.21): a service built against a later revision may send a field an older manager does not know, and the older manager applies the rest.

The third is the one that surprises. READY=0 is not a malformed line and does not reject the datagram; READY expects the value 1 and anything else is silently ignored. A service MUST NOT send a recognised key with a value the field does not define, and MUST NOT expect to be told when it does. §4.19 gives each field's accepted values.

4.17.4 Rejection is recorded, not answered #

The manager MUST record a rejected datagram, with at least the sender's identity and the reason, and MUST attribute it to a service where the sender could be identified.

It MUST NOT reply. There is nothing to reply on.

4.18 Sender Authentication

Peios / Peios technical reference / PSPU / Service Control and Notification

A datagram on this channel claims to be a supervised process — a service, or a submitted job (§7) — talking about itself. The manager MUST establish that it is.

4.18.1 The requirements #

The manager MUST enable SO_PASSCRED on the socket, and MUST reject any datagram arriving without a kernel-attested credentials control message.

It MUST then establish all of the following, and MUST drop the datagram if any fails:

  1. The sender is a service's current main job, or a submitted job. The manager MUST match the attested PID against the main jobs and the submitted jobs it is supervising. A hook process, a health check, or a child a service or a job forked MUST NOT be able to notify on its behalf.
  2. That job has exec'd and is running. A job still in setup has not become the service yet.
  3. That job has a kernel handle on the process — a pidfd, or an equivalent that refers to one specific process rather than to a number.
  4. The handle still refers to the attested PID. The manager MUST verify the PID against the handle rather than trusting the PID alone.
  5. For a service's job, its activation generation is the service's current one. A submitted job has no generation and no replacement; the step does not apply to it.

4.18.2 Why steps 3 and 4 exist #

A PID identifies a process only until that process exits. Between a service writing a datagram and the manager reading it, the service can die and its PID be recycled onto something else — and PID matching alone would then attribute the unrelated process's message to the service, or attribute the service's message to whatever now holds the number.

A handle obtained atomically at fork does not have that property. Verifying the attested PID against the handle is what turns a probable match into a certain one.

4.18.3 Why step 5 exists #

A datagram sent by an incarnation of a service that has since been restarted MUST NOT be applied to its replacement. Without the generation check, a READY=1 written by a process moments before it crashed could mark the process that replaced it ready — declaring a service healthy on the strength of a message from the one that just failed.

Readiness is per activation generation, and so is everything else on this channel.

4.18.4 What the manager MUST NOT use #

The manager MUST NOT use the sender's UID or GID as an authorisation input, and MUST NOT accept any identity a service asserts in the datagram's content.

Identity on this channel is which supervised process this is, and only the kernel can attest that. A service does not have a name here that it gets to state, and a job does not have an identifier.

4.18.5 What a job may send #

A submitted job may send every field in §4.19. Those that address a service transition it is not in — RELOADING, EXTEND_TIMEOUT_USEC outside a readiness wait, the descriptor-store fields, which a job has no store for — MUST be applied as §4.19 says for the case where they do not apply: ignored, or closed and recorded. READY=1 from a readiness: none job is ignored. A job's STATUS and PROGRESS are retained and exposed in its job view (§7.7).

4.19 Notification Fields

Peios / Peios technical reference / PSPU / Service Control and Notification

Every field a service may send. A manager MUST implement all of them. A service MUST NOT send a recognised key with a value the field does not define (§4.17).

4.19.1 Lifecycle #

FieldValueMeaning
READY1Startup is complete and the service is serving.
RELOADING1Configuration reload has begun.
STOPPING1Graceful shutdown has begun.
LEVELfree textA currently true readiness condition. Repeated fields publish the complete set; an empty-only set retracts it.

READY=1 is what a service using notification readiness sends when it is genuinely able to serve, not when its process exists. Anything depending on the service starts on the strength of it, so a service that signals early declares its dependents' assumptions true before they are.

RELOADING=1 opens a reload. The manager waits a bounded period after issuing a reload for this field; a service that sends it MUST follow with READY=1 when the reload is complete, and the pair is what lets the manager report the reload confirmed rather than advisory (§4.13). A service that never sends either still reloads — it just cannot be observed to have done so.

STOPPING=1 tells the manager the service is already shutting down. A manager that receives it MUST NOT send a further termination signal to that service. It MUST NOT extend or reset the stop timeout: the service still has to exit within it, and a service needing longer sends EXTEND_TIMEOUT_USEC.

LEVEL names a condition the service currently satisfies, in the service's own vocabulary. A service MAY repeat this field within one datagram to publish several conditions. For example, a routed netd sends LEVEL=link, LEVEL=addressed and LEVEL=routed as three lines in one datagram.

When a datagram contains at least one LEVEL field, the manager MUST atomically replace the sender's retained set with the distinct nonempty values from all its LEVEL fields. Field order and duplicates MUST NOT change the resulting set. An empty value adds no member; a datagram with only empty LEVEL values MUST clear the set. A datagram without LEVEL MUST leave the retained set unchanged. The entire replacement MUST be visible before releasing dependents, including when the datagram also contains READY=1.

The manager MUST discard the set whenever the service leaves a state that satisfies dependents: these are claims made by a running process.

A level-conditioned dependency (Requires = ["netd:routed"]) MUST NOT be satisfied unless the target is in a dependent-satisfying state and the retained set contains the named level. Names are opaque, case-sensitive and per-publisher; commas have no special meaning. The manager MUST NOT assume an ordering or implication between them. A publisher that knows several conditions hold MUST publish each one that it promises to expose to dependents.

The value SHOULD be a single token of at most 64 printable ASCII characters. A value outside that shape is not an error, but a dependency condition cannot name it, so nothing can ever wait on it.

4.19.2 Health #

FieldValueMeaning
WATCHDOG1A keepalive.
WATCHDOG_USECunsigned integerChange the expected keepalive interval, in microseconds.
EXTEND_TIMEOUT_USECunsigned integerExtend the current transition's deadline, in microseconds.

WATCHDOG_USEC with a value above zero sets the interval and MUST re-arm the timer from the moment the message arrives, rather than letting the new interval apply only from the next keepalive. A value of zero MUST disable the watchdog.

The value MUST NOT persist across a restart. A restarted service gets the interval its definition specifies.

EXTEND_TIMEOUT_USEC sets the current transition's deadline to expire that many microseconds from the message's arrival. It replaces the deadline rather than adding to it, and MAY be sent repeatedly.

Because it replaces, a value smaller than the time remaining shortens the deadline, and zero expires it immediately. A service MUST NOT send a value expecting it to be treated as a floor.

The manager MUST cap the extended deadline at four times the base timeout of the phase being extended, and MUST clamp rather than reject a value beyond the cap. During a system shutdown the manager MUST additionally cap it at the time remaining in the shutdown, and where both apply the stricter MUST win.

A message arriving while the service is not in a transition MUST be ignored. There is no deadline to extend.

4.19.3 Reporting #

FieldValueMeaning
STATUSfree textA human-readable statement of what the service is doing.
PROGRESSsee belowHow far the service has got.
PROGRESS_UNITbytes, items or percentWhat PROGRESS counts.
ERRNOfree textAn errno-style error number.
EXIT_STATUSfree textAn exit status, informationally.

All five MUST be authenticated like any other field and MUST be recorded by the manager as structured events (PGSS §6), which, like every event, are not written while the emission policy has their type switched off (PGSS §6.9). They MUST NOT be forwarded to a log sink as though they were the service's output — they are the service speaking to the manager.

STATUS, PROGRESS and PROGRESS_UNIT MUST additionally be retained and exposed — as status_text and progress in the status shape (§4.14), and in a submitted job's view (§7.7). ERRNO and EXIT_STATUS MUST NOT be retained.

4.19.3.1 Progress #

PROGRESS takes one of three forms, each of two unsigned decimal integers at most, separated by /:

FormMeaningA viewer shows
NCounting, with no end.A rising count.
N/Counting towards an end that exists but is not yet known.A rising count, and expects a bound to arrive.
N/TN of T.A bar.

T MUST be at least 1, and N MUST NOT exceed T when T is given. A value outside these forms, or violating either rule, is an unexpected value under §4.17: the line is ignored and the datagram is otherwise applied. A manager MUST NOT clamp or repair a progress value.

The manager retains the most recent accepted value and exposes it as a progress object: current is N; total is T or null; bounded is true for the second and third forms and false for the first; unit is the most recent accepted PROGRESS_UNIT or null. A PROGRESS_UNIT outside its three values is an unexpected value and is ignored. Each field is replaced only by a datagram that carries it: a STATUS alone leaves progress as it was.

T MAY change from one datagram to the next — a job that rescans has learned something — and N MAY fall. A viewer renders the latest value; it MUST NOT infer motion from the difference between two.

A service that sends progress with no end in sight MUST send the first form and not a guessed T. A service that has not started counting sends nothing: absence means no progress concept, and a viewer shows an indeterminate indicator.

The manager MUST bound how often a change of progress becomes an event on its event stream, so that a sender updating a thousand times a second cannot produce a thousand events; the retained value is the latest regardless. The bound a Peios service manager uses is §4.A.

A service MUST NOT include a newline or carriage return in a STATUS value: it would frame as two lines, the second of which is almost certainly malformed.

4.19.4 The descriptor store #

FieldValueMeaning
FDSTORE1Store the descriptors attached to this datagram.
FDNAMEfree textThe name to store or remove them under.
FDSTOREREMOVE1Remove the descriptors stored under FDNAME.
FDPOLL0Do not monitor the stored descriptors for error conditions.

§4.20.

4.19.5 Fields that are not supported #

FieldWhy
MAINPIDA manager supervises the process it forked, through a kernel handle obtained at fork. There is no mechanism for redirecting supervision to another process, and there is deliberately none: a service that could nominate its own supervision target could nominate anything.
BUSERRORPeios has no D-Bus.

Neither is rejected distinctly. Both are simply unrecognised keys and are ignored like any other (§4.17). A service MUST NOT rely on being told that it sent one.

4.20 The Descriptor Store

Peios / Peios technical reference / PSPU / Service Control and Notification

A service may hand file descriptors to the manager and get them back after a restart it did not choose. This is what lets a stateful daemon — one holding a listening socket, say — restart without dropping what it already had.

The manager MUST support a per-service maximum, which MAY be zero. Zero disables the store for that service, and a service MUST NOT assume a store exists.

4.20.1 Storing #

On an authenticated datagram carrying FDSTORE=1 with descriptors attached, the manager MUST:

  1. If the store is disabled for this service, close the descriptors and record the rejection.
  2. If the store already holds its maximum, close the descriptors and record the rejection. It MUST NOT evict an existing entry — a full store is full, and silently discarding something the service is relying on to survive a restart would be worse than refusing the new one.
  3. Store them under the value of FDNAME if present and non-empty, and under the name stored otherwise.
  4. Note FDPOLL=0 if present.

A datagram MAY carry several descriptors. Each becomes its own entry under the one name, and each is independently subject to the maximum — so a datagram carrying more than will fit has some stored and the rest closed.

Several entries MAY share a name.

FDPOLL=0 asks the manager not to monitor the descriptors for error conditions. A manager MAY monitor stored descriptors and remove ones that have become invalid; a manager that does not MUST still accept the field.

4.20.2 Removing #

FDSTOREREMOVE=1 with FDNAME MUST remove every entry of that name and close its descriptors. A name matching nothing is a no-op and MUST NOT be an error.

FDSTOREREMOVE=1 without FDNAME MUST be treated as a malformed line, rejecting the whole datagram (§4.17). A remove with no name has no defined meaning, and the alternative readings — remove everything, remove the default name, do nothing — are far enough apart that guessing between them silently is worse than refusing.

4.20.3 Returning them #

When the service starts again, the manager MUST pass the stored descriptors to the new process:

  1. Placed consecutively, starting at descriptor 3, with close-on-exec cleared.
  2. LISTEN_FDS set to the number of descriptors passed.
  3. LISTEN_FDNAMES set to the names, colon-separated, in the same order as the descriptor numbers.
  4. LISTEN_PID set to the new process's own PID.
  5. The store cleared.

LISTEN_PID is what lets a service verify that the variables are addressed to it rather than inherited from an ancestor. A conforming client checks it against its own PID before trusting LISTEN_FDS, and treats a mismatch as meaning no descriptors were passed — so a manager that omits it hands descriptors to a service that will not take them.

All four variables MUST be absent when no descriptors are passed, and the manager MUST NOT allow any of them to be set by a configurable environment layer. A LISTEN_FDS reaching a service that was passed nothing points its descriptor-adopting code at whatever happens to be at descriptor 3.

Descriptors are returned to the service's main process only. A hook or a probe MUST NOT receive them.

The store MUST be cleared once the descriptors have been passed. The manager MUST NOT clear it when a start attempt fails before that point — the descriptors are still the service's, and the next attempt should get them.

A returned listening socket conveys, to every client that connects, the identity of the process that called listen() on it — the previous instance of the service. A service that receives listening sockets from the store SHOULD restamp them with its own identity before accepting (the Peios Kernel TRM §3.5), so that clients verifying the peer see the instance actually serving them.

4.20.4 When the store is emptied #

The manager MUST clear the store, closing its descriptors, when:

  • the service is stopped deliberately — by a client, or as part of a system shutdown; or
  • the service's definition is withdrawn and its entry is finally discarded.

The manager MUST NOT clear it on a restart the service did not ask for — a crash, or a restart policy acting on one. That case is the entire purpose of the mechanism: the descriptors survive exactly the restart the service could not prepare for.

Note

LISTEN_FDS, LISTEN_FDNAMES and LISTEN_PID are the convention established by systemd's sd_listen_fds, and are specified here in the same form deliberately. Software already written to adopt descriptors that way works against a Peios service manager without modification.

4.21 Extension

Peios / Peios technical reference / PSPU / Service Control and Notification

Neither channel carries a version number. Both are extended by the rules below, which are what allow a client and a manager built against different revisions to interoperate.

4.21.1 What may be added #

A request field. A manager MUST ignore a request field it does not recognise (§4.8). A client MAY therefore send a field a manager may not know, and MUST NOT depend on the field having had an effect.

A response field. A client MUST ignore a response field it does not recognise, and MUST NOT treat its presence as an error. This includes an unrecognised member of summary in a reload-config response, and an unrecognised key in current_job or current_operation.

A notification field. A manager MUST ignore an unrecognised key (§4.17). A service MAY therefore send a field a manager may not know.

A type value in a status warning. A client MUST accept a warning whose type it does not recognise and MUST NOT discard it. An unclassifiable warning is still a warning.

A right in granted. A client MUST ignore a right name it does not recognise in a status's or a job view's granted (§4.14). A right it does not know of is one it offers no command for.

A command, together with the error codes and enumerated values that appear only in responses to it. A client is exposed to a command only by sending it, so a client that does not know a command is never exposed to anything it introduces. A manager MAY therefore add a command without a version, provided that nothing it introduces can reach a client through any existing command's response.

4.21.2 What may not be added without a version #

Anything a client must recognise in order to behave correctly cannot be added compatibly, because an older client's only options are to fail or to misbehave.

A manager MUST NOT, without a negotiated version:

  • introduce an error code outside §4.10 on an existing command;
  • introduce a service state, transition cause, operation state, operation type, operation source or job type outside §4.B, or a job state or cause outside §7.B, where an existing command could return it;
  • introduce a reload mode outside the three in §4.13;
  • change what an existing command does;
  • change the shape of an existing response, including changing a field's type or making a non-nullable field nullable.

A client encountering one of these has no correct behaviour available. Faced with an unknown state it cannot decide whether the service is running; faced with an unknown error code it cannot decide whether to retry.

4.21.3 What a client must do with the unknown #

A client MUST treat an unrecognised enumerated value in a field it depends on as an error for that request, and MUST NOT map it onto the nearest value it does know. Guessing that an unfamiliar state is probably like active is how a monitoring tool reports a broken system as healthy.

A client MUST treat an unrecognised error code as unrecoverable for that request. It MUST NOT retry, since it cannot know whether the condition is transient.

4.21.4 Versioning, when it comes #

A future revision introducing an incompatible change MUST do so through an explicit negotiation, in which a client states what it understands and the manager answers within that. Until such a mechanism exists, this chapter's contract is fixed and the rules above are the whole of the supported way for it to grow.

4.22 Conformance

Peios / Peios technical reference / PSPU / Service Control and Notification

4.22.1 A conforming manager #

The channels. Listens on a Unix stream socket at a well-known path and on a Unix datagram socket whose path it gives each service in NOTIFY_SOCKET. Ensures both sockets, and the directories containing them, carry a Security Descriptor admitting the parties intended to reach them, and relies on no POSIX mode bits (§4.3, §4.4, §4.16).

Framing. Emits exactly one compact JSON object per newline-terminated frame. Answers a malformed frame with MALFORMED_REQUEST and an oversized one with REQUEST_TOO_LARGE, closing the connection after a frame-level failure and holding it open after a command-level one (§4.5).

Identity. Obtains every client's identity from the kernel once, at accept, and uses no UID, GID or asserted identity (§4.6).

Authorisation. Checks every command against the appropriate Security Descriptor with the mappings in §4.7, naming the target in each check's audit context and giving every default descriptor a SACL that audits every refusal, writes no access-denied event of its own, filters list rather than denying it, and does not let operation-status distinguish an operation the caller may not see from one that does not exist. Reports a status's and a job view's granted from that same check asking for MAXIMUM_ALLOWED, and does not treat it as a denial (§4.14).

Commands. Implements all fourteen, with the outcomes in §4.12 for every command-and-state pair, the response shapes in §4.9, §4.14 and §4.15, and only the error codes in §4.10. Reports a boot as confirmed only once its attempt count has been put back to 0 (§4.15). Filters job-list by JOB_QUERY as it filters list, and answers job-status and job-stop against the job's own descriptor (§4.7, §4.14).

Operations. Returns an identifier from every lifecycle command that produced one and none where it did not; merges same-type requests and returns the surviving identifier; measures every operation's lifetime from its creation including queue time; and holds a terminal record for at least the grace period (§4.11, §4.14).

Waiting. Honours the per-command wait default, holds a waiting connection open past the idle timeout, and carries a mode on every reload response (§4.13).

Notification. Authenticates every datagram through all five steps of §4.18, including verifying the attested PID against a kernel handle and checking the activation generation for a service's job. Applies all lines of an accepted datagram and none of a rejected one. Rejects a truncated datagram rather than processing it. Implements every field in §4.19, retaining STATUS, PROGRESS and PROGRESS_UNIT and bounding how often a progress change becomes an event.

The descriptor store. Closes rather than keeps what it refuses; returns descriptors from 3 upward with LISTEN_FDS, LISTEN_FDNAMES and LISTEN_PID set; clears the store on a deliberate stop and keeps it across a restart the service did not ask for (§4.20).

Extension. Ignores unrecognised request and notification fields, and introduces nothing from §4.21's closed list without a negotiated version.

4.22.2 A conforming client #

Sends one compact JSON object per newline-terminated frame. Treats an immediate close with no response as a refusal. Does not parse message. Accepts null for every nullable field, and unrecognised fields everywhere it is told to. Treats an unrecognised enumerated value or error code as an error for that request rather than guessing. Reads state rather than the presence of error to decide whether an operation succeeded. Does not infer its own rights from an INVALID_STATE received during shutdown. Opens a new connection to act under a different identity.

4.22.3 A conforming service #

Reads NOTIFY_SOCKET from its environment and hardcodes no path. Sends READY=1 when it can genuinely serve, not when its process exists. Sends no recognised key with an undefined value, no newline inside a STATUS value, and no PROGRESS outside its three forms. Sends no datagram exceeding the bounds in §4.A. Expects no reply, and no acknowledgement that a field was applied. Treats EXTEND_TIMEOUT_USEC as replacing a deadline rather than adding to one. Checks LISTEN_PID against its own PID before adopting any descriptor.

4.22.4 What conformance is not #

A system that offers neither channel is still Peios (PSPU §1.2). These are contracts for the components that do offer them, not a bar the platform requires anything to clear.

Appendix 4.A Limits and Defaults

Peios / Peios technical reference / PSPU / Service Control and Notification

The values a Peios service manager uses. A manager MAY use different ones; where a value is configurable, it MUST be discoverable to an administrator through the same surface that sets it.

4.A.1 Control channel #

BoundValueConfigurableDefined in
Socket path/run/services/peinit/control.sockNo§4.4
Concurrent connections per caller16; SYSTEM exemptYes§4.4
Concurrent connections in all256Yes§4.4
Request size65536 bytes, excluding the terminating newlineYes§4.4
Idle timeout30 secondsYes§4.4
Listen backlog32No§4.4

4.A.2 Operations #

BoundValueDefined in
Terminal record retention60 seconds§4.14
Operation lifetimeThe target service's own start or stop timeout§4.11

4.A.3 Notification channel #

BoundValueDefined in
Maximum datagram65536 bytes§4.16
Descriptors per datagram64§4.16
First returned descriptor3§4.20
Descriptor store maximumPer service; 0 disables§4.20
Timeout extension cap4 × the phase's base timeout§4.19
Progress event rateAt most one per sender per second§4.19

4.A.4 Composing the two channels #

A STATUS value a service sends on the notification channel is returned as status_text on the control channel. The notification datagram bound is 65536 bytes and the control response is not bounded by the request limit, so a status string that fits in a datagram is always returnable.

The bounds are stated at their values here rather than left to each implementation because a producer has no other way to learn them. Lowering either without telling anyone breaks every service that was sizing to the old one, and the failure — a truncated datagram, or a connection closed mid-request — does not name its cause.

Appendix 4.B Wire Vocabulary

Peios / Peios technical reference / PSPU / Service Control and Notification

Every enumerated value that appears on the control channel. All are lower snake case. Except for status warning types and granted rights, these sets are closed: a manager MUST NOT emit a value outside them without the version negotiation in §4.21, and a client MUST treat one it does not recognise as an error for that request rather than mapping it onto a value it knows. The open sets state their handling rules below.

These sets are the control channel's. The same words appear in the manager's structured events in kebab-case, as every event value is (PGSS §6.5): explicit_start on the channel is explicit-start in an event.

4.B.1 Response status #

ok, error

4.B.2 Service state #

ValueProcess?Satisfies dependents?
inactiveNoNo
startingMaybeNo
activeYesYes
reloadingYesYes
stoppingBrieflyNo
completedNoYes
backoffNoNo
failedNoNo
abandonedYes, unkillablyNo
skippedNoYes

Exactly four states satisfy dependents: active, reloading, completed and skipped. A client deciding whether something depending on this service could be running MUST use that set and no other.

4.B.3 Transition cause #

explicit_start, dependency_start, restart_policy, binds_to_recovery, timer, explicit_stop, explicit_reload, explicit_reset, conflict_eviction, binds_to_propagation, shutdown_wave, process_crash, clean_exit, clean_exit_restart, readiness_timeout, watchdog_timeout, health_check_failure, pre_hook_failure, parent_setup_failure, pre_exec_failure, dependency_failure, restart_budget_exhausted, cycle_detected, validation_error, assertion_error, condition_skipped, tty_unavailable, process_unkillable, internal_error

A cause may also be null, for a service that has not transitioned.

4.B.4 Service health #

healthy, unhealthy, unknown

health is null when the service has no health check configured, which is distinct from unknown — the latter means one is configured and has not produced a result yet.

4.B.5 Job type #

service_main, pre_exec_hook, post_exec_hook, reload_hook, health_check, submitted

Only service_main appears in current_job. Only submitted appears in a job view (§7.7); a job's state and cause vocabularies are §7.B's.

4.B.6 Operation type #

start, stop, restart, reload, reset

4.B.7 Operation state #

ValueTerminal?Meaning
pendingNoQueued, not yet executing.
runningNoExecuting.
completedYesReached its goal.
failedYesDid not reach its goal, or expired while queued.
mergedYesMerged into another operation.
cancelledYesTerminated while pending. Never executed.
abortedYesTerminated while running.

4.B.8 Operation source #

admin, boot, shutdown, dependency_propagation, restart_policy, timer, binds_to_recovery, binds_to_propagation, conflict_resolution, on_failure, tty_release

admin is the only source a client's own command produces. The rest describe operations the manager created for its own reasons, and a client observing one has learned something about what the manager is doing rather than about anything it asked for.

4.B.9 Reload mode #

confirmed, advisory, failed

4.B.10 Status warning type #

service_tree, health, hooks, helper

These name what part of a service's process containment could not be reclaimed, service_tree being the whole of it and therefore the most serious. A client MUST accept a value outside this set and MUST NOT discard the warning (§4.21).

4.B.11 Granted right #

In a status's granted: query_status, start, stop, interrogate

In a job view's granted, on this channel: query, stop, signal

Each names a right of §4.7 or §7.8, lowercased and without its SERVICE_ or JOB_ prefix, and they appear in the order given here (§4.14). A client MUST ignore a name outside these sets rather than treat it as an error: an unrecognised right is one it has no command for, and the rights it does know are still correctly reported.

4.B.12 Shutdown type #

poweroff, reboot, halt

Request-only; the manager does not echo it.

4.B.13 Boot mode #

full, safe, recovery

The modes §4.15 defines. Only boot returns one, and a manager in recovery does not serve this channel, so in practice a client reads full or safe.

4.B.14 Boot mode reason #

ValueMeaning
normalA full boot, which is what a boot is unless something says otherwise.
requestedThe boot was asked for in this mode, on the kernel command line or its equivalent.
safe_mode_downgradeA full boot that the manager downgraded to safe because it found a fault it could not start the full configuration past, such as a critical service in a dependency cycle. downgrade lists each finding.

5.1 Scope and Roles

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

This chapter specifies the peipkg package format and the peipkg repository protocol: the artifact by which compiled software is distributed to a Peios system, and the static-HTTP protocol by which a system discovers, trusts, and fetches those artifacts.

A package is the binary distribution primitive of Peios — the unit of build, distribution, and trust. It is deliberately narrow: it defines how binaries reach a system, not how they are integrated into services, roles, or features. Higher-level artifacts reference packages; a package knows nothing of them.

5.1.1 Roles #

Three roles speak this specification. A requirement is stated against the role, not the program; one program may serve more than one.

RoleObligation
ProducerBuilds package files. Everything a .peipkg contains is a producer obligation.
RepositoryPublishes a descriptor, two indexes, and package files over static HTTP, and signs the metadata.
ConsumerFetches, verifies, and installs packages. Every validation and rejection rule binds the consumer.

A repository operator is usually also a producer, but need not be: a repository may publish packages built elsewhere, and the format's signatures survive the journey.

5.1.2 In scope #

  • The on-wire package file: container, internal layout, manifest schema, payload layout, per-file integrity
  • Package identity: names, versions, version comparison, architectures
  • How a package expresses its relationships to other packages, and what it means for one to satisfy another
  • Package signing: algorithm, envelope, verification
  • The repository protocol: descriptor, active and archive indexes, URL conventions, freshness and rollback protection
  • Establishing and maintaining trust in a repository
  • The rules under which the format may be extended

5.1.3 Out of scope #

  • How a consumer decides what to install. Given several candidates that all satisfy a dependency, which one it picks, in what order it applies a plan, and how it recovers from an interrupted one are the consumer's own design.
  • How a consumer stores its state. The installed-package database, its transaction journal, and its cache format are private.
  • How a producer builds a package. Recipes, build farms, and source trees are producer mechanics; only their output is specified here.
  • Roles, role features, core features, and applets. These are separate subsystems that reference packages.
  • Integration metadata attached to packages — service definitions, registry seeds, reconciller manifests. These belong to the artifacts that compose packages, not to packages.
  • Security descriptor semantics. A package carries security descriptor bytes; what they mean is specified with the kernel's access-control subsystem.

5.1.4 Relationship to other chapters #

Nothing in this chapter is a conformance requirement on a Peios system: a system that ships software some other way is still Peios (§1.1). What this chapter guarantees is that the format and the protocol are written down and will not move.

5.2 Terminology

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

  • Package — a binary distribution artifact: one or more files, metadata describing its identity and relationships, and, when signed, a signature. The unit of build, distribution, and installation.

  • Manifest — the JSON document at .peipkg/manifest.json inside a package that declares its identity, relationships, side-effect requirements, and build provenance. The manifest is authoritative for a package's metadata (§5.18).

  • Files manifest — the JSON document at .peipkg/files.json carrying one content hash per regular payload file (§5.25).

  • Payload — the tar entries of a package that are not metadata: the files, directories, and symlinks it installs.

  • Repository — a collection of packages addressable as a unit, identified by its base URL.

  • Repository descriptor — the small JSON document at a well-known path within a repository declaring its identity, signing keys, and the locations of its indexes (§5.31).

  • Index — a signed JSON document listing packages available from a repository. Every repository publishes two: an active index (§5.33) listing the current version of each package, and an archive index (§5.35) listing every version ever shipped.

  • Virtual name — a capability name, rather than a package name, that a package may require or provide (§5.4).

  • Role — a virtual name that several installed packages may contend to own on the filesystem, with at most one holding it (§5.23).

  • Claim — the binding of a contended filesystem name (a claim path) to a file supplied by the package that holds a role (a target).

  • Holder — the single installed package that currently owns a role. A role with no holder is unheld.

  • Side-effect declaration — a manifest flag naming a standard maintenance operation to be invoked after install, drawn from a closed set (§5.24).

  • Installation root — a self-contained filesystem tree into which packages are installed. The default root is the system root; a system may define others (§5.19).

  • Epoch, upstream version, peios revision — the three components of a version string (§5.5).

  • Trust anchor — a key fingerprint supplied to a consumer out-of-band, against which a repository's descriptor signature is first verified (§5.37).

5.3 Package Names

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A package's name identifies it within a repository and across every repository that may serve it.

5.3.1 Character set #

A package name MUST consist of ASCII characters drawn from:

  • lowercase letters a–z
  • digits 0–9
  • hyphen -
  • period .
  • plus sign +

A package name MUST NOT contain uppercase letters, whitespace, underscores, or any character outside that set.

5.3.2 Structure #

A package name MUST start with a lowercase letter or a digit, and MUST end with a lowercase letter, a digit, or a plus sign.

The hyphen and the period are separator characters. The plus sign is not a separator but an ordinary name character: it is intrinsic to names such as libstdc++ and g++, so it MAY repeat and MAY end a name.

A package name MUST NOT contain two consecutive separators — --, .., -., or .-.

A package name MUST be at least 2 and at most 64 characters long.

Note

The character set admits the common upstream patterns: library suffixes (libstdc++), architecture prefixes (lib32-foo), and dotted module names (python3.example). Underscores are excluded so that the filename separator below stays unambiguous.

5.3.3 Case #

Package names are case-sensitive. Because uppercase letters are forbidden, this is equivalent to byte-for-byte equality.

5.3.4 Filename convention #

A package file's name, on disk and in URLs, MUST be:

<name>_<version>_<architecture>.peipkg

The separator between fields is the underscore, and the extension is .peipkg.

A filename is parsed by splitting at the first underscore and then at the second: what precedes the first is the name, what lies between them is the version, and what follows the second — up to the .peipkg extension — is the architecture. The underscore MUST NOT appear in the name (§5.3) or in the version (§5.5). It MAY appear in the architecture (§5.8), and does in x86_64, which is why the architecture field is defined as the remainder rather than as the text after the last underscore.

Examples:

nginx_1.26.2-3_x86_64.peipkg
jq_1.7.1-2_x86_64.peipkg
peios-docs_0.22-1_noarch.peipkg
libstdc++_13.2.1-4_x86_64.peipkg

A consumer MUST NOT derive a package's identity from its filename. The manifest is authoritative (§5.18); the filename is a convenience for humans and for static hosting.

5.3.5 Sub-package conventions #

Packages shipping related but separable content SHOULD use a hyphen-suffix convention:

SuffixContent
-docDocumentation, man pages, examples
-debugDebug symbols
-devHeaders, static libraries, build-time dependencies
-sourceCorresponding source (§5.14)

These are advisory. The format does not enforce them, and other suffixes MAY be used for other purposes.

5.4 Virtual Names

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

The name of a dependencies, optional_dependencies, or provides entry (§5.21) MAY be a virtual name rather than a real package name. A virtual name expresses a capability that is required or provided but is not itself a package — most importantly a machine-derived capability such as an ELF soname or a pkg-config module (§5.22).

conflicts and replaces entries target real packages, and so MUST use the package-name grammar of §5.3, not the grammar below.

5.4.1 Grammar #

The virtual-name grammar is a strict superset of the package-name grammar, in two respects.

Uppercase letters are permitted. A virtual name often mirrors an exact machine identifier — libGL.so.1, libICE.so.6, a foreign module name — which is case-sensitive. Case MUST be preserved: folding it would be unsound, because a case-sensitive dynamic loader treats libGL.so.1 and libgl.so.1 as distinct.

A namespaced form namespace(argument) is permitted, for capabilities drawn from a foreign namespace. The namespace is lowercase letters and digits, beginning with a letter. The argument is bracketed by parentheses, is non-empty, and may contain letters, digits, the separators -, ., +, and additionally _, :, and / — so that pkgconfig(gtk+-3.0), perl(Foo::Bar), and python3dist(ruamel.yaml) are all well-formed.

Outside the namespaced form, a virtual name uses the package-name character set extended with the underscore _, which is common in real sonames (libgcc_s.so.1, libnss_files.so.2). It MUST start with a letter or a digit and MUST end with a letter, a digit, or +. Unlike a package name, a virtual name MAY contain consecutive separators, so that libstdc++.so.6 is well-formed.

A virtual name MUST be at least 2 and at most 128 characters long.

5.4.2 One namespace #

Virtual names share a namespace with real package names. A dependency on libssl is satisfied by a package literally named libssl, or by any package whose provides includes libssl.

The namespaced form exists to keep machine-derived capabilities from colliding with package names: pkgconfig(zlib) is unambiguously the pkg-config module, never a package.

5.5 Versions

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Every package carries a version string that identifies one build of that package. Version strings have a defined structure and a defined comparison order (§5.6), so that "newer" and "older" are unambiguous across every implementation.

5.5.1 Structure #

[<epoch>:]<upstream>-<peios_revision>
  • Epoch — an OPTIONAL non-negative integer, separated from the rest by a colon. Absent means zero.
  • Upstream — the version the upstream project assigned, or, for Peios-native software, the version Peios assigned as vendor.
  • Peios revision — a REQUIRED positive integer identifying the build of this upstream version produced by the distributor.
1.26.2-3            upstream 1.26.2, revision 3
1.26.2-rc.1-1       upstream 1.26.2-rc.1, revision 1
2:0.5.0-1           epoch 2, upstream 0.5.0, revision 1
0.22-1              upstream 0.22, revision 1 (Peios-native)

5.5.2 Epoch #

The epoch MUST be encoded as ASCII decimal digits with no leading zeros, except that zero is encoded as the single digit 0. The separator is a single colon.

Epoch exists solely to override the natural ordering of upstream version strings when an upstream regression makes a later release compare as older than an earlier one. Bumping it SHOULD be a deliberate, documented decision; a routine version update MUST NOT bump it.

Note

An upstream project releases v2.0, abandons that line, and releases v0.5 as its new stable branch. Without an epoch, v0.5 compares as older than v2.0 and nobody on v2.0 can upgrade. Bumping the epoch to 1 says "v0.5 in this epoch is newer than anything in epoch 0".

5.5.3 Upstream version #

The upstream version is everything between the optional epoch separator and the final hyphen preceding the revision.

It MUST consist of ASCII characters drawn from: letters a–z and A–Z, digits 0–9, period ., plus sign +, hyphen -, and tilde ~. It MUST start with a digit or a letter, and MUST NOT contain whitespace or any character outside that set.

Note

The set is permissive because upstream projects format versions in every way imaginable: numeric (1.26.2), hyphenated pre-release (1.0.0-rc.1), concatenated pre-release (16beta1), build metadata (1.0+build.42), and tilde-separated pre-release (1.0~rc.1).

5.5.4 Peios revision #

The peios revision MUST be a positive integer encoded as ASCII decimal digits with no leading zeros. It is incremented when the distributor produces a new build of the same upstream version — a backported security patch, a build-configuration change, a dependency bump, a packaging fix.

The first revision of any upstream version MUST be 1. Revision 0 is reserved and MUST NOT appear in a published package.

5.5.5 Parsing #

A version string is parsed as follows:

  1. If the string contains a colon, split at the first colon: what precedes it is the epoch, what follows is the remainder. Otherwise the epoch is 0 and the remainder is the whole string.
  2. Split the remainder at the last hyphen: what follows is the peios revision, what precedes is the upstream version.
  3. The peios revision MUST parse as a positive integer.
  4. The upstream version MUST satisfy the constraints above.

A version string that does not parse is invalid, and an implementation MUST reject it.

5.5.6 Stability #

The comparison algorithm of §5.6 is frozen. Any two conforming implementations MUST produce identical comparison results for every pair of valid version strings. An implementation that disagrees with another on any such pair is non-conformant, whichever of the two is at fault.

5.6 Comparing Versions

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Two version strings are compared in three stages:

  1. Compare epochs as integers. If they differ, the higher epoch is greater.
  2. If equal, compare upstream versions by the algorithm below.
  3. If equal, compare peios revisions as integers. The higher revision is greater.
  4. If all three are equal, the versions are equal.

5.6.1 Tokenising the upstream version #

A tokeniser walks the upstream string left to right and emits segments:

  1. The non-alphanumeric characters ., +, -, and ~ are separators and belong to no segment.
  2. A maximal run of digits forms a numeric segment.
  3. A maximal run of letters forms an alphabetic segment.
  4. A transition between a digit and a letter ends the current segment and begins a new one.

5.6.2 Pre-release segments #

A segment is a pre-release segment if it falls at or after the earlier of:

  • the first ~ separator — the tilde and every segment following it; or
  • the first recognised pre-release token: a segment whose token carries a rank of 0 to 4 in the table below, that segment and every segment following it.

Once the pre-release tail begins it extends to the end of the upstream version: every later segment is a pre-release segment, whatever the separators between them. A - separator is an ordinary separator; it is not itself a pre-release marker.

Note

In 1.0.0-rc.1, rc is recognised at rank 4, so rc and the following 1 are pre-release. In 16beta1, beta is recognised at rank 2, so beta and 1 are pre-release with no separator involved. In 1.0-foo, foo is alphabetic but not recognised: it is an ordinary rank-5 segment, and the - before it begins nothing.

UpstreamSegments
1.26.21, 26, 2
1.0.0-rc.11, 0, 0, rc (pre), 1 (pre)
1.0~rc11, 0, rc (pre), 1 (pre)
16beta116, beta (pre), 1 (pre)

5.6.3 Pre-release rank #

TokenRank
dev0
alpha1
a1
beta2
b2
pre3
rc4
any other alphabetic token5

Rank 0 sorts lowest. Rank lookup MUST be case-insensitive: Alpha, ALPHA, and alpha all carry rank 1.

5.6.4 Comparing two segments #

The pre-release flag is compared first, before the kinds. If exactly one of the two segments is a pre-release segment, that segment is the lesser, whatever either segment contains. A pre-release segment sits at or after the point where the version was marked as preceding a release, and that is a property of position rather than of content.

When both segments carry the same flag — both pre-release, or neither — their kinds decide:

  1. Both numeric — compare as integers. Leading zeros are insignificant.
  2. Both alphabetic — compare by pre-release rank. When the ranks are equal:
    • at a rank of 0 to 4, the segments are equivalent. The table assigns several tokens to one rank as aliases, so two segments at the same recognised rank sort equal whichever alias appears.
    • at rank 5, the segments tiebreak by ASCII byte order against other rank-5 tokens.
  3. One numeric, one alphabetic — the alphabetic segment is the lesser if the pair is a pre-release pair, and the greater if it is not. (Where only one of them is a pre-release segment, the rule above has already decided.)

Note

1.0~alpha-1 == 1.0~a-1       # both rank 1
1.0~beta-1  == 1.0~b-1       # both rank 2
1.0~Alpha-1 == 1.0~ALPHA-1   # case-insensitive recognition
1.0-foo-1   <  1.0-zzz-1     # rank-5 lexical tiebreak

Two upstream versions that differ only in which alias they use are equal. A repository publishing both forms produces archive entries at the same logical version; the active index may carry either. A producer SHOULD pick one canonical form — conventionally the long token, alpha and beta — and keep to it.

5.6.5 Unequal lengths #

When the segments of one version run out and every common segment compared equal, the next segment of the longer sequence decides. Its pre-release flag decides it, and its kind is irrelevant:

Next segment in the longerResult
a pre-release segmentthe shorter is greater
anything elsethe shorter is less
Example tailResult
~1numeric, pre-releasethe shorter is greater
~rcalphabetic, pre-releasethe shorter is greater
.1numericthe shorter is less
-fooalphabetic, rank 5the shorter is less

5.6.6 Worked examples #

ABResultWhy
1.0~21.0-2A < Bthe pre-release flag decides before the kinds
1.0~foo1.0-fooA < Bthe same, for two rank-5 tokens
1.01.0A = Bidentical
1.02.0A < Bnumeric segment differs
1.101.9A > Bnumeric, not lexical
1.01.0.1A < Blonger continues numerically
1.01.0-rc.1A > Blonger continues with a pre-release
1.0-rc.11.0-rc.2A < Bnumeric segment within the tail
1.0-alpha1.0-betaA < Brank 1 < rank 2
1.0-rc1.0-preA > Brank 4 > rank 3
1.0a11.0a2A < Bnumeric within a concatenated tail
1.0a11.0b1A < Brank 1 < rank 2
1.0~rc11.0A < Bthe tilde forces a pre-release
1.0~11.0A < Bthe tilde forces a pre-release, numeric or not
5.2~202401015.2A < Ba dated snapshot precedes its release
0:1.01:0.5A < Bepoch dominates
1.0-11.0-2A < Bpeios revision differs
1.0-foo-11.0-1A > Bfoo is rank 5, sorting after a number

5.7 Version Constraints

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A version constraint restricts which versions of a package satisfy a relationship (§5.21).

5.7.1 Operators #

OperatorMeaning
=exactly equal
>strictly greater than
>=greater than or equal
<strictly less than
<=less than or equal
!=not equal

Comparison is by §5.6 in every case.

A bare version string with no operator is equivalent to =.

5.7.2 Combining #

Multiple expressions within one constraint string are separated by commas and combined with logical AND. A version satisfies the constraint if and only if it satisfies every expression.

libssl >= 3.0
libssl >= 3.0, < 4.0
nginx = 1.26.2-3

Whitespace around operators and commas is optional and MUST be ignored.

A constraint string MUST parse as one or more operator-and-version expressions separated by commas. One that does not parse is invalid, and an implementation MUST reject it.

5.7.3 Revision-relaxed operands #

A constraint's version operand MAY omit the -<revision> that a complete version string otherwise requires.

An operand written without a revision — >= 3.0 — constrains the epoch and the upstream version only. A candidate satisfies it whenever its epoch and upstream version satisfy the operator, whatever its revision.

An operand written in full constrains the revision as well.

Note

This is what lets a dependency track a capability level rather than a packaging iteration. libssl >= 3.0 is satisfied by 3.0-1 and by 3.0-7 alike; nothing about a repackaging of the same upstream release should change whether a dependency is met.

5.8 Architectures

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A package's architecture identifies the instruction-set architecture its binaries were built for. It is a separate identifier from the name and the version.

5.8.1 Identifier format #

An architecture identifier MUST consist of lowercase letters a–z, digits 0–9, and the underscore _. It MUST start with a lowercase letter and MUST NOT exceed 16 characters.

5.8.2 Defined architectures #

IdentifierMeaning
x86_6464-bit x86 (AMD64, Intel 64)
aarch6464-bit ARM (ARMv8-A or later)
noarcharchitecture-independent

An implementation MUST recognise all three. x86_64 is the primary target; every other architecture is secondary in this version of the specification.

Additional identifiers MAY be defined in a future version. A new identifier MUST satisfy the format above and SHOULD be the canonical Linux machine name — the value uname -m reports — where one exists.

5.8.3 Triplets #

Each architecture identifier that is not noarch has a corresponding triplet, used in the install paths where arch-specific content is namespaced (§5.15):

<identifier>-linux-peios
IdentifierTriplet
x86_64x86_64-linux-peios
aarch64aarch64-linux-peios

noarch has no triplet form, and architecture-independent payload MUST NOT be installed under an arch-namespaced path.

Note

The peios suffix distinguishes Peios binaries from foreign-arch binaries originating elsewhere — Debian uses gnu, Alpine uses musl. It leaves room for a future multi-architecture system to host foreign-distribution binaries without filesystem-path collisions.

5.8.4 Architecture-independent packages #

The noarch identifier denotes a package whose payload contains no architecture-dependent content: documentation, configuration templates, scripts in interpreted languages, or metadata only.

A package MUST NOT declare noarch if its payload contains compiled binaries, shared libraries, or any other content whose semantics depend on the target architecture.

5.8.5 Installability #

Each Peios system has a single primary architecture, fixed at install time.

  • A package whose architecture equals the system's primary architecture MAY be installed.
  • A package whose architecture is noarch MAY be installed on any system.
  • A package whose architecture is neither MUST NOT be installed.

Note

This version of the specification does not define multi-architecture systems — systems installing foreign-architecture packages alongside native ones. Running foreign-architecture binaries for emulation, cross-compilation, or legacy compatibility is addressed by mechanisms outside the package format. The triplet convention above applies regardless, so that a package conforming to this version stays forward-compatible with such an extension.

5.9 Document Conventions

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Every artifact this chapter defines — the manifest, the files manifest, the signature envelope, the repository descriptor, and both indexes — is a JSON document. The rules below apply to all of them.

5.9.1 JSON #

Documents conform to RFC 8259 and are UTF-8 encoded (RFC 3629). Field names are lowercase with underscores between words: schema_version, never schemaVersion. Field order is not significant.

Unknown fields MUST be ignored on parse, so that the format can be extended compatibly (§5.38). The signature envelope (§5.28) is the one exception, and mandates strict parsing.

5.9.2 Parser hardening #

A consumer's JSON parser processes attacker-supplied input. It MUST therefore enforce the following, on every document defined in this chapter:

  • Duplicate keys in any object MUST cause the document to be rejected. A parser that silently takes first-wins or last-wins is not conformant.
  • Integer fields MUST fit in the unsigned 64-bit range and MUST NOT use exponent notation.
  • Nesting depth MUST be capped at 64; a document exceeding that depth MUST be rejected.
  • A string value MUST NOT exceed the document size limit applicable to its containing artifact (§5.A).
  • A Unicode escape within a string MUST resolve to a valid code point per RFC 8259 §7.

Note

Duplicate-key rejection is the load-bearing rule of the list. A parser that takes last-wins and a parser that takes first-wins disagree about what a document says, which means the bytes a producer signed and the bytes a consumer acts on can differ while every signature still verifies. Several widely used JSON libraries take last-wins silently; conformance requires the rejection to be added deliberately.

5.9.3 Hashes #

Hash values are encoded in lowercase hexadecimal unless stated otherwise. Hash algorithms are identified by their IANA-registered names (sha256, blake3).

5.9.4 Signatures #

Signatures use Ed25519 as defined in RFC 8032 unless stated otherwise. Signature values are encoded in base64 (RFC 4648 §4) without padding. A base64 value carrying padding MUST be rejected.

5.9.5 Strings #

String comparison uses byte-for-byte equality unless stated otherwise.

5.9.6 URLs #

URLs follow RFC 3986. Relative URLs in a repository index are resolved against the repository descriptor's URL (§5.36).

5.9.7 Compression #

Compression uses the Zstandard format (RFC 8478). This specification does not constrain the compression level.

5.10 The Container

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A package is a single file: a tar archive compressed with Zstandard.

5.10.1 Extension #

A package file's extension MUST be .peipkg. There is no intermediate .tar form; a producer emits the compressed form directly, and the compressed file is the whole artifact.

5.10.2 Tar format #

The tar archive MUST conform to the POSIX pax interchange format (IEEE Std 1003.1-2017, Chapter 14).

Note

pax supports arbitrary path lengths through extended headers. The older ustar format limits paths to 100 characters, or 255 with a prefix, which is not enough for some real packages. GNU tar, libarchive, and BSD tar all read and write pax by default.

5.10.3 Compression #

The archive MUST be compressed with Zstandard (RFC 8478).

The compression level is at the producer's discretion. zstd is deterministic at every level, so a producer MAY choose any level to trade build time against on-wire size. Levels 19 and above, including --ultra, increase build time substantially for a smaller result; level 3 is a common default.

5.10.4 Reproducibility #

A package MUST be reproducible: given identical source inputs, an identical build environment, identical metadata — including the build timestamp recorded in the manifest — and identical compression parameters, two independent producers MUST produce byte-identical package files.

The determinism rules of §5.11 are what make this achievable at the format level. They are necessary rather than sufficient: they constrain what the archive looks like, not how the producer arrived at its contents.

Byte-identity is a property of the uncompressed tar stream and of the compression applied to it. This specification fixes the former completely and the latter not at all: the compression level, the Zstandard implementation, its version, and its frame parameters all affect the resulting bytes and are none of them constrained here. Two producers seeking byte-identical output MUST therefore agree on their compression parameters out of band. What the format guarantees unconditionally is that the signed bytes — the uncompressed tar prefix of §5.28 — are identical, so a signature survives recompression at any level.

5.10.5 Streaming #

A consumer MAY process the archive as a stream. The internal layout (§5.12) places metadata before payload precisely so that a consumer can read a package's identity and reject a mismatched package without buffering the payload.

5.10.6 No outer wrapping #

The compressed archive contains tar entries and nothing else: no enclosing directory, no concatenated archives, no container metadata outside the tar entries themselves.

5.11 Determinism

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

To make a package byte-reproducible (§5.10), the tar archive MUST obey every rule below. A consumer MUST reject a package that violates any of them.

  1. Tar entries MUST be ordered lexicographically by the entry name as written into the tar header, compared byte-for-byte over its UTF-8 bytes. A directory's entry name carries a trailing /, and that slash participates in the comparison.
  2. Every entry's modification time MUST equal the value of build.timestamp in the manifest (§5.18), which MUST NOT carry sub-second precision.
  3. Every entry's owner numeric ID and group numeric ID MUST be 0.
  4. Every entry's owner name and group name MUST be the string root.
  5. Entries MUST NOT carry extended attributes. Security descriptors are applied at file-creation time (§5.20), never through tar attributes; a binary signature travels as a detached-signature payload entry (§5.16), from which the consumer derives the attribute; other install-time attributes are applied through side-effect declarations (§5.24) or by higher-level mechanisms outside this specification.
  6. Entry permission bits MUST be 0777 for every entry, with the setuid and setgid bits cleared (§5.16).
  7. PAX extended header records, when present, MUST appear in a fixed canonical order: path first, then linkpath if present, then any other record sorted by record name lexicographically.
  8. The tar header magic MUST be ustar\0 and the version MUST be 00.
  9. The devmajor and devminor header fields MUST be 0 for every entry type this specification permits — none of which is a device entry.
  10. Header padding bytes MUST be NUL (0x00).
  11. PAX global header records (typeflag g) MUST NOT appear.
  12. PAX extended header records (typeflag x) MUST appear only when an entry's path exceeds the ustar 100-byte limit, in which case a path record is emitted, or its linkname exceeds that limit, in which case a linkpath record is emitted. A record with any other key MUST NOT be emitted.
  13. A path exceeding the ustar 100-byte limit MUST be carried by a path record. The ustar prefix field MUST NOT be used to split such a path across prefix and name.
  14. An extended header entry's own name MUST be the containing directory's path, then PaxHeaders.0/, then the base name of the entry it describes. For an entry at the archive root the directory part is absent.

Rules 13 and 14 exist because a tar library given a long path may legitimately choose either encoding, and either choice produces a different byte stream from the same input. Determinism requires the choice be made here rather than by whichever library a producer reached for.

Note

Rule 2's sub-second prohibition is not fussiness. A timestamp carrying a fractional part forces an mtime extended header onto every entry, violating rule 12 across the whole archive and changing the block count of the signature entry's own header — which is exactly the quantity a verifier uses to find the end of the signed range (§5.28). The symptom is a signature that does not verify, reported against the key rather than against the timestamp.

Note

Rules 3, 4, and 6 together make a payload identity-free and permission-free: bytes in transit, carrying no claim about who may read them. What access control an installed file gets is decided at install time (§5.20), not declared by the package. §5.16 states the consequence for anyone extracting a package with a generic tar tool.

5.12 Internal Layout

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A package's tar entries divide into metadata entries under a reserved prefix and payload entries — the files that will be installed.

5.12.1 The reserved prefix #

Every metadata entry MUST appear under the path prefix .peipkg/ at the archive root. That prefix is reserved: a payload entry MUST NOT use any path beginning with .peipkg/, and no payload entry may be named literally .peipkg.

5.12.2 Required entries #

Entry pathPurposeSection
.peipkg/manifest.jsonAuthoritative package metadata§5.18
.peipkg/files.jsonPer-file integrity manifest§5.25
.peipkg/signatureInline package signature§5.28

.peipkg/manifest.json and .peipkg/files.json MUST be present in every package. .peipkg/signature MUST be present in every signed package; a package without it is unsigned (§5.28).

5.12.3 Entry order #

Tar entries MUST appear in exactly this order:

  1. .peipkg/manifest.json
  2. .peipkg/files.json
  3. Any optional metadata entries, sorted lexicographically by path
  4. All payload entries, sorted lexicographically by path (§5.11 rule 1)
  5. .peipkg/signature

The manifest comes first so that a streaming consumer can read a package's identity and reject a mismatched one — wrong name, wrong version, wrong architecture — before reading any payload.

The signature comes last because it signs everything preceding it (§5.28). A consumer MUST reject a package in which any named entry follows .peipkg/signature.

5.12.4 Optional metadata entries #

A package MAY carry additional entries under .peipkg/. The set this specification recognises is fixed at the three above; an unrecognised entry under .peipkg/ MUST be ignored on parse and MUST NOT prevent installation.

When present, an optional metadata entry MUST appear between .peipkg/files.json and the first payload entry.

Note

A future version may introduce build attestations, reproducibility manifests, or a software bill of materials as further metadata entries. A producer targeting that future version MAY emit them into a package conforming to this one; a consumer conforming to this version ignores them.

5.12.5 Permitted entry types #

A payload entry MUST be one of:

  • a regular file (typeflag 0 or \0)
  • a directory (typeflag 5)
  • a symbolic link (typeflag 2)

Any other entry type MUST cause the package to be rejected. This excludes hardlinks (typeflag 1), character devices (3), block devices (4), FIFOs (6), contiguous files (7), and every vendor-specific type.

Note

Hardlinks are excluded because they share an inode with their target, which would let a package install a payload entry aliasing an existing system file and so gain shared access to it. Kernel hardlink-creation permissions provide some defence, but excluding the entry type at the format level is simpler and removes the class entirely. Device, FIFO, and contiguous entries have no use in a package payload.

5.13 Payload Paths

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A payload entry's tar path is its install location, resolved against the installation root (§5.19). A tar entry at usr/bin/nginx installs to /usr/bin/nginx.

5.13.1 Constraints #

A payload path MUST:

  • be relative — it MUST NOT begin with /
  • contain no segment equal to . or .., or any encoding thereof
  • be valid UTF-8 (RFC 3629)
  • contain no NUL byte (0x00) and no ASCII control character (0x01–0x1F, 0x7F)
  • contain no backslash (\)
  • be in Unicode Normalization Form C, per Unicode 16.0
  • have every component at most 255 bytes when encoded as UTF-8
  • be at most 4096 bytes in total when encoded as UTF-8
  • have at most 256 components
  • not begin with .peipkg/, and not be literally .peipkg (§5.12)

A consumer MUST validate every payload path against these constraints before any further processing of the entry. A package containing a non-conforming payload path MUST be rejected.

5.13.2 No canonicalisation #

Path resolution MUST NOT canonicalise away .. or . segments by interpretation. Such segments are forbidden above; any appearance is a format error, not a question of path canonicalisation.

Note

The distinction matters. A consumer that normalises a/../b to b and proceeds has accepted a path this specification forbids, and has done so by a rule the producer never agreed to. Rejecting is the only behaviour both sides can predict.

5.13.3 Empty payloads #

A package MAY have zero payload entries. Such a package carries only metadata; installing it records the package and runs any declared side effects (§5.24).

Note

An empty-payload package is useful as a virtual aggregator: it ships no binaries of its own and exists to declare dependencies on a curated set of other packages.

5.14 Install Destinations

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Peios separates package-owned vendor storage under /usr from the root-level runtime views such as /bin, /lib, and /sbin. Those views are filesystem topology assembled by the boot and base-filesystem layers; they are not package storage. A package installs its files under /usr, and the runtime topology projects them at their canonical paths.

Within /usr, executables split by kind. /usr/sbin/ holds system binaries — daemons, init and boot binaries, and service executables not normally invoked directly by a person. /usr/bin/ holds everything else, including administrative tools a person does invoke directly, even those requiring administrator privileges.

5.14.1 Permitted top-level destinations #

PathPurpose
/usr/bin/Executables that are not system binaries — user-facing tools, and admin tools invoked directly
/usr/sbin/System binaries — daemons, init and boot binaries, service executables
/usr/lib/<triplet>/Architecture-specific libraries and arch-dependent data (§5.15)
/usr/lib/debug/Separated debug information, mirroring the install paths of the files it describes
/usr/lib/modules/<release>/Kernel content for one kernel release: its modules, and the kernel image, System.map, and build config alongside them
/usr/lib/firmware/Device firmware blobs, addressed by device rather than by host triplet
/usr/lib/os-releaseThe freedesktop OS-identity file, at a fixed external contract path
/usr/libexec/Architecture-independent helper executables run by another program rather than by a person
/usr/share/Architecture-independent data
/usr/include/Header files
/usr/src/debug/Debugger source files, mirroring the build's source tree
/usr/src/dist/Corresponding source shipped by -source packages
/usr/etc/Vendor-shipped default configuration for legacy applications — the bottom layer of the /etc merge
/usr/conf/Vendor-shipped defaults for the supplementary configuration of native applications — the bottom layer of the /conf merge
/var/Runtime variable state directories, empty at install time
/boot//boot/initramfs/, a complete independent root filesystem, and /boot/efi/, the EFI System Partition
/hooks/Initramfs boot hooks, discovered and ordered when the initramfs cpio is packed
/++/Initramfs early-cpio segments, prepended uncompressed ahead of the main archive

A payload entry MUST NOT install under any other top-level path, unless the package declares itself a special system package (below).

A consumer MUST enforce this at install time. Producer-side validation proves nothing about a package file that arrives from elsewhere.

5.14.2 Notes on individual destinations #

Only the debug/ and dist/ subtrees of /usr/src/ are permitted; the rest of /usr/src/ is administrator territory.

/usr/etc/ is where package configuration goes. A package never writes /etc directly, because /etc is a merged view resolving /usr/etc < /system/retc < /lcl/etc, not storage. /usr/conf/ is the equivalent bottom layer of the /conf merge (/usr/conf < /lcl/conf); native software reads the registry directly, so there is no reconciled layer between them.

/var/ accepts empty directories only, establishing locations a runtime will write to — /var/log/<service>/, /var/state/<service>/. Populated content under /var/ is invalid: variable state is owned by the runtime, not by the package.

/hooks/ is meaningful only in an initramfs root, where the cpio packer scans it. In an ordinary system root it is an unused permitted destination.

An entry under /boot/ SHOULD be a symlink whose target resolves to a regular file under one of the other permitted destinations — typically /usr/lib/<triplet>/ for a kernel image, initramfs, or device tree. /boot/ is a discovery directory a bootloader reads, not storage where real package content lives. This is a SHOULD rather than a MUST because recovery images and embedded bootloader integrations that cannot follow symlinks exist; a format-level validator does not enforce it.

A package reaching /boot/initramfs/ is cross-targeting a different root, not installing into this one (§5.19).

Note

What is absent from the list, and why:

  • /bin, /sbin, /lib, /lib64, /libexec, /share, /include, /etc, /conf — merged views, computed from the trees beneath them so that software sees one path while authority stays separated. A package writes the /usr layer and the view does the rest.
  • /lcl — the operator's tree, the peer to /usr that is backed up and survives reinstall. /lcl/policy in particular holds inputs that grant authority; writing there yields arbitrary code at boot, which is why packages are excluded structurally rather than by rule.
  • /system — derived from the image, registry, or platform, and always reconstructible.
  • /opt — operator territory, deliberately off the list. Software there brings its own layout, is invisible to package verification, and participates in none of these guarantees.
  • /dev, /proc, /sys, /run, /tmp — kernel interfaces and runtime-only directories; not storage a package can populate, not even as empty directories.
  • /srv, /data, /home, /media, /mnt — operator and user namespaces.
  • /usr/local — does not exist. /usr is meant to be untouchable, and a writable subdirectory of it makes that a lie; /lcl is the honest version.
  • /root — does not exist. There is no root.

5.14.3 Special system packages #

A few packages exist precisely to lay down the structure these rules protect — the base-filesystem package that mints the runtime mountpoint tree is the archetype. For those, the allowlist is not a guardrail but the thing being installed.

Such a package MAY set special_system_package in its manifest (§5.18). The declaration waives the layout checks at production time only. It grants nothing at install time: a consumer MUST refuse an out-of-layout payload unless the operator has also explicitly opted in, through a distinct and deliberate act naming that intent.

This is two keys held by two parties. A package may propose its own exemption; only whoever installs it can grant one.

When a consumer meets the declaration without having been given the opt-in, it MUST refuse the package with an error naming the refused request, so that an operator can tell "this package asked for an exemption I did not grant" from "this package is malformed".

/lcl/policy MUST NOT be reachable by this route under any circumstance. It is the tree whose contents grant authority, and an exemption that could reach it would convert a structural guarantee into a policy one.

5.14.4 Drop-in directories #

Several subdirectories of the /etc merge are drop-in directories: their contents are interpreted as code or configuration by other tools, notably the side-effect tools of §5.24 and system daemons that read configuration drop-ins. A package writing into one has indirect influence on the behaviour of components that read it.

A package from a repository other than the system's official repository MUST NOT install a file at the top level of the /usr/etc layer of any of these:

  • ld.so.conf.d/
  • profile.d/
  • sudoers.d/
  • cron.d/, cron.daily/, cron.hourly/, cron.weekly/, cron.monthly/
  • sysctl.d/
  • modules-load.d/
  • modprobe.d/
  • binfmt.d/
  • any directory the system declares as a drop-in directory through its configured list

The consumer's drop-in directory list MUST be stored under a security descriptor granting write access only to a recovery-class operator principal, never to the principal performing installs. Operator configuration MAY add entries to the list but MUST NOT remove an entry this specification requires: the list is purely additive.

A non-official-repository package whose payload installs to one of those paths MUST be rejected at install time.

A non-official-repository package MAY install drop-in files under its own subdirectory of a drop-in path — for example /usr/etc/ld.so.conf.d/<repo-name>/<package>.conf — provided the subdirectory is namespaced by both the repository's name and the package's name, so that two such packages cannot collide.

Note

The restriction closes a concrete chain. A low-trust repository installs a configuration file at the top level of ld.so.conf.d/ that extends the loader's library search path. At the next transaction that declares ldconfig — from any repository, not necessarily the same one — the drop-in is honoured and the loader's behaviour changes. Restricting top-level drop-in writes to the official repository, with a namespaced escape hatch for legitimate non-official content, breaks the chain without forbidding the use case.

5.15 The Architecture Triplet

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A package whose architecture is not noarch MUST install all of the following under /usr/lib/<triplet>/, where <triplet> is the architecture triplet of §5.8:

  • shared libraries (.so, .so.*)
  • static libraries (.a)
  • loadable modules — plugin shared objects, and kernel modules outside /usr/lib/modules/
  • architecture-dependent helper binaries not on the user's search path
  • any other arch-dependent file that is not a user-facing binary

Architecture-independent helper executables — a shell script run by another program, say — go under /usr/libexec/ instead, which carries no triplet rule because the rule is scoped to /usr/lib/.

A package whose architecture is noarch MUST NOT install any file under /usr/lib/<triplet>/. A noarch package containing any of the categories above is invalid.

5.15.1 Exemptions #

Three arch-dependent payload categories are exempt from the triplet path, because each is addressed by something other than the host triplet:

CategoryPathAddressed by
Kernel content/usr/lib/modules/<release>/kernel release
Device firmware/usr/lib/firmware/device
Separated debug information/usr/lib/debug/the install path of the file it describes

A noarch package MUST NOT install under /usr/lib/modules/ or under /usr/lib/debug/: kernel content and debug information are both arch-dependent. /usr/lib/firmware/ carries no such restriction, firmware being opaque data rather than host-architecture content.

Debug files mirror the full install path of what they describe. The debug information for /usr/bin/foo is /usr/lib/debug/usr/bin/foo.debug; for /usr/lib/<triplet>/libfoo.so.1 it is /usr/lib/debug/usr/lib/<triplet>/libfoo.so.1.debug. Debug files MAY additionally be indexed by build ID under /usr/lib/debug/.build-id/.

The freedesktop os-release file is a fourth exemption of a different kind: it installs at exactly /usr/lib/os-release, a fixed external contract path the ecosystem hard-codes. Unlike debug information it is arch-independent, so a noarch package — the OS-identity package — MAY ship it. It is conventionally paired with a /usr/etc/os-release symlink, which the /etc merge projects to /etc/os-release.

5.15.2 Source #

The debugger source files that debug information references install under /usr/src/debug/, not under /usr/lib/. Source is architecture-independent, so /usr/src/debug/ carries neither a triplet rule nor the noarch restriction: it is a plain permitted destination that both arch-specific and noarch packages MAY use. The same applies to /usr/src/dist/, the home of corresponding-source packages.

Files retained inside /usr/src/dist/ are reconstruction inputs rather than installed library providers. Captured upstream or vendored inputs MAY include library-shaped test fixtures or import archives for other target platforms (such as Cargo's Windows crates), including in a noarch source package. Their .so or .a suffix does not make that reserved source-data tree a runtime library installation. The normal library-placement rule still applies outside this subtree; source data remains subject to path and symlink containment. Automatic ELF and pkg-config capability derivation MUST ignore /usr/src/ inputs: their libraries and .pc fixtures do not provide installed interfaces or create runtime dependencies of a source package.

Note

A corresponding-source package conventionally lays out /usr/src/dist/<name>-<version>/ with upstream/ — the pristine source artifact, byte-identical to the producer's pinned input — patches/ for the applied patch series, and recipe/ for the build-controlling recipe files including the source lock that makes the shipped upstream artifact verifiable. This is a producer convention, not a format rule.

5.15.3 Architecture-independent data #

/usr/share/ holds architecture-independent files shared across every architecture of a system: documentation, man pages, locales, configuration templates, and static data such as icons, images, and fonts.

Both noarch and arch-specific packages MAY install under /usr/share/. A file installed there by an arch-specific package MUST be byte-identical across every architecture build of the same upstream version.

5.16 Payload Entries

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

5.16.1 Permissions #

Tar entry permission bits in a package are distribution-format metadata only. They establish no access control on the installed file. Access control is the consumer's responsibility: on Peios, through a security descriptor applied at file-creation time (§5.20); on any other system extracting a package for inspection or migration, through that system's native mechanism applied after extraction.

Every payload entry's permission bits MUST be 0777. Any other value MUST cause the package to be rejected.

The setuid and setgid bits MUST NOT be set on a payload entry. Privilege escalation on Peios is mediated by the kernel's access-control subsystem, not by filesystem-level setuid; a setuid bit is meaningless to the access-check path and MUST NOT appear in installed content.

Note

The 0777 rule is honest signalling. A 0644 or 0755 mode would imply a permission contract the format does not enforce and the kernel does not consult. Fixing every entry to 0777, combined with the uid/gid and owner/group rules of §5.11, treats a payload as identity-free, permission-free transport bytes.

One consequence: extracting a package with a generic tar tool on a non-Peios host produces world-writable files. This is intended. Tooling that needs sensible host-native permissions on extracted files is responsible for applying them after extraction.

5.16.2 Empty directories #

A package MAY install an empty directory: a tar entry of type directory with no content. Empty directories establish paths a runtime will need, and are the only content permitted under /var/ (§5.14).

5.16.3 One package per path #

Two packages MUST NOT install a file at the same install path. A consumer MUST detect the collision and reject the second install.

A package MAY install content into a directory another package created; directory creation is idempotent. The rule applies to non-directory entries only.

The one exception is a claim link (§5.23), which belongs to the consumer rather than to any package and is materialised only at a path no installed package owns.

5.16.4 Detached signatures #

A payload entry whose path ends in .peios.sig is a detached signature: the 3310-byte binary-signature blob of PSPK chapter 3 for the entry whose path is the same with that suffix removed (its target), stored as ordinary payload bytes. The suffix is reserved; a package MUST NOT use it for any other purpose.

This is how a signature reaches a file that cannot carry one in an ELF section. §5.11 forbids extended attributes on tar entries, so the security.peios.sig placement PSPK defines cannot travel inside a package; the detached entry carries the same blob as payload instead, and the consumer derives the attribute from it.

A consumer MUST reject a package in which a detached signature:

  1. has no target — the path without the suffix is not a payload entry of the same package, or is a directory or symlink entry;
  2. is not exactly 3310 bytes, or its first byte is not 0x01; or
  3. has a target whose first four bytes are \x7fELF. An ELF file carries its signature in its .peios.sig section; giving it a detached signature as well would leave two carriers for one file, and PSPK's lookup order would silently ignore the second.

A consumer that installs the target MUST, as part of creating the file and before the file becomes visible at its install path, set the extended attribute security.peios.sig on it to exactly the detached entry's bytes, and MUST NOT create a file at the detached entry's own path. The detached entry is transport for the attribute, not content: it is not installed, is not recorded as a file the package owns, and takes no part in the one-package-per-path rule above. Removing or replacing the target removes or replaces the attribute with it.

The consumer verifies nothing about the signature beyond its shape. The blob is the signer's assertion and the kernel's to verify; a consumer that can install the package cannot generally hold the keys that would let it check the blob, and MUST NOT refuse a package because it cannot.

Note

A consumer extracting a package on a system where the attribute cannot be set — an unprivileged inspection, a filesystem without security.* support — has produced a root in which those files are unsigned, exactly as if a copy had dropped the attribute. That is the fail-closed outcome the kernel's verifier is designed for, but such a consumer SHOULD report it rather than proceed silently.

5.16.5 Forward compatibility #

The triplet path convention of §5.15 is designed so that a future multi-architecture system MAY install foreign-architecture packages alongside native ones without filesystem-level collisions.

In this version only one architecture's packages may be installed on a given system at a time (§5.8). The triplet convention applies regardless, so that a package conforming to this version stays forward-compatible with such an extension.

5.17 Symlinks

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Symlinks are first-class payload entries. The tar entry's linkname is the symlink target.

5.17.1 Target constraints #

A symlink target MUST be a relative path.

A symlink target MUST resolve, when joined with the symlink's parent directory, to a path that is either within the package's own payload tree or under one of the permitted top-level install destinations of §5.14. An absolute target is forbidden, as is a target whose resolution escapes those destinations entirely.

A symlink target is subject to the same path-validity constraints as a payload path (§5.13): valid UTF-8, no NUL bytes, no ASCII control characters, no backslashes, NFC normalisation, and the length limits.

A consumer MUST validate every symlink target against these constraints before extracting the entry. A package containing a non-conforming symlink target MUST be rejected.

Note

Format-level validation cannot distinguish a symlink whose resolved target is an installable path that no package actually installs. A target reached by .. traversal into a permitted destination passes validation and produces a dangling symlink; one that would overwrite an existing owned file is caught by the one-package-per-path rule (§5.16). Both are consumer-side outcomes, not format errors.

5.17.2 Cross-package targets #

A producer MAY emit a symlink whose target resolves into a different package's payload tree, provided the resolved path is under a permitted destination. The canonical case is the conventional library split, where a -dev package ships a developer link (libfoo.so) whose target (libfoo.so.1) lives in the corresponding runtime package.

A producer SHOULD declare the target's owning package as a dependency, so that the target is present at extraction time. The format does not record this relationship at the symlink level; it is captured at the package level through dependencies (§5.21).

Note

Prohibiting cross-package symlinks was considered — it would have forced restructured -dev splits with developer links living in runtime packages — and rejected. Forbidding them deviates from universal Linux convention with no corresponding security gain: the defence below operates on resolved-target validity, not on whether the resolution crosses a package boundary.

5.17.3 Integrity #

A symlink has no content body, and so is not hashed in the files manifest (§5.25). Its target is integrity-checked directly: the linkname stored in the tar header is what the consumer compares, and that header is inside the signed bytes (§5.28).

5.17.4 Security descriptors #

A symlink does not carry an independent security descriptor. Access to a symlink is governed by access to its target. A security descriptor override (§5.20) MUST NOT target a symlink entry.

Note

The classic symlink TOCTOU attack — install /usr/share/foo as a symlink to a system file, then have a later write at /usr/share/foo/bar traverse it — is prevented in two layers. The format forbids symlink targets resolving outside the managed tree, and extraction resolves every path component without following a symlink, so even an in-tree symlink cannot redirect a later write.

5.18 The Manifest

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

The manifest is the authoritative metadata for a package: its identity, its relationships, its side-effect requirements, and its build provenance. It is a JSON document at .peipkg/manifest.json.

5.18.1 Schema #

{
  "schema_version": 1,
  "name": "<string>",
  "version": "<string>",
  "architecture": "<string>",
  "description": "<string>",
  "license": "<string>",
  "license_class": "<string>",
  "homepage": "<string>",
  "default_root": "<root reference>",
  "special_system_package": <bool>,
  "alternate_upgrade": {<alternate_upgrade>},
  "dependencies": [<dependency>...],
  "optional_dependencies": [<dependency>...],
  "conflicts": [<dependency>...],
  "provides": [<provides>...],
  "replaces": [<replaces>...],
  "side_effects": [<string>...],
  "size_installed": <integer>,
  "sd_overrides": [<sd_override>...],
  "build": {<build>}
}

5.18.2 Required fields #

FieldTypeDescription
schema_versionintegerMUST be 1 in this version.
namestringPackage name conforming to §5.3.
versionstringVersion conforming to §5.5.
architecturestringArchitecture identifier conforming to §5.8.
dependenciesarrayRequired dependencies. MAY be empty; MUST be present.
conflictsarrayConflicting packages. MAY be empty; MUST be present.
size_installedintegerTotal size in bytes of the installed payload.
buildobjectBuild provenance.

A manifest missing any required field MUST be rejected.

The package format treats name as an opaque canonical identifier within the §5.3 grammar; it does not derive or authenticate namespace ownership. Peios catalogues assign reverse-DNS names whose leading segments identify the upstream namespace and whose final segment is the familiar package name, such as com.amd.amd-ucode. Peios-originated packages use org.peios. Dependency and package-operation inputs that identify a concrete package carry the complete canonical name; virtual capabilities retain their own names. Repository signatures and build provenance, rather than the spelling of name, establish who produced a particular package.

5.18.3 Optional fields #

FieldTypeDescriptionAbsent means
descriptionstringOne-line human-readable description.empty string
licensestringSPDX identifier or expression.empty string
license_classstringWhich of the closed set of licence classes (§5.a2) the package's terms fall into: unknown, free, firmware or proprietary.unknown
homepagestringURL of the upstream project.empty string
default_rootstringThe root a top-level install of this package lands in when the operator names none (§5.19).the operator's current root
special_system_packagebooleanDeclares the package exempt from the §5.14 layout rules at production time (§5.14).false
alternate_upgradeobjectDeclares that the package is meant to be installed and upgraded through a path outside the package manager (see "The alternate_upgrade object" below).none
optional_dependenciesarrayDependencies that enhance but are not required.empty array
providesarrayVirtual names this package satisfies.empty array
replacesarrayPackages this one supersedes.empty array
side_effectsarrayMaintenance operations to invoke (§5.24).empty array
sd_overridesarrayPer-entry security descriptor overrides (§5.20).empty array

A manifest carrying an unknown field MUST NOT be rejected; the unknown field MUST be ignored (§5.9).

5.18.4 The alternate_upgrade object #

{
  "message": "<string>"
}
FieldRequiredDescription
messageyesText shown to the operator in place of proceeding. Non-empty UTF-8, at most 1024 bytes; MAY contain newlines and MUST NOT contain other control characters.

A package that sets alternate_upgrade states that it is meant to be installed and upgraded by some means other than a routine package operation — an operating-system edition moved by a dedicated tool that also reconciles what the package manager deliberately does not touch, for example. The package manager grants nothing on the declaration's behalf. Its whole effect is on the consumer:

  1. A request that names the package — install or upgrade with the package as an argument — or any install or named-upgrade plan whose operations would install or upgrade the package as a dependency, MUST be refused: nothing is applied, and the consumer MUST report the package's name, its message verbatim, and this warning:

    Alternate upgrade paths may bypass normal peipkg protections; ensure you fully trust the authors of the package before running.

  2. An upgrade of every installed package MUST hold the package back at its installed version, report it as in rule 1, and proceed with the rest of the upgrade. Holding back is not a failure.

  3. A consumer MUST provide a deliberate, per-invocation override (--bypass-alternate-upgrade in the reference implementation) under which rules 1 and 2 do not apply. The override is the intended way for the out-of-band tool to drive the package manager; it is never a default and is never persisted.

Composing a root from nothing — an image builder populating an empty tree, rather than a consumer changing an installed system — is not an install or upgrade in the sense above, and an image builder MUST ignore the field.

Note

This is the one place the format lets a package say "not through peipkg", and it is deliberately the weakest possible form of that: a message, and a refusal. A package cannot name a command for the consumer to run, cannot run anything itself, and cannot make the consumer do more than stop. The message may tell the operator what to run; the operator, not the package, runs it. The warning is mandatory precisely because whatever the message names sits outside every protection the package manager provides.

5.18.5 The build object #

{
  "timestamp": "<RFC 3339 timestamp>",
  "farm_id": "<string>",
  "source_ref": "<string>"
}
FieldRequiredDescription
timestampyesRFC 3339 timestamp of the build. MUST be UTC, MUST end with Z, and MUST NOT carry sub-second precision.
farm_idyesIdentifier of the build farm that produced this package.
source_refyesReference to the build inputs, sufficient to reproduce the build.
source_packagenoName of the corresponding-source package produced from the same recipe and inputs (§5.15).
recipe_refnoVCS identity of the recipe tree the build ran from — for example git:<commit>, suffixed +dirty when the work tree held uncommitted changes.
buildernoIdentity and revision of the producing tool, for example pekit/<revision>.

A consumer MUST treat an absent optional field as the empty value.

timestamp is also the modification time of every tar entry (§5.11 rule 2). A producer MUST set both identically.

source_ref is producer-defined but SHOULD be a machine-resolvable reference. The conventional form is a version-control URL with an explicit ref:

git+https://git.peios.org/sources/nginx#refs/tags/v1.26.2-3

Note

The build object exists to make reproducibility verifiable: a third party in possession of the build inputs and the recorded timestamp can re-run the build and compare the output bytes. The format does not mandate that verification; it supplies the inputs for it.

5.18.6 Field constraints #

description, when present, MUST consist only of printable ASCII in the range 0x20–0x7E. ASCII control characters and non-ASCII bytes MUST NOT appear. It SHOULD be a single line under 80 characters; longer descriptions belong in upstream documentation.

Note

The byte-range rule is deliberately cruder than a Unicode-category test. description is displayed to an operator deciding whether to install, so it is both a terminal-escape-injection surface and a homoglyph surface. A hard ASCII whitelist closes both without anyone having to reason about which Unicode categories are safe to render.

license, when present, SHOULD be a valid SPDX expression. A producer MAY use another form; this specification does not validate license strings. A LicenseRef- term names a licence with no SPDX identifier; a producer using one SHOULD ship the licence text under /usr/share/licenses/<name>/ in the same package.

license_class, when present, MUST be one of unknown, free, firmware, or proprietary; any other value MUST cause the package to be rejected. Absent means unknown, and a consumer MUST treat the two identically. A producer SHOULD declare a class other than unknown whenever license contains a LicenseRef- term, since that is precisely the case in which the expression alone cannot say.

Note

A licence expression cannot be read by a machine for what an operator actually wants to know — whether the package is free software, a redistributable device firmware blob, or proprietary code — and the LicenseRef- escape hatch makes every non-SPDX licence opaque. The class carries that one fact so a consumer can answer "what non-free is installed" or apply an image policy without parsing licences. firmware is a class of its own rather than a kind of proprietary because it is a different bargain: no source, executes on a device rather than the CPU, and needed to use hardware the user owns — the distinction Debian's non-free-firmware and Fedora's firmware exception both draw. unknown exists so every package that predates the field stays valid and honest: nothing has been asserted.

homepage, when present, MUST be a syntactically valid URL per RFC 3986 and MUST use the https or http scheme. Any other scheme MUST cause the package to be rejected.

size_installed MUST be a non-negative integer, and MUST equal the sum of the size fields of every entry in the files manifest (§5.25). A consumer MUST verify that equality and MUST reject a package where it does not hold.

Note

size_installed is not merely advisory. It is the input to the decompression bound of §5.27, so a package whose declared size is smaller than what it actually unpacks to is a resource-exhaustion attempt, and one that is larger inflates the bound. Tying it to a quantity a consumer can independently compute is what makes it trustworthy.

5.18.7 Authoritative status #

The manifest is authoritative for a package's metadata. Where it disagrees with any other source — the repository index, the filename, secondary documentation — the manifest MUST be treated as correct, and the disagreement MUST be reported (§5.32).

5.18.8 Encoding #

The manifest MUST be UTF-8 encoded JSON and MUST end with a single newline.

A producer that intends its packages to be byte-reproducible MUST serialise the manifest canonically: compact, with no insignificant whitespace, with HTML-escaping of <, >, and & disabled, with fields in the declaration order of the schema above, and with every optional field either always emitted or never emitted for a given producer.

Note

The manifest's bytes are inside the archive that gets hashed and signed, so two semantically identical manifests with different whitespace produce different packages. Pinning the serialisation is what makes independent reproduction possible at all; leaving it to the producer's discretion means only that producer can ever reproduce its own output.

5.19 Installation Roots

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

An installation root is a self-contained filesystem tree into which packages are installed. The default root is the system root; a system MAY define additional named roots — an initramfs image built and maintained alongside the main system is the motivating case.

How roots are registered, and how a name resolves to a filesystem location, is consumer mechanics and is not part of the package format.

5.19.1 Root references #

A root reference is the string form by which a manifest names a root. Within a manifest a root reference MUST be a named reference: one or more segments separated by ., where each segment matches [a-z0-9][a-z0-9_-]*. Nesting is expressed by further segments, so initramfs.subroot names the root subroot registered within the root initramfs.

A root reference in a manifest MUST NOT be an absolute or relative filesystem path. A package names roots and never dictates a filesystem location: placement is the installing system's prerogative.

A manifest whose root reference is not syntactically valid is invalid and MUST cause the package to be rejected. Whether the named root exists is a consumer-side resolution concern, not a format-validity one.

5.19.2 default_root #

The manifest's default_root field (§5.18) governs only the placement of a top-level install of the package — an operator request naming this package directly, with no explicit root.

It has no effect when the package is pulled in as a dependency; dependency placement is governed by the depending package and by the dependency's own root field (§5.21). An explicit operator-supplied root always overrides default_root.

5.19.3 Satisfaction is per-root #

The identity of a satisfier is the pair (name, root). The same package name installed in two different roots is two independent satisfactions, possibly at different versions, and a dependency is satisfied only by an installation in the named — or defaulted — root. A constraint or architecture qualifier is evaluated against that installation.

Note

Cross-root dependencies let a root be composed through the dependency graph. An initramfs package may depend on ordinary packages — a shell, core utilities — and have them placed into the initramfs root, either implicitly by living in that root itself or explicitly through the dependency's root field. The depended-on package declares no root affinity of its own; where it lands is the depender's and the operator's choice. This generalises the fixed build-host/target split other systems draw to an open set of named roots.

5.20 Security Descriptor Overrides

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Every installed file and directory carries a security descriptor. A consumer applies it at file-creation time, through the kernel's file-creation interface — never through a tar attribute, which §5.11 rule 5 forbids outright.

5.20.1 The default is inheritance #

When a payload entry has no override, a consumer MUST create the entry without supplying an explicit security descriptor, so that the kernel computes one by inheritance from the parent directory's inheritable entries at creation time.

Inheritance is the default for the overwhelming majority of installed entries, and most packages declare no overrides at all. An override is appropriate when a file needs more restrictive access than its parent would give it, when it needs explicit access for a principal absent from the parent's inheritable entries, or when a directory needs to begin a new inheritance scope.

5.20.2 Declaring an override #

An entry in the manifest's sd_overrides array has the form:

{
  "path": "<payload-relative path>",
  "sd": "<base64-encoded security descriptor>"
}
FieldDescription
pathPayload-relative path, matching a tar entry exactly.
sdBase64-encoded binary self-relative security descriptor, per RFC 4648 §4 without padding.

The sd_overrides array MUST be sorted lexicographically by path, and MUST NOT contain two entries with the same path.

path MUST refer to a regular-file entry or a directory entry. An override MUST NOT target a symlink entry, which carries no independent descriptor (§5.17).

An override referring to a non-existent payload entry, or to a symlink entry, is invalid and MUST cause the package to be rejected.

sd MUST decode to a syntactically valid binary self-relative security descriptor. One whose decoded bytes do not parse is invalid and MUST cause the package to be rejected.

A consumer MUST perform all three of those checks — entry existence, entry type, and descriptor parseability — before installing anything from the package. Deferring them to the moment the descriptor is applied turns a malformed package into a partially completed install.

5.20.3 The consumer's policy obligation #

The kernel validates that a declared descriptor is well-formed. It does not validate that the producer of a package had any authority to declare that descriptor on behalf of the principals it grants access to. A package can therefore declare a descriptor granting access to any principal the system knows about. The format treats the bytes as opaque; whether a given package may declare a given descriptor is policy, and that policy is the consumer's to enforce.

A consumer MUST enforce a per-repository override policy:

  1. Before applying any override, the consumer MUST surface it to the operator in human-readable form, including the payload path, the principals and rights granted, and a diff against what inheritance would have produced.
  2. For a package from the system's official repository, overrides MAY be applied without per-operation confirmation, but the operator-visible install report MUST list every override applied.
  3. For a package from any other repository, the consumer MUST require explicit operator confirmation before applying an override that grants rights to a principal outside a configured allowlist. The default allowlist contains the well-known system principals, plus any principal the operator has added to that repository's allowlist. It MUST NOT contain any principal derived from the package itself — from its manifest fields, its build metadata, or its payload. A package cannot elect its own principals into the allowlist.
  4. A package whose overrides the policy rejects MUST be refused. A consumer MUST NOT silently drop the overrides and proceed with inheritance defaults.

5.20.4 Inherited descriptors are covered too #

The policy applies both to explicitly declared descriptors and to descriptors that result from inheritance from a directory whose own descriptor was declared by any package's overrides.

Specifically: when installing a file under a directory whose descriptor was overridden by any package — from any repository — the resulting inherited descriptor MUST pass the policy as if the installing package had declared it.

Note

Without this, package A declares an override on a directory that grants A's author rights, and package B's files installed under that directory silently inherit it with the operator never prompted. The check fires on what descriptor ends up on the file, regardless of how it got there — which also closes the case where a carefully-mimicked "looks like the default" override would have evaded a test for non-default descriptors.

5.20.5 Failure #

If file creation fails because the kernel rejects the descriptor — most often because it references a principal the system does not know — the install MUST be treated as failed and any partial state rolled back.

A package MUST NOT be installed into a parent directory whose descriptor denies the caller the access required to create the entry. A consumer detects this at install time and treats it as an install failure.

5.21 Relationships

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A package expresses its relationships to other packages in five manifest fields:

  • dependencies — packages that must be installed for this one to function
  • optional_dependencies — packages that enhance functionality but are not required
  • conflicts — packages that must not be installed alongside this one
  • provides — virtual names this package satisfies on behalf of dependencies declared elsewhere
  • replaces — packages this one supersedes

5.21.1 Dependency entries #

An entry in dependencies or optional_dependencies:

{
  "name": "<package or virtual name>",
  "constraint": "<version constraint>",
  "arch": "<arch qualifier>",
  "root": "<root reference>",
  "claims": { "<slot>": { "path": "<absolute path>" } }
}
FieldRequiredDescription
nameyesThe depended-on package name (§5.3) or virtual name (§5.4).
constraintnoA version constraint per §5.7. Absent means any version satisfies.
archnoAn architecture qualifier. Default any.
rootnoA root reference (§5.19) naming the root this dependency is placed and satisfied in. Absent means the same root as the depending package.
claimsnoClaim paths this dependency expects a holder to materialise (§5.23).

root, when present, MUST be a syntactically valid named root reference — never a filesystem path. An entry whose root is not one is invalid.

5.21.2 Conflict entries #

An entry in conflicts has the same shape as a dependency entry, minus root and claims, and expresses incompatibility rather than requirement: a package MUST NOT be installed simultaneously with any package matching the entry. A conflict whose constraint is absent expresses incompatibility with any version of the named package.

5.21.3 The architecture qualifier #

arch restricts the qualified package's architecture. In this version the only valid value is any, which is the default. Any other value MUST be rejected.

any means: the qualified package's architecture MUST equal the depending package's effective architecture, or be noarch.

A depending package's effective architecture is its own architecture when arch-specific, and the system's primary architecture (§5.8) when the depending package is noarch. A noarch label describes an architecture-independent payload, not an architecture-independent resolution context: a noarch package's dependencies on arch-specific packages — a script on its interpreter, a meta-package on native tools — resolve against the concrete system being assembled, exactly as a native package's do.

Note

A future version may permit explicit architecture identifiers here, to support multi-architecture systems. Reserving the field now is what makes such an extension parse compatibly.

5.21.4 Provides entries #

{
  "name": "<virtual name>",
  "version": "<version string>",
  "claims": { "<slot>": { "target": "<absolute path>",
                          "path": "<absolute path>" } }
}
FieldRequiredDescription
nameyesThe virtual name provided, conforming to §5.4.
versionnoThe version of the capability provided. Parsed revision-relaxed (§5.7), because a provides version is a capability level rather than a packaging iteration. Absent means any version of the name is provided.
claimsnoFilesystem targets this package materialises when it holds the named role (§5.23).

A virtual name that collides with a real package name MAY be provided; both are then valid satisfiers of a dependency on that name.

provides.version SHOULD reflect the providing package's actual functional compatibility level. A provides.version greater than the providing package's own version MUST generate an operator warning at install time, because an inflated provides-version defeats constraint-based resolution.

Note

The attack the warning exists for is concrete: a package at version 1.0-1 declaring provides: [{name: libfoo, version: "5.0"}] satisfies a dependency on libfoo >= 4.0 and gets installed in place of a real libfoo — most easily when the real one is absent from the candidate set, which is precisely the shadowing case.

The provides relation does not flow transitively: providing smtp-server does not provide whatever smtp-server itself provides.

5.21.5 Replaces entries #

{
  "name": "<package name>",
  "constraint": "<version constraint>"
}

name is required and MUST conform to the package-name grammar (§5.3). constraint is optional; absent means this package replaces any version of the named one.

A replaces entry expresses supersession. During upgrade the replaced package is removed and this one installed in its place: files owned by the replaced package that no longer exist in this one are removed, and files existing in both are updated.

A replaces entry does not imply a conflict. A package MAY both replace and conflict with the same target, but a replaces entry is typically sufficient on its own.

Note

Replaces is the rename mechanism. When nginx-core becomes nginx, the new nginx declares { "name": "nginx-core" } in replaces, and existing systems transition on their next upgrade.

5.21.6 Field constraints #

Each of the five fields is an array of objects matching the appropriate schema. dependencies and conflicts MUST be present, and MAY be empty. The other three MAY be omitted, which is equivalent to an empty array.

Within a single field, entries MUST be sorted lexicographically by name, and two entries MUST NOT carry identical name values. A package with several constraints on one target MUST combine them into that entry's single constraint string.

5.21.7 What satisfies a dependency #

A dependency is satisfied by a candidate package when all of the following hold:

  1. The candidate's name equals the dependency's name, or the candidate has a provides entry whose name equals it.
  2. If the dependency carries a constraint, the version satisfies it — the candidate's own version when matched by name, and the matching provides entry's version when matched through provides.
  3. The candidate's architecture satisfies the arch qualifier.
  4. The candidate is installed, or is being installed, in the dependency's root (§5.19).

A conflict is triggered by a candidate when the same conditions hold with respect to a conflicts entry.

A claims field has no effect on satisfaction. A dependency on a role is satisfied by any installed eligible provider regardless of which one currently holds the role; claims govern which installed file owns a contended filesystem name, nothing more (§5.23).

5.22 Derived Capabilities

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Some capabilities are derived mechanically from a package's built contents rather than declared by hand. So that a producer and a consumer agree on the name whichever way it arrived, the conventions below are normative for the capability name.

CapabilityVirtual nameVersion
Shared libraryThe ELF soname, verbatim — libssl.so.3.None by default.
ELF symbol-version nodeelfver(<soname>:<token>), for example elfver(libstdc++.so.6:GLIBCXX_3.4.30).None: the whole name is an exact capability.
pkg-config modulepkgconfig(<module>), where <module> is the .pc file's base name — pkgconfig(glib-2.0).The .pc file's Version: field, matched as an ordered constraint per §5.7.

5.22.1 Shared libraries #

A shared-library dependency is the soname listed in a binary's DT_NEEDED; the corresponding provide is the soname in the providing library's DT_SONAME.

The soname's ABI-version field is part of the name and is matched by exact equality: libssl.so.3 is never satisfied by libssl.so.4. A version MAY be carried on a soname provide when the library's symbol versions are commensurable with the providing package's own version, as they are for a C library shipping versioned symbols.

5.22.2 ELF symbol-version nodes #

A strong GNU ELF version need is represented by elfver(<soname>:<token>); the matching provide comes from that SONAME's non-base version definition. Both components preserve case and exact bytes. The colon separates components; it is not permitted within either component. Tokens use ASCII letters, digits, underscores, dots, plus and minus characters; the complete capability obeys the normal capability-name length bound. Weak version needs do not impose a mandatory dependency. A producer must reject unrepresentable metadata rather than omit a selected version requirement.

These capabilities supplement SONAME identity and are independent of the provider package's version. All required nodes must be present; defining a numerically greater node alone is insufficient. A token from another SONAME does not satisfy the dependency. Producer policy selects the libraries to which this derivation applies; Peios selects libstdc++ to cover GLIBCXX and CXXABI.

5.22.3 pkg-config modules #

A pkg-config dependency is a module named in a .pc file's Requires: or Requires.private:; the corresponding provide is the .pc file itself.

5.22.4 Derivation is a producer concern #

Whether a producer derives these automatically is its own business. This section fixes only the names, so that a hand-written entry and a derived entry for the same capability are byte-identical.

Note

A producer that derives from ELF metadata will encounter cases this section does not name: a symlink that carries no DT_SONAME of its own, a shared-library-shaped file with no soname at all, symbol version tokens from DT_VERNEED that could be turned into a version constraint. Any additional constraint a producer synthesises is a constraint like any other and is evaluated by §5.7; what this section forbids is inventing a different name for a capability that has one.

5.23 Claim Declarations

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

provides (§5.21) lets several installed packages satisfy one virtual name. A claim extends that to the filesystem: it lets several installed packages contend for a single shared filesystem name, with exactly one owning it at a time. The canonical case is a role daemon — two registry sources may both be installed, but only one may own /usr/sbin/registryd.

This section specifies what a package declares. Which provider holds a role, and when the consumer re-evaluates that, is consumer mechanics.

5.23.1 Vocabulary #

  • Role — a virtual name (§5.4) that one or more packages contend to own. A role is identified by the name of a provides or dependency entry carrying a claims field.
  • Slot — a named channel within a role. Each slot materialises one filesystem name. A role has one or more slots.
  • Claim path — the absolute path a slot materialises at. A slot MAY have more than one.
  • Target — the file a claim path points at while a given provider holds the slot. The target is a payload file of the holding package.
  • Holder — the single installed package that currently owns a role. A role with no holder is unheld.

Note

The two halves of a claim are declared by two different parties. The consumer — whatever hard-codes /usr/sbin/registryd and expects to find a registry daemon there — declares the path. The provider declares the target, the file of its own that should answer it. The consumer joins the two by (role, slot). Splitting the declaration this way puts the path where the dependency on it lives and the target where the implementation lives.

5.23.2 The claims field #

A claim is declared by adding a claims field to a dependency entry, an optional-dependency entry, or a provides entry. It maps a slot name to a slot descriptor:

"claims": {
  "<slot name>": { "path": "<absolute path>",
                   "target": "<absolute path>" }
}

A slot name MUST conform to the package-name grammar (§5.3).

Which of the two descriptor fields is permitted depends on where the claims field appears:

  • On a dependency or optional-dependency entry — the consumer side — each slot descriptor MUST contain path and MUST NOT contain target. A consumer declares only where it expects the name; it supplies no implementation.
  • On a provides entry — the provider side — each slot descriptor MUST contain target and MAY contain path. A provider declares the file that answers the slot, and MAY additionally declare a default claim path.

A claims field MUST NOT appear on a conflicts or replaces entry.

Slot keys within a claims object are an unordered JSON object and carry no ordering requirement. The enclosing arrays remain sorted and unique by name (§5.21).

Example — one package consumes the role, another provides it:

// the consumer's manifest
"dependencies": [
  { "name": "registryd",
    "claims": { "binary": { "path": "/usr/sbin/registryd" } } }
]

// the provider's manifest
"provides": [
  { "name": "registryd",
    "claims": { "binary": { "target": "/usr/sbin/loregd" } } }
]

5.23.3 Where a target may point #

A target MUST name a path the declaring package itself installs as a payload entry, and MUST therefore lie within the permitted install destinations of §5.14. A target that does not correspond to one of the declaring package's own payload paths is invalid and MUST cause the package to be rejected.

A consumer MUST verify this itself, against the payload it actually received. Producer-side validation says nothing about a package built elsewhere.

5.23.4 Where a claim path may lie #

A claim path is not a payload entry — it is the location of a consumer-managed link — and is governed by its own rule. It MUST satisfy the payload path-syntax and safety constraints of §5.13, and it MUST lie in one of:

  • the permitted install destinations of §5.14;
  • under /run/; or
  • the well-known root-level name /init.

Any other location MUST cause the package to be rejected.

Note

A link location and a target are constrained differently because they are different kinds of object. A target is a real file the provider ships, so it obeys the ordinary payload rules. A claim path is a name the consumer owns and points at that file; permitting /run/ for it — without permitting packages to ship payload there — lets a role expose a runtime socket while keeping /run off-limits to package payloads. /init is admitted for the same reason in the other direction: it is a well-known file a provider must own directly, and it is not a payload destination.

/lcl/policy MUST NOT be reachable as a claim path under any circumstance, by the same rule and for the same reason as §5.14.

5.23.5 Eligibility #

A package is an eligible provider of a role when it has a provides entry whose name is the role and whose claims field declares a target for at least one of the role's slots. Only an eligible provider may hold a role.

A package that depends on a role and declares a claim path for it, but does not provide the role, is a consumer only: it contributes claim paths and can never hold.

5.23.6 What a consumer guarantees #

The materialised links for a role MUST at all times equal the cross-product of the role's computed claim paths with the holder's targets, where the computed claim path set for a slot is the union of:

  • every path declared for that slot by an installed consumer, and
  • the path declared for that slot by the holder's own provides entry, if present.

A consumer MUST re-evaluate that set within any transaction that changes its inputs — a change of holder, or the installation or removal of any package declaring a claim path or a target for the role. A claim path declared for an already-held role MUST be materialised retroactively against the current holder; the holder is not re-decided.

A role MAY be held with no materialised links at all, when its computed path set is empty. Holder state is therefore recorded independently of whether any link exists.

A claim link is owned by the consumer, not by any package. It MUST NOT appear in any package's payload and MUST NOT be recorded as a package-owned path. This is what lets two eligible providers coexist: neither ships the contended path, so the one-package-per-path rule (§5.16) is never engaged by the providers themselves.

A claim path MUST NOT collide with a path owned by any installed package, evaluated against the state the containing transaction will produce rather than the state it started from. On collision, materialisation MUST fail and the transaction MUST be rolled back.

A holder swap MUST repoint every one of the role's links within a single transaction, and each repoint MUST be atomic, so that no consumer of a claim path ever observes the path absent.

Note

Atomic repoint matters because a claim path is typically on the critical path of a running system: a contended daemon binary may be executed at any moment. Tearing the old link down and building the new one as two steps exposes a window in which the name does not exist.

5.23.7 What claims are not #

  • Not a general symlink mechanism. A package needing a fixed symlink among its own files ships a payload symlink entry (§5.17). Claims exist for names contended by several packages.
  • Not a service-registration or activation mechanism. A materialised claim link is a symlink and nothing more.
  • Not an input to dependency resolution (§5.21).
  • Not a way to escape the one-package-per-path rule for ordinary payload files. Only consumer-owned claim links are exempt, and only at paths no package owns.

5.24 Side-Effect Declarations

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Some standard maintenance operations must run after files are installed or removed for a system to function: rebuilding the kernel module dependency cache when modules change, rebuilding the man page index when man pages are added.

These are not install scripts. The format does not permit a package to specify its own install script. A package instead declares which of a closed, enumerated set of maintenance operations it requires, and the consumer invokes them.

5.24.1 Schema #

side_effects is an array of strings:

"side_effects": ["depmod", "man-db"]

Each string MUST be drawn from the set below. An unknown value is invalid and MUST cause the package to be rejected. The array MUST NOT contain duplicates. It MAY be empty, or omitted entirely, for a package requiring no maintenance operation.

5.24.2 The recognised set #

5.24.2.1 depmod #

Rebuilds the kernel module dependency cache for a kernel release.

A package MUST declare depmod if its payload contains kernel module files (.ko, .ko.*) under /usr/lib/modules/. A package MUST NOT declare it if its payload contains no kernel modules.

The consumer MUST invoke it once per affected kernel release, naming that release. A package shipping modules for two releases causes two invocations.

Note

Naming the release matters more than it appears to. An invocation with no release operand acts on the running kernel, which during a kernel update or an image build is precisely not the kernel whose modules were installed — leaving that release's dependency cache unbuilt and its modules unloadable.

5.24.2.2 man-db #

Rebuilds the man page index, so that lookups by keyword are fast.

A package SHOULD declare man-db if its payload contains man pages under /usr/share/man/.

Note

man-db is SHOULD rather than MUST because man page lookup degrades gracefully without it — queries fall back to a filesystem scan. A package omitting it is suboptimal, not broken.

5.24.3 Semantics #

A side effect MUST be idempotent: running it several times in succession MUST leave the system in the same state as running it once. The recognised set has this property by construction.

A side effect MUST be safe to invoke non-interactively.

A consumer MUST invoke each declared side effect once per transaction, after every file operation in that transaction is in place and after the transaction has committed. Side effects MUST be deduplicated across the packages in a transaction: several packages each declaring man-db cause one invocation, not several.

A consumer MUST also invoke a side effect when a transaction removes files whose absence affects that effect's target — removing a kernel module requires depmod, removing a man page requires man-db — whether or not any package in the transaction declared it.

5.24.4 Why there is no shared-library cache #

Other systems carry an ldconfig side effect to rebuild /etc/ld.so.cache. Peios has no such cache and no such side effect, and this is a property of the layout rather than an omission.

A cache exists to do two things: make lookup fast when the loader must search many directories, and let it find libraries in directories it would not otherwise search. Peios has neither problem. The C library is configured with its library directory, its system library directory and its runtime-loader directory all set to /usr/lib/<triplet>, and the loader carries that path compiled in as its default. There is exactly one shared-library directory, and it is the one the loader already searches.

So the rule that replaces the declaration is a layout rule, and it is normative: a package shipping a shared library MUST install it into /usr/lib/<triplet>. A library installed anywhere else will not be found, and no maintenance operation exists to make it findable.

Reintroducing a cache would mean reintroducing everything a cache brings with it — a file to keep coherent with the filesystem, a tool in the base to regenerate it, and a failure mode where the two disagree. That trade is only worth making if Peios ever needs more than one library directory.

5.24.5 Ordering #

Side effects are invoked in an implementation-defined order. The recognised set is chosen so that order between distinct effects is not significant, and a consumer MAY invoke them concurrently.

5.24.6 Invocation hardening #

A consumer MUST invoke a side-effect tool with:

  • a fixed absolute path to the tool. The set is closed, so the consumer knows each tool's location; it MUST NOT search a path variable.
  • a cleared environment containing only well-defined variables. Environment inherited from the invoking context MUST NOT be passed through.
  • standard input closed.

Note

If a consumer located the tool by searching a path variable, a package could shadow the intended tool through an inherited search path. Because the set is closed the consumer needs no configurable allowlist — it invokes a known path. A cleared environment closes the matching injection route.

A consumer MUST invoke the tool against the installation root the transaction acted on, not against the root the consumer itself is running from.

5.24.7 Failure #

Side effects run after the transaction commits, so a side-effect failure does not — and cannot — roll the transaction back. A consumer MUST report the failure to the operator; the transaction stands.

Because side effects are idempotent, a failed one is self-correcting: re-invoking it, explicitly or as part of the next transaction that declares it, reaches the correct state. A consumer SHOULD make re-invocation straightforward.

Note

Rolling a committed transaction back because a cache rebuild exited non-zero would be disproportionate: the packages installed correctly and only a cache lagged, recoverably.

5.24.8 Extension #

A future version MAY recognise further identifiers — likely candidates include update-mime-database, update-desktop-database, and udev-reload, all excluded here as irrelevant to the scope Peios is built for. A conforming implementation of this version MUST reject a manifest declaring any identifier outside the set above.

A future version introducing a new identifier MUST state whether it is order-independent with respect to the existing set. An order-dependent side effect, if one is ever added, MUST be specified with a normative ordering relative to every other recognised effect.

5.24.9 What side effects are not #

  • Not a general install-script mechanism. The closed enumeration is what prevents arbitrary code execution at install time.
  • Not a way to register a service with the init system. Service integration belongs to the higher-level artifacts that compose packages.
  • Not a way to seed registry state.
  • Not a way to apply security descriptors, which are applied at file-creation time (§5.20).

A package whose required behaviour cannot be expressed through the manifest is incomplete and cannot be installed through the package format alone. That behaviour MUST be supplied by the higher-level artifact that composes the package.

5.25 The Files Manifest

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A package's integrity is verified at two levels. At the package level, the whole file has a hash and a signature proving it has not been altered since signing. At the per-file level, each payload file has an individual hash proving its content has not been altered between archive creation and installation.

The per-file level lives in the files manifest at .peipkg/files.json.

5.25.1 Schema #

{
  "schema_version": 1,
  "algorithm": "sha256",
  "entries": [
    { "path": "<string>", "size": <integer>, "hash": "<hex string>" }
  ]
}
FieldDescription
schema_versionMUST be 1 in this version.
algorithmMUST be sha256 in this version.
entriesOne entry per regular-file payload entry.
Entry fieldDescription
pathPayload-relative path, identical to the corresponding tar entry path.
sizeSize in bytes of the file's content.
hashLowercase hexadecimal hash of the file's content under the declared algorithm.

The entries array MUST be sorted lexicographically by path and MUST NOT contain duplicates.

5.25.2 Coverage #

The files manifest MUST contain exactly one entry per regular-file payload entry, and MUST NOT contain an entry for a metadata entry under .peipkg/, a directory entry, or a symlink entry.

A regular-file payload entry with no corresponding files-manifest entry is invalid. A files-manifest entry with no corresponding tar entry is invalid. Either MUST cause the package to be rejected on parse.

Note

The correspondence is checked in both directions because each direction catches a different failure. A file with no entry is an unverifiable file smuggled into the payload; an entry with no file is a hash for something that was never shipped, which makes the manifest's own count untrustworthy.

Symlinks are integrity-checked through the tar entry's linkname directly, and directories have no content. The files manifest covers only what is verifiable by content hash.

5.25.3 The package hash #

The package hash is the hash of the entire .peipkg file in its compressed on-wire form, computed with the algorithm declared in the repository index (§5.33). The required algorithm in this version is SHA-256.

It is recorded in the repository index, to verify that a downloaded file matches what the repository advertises, and in the signature payload (§5.28), to bind a signature to that exact file.

The package hash is not recorded inside the package: a package cannot contain its own hash.

5.25.4 Algorithm agility #

This version supports SHA-256 only. The algorithm field here and the hash identifier in the index reserve syntactic space for future algorithms. A conforming implementation of this version MUST reject any algorithm value other than sha256.

Note

BLAKE3 is a likely future addition for performance on large packages. The reservation means such a migration can be additive: producers continue emitting SHA-256 for compatibility with this version while a future version permits BLAKE3 as well.

5.25.5 Two levels, two threats #

The two levels defend against different things, and a consumer MUST verify both.

The package hash plus the signature defends against substitution of the package as a whole. The files manifest defends against corruption or tampering during extraction, after the signature has been verified.

Verifying only the signature leaves extraction errors and on-disk corruption undetectable. Verifying only the per-file hashes leaves the files manifest itself untrusted.

5.26 Verifying a Package

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A consumer MUST perform the following steps, in this order, before installing anything from a package:

  1. Compute the SHA-256 of the downloaded .peipkg file.
  2. Compare it against the hash recorded in the repository index (§5.33). If they differ, the package is corrupted or substituted; abort.
  3. Verify the inline signature (§5.30). If verification fails, the package's authenticity is unproven; abort.
  4. Decompress and parse the tar archive, enforcing the layout rules of §5.12, the determinism rules of §5.11 and the detached-signature rules of §5.16.
  5. Read .peipkg/manifest.json and verify it against §5.18.
  6. Read .peipkg/files.json and verify it against §5.25, including the two-way coverage check and the size_installed equality of §5.18.
  7. For each payload entry, compute its content hash and compare it against the files manifest. If any file's hash does not match, abort.
  8. Compare the manifest against the index entry that led here (§5.32). If any field disagrees, abort.

A consumer MUST NOT install any payload before all eight steps complete successfully. Partial installation after a verification failure leaves the system indeterminate and is forbidden.

5.26.1 Ordering is logical, not temporal #

A consumer MAY compute the hashes for steps 1, 3, and 7 in a single streaming pass: feeding the compressed bytes simultaneously through a hasher and a decompressor, piping the decompressed bytes through a second hasher up to the signature entry, and hashing each file's content as the tar walk reaches it.

What the ordering requires is that no payload is committed to its final install path, and no decompressed byte is made visible outside the consumer's own private state, until every step has completed.

5.26.2 Nothing observable before step 3 #

A consumer MUST NOT make any decompressed payload byte visible to another process — including through a staging directory reachable from outside the consumer's own process tree — before signature verification has succeeded.

Streaming decompression and hashing are permitted. Observable filesystem effects are not.

5.26.3 Verifying the whole transaction first #

When a consumer installs several packages together, it MUST complete steps 1 through 8 for every package before extracting any package's payload, and it MUST do so across every installation root the operation touches.

Note

This closes a class of multi-package attack: package A is verified, extracted, and its contents then influence the verification or extraction of package B — A installs a tool B's extraction invokes, or A creates a directory whose descriptor decides where B's files land. Verifying everything first means extraction operates on a known-good set of payloads. The rule binds across roots as well as within one, because a package extracted into one root is just as present on the filesystem as one extracted into another.

5.26.4 Committing a payload #

A consumer MUST resolve every path component of an install location relative to a verified parent-directory file descriptor, without traversing any symbolic link — including one the consumer itself created earlier in the same operation. A resolution that would traverse a symlink MUST abort the operation.

A pre-existing symlink at an install path MUST be removed atomically before the write, and MUST NOT be followed.

A well-formed package never contains a payload entry whose ancestor component is a symlink. A resolution failure therefore indicates a malformed or hostile package, or hostile filesystem state.

On Linux this is achieved with openat2(..., RESOLVE_NO_SYMLINKS) anchored at the relevant permitted top-level destination (§5.14), or with equivalent semantics using O_NOFOLLOW on every component against a carried directory descriptor. A consumer SHOULD additionally apply RESOLVE_BENEATH, RESOLVE_NO_XDEV, and RESOLVE_NO_MAGICLINKS as defence in depth, and SHOULD commit a staged file with renameat2 against the same pinned parent descriptor rather than a re-walked path string.

Note

The format's symlink-target rules (§5.17) are the first layer of the defence and this is the second, and neither is sufficient alone. The format forbids a symlink whose target resolves outside the managed tree; component-wise resolution ensures that even an in-tree symlink — or one already on the filesystem before this package arrived — cannot redirect a write. Without the second layer, a package shipping a symlink in one transaction and a file under that symlink in the next silently writes outside where it claims to, with no race required.

5.27 Decompression Bounds

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A consumer MUST bound package decompression, to prevent resource-exhaustion attacks by packages with extreme compression ratios.

Two bounds apply, and both MUST be enforced.

5.27.1 The index-declared bound #

The repository index entry's size_compressed and size_installed fields (§5.33) bound the legitimate sizes of the compressed and uncompressed forms. During streaming decompression a consumer MUST verify that:

  • the cumulative compressed bytes consumed do not exceed size_compressed by more than the lesser of 1% or 16 MiB; and
  • the cumulative decompressed bytes produced do not exceed size_installed plus a fixed overhead allowance of 320 MiB.

Both figures MUST be taken from the index entry, not from the package's own manifest. The manifest lives inside the compressed stream and is therefore under the control of whoever produced the bytes being bounded.

Note

This is the whole point of the bound, and it is easy to get backwards. A consumer that reads size_installed out of the manifest it is in the middle of decompressing has derived its cap from the input it is defending against: a hostile package simply declares a large enough figure. The index entry is signed by the repository, independently of the package, which is what makes it usable as a bound.

The 320 MiB decompressed allowance bounds the structural overhead a conforming package may legitimately carry above its installed payload: tar headers and block padding for up to the §5.A limit of 100,000 entries, plus the metadata files at their maximum sizes. A typical package's overhead is a tiny fraction of it; the allowance is sized so that a consumer never rejects a package conforming to §5.A.

5.27.2 The absolute cap #

Independently of any declared size, a consumer MUST abort decompression when the cumulative decompressed output exceeds an absolute cap. The default cap is 4 GiB. A consumer MAY raise it through operator configuration but MUST NOT raise it silently.

5.27.3 Checked continuously #

Both bounds MUST be checked on every chunk of output, not at end-of-stream.

Note

Constructed Zstandard payloads can achieve compression ratios beyond 10,000:1, so a 10 MB package can decompress to terabytes. A check deferred to end-of-stream never runs.

5.27.4 On exceeding a bound #

Exceeding either bound MUST cause the package to be rejected with no further processing and nothing committed to disk.

5.27.5 Cross-checking the declared size #

The manifest's size_installed and the index entry's size_installed MUST be equal, and a consumer MUST verify that equality (§5.32). Together with the files-manifest sum required by §5.18, this makes the figure a quantity all three of the producer, the repository, and the consumer can compute independently and agree on.

5.28 Package Signatures

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A package signature binds a package's bytes to a signing key. Verifying it establishes that the package has not been altered since signing, and that the signer held the trusted private key.

5.28.1 The signature entry #

The signature is the final entry in the tar archive, at .peipkg/signature (§5.12). Its content is a UTF-8 JSON document. Every tar attribute of the entry — mode, owner, mtime, magic — follows the determinism rules of §5.11 unmodified, so the entry is mode 0777 like every other, and §5.16's rationale applies to it identically.

5.28.2 The signed bytes #

The signature is over a SHA-256 hash computed across the concatenation of every complete tar entry block — header, content, and content-block padding to the next 512-byte boundary — for every entry preceding .peipkg/signature, in archive order.

The signed bytes do not include:

  • the tar entry header or content of .peipkg/signature itself;
  • the two trailing zero blocks that terminate a tar archive;
  • any compression artifact — signing operates on the uncompressed tar bytes.

Note

Compressing the signed tar produces the on-wire file. Compression is independent of signing: the same signed tar compressed at different levels verifies identically once decompressed. This is what makes §5.10's inability to fix compression parameters harmless for authenticity.

5.28.3 The envelope #

{
  "schema_version": 1,
  "algorithm": "ed25519",
  "key_fingerprint": "<hex string>",
  "signature": "<base64 string>"
}
FieldDescription
schema_versionMUST be 1 in this version.
algorithmMUST be ed25519 in this version.
key_fingerprintFingerprint of the public key (§5.29). Lowercase hex, 64 characters.
signatureThe signature value, base64 per RFC 4648 §4 without padding. For Ed25519 the decoded value is 64 bytes.

An envelope MUST contain all four fields. A missing field, or an unrecognised algorithm or schema_version, MUST cause the package to be rejected.

5.28.4 Strict parsing #

The envelope MUST NOT contain any field beyond the four above, and MUST NOT contain a duplicate key. An implementation of this version parsing an envelope from a future version MUST reject the package with an error naming the schema version mismatch, rather than silently ignoring the unknown fields.

This is a deliberate exception to §5.9's forward-compatibility rule. For security-critical signing data, strict parsing is preferred to permissive ignoring.

Note

The error must name the version, not the field. An implementation that reports "unknown field x" when the real condition is "this envelope is from a newer specification version" sends the operator looking for a malformed package instead of an outdated tool.

5.28.5 Signing procedure #

To sign a package, a producer:

  1. Constructs every tar entry except .peipkg/signature.
  2. Serialises them as an uncompressed tar byte stream in archive order.
  3. Computes the SHA-256 of that stream.
  4. Signs the resulting 32-byte hash with its Ed25519 private key, per RFC 8032.
  5. Constructs the envelope with the signature value and key fingerprint.
  6. Appends the .peipkg/signature entry — header, JSON content, and padding — to the tar byte stream.
  7. Compresses the complete stream to produce the .peipkg file.

Note that the Ed25519 message is the 32-byte SHA-256 digest, not the tar stream itself. A verifier MUST do the same.

Note

The digest indirection is deliberate and applies to every signature in this chapter, package and detached alike: one signature construction covers a small JSON index and a multi-gigabyte package, and the large case is verifiable in a single streaming pass without buffering.

5.28.6 Determinism #

Given identical signed bytes and an identical key, the Ed25519 signature is deterministic per RFC 8032 §5.1.6. A producer that builds the same tar archive and signs it with the same key MUST produce a byte-identical signature entry.

5.28.7 Unsigned packages #

A package without a .peipkg/signature entry is unsigned.

The format permits unsigned packages. A consumer MAY install one if the originating repository's trust policy permits it (§5.37).

An unsigned package MUST conform to every other requirement of this chapter. The manifest, the files manifest, the payload rules, and the integrity rules apply identically to signed and unsigned packages.

Note

The official repository requires signatures. Other repositories may permit unsigned packages for development, homelab, or air-gapped use. The format is permissive; the policy is enforced by the consumer, per repository.

5.29 Keys and Fingerprints

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

5.29.1 Algorithm #

Signatures use Ed25519 as defined in RFC 8032. A conforming implementation MUST support Ed25519 signing and verification. Other algorithms are reserved for future versions.

Note

Ed25519 was chosen for a small public key (32 bytes) and signature (64 bytes), deterministic output with no nonce-reuse risk, fast verification, resistance to most side channels, and broad library support.

5.29.2 Public key encoding #

A public key is the raw 32-byte Ed25519 public key value of RFC 8032 §5.1.5.

When published as a file, a public key MUST be encoded either as the raw 32 bytes, or as a PEM PUBLIC KEY block per RFC 7468 in the SubjectPublicKeyInfo form. Tooling MUST accept both.

A published public key file MUST contain only the public key, in one of those two encodings.

5.29.3 Fingerprint #

A public key's fingerprint is the lowercase hexadecimal SHA-256 of the raw 32-byte public key:

fingerprint = lowercase_hex(sha256(public_key_bytes))

The fingerprint is 64 hexadecimal characters. It is computed over the raw key bytes and never over a PEM or SubjectPublicKeyInfo encoding of them.

The fingerprint is the canonical identifier of a public key throughout this chapter: the signature envelope's key_fingerprint (§5.28) and the repository descriptor's signing key declarations (§5.31) both use this form.

A consumer that fetches a public key MUST verify the key's fingerprint against the fingerprint that identified it before admitting it to a trust set.

Note

Fingerprints SHOULD be displayed to people in a form that makes a mismatch visually obvious — conventionally four-character groups separated by spaces or colons. The normative on-wire form remains the unbroken 64-character lowercase hex string.

5.29.4 Key roles #

Two roles are distinguished by usage, not by structure:

  • Signing keys are used by a producer to sign packages, descriptors, and indexes.
  • Trusted keys are configured into a consumer as keys whose signatures it accepts.

A single key MAY play both roles.

5.29.5 The trust set #

A consumer maintains a trust set: the public keys whose signatures it accepts.

The trust set MUST be partitioned per repository. Each configured repository contributes its declared signing keys to the trust set, scoped to that repository's content.

A signature MUST be accepted only if its key_fingerprint matches a key in the trust set scoped to the repository the content was fetched from.

Note

Cross-repository acceptance is forbidden. A package fetched from repository R and signed by a key trusted only for repository S MUST be rejected, even though both keys are in the consumer's overall configuration. Without this, a compromised low-trust repository could serve packages whose signatures verify against a high-trust key, which is an escalation from one context to the other.

5.29.6 Private keys #

Private key material is not the concern of this specification. Its generation, storage, custody, and rotation are operational matters for the key holder.

Hardware security modules, threshold signing schemes, and air-gapped signing are all compatible with this format, so long as the resulting signature conforms to the envelope of §5.28. This specification cares about the bytes, not how they were produced.

5.30 Verifying a Signature

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

To verify a package's signature, a consumer MUST:

  1. Decompress the .peipkg file to the uncompressed tar bytes.
  2. Walk the tar archive in order, accumulating each entry's complete blocks — header, content, and content-block padding — until reaching the entry at path .peipkg/signature.
  3. Stop at that entry. What has been accumulated is the signed byte range of §5.28.
  4. Parse the content of .peipkg/signature as the signature envelope.
  5. Validate the envelope's schema_version and algorithm. Reject if either is unrecognised, naming the version mismatch where that is the cause.
  6. Look up the public key by key_fingerprint in the trust set scoped to the originating repository (§5.29). If no matching key is in that trust set, reject.
  7. Determine whether the key is usable for verification given its status (§5.32). Reject a revoked key, and a transitioning key past its validity, before performing any cryptographic operation.
  8. Compute the SHA-256 of the signed bytes.
  9. Verify the signature against that hash with the looked-up key, per RFC 8032.

If step 9 succeeds the signature is valid. If it fails, reject the package.

Note

Step 7 before step 9 is deliberate. A revoked key's signatures are rejected regardless of cryptographic validity, so checking status first means a revoked key can never produce a "signature verified" result anywhere in the implementation, even transiently.

5.30.1 Streaming #

The accumulation in step 2 is conceptual. An implementation MAY hash the signed bytes incrementally as it walks, without retaining the stream; steps 8 and 9 then operate on the running hash state.

Streaming MUST NOT be conflated with early commitment. A consumer hashing incrementally MUST still defer every externally observable filesystem effect until step 9 has succeeded (§5.26).

5.30.2 Locating the end of the signed range #

A verifier computing the signed range by subtracting a fixed header size from a stream offset MUST account for an extended header block preceding the signature entry. §5.11 makes such a header unnecessary for a short-named entry, but a verifier that assumes it away will mis-locate the range for any archive that carries one, and report a signature failure for what is really a framing difference.

5.30.3 Failure conditions #

A consumer MUST reject a package as unverified when any of these holds:

  1. The package contains no .peipkg/signature entry and the trust policy for its originating repository requires signed packages.
  2. The .peipkg/signature entry is not the last named entry in the archive.
  3. The envelope does not parse, or carries an unknown or duplicate field.
  4. The envelope's schema_version is not 1.
  5. The envelope's algorithm is not recognised.
  6. The envelope's key_fingerprint matches no key in the trust set scoped to the originating repository.
  7. The matching key's status does not permit verification.
  8. The cryptographic verification fails.

A rejected package MUST NOT be installed, and the consumer MUST report which condition triggered the rejection.

Note

Condition 2 is not redundant with §5.12's ordering rule; it is the same rule stated where its consequence is visible. Everything after the signature entry is unsigned, so a consumer that tolerates a trailing entry has accepted attacker-chosen bytes inside a package whose signature verifies.

5.30.4 A package with no originating repository #

A consumer MAY accept a package supplied directly rather than fetched from a configured repository — a file handed to it on the command line. Such a package has no originating repository, and therefore no trust set to verify against.

A consumer that accepts one MUST treat it as unverified: it MUST NOT report the package as signature-verified, and it MUST surface to the operator that the package's authenticity was not established.

5.30.5 What verification proves #

A verified signature establishes integrity — the archive bytes preceding the signature entry have not been altered since signing — and authenticity — the signer held the private key corresponding to a trusted public key at the time of signing.

It does not establish that the signed bytes encode meaningful content: a consumer MUST still validate the manifest, the files manifest, and the per-file integrity (§5.26). It does not establish that the signer intended the package for any particular system. And it does not establish that the content is free of bugs or malice. Signing certifies provenance, not safety.

5.30.6 Replay and substitution #

Signature verification alone does not prevent replay — an attacker substituting an older, validly signed package for a newer one. Defence against substitution comes from the repository index (§5.33), which is itself signed, declares the current authoritative version of each package, and records each package's hash.

A consumer MUST consult the index and verify the package's hash against it before accepting the package, even when the signature verifies.

Note

An old signed package whose signature still validates against a still-trusted key is otherwise indistinguishable from the current one. The index's per-package hash binds "what is current" to "this exact file", which is what closes the gap.

5.31 The Repository Descriptor

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

A repository descriptor is a small JSON document at a well-known URL describing a repository's identity, its signing keys, and where its indexes live. It is the entry point a consumer fetches when adding or refreshing a repository.

5.31.1 Location #

A repository's descriptor MUST be reachable at <repo-base>/repo.json, where <repo-base> is the base URL the repository was added under (§5.36). It MUST be served as static content.

5.31.2 Schema #

{
  "schema_version": 1,
  "repo": {
    "name": "<string>",
    "description": "<string>",
    "signing": { "algorithm": "<string>", "keys": [<key>...] }
  },
  "indexes": {
    "active":  { "url": "<string>", "signature_url": "<string>" },
    "archive": { "url": "<string>", "signature_url": "<string>" }
  }
}
FieldDescription
schema_versionMUST be 1 in this version.
repo.nameA short identifier for the repository. MUST be non-empty. SHOULD be kebab-case.
repo.descriptionOPTIONAL. A human-readable one-line description.
repo.signingSigning key information.
indexes.activePointer to the active index (§5.33).
indexes.archivePointer to the archive index (§5.35). REQUIRED.

The archive pointer is required even when the archive is empty, as it is for a newly established repository. A repository without an archive index is non-conformant.

5.31.3 The signing object #

{
  "algorithm": "ed25519",
  "keys": [
    { "fingerprint": "<hex>", "url": "<string>", "status": "active" }
  ]
}
FieldDescription
algorithmMUST be ed25519 in this version.
keysOne or more keys. MUST contain at least one with status active.
Key fieldDescription
fingerprintThe key's fingerprint (§5.29): lowercase hex, 64 characters.
urlWhere the public key file is published. MAY be relative to <repo-base>.
statusOne of active, transitioning, revoked (§5.32).
valid_untilRFC 3339 UTC timestamp after which a transitioning key MUST NOT be accepted. REQUIRED for transitioning; ignored otherwise.

The keys array MUST be sorted lexicographically by fingerprint. Two entries with the same fingerprint in one descriptor are invalid.

5.31.4 Index pointers #

FieldDescription
urlWhere the index is published. MAY be relative to <repo-base>.
signature_urlWhere the index's detached signature is published. MAY be relative to <repo-base>.

The conventional URLs are:

<repo-base>/index/active.json
<repo-base>/index/active.json.sig
<repo-base>/index/archive.json
<repo-base>/index/archive.json.sig

A repository MAY use other URLs by declaring them. The descriptor's URLs are authoritative; the conventional paths are defaults for tooling that has nothing else to go on.

5.31.5 Descriptor signing #

The descriptor MUST be accompanied by a detached signature published at <repo-base>/repo.json.sig. The detached signature is a signature envelope (§5.28) over the SHA-256 digest of the descriptor file's exact bytes — the same construction as a package signature.

The signing key MUST be one of the keys listed in the descriptor's own repo.signing.keys, with status active or transitioning.

Note

The descriptor signature defends against an attacker who can serve content from <repo-base> substituting alternate signing keys. Without it, whoever can substitute the descriptor can substitute the keys, and from there re-sign the indexes and the packages. The chicken-and-egg at first add is broken by the operator supplying an expected fingerprint out of band (§5.37).

A repository configured to permit unsigned content MAY publish an unsigned descriptor and unsigned indexes. This is a security weakening opted into per repository, and a consumer MUST NOT treat the absence of a signature as a fetch failure for such a repository.

5.31.6 Canonical form #

The descriptor SHOULD be canonically formatted so that signing is reproducible: fields in the schema's order, key arrays sorted as specified, no trailing whitespace, a single trailing newline.

5.31.7 Naming #

A consumer MAY refer to a repository by a local handle of its own choosing. When it does, it MUST NOT require that handle to equal repo.name, and MUST NOT compare an index's repo field against the local handle. An index's repo field is compared against the descriptor's repo.name.

5.32 Signing Key Status and Rotation

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

5.32.1 Statuses #

A key's status describes its role in the repository's current operation.

StatusUsed for new signaturesAccepted for verification
activeyesyes
transitioningnoyes, until valid_until
revokednonever, whatever the cryptography says
  • active — the key currently signs new packages and indexes. A consumer MUST accept its signatures.
  • transitioning — the key was active and remains acceptable for verification until its valid_until timestamp, but no longer produces new signatures. A consumer MUST accept its signatures while the current time is at or before valid_until, and MUST reject them afterwards. A transitioning key entry MUST carry a valid_until.
  • revoked — the key is no longer trusted under any circumstance. A consumer MUST reject its signatures regardless of when they were produced and regardless of whether they verify cryptographically.

A status other than these three is invalid.

A repository MAY have several active keys, permitting parallel signing; any number of transitioning keys, each with its own valid_until; and any number of revoked keys.

revoked is the explicit signal of a compromise event. transitioning is for routine rotation only, and the two MUST NOT be conflated.

5.32.2 Retention of revoked entries #

A revoked entry MUST be retained in the descriptor for at least one year after the revocation. Removing it prematurely would hide the public acknowledgement of compromise from consumers with stale caches.

A repository MUST continue to serve the public key file of a revoked key for as long as its entry is retained, so that a consumer fetching the descriptor can resolve every key it declares.

Note

Keeping a revoked key in the descriptor is useful even though its signatures are rejected regardless: it is a public acknowledgement a consumer can audit, and it means a consumer encountering a signature from that key gets "this key was revoked" rather than a generic "key not in trust set".

5.32.3 Rotation #

A repository rotates a signing key by:

  1. Generating a new key pair.
  2. Adding the new public key to the descriptor's key list alongside the existing one.
  3. Beginning to sign new content with the new key.
  4. After a transition period during which both are advertised, marking the old key transitioning with a valid_until, and eventually removing it.

During the transition, content signed with either key is acceptable. After the old key's validity lapses, only the new key's signatures remain acceptable.

The length of the transition period is operational policy and is not specified here. Its purpose is to give consumers time to fetch the updated descriptor and learn the new key before old signatures stop being honoured.

5.32.4 The offline emergency key #

A repository SHOULD maintain at least one offline active signing key in addition to its routine signing keys. The offline key's private material is stored separately from build infrastructure and is used only for descriptor updates and emergency rotations.

The offline key exists to break a chicken-and-egg in compromise response. Revoking a compromised signing key requires publishing a new descriptor, which must itself be signed. If the only trusted key is the compromised one, the operator must sign the revocation with the compromised key — giving an attacker who holds that same key the ability to substitute their own revocation that adds a key of their choosing.

With an offline key, the operator signs the descriptor update revoking the compromised key without relying on the compromised key at all. Consumers holding the offline key in their trust set accept the update; consumers who do not must perform an out-of-band trust-anchor refresh (§5.37).

Note

This is SHOULD rather than MUST because the implementation question — hardware module, air-gapped machine, threshold custody — is the operator's. A future version may make it mandatory.

5.32.5 Compromise #

A key SHOULD be considered compromised if its private material may have been obtained by an unauthorised party.

A compromised key MUST be marked revoked in the descriptor immediately on discovery. Packages signed with it SHOULD be re-signed with a fresh key and re-published at new revisions.

The revoked status is the in-band revocation channel this specification defines. It defends at descriptor-update granularity: a consumer that successfully refreshes learns of the revocation at once, and a consumer caching an older descriptor retains trust in the revoked key only until it re-syncs — a window bounded by the maximum trusted age of §5.37.

Note

A richer out-of-band mechanism — signed revocation lists, a key transparency log — is reserved for a future version. The combination of revoked status, a bounded maximum trusted age, and signed descriptor updates closes the practical compromise-response window without requiring separate revocation infrastructure.

The corollary a consumer must not miss: revocation only protects the paths that consult the trust set. Any code path that installs a package without resolving its key against the repository's trust set — a build or image-composition path, say — is a path on which revocation has no effect at all.

5.33 The Active Index

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

The active index lists the current version of every package a repository advertises. It is the index a consumer fetches on a routine sync.

5.33.1 Location and signing #

The active index's URL and its detached signature's URL are declared by the descriptor (§5.31).

The index MUST be accompanied by a detached signature: a signature envelope (§5.28) over the SHA-256 digest of the index file's exact bytes. The signing key MUST be one of the descriptor's keys with status active or transitioning.

A repository configured to permit unsigned content MAY publish the active index unsigned.

Note

Detached metadata signatures and package signatures share a single construction — an envelope over a SHA-256 digest — so a verifier implements it once. The digest indirection is free for a small JSON index and lets a multi-gigabyte package be verified in one streaming pass.

5.33.2 Schema #

{
  "schema_version": 1,
  "repo": "<string>",
  "kind": "active",
  "index_version": <integer>,
  "generated_at": "<RFC 3339 timestamp>",
  "packages": [<package_entry>...]
}
FieldDescription
schema_versionMUST be 1 in this version.
repoThe repository's name, matching repo.name in the descriptor.
kindMUST be active.
index_versionA monotonically increasing positive integer identifying this index revision (§5.34).
generated_atRFC 3339 UTC timestamp of generation.
packagesOne entry per package currently advertised.

A consumer MUST verify that repo matches the descriptor's repo.name and that kind matches the index it requested. An archive index served in place of an active one MUST be rejected.

5.33.3 Package entries #

{
  "name": "<string>",
  "version": "<string>",
  "architecture": "<string>",
  "description": "<string>",
  "license": "<string>",
  "license_class": "<string>",
  "homepage": "<string>",
  "default_root": "<root reference>",
  "alternate_upgrade": {<alternate_upgrade>},
  "dependencies": [<dependency>...],
  "optional_dependencies": [<dependency>...],
  "conflicts": [<dependency>...],
  "provides": [<provides>...],
  "replaces": [<replaces>...],
  "side_effects": [<string>...],
  "size_compressed": <integer>,
  "size_installed": <integer>,
  "hash": { "algorithm": "<string>", "value": "<hex string>" },
  "url": "<string>",
  "build": { "timestamp": "<RFC 3339>", "farm_id": "<string>" }
}

An entry MUST contain name, version, architecture, dependencies, conflicts, provides, replaces, side_effects, size_compressed, size_installed, hash, and url. The array fields MUST be present even when empty, emitted as []. The remaining fields are RECOMMENDED and MAY be omitted.

name, version, and architecture MUST each be validated against §5.3, §5.5, and §5.8 respectively on parse — with the same strictness a manifest receives. An index is fetched from the network and its values flow into URL construction and into the consumer's own records.

size_compressed and size_installed are required because they are the input to the decompression bound of §5.27.

license_class, when present, MUST be validated against the closed set of §5.18 on parse; absent means unknown, as in the manifest. It is carried so a consumer can apply a licence policy at planning time, before fetching anything.

alternate_upgrade, when present, MUST be validated exactly as the manifest's (§5.18). It is carried so a consumer can refuse or hold back a package (§5.18 rules 1 and 2) at planning time, before fetching anything, and so an every-package upgrade can report what it held back without downloading it.

hash carries algorithm, which MUST be sha256 in this version, and value, the lowercase hexadecimal SHA-256 of the .peipkg file in its compressed on-wire form.

5.33.4 The derivation rule #

The active index is a derived view of the packages it advertises. Every field of an entry MUST exactly match the corresponding field of that package's manifest where one exists, and MUST exactly match the properties of the actual package file for hash, size_compressed, and url.

Tooling generating an index MUST extract values directly from package manifests. Editing an index by hand is forbidden.

Where a manifest contradicts an index entry, the manifest is authoritative (§5.18) — and the contradiction is a defect in the repository, not a difference to accommodate. A consumer MUST compare the downloaded package's manifest against the index entry that led to it, across every field the index carries, and MUST reject the package on any mismatch (§5.26 step 8).

Note

Comparing only name, version, and architecture is not enough, and the gap is not theoretical. A consumer builds its entire dependency and conflict graph from index claims. A repository that publishes an entry declaring no conflicts for a package whose manifest declares one against a critical installed package gets a plan computed on the lie, approved by the operator on that basis, and applied — with the real relations discovered by nobody. The index is a convenience for planning without downloading; it is not a second source of truth.

5.33.5 Deliberate omissions #

The index omits three manifest fields:

  • sd_overrides — not relevant to planning, and potentially large.
  • build.source_ref — long and low in information density; consult the package when it is wanted.
  • the manifest's own schema_version — the index carries its own.

These remain in the manifest and are available to a consumer that fetches the package. Because they are omitted rather than mismatched, they are outside the comparison above.

5.33.6 URLs #

url declares where the package file is fetched from, and MAY be relative or absolute (§5.36). The conventional form is relative:

"url": "/p/nginx/1.26.2-3/nginx_1.26.2-3_x86_64.peipkg"

This keeps an index portable: the same file is valid at any <repo-base> hosting the same package files.

5.33.7 Ordering #

The packages array MUST be sorted lexicographically by name. Two entries with the same name in an active index are invalid: each name appears exactly once.

Note

Per-name uniqueness is what makes the index "active" — one current version of each package. The archive index (§5.35) relaxes exactly this constraint and nothing else.

5.33.8 Unknown fields #

A consumer MUST ignore unknown fields, at the top level and per package, per §5.9. A producer MAY emit additional fields in a future schema version.

The exception is a field whose meaning is critical to correctness, such as a hash algorithm identifier. Such changes are expected to arrive through a schema_version bump, not as a silent addition.

5.33.9 Size and caching #

For a repository of a few hundred packages the active index is on the order of 100 KB compressed. A consumer SHOULD fetch with HTTP-level compression where it is offered, and SHOULD cache the parsed index between invocations: the index changes only when the repository publishes, which is far less often than a consumer reads.

A cached index MUST be stored under a security descriptor granting write access only to the principal permitted to install packages.

A consumer MUST re-verify a cached index's signature on every operation that relies on it, rather than trusting its cached state across operations. Caching avoids re-parsing; it does not avoid re-verifying.

A consumer SHOULD additionally cross-check a cached index against its own recorded freshness state (§5.34), and reject a cached index whose index_version or generated_at disagrees with what it recorded.

Note

Without re-verification, whoever can write to the cache can substitute metadata between the cache write and the next read. Re-verifying on every use closes that race, and the cost is negligible: verifying a signature over a few hundred kilobytes is sub-millisecond. The cross-check against recorded state closes the matching hole, where an attacker substitutes an older validly signed index directly into the cache, bypassing the refresh path where §5.34's floor is enforced.

5.34 Freshness and Rollback Protection

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

An index that verifies is not necessarily current. This section defends against rollback — replaying an older signed index to hide newer packages — and freeze — holding a consumer at a current-but-stale index while its clock runs on.

Every requirement here applies to both the active index and the archive index. An attacker who can replay one can replay the other, and the archive index is the candidate source for every downgrade and pin.

5.34.1 Monotonic index versions #

Each publication of an index MUST set index_version to a value strictly greater than any previously published value for the same repository.

A consumer MUST record, per repository, the highest index_version it has ever observed. On each fetch it MUST reject an index whose index_version is less than that recorded value, even when the index is correctly signed by a still-trusted key.

A consumer MUST also record the generated_at of the last index it trusted, and MUST reject an index whose generated_at is older than the recorded value.

5.34.2 No progress is a failed fetch #

A fetch returning an index whose index_version and generated_at both equal the recorded values is a failed refresh, not a successful one. A consumer MUST NOT advance its "last successful refresh" timestamp on such a fetch.

Note

This is the anti-freeze rule, and it is the one most often skipped. Without it, an attacker who can serve the same signed index indefinitely keeps every consumer's refresh timestamp advancing while the content never changes, so the maximum-trusted-age check of §5.37 never fires and the consumer never notices it has been pinned.

5.34.3 The initial floor #

Adding a repository bootstraps the consumer's recorded floor. To defend against an attacker serving a stale-but-signed index at that moment, a repository SHOULD distribute a minimum acceptable index_version alongside its trust anchors, through the same out-of-band channel. A consumer SHOULD use that minimum as its initial floor, and MUST refuse the add when the first index fetched falls below it.

A consumer MUST NOT reset a recorded floor as a side effect of any operation other than removing the repository. In particular, re-adding an already-configured repository MUST NOT lower the floor: the operation either applies the recorded floor as a refresh would, or is refused.

Note

Repository-add reads as idempotent, and configuration-management convergence loops treat it that way. If adding an already-known repository rewrites the floor unconditionally, a rollback that the refresh path correctly refuses becomes permanent and invisible the next time that loop runs. Removing the repository first is the sanctioned reset (§5.37), because it discards the trust state too.

5.34.4 Maximum index staleness #

A consumer MUST enforce a maximum staleness window on the index itself, measured from its generated_at. An index older than 90 days MUST trigger a refresh attempt before any install operation proceeds.

The 90-day default MAY be tuned by operator configuration; a value greater than 365 days SHOULD generate a warning each time it is exercised.

Note

This is a different measurement from the maximum trusted age of §5.37, and both are needed. Trusted age asks "how long since I successfully refreshed"; index staleness asks "how old is the metadata I am acting on". A repository that bumps index_version on every publication while stamping an ancient generated_at satisfies the first check forever and fails the second immediately.

5.34.5 What these checks buy #

Per-package signing and index signing both still verify under a rollback: the attacker is replaying genuine, correctly signed content. What changes is the set of packages the consumer believes is current. The monotonic version check is what makes that set unable to move backwards, and the no-progress rule is what stops it from being frozen in place.

5.35 The Archive Index

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

The archive index lists every version of every package a repository has ever advertised, including versions superseded by newer releases. It is the source of historical data for downgrade, version pinning, and forensic queries.

5.35.1 Retention #

A repository MUST retain every package version it has ever advertised. Once a package has been published at version V, the repository MUST continue to make V fetchable indefinitely, and the archive index MUST continue to list it.

Note

This is a deliberate departure from rolling-only models. Retention is what supports rollback, reproducible deployment, security forensics, and long-running systems held at an older version.

A repository MAY retire pre-release or development versions under a stated retention policy. Retirement MUST NOT silently remove a package a consumer might be using, and SHOULD be coordinated with consumer notice.

A pruned package MUST also be removed from the repository's package storage: the archive index MUST NOT reference a package file that is no longer fetchable.

Note

Reasonable policies include retaining all stable releases indefinitely, retaining pre-releases for a year after each successor, or never pruning anything. This specification neither mandates nor forbids pruning.

5.35.2 Location and signing #

The archive index's URL and its detached signature's URL are declared by the descriptor (§5.31). It MUST be signed under the same rules as the active index (§5.33).

5.35.3 Schema #

The top-level schema is identical to the active index (§5.33), except:

  • kind MUST be archive;
  • the packages array MAY contain several entries with the same name, at different versions.

The per-package entry schema is identical. Each historical version contributes one entry.

index_version semantics are identical, and every freshness and rollback requirement of §5.34 applies to the archive index exactly as it does to the active one.

5.35.4 Ordering #

The packages array MUST be sorted lexicographically first by name, then within a name by version descending per §5.6. The first entry for any name is its highest version; subsequent entries for that name are progressively older.

Where two entries of one name share a version — differing only in architecture — the ordering between them MUST be total and MUST be stated by the producer's tooling, so that the file is reproducible.

Note

Highest-first ordering puts the most recently shipped version of each package at the top of its name group, so a consumer scanning for the latest version satisfying a constraint can stop as soon as the constraint is satisfied or exceeded.

5.35.5 Relationship to the active index #

For every entry in the active index there MUST be at least one entry in the archive index with the same name, version, architecture, and hash. The archive index is a superset of the active index.

Equivalently: the active index is the per-name maximum projection of the archive index, where "maximum" is the highest version per name under §5.6.

A repository publishing both indexes SHOULD publish them at the same index_version and generated_at, so that a consumer holding one has a usable floor for the other.

5.35.6 Fetch frequency #

The archive index is large compared to the active index — potentially many megabytes for a long-running repository. A consumer SHOULD fetch it only when it is needed: for a historical query, for a pin or a downgrade, or when its cached copy expires. A routine sync SHOULD fetch only the active index.

A consumer SHOULD cache the archive index aggressively, since it changes only when a version is published or pruned.

5.36 URL Conventions

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Every URL in this chapter maps to a static file. The protocol requires no server-side computation, no dynamic response, and no content negotiation beyond optional HTTP-level compression.

5.36.1 The repository base #

A repository is identified by a base URL, <repo-base>.

The base URL MUST be a syntactically valid HTTP or HTTPS URL per RFC 3986, and MUST NOT have a trailing slash: the well-known relative paths below are appended directly.

HTTPS MUST be used, unless the consumer has been configured with an explicit per-repository insecure-transport allowance. There is no global form of that allowance, and its use MUST generate a per-operation warning.

Enabling the allowance on a repository that has already been added MUST require explicit operator authorisation and MUST emit an audit event. Setting it as part of the initial add is covered by the operator's trust decision at that moment and requires no separate event beyond the add's own.

Note

Insecure transport is intended for a trusted local network during development. Relying on package signing alone for transport integrity leaves the consumer exposed to traffic analysis and to metadata-substitution attacks even when content verification succeeds.

A consumer MAY additionally support a file:// base URL for local development. A file:// repository MUST be subject to the same per-repository allowance as an HTTP one: it is not HTTPS, and admitting it silently makes removable or network-mounted media a trusted source without the operator ever acknowledging it.

5.36.2 Conventional paths #

PathContent
<repo-base>/repo.jsonRepository descriptor (§5.31)
<repo-base>/repo.json.sigDetached signature on the descriptor
<repo-base>/index/active.jsonActive index (§5.33)
<repo-base>/index/active.json.sigDetached signature on the active index
<repo-base>/index/archive.jsonArchive index (§5.35)
<repo-base>/index/archive.json.sigDetached signature on the archive index
<repo-base>/keys/<fingerprint>.pubPublic key file, named by full fingerprint
<repo-base>/p/<name>/<version>/<filename>Package file

A repository SHOULD use these paths unless it has a reason not to; when it does not, the descriptor declares the ones it uses. A consumer that knows only <repo-base> MUST be able to locate repo.json at the conventional path. The descriptor carries the URLs for everything else.

5.36.3 Package URLs #

<repo-base>/p/<name>/<version>/<filename>

where <name> conforms to §5.3, <version> is the full version string of §5.5, and <filename> is <name>_<version>_<architecture>.peipkg.

https://pkgs.peios.org/p/nginx/1.26.2-3/nginx_1.26.2-3_x86_64.peipkg

5.36.4 Sibling artifacts #

The directory containing a package file MAY hold additional siblings for that version. These are reserved for future use and are not normative here:

<repo-base>/p/<name>/<version>/<filename>.debug.peipkg
<repo-base>/p/<name>/<version>/<filename>.sbom.json
<repo-base>/p/<name>/<version>/<filename>.attestation.json

A consumer conforming to this version MUST NOT attempt to fetch a sibling artifact. A producer MAY publish them; their meaning is defined by a future version.

5.36.5 Relative URLs #

A URL field in a descriptor or an index MAY be absolute or relative.

  • An absolute URL, carrying a scheme, is used as-is.
  • A URL beginning with / is resolved against <repo-base> by prepending the base.
  • A URL with neither a scheme nor a leading / is resolved against the URL of the document containing the reference, per RFC 3986 §5.

Note

Relative URLs are RECOMMENDED: they keep an index portable, valid under any <repo-base> hosting the same layout. An absolute URL pins the index to a host and requires regeneration when the host changes.

5.36.6 Hosting #

A conformant repository may be hosted on a plain HTTP server, an object store with an HTTP frontend, a static site host, a CDN in front of any of those, or a combination — descriptor and indexes on a static host, package files on object storage behind redirects.

5.36.7 Network failure #

A consumer that fails to fetch a URL MUST NOT silently fall back to outdated cached data. Using a stale cache without explicit operator consent can mask substituted content or a revoked-key update.

A consumer SHOULD offer a way to configure cache-staleness tolerance per repository.

A consumer whose cached index for a configured repository fails to load or verify MUST treat that as a failure of the operation rather than proceeding without that repository.

Note

Silently continuing is worse than it looks. Dropping a repository from consideration mid-operation does not merely lose candidates: it can silently promote a lower-priority repository's package into a role the dropped one was filling, which is an escalation dressed as a warning.

5.37 Establishing Trust in a Repository

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Trust is configured per repository, never globally. Each repository a consumer is configured with has its own trusted signing keys (§5.29), its own signature policy, and its own priority.

5.37.1 Adding a repository #

To add a repository, the operator supplies its <repo-base> URL, one or more expected key fingerprints — the trust anchors — and a signature policy.

The consumer then:

  1. Fetches <repo-base>/repo.json and <repo-base>/repo.json.sig.
  2. Fetches the public key for each supplied anchor fingerprint, from the conventional URL or from the URL the descriptor declares, and verifies each fetched key against the fingerprint that named it (§5.29).
  3. Verifies the descriptor's signature against those anchor keys and only those.
  4. On success, records the descriptor's contents — including every signing key and status — as the repository's initial trust state.
  5. On failure, rejects the add and reports why.

A consumer MUST NOT add a repository whose signing key it learned from the repository itself without prior verification against an anchor. Trust anchors are obtained out of band: through a project's website, its documentation, or the operating system image.

Note

Step 2 necessarily issues requests derived from an unverified document, since the descriptor is what names the key URLs. A consumer SHOULD limit those requests to the keys matching the supplied anchors, and MUST NOT follow a key URL for a fingerprint the operator did not name — otherwise a substituted descriptor can direct the consumer to fetch from arbitrary hosts before anything has been verified.

5.37.2 Guarding against a mistyped fingerprint #

When presenting a fetched key for confirmation, a consumer MUST:

  • display the 64-character fingerprint in groups separated by spaces or colons — conventionally four characters per group, as 1a2b 3c4d 5e6f ...;
  • display the fetched key's fingerprint alongside the one the operator supplied, for visual comparison, before recording any trust state;
  • require explicit confirmation before recording. Automatic confirmation on the basis of a bit-for-bit match is permitted only in a non-interactive context where the operator pre-supplied the fingerprint through a configured channel.

When a repository declares several active keys, the operator is RECOMMENDED to supply anchors for at least two of them, as defence-in-depth against a single mistyped anchor.

A consumer MUST report an anchor mismatch by naming both the anchor the operator supplied and the fingerprints the descriptor actually declares. A mismatch is most often a transcription error, and that is precisely the diagnostic needed to find one.

5.37.3 Signature policy #

PolicyMeaning
requiredEvery package and index MUST be signed and verify. Unsigned content from this repository is rejected.
optionalSigned content is verified. Unsigned content is accepted with a per-operation warning.

These are the only two policies. There is no silently-accept-unsigned policy: a consumer intentionally permitting unsigned content does so through optional, which always warns.

The warning MUST surface on every install, upgrade, and refresh that accepts unsigned content — not once per session — so that a misconfigured trust state stays continuously visible.

A consumer's default policy for a newly added repository SHOULD be required unless the operator explicitly chooses otherwise, and the official repository SHOULD be configured required.

optional means signed content is verified. A consumer MUST NOT treat the absence of trust anchors as licence to stop verifying: a repository under optional that publishes signatures MUST have them verified, and one that publishes none MUST produce the warning rather than a fetch error.

Note

The two failure modes here are mirror images and both are real. A consumer that demands a signature file under optional cannot add a repository that §5.31 explicitly permits to publish none. A consumer that stops verifying entirely because no anchors were configured leaves a repository fully substitutable by anyone on the network path, permanently, even after it starts publishing good signatures.

5.37.4 Refresh #

A consumer SHOULD refresh its cached repository state periodically. A refresh MUST:

  1. Fetch the current descriptor and its signature.
  2. Verify the signature against any key whose status was active or transitioning in the previously trusted descriptor.
  3. On success, record the new descriptor as the current trust state, replacing the previous key set with the new one.
  4. Fetch the active index and verify it against the new descriptor's keys, applying §5.34.
  5. Optionally fetch and verify the archive index, applying §5.34 to it as well.

A failed refresh MUST leave the previous trust state in place and be reported. A consumer MUST NOT fall back to unverified state.

Note

A failed refresh may mean the repository is unavailable, the network is interrupted, or the signing key was rotated to one the previously trusted set does not contain. These are distinct operational concerns; the consumer surfaces the failure and lets the operator distinguish them.

5.37.5 Maximum trusted age #

A consumer MUST track the time of the last successful refresh per repository. When that exceeds the maximum trusted age, the consumer MUST attempt a refresh before any install, upgrade, or downgrade against that repository. If the attempt fails, the consumer MUST report the failure and refuse the operation, unless the operator explicitly authorises proceeding on stale trust state.

The default maximum trusted age is 30 days. It MAY be tuned by operator configuration; a value above 180 days SHOULD produce a per-operation warning, so that a configuration effectively disabling the check stays visible.

Note

The maximum trusted age bounds the window in which a compromised-but-not-yet-revoked key can be used against a consumer that has not refreshed. Without it, a long-offline consumer trusts a rotated key indefinitely.

5.37.6 Priority #

A consumer MAY configure several repositories. Each has a numeric priority: a positive integer, where a lower number is a higher priority.

A consumer's default assignment SHOULD give the official repository the lowest number. Other repositories receive priorities at the operator's discretion.

5.37.7 Removal #

A consumer MAY remove a configured repository at any time. Removal deletes the cached state and the trust set scoped to that repository. It does not uninstall packages already installed from it; those remain installed, and their origin is retained.

Re-adding a removed repository performs the full trust ceremony afresh; previous state is not implicitly restored.

5.37.8 Orphaned packages #

A package whose originating repository has been removed or revoked is orphaned: its trust chain is no longer verifiable by the current trust state. A consumer MUST:

  • display an orphaned package with a clear indicator in query output;
  • surface the orphan state on any operation involving it, and recommend an audit before proceeding;
  • refuse an upgrade to an orphaned package unless a currently trusted repository now claims it by name.

A consumer MUST NOT treat an unknown origin as an absent origin. Wherever this chapter gates an operation on the relative priority of two repositories, an orphaned package's origin MUST be treated as at least as trusted as any configured repository, so that the gate still fires.

Note

The failure this prevents is subtle and severe: a repository removed because its keys were stolen leaves packages behind whose origin no longer resolves. If an unresolvable origin is quietly treated as lowest-priority, every cross-repository guard below stops firing for exactly those packages — so revoking a repository would lower the protection on what it left behind, and any newly added low-trust repository could take an orphaned ex-official package over without confirmation.

Operators meeting an orphaned package SHOULD audit it: verify the installed files' hashes against trustworthy out-of-band records, and consider reinstalling or removing it through a trusted repository.

5.37.9 Between repositories #

When two configured repositories publish a package of the same name, no conflict exists at the format level; the consumer resolves which to install by priority. The same applies to overlapping provides or replaces relations: the higher-priority repository's claim wins.

An operator publishing a provides that shadows a package of the official repository SHOULD document it clearly, and a consumer SHOULD warn when a lower-priority repository's provides shadows a higher-priority package.

Two guards require explicit operator confirmation, and neither may be satisfied by a general "proceed" affirmation:

  • Applying a replaces declared by a lower-priority repository against a package originally installed from a higher-priority one. A repository silently replacing a more-trusted package is a real escalation path, and confirmation stops it happening as a side effect of a routine upgrade.
  • Applying a conflicts declared by a lower-priority repository that would cause the cascade-removal of a package from a higher-priority one. That is a denial-of-availability vector, and confirmation stops a low-trust install from silently uninstalling a high-trust package.

A consumer that resolves a conflict by rejecting the plan outright, rather than by cascading removals, satisfies the second guard vacuously.

5.37.10 Compromise response #

If a repository's signing key is suspected of compromise, a consumer SHOULD disable the repository immediately, audit the packages installed from it for tampering, and, once the operator has published a new descriptor with the compromised key removed, perform a fresh trust-add with new anchors.

This version defines no automated revocation mechanism beyond the revoked key status (§5.32). Compromise response is operational.

5.38 Extension

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Every document in this chapter carries a schema_version, currently 1. A change that a conforming implementation of this version cannot process correctly requires a version bump; a change it can safely ignore does not.

5.38.1 Additive changes #

The following are additive and do not require a version bump:

  • A new optional field in the manifest, an index entry, or the repository descriptor. A consumer ignores it (§5.9).
  • A new optional metadata entry under .peipkg/ (§5.12). A consumer ignores it, and its presence MUST NOT prevent installation.
  • A new sibling artifact alongside a package file (§5.36).

A producer emitting an additive extension MUST ensure that a consumer ignoring it still behaves correctly. An extension whose omission changes what gets installed is not additive.

5.38.2 Changes requiring a version bump #

  • Adding, removing, or changing the meaning of a required field.
  • Adding a value to a closed enumeration: the side-effect identifiers of §5.24, the architecture identifiers of §5.8, the hash algorithms of §5.25, the signature algorithms of §5.28, the key statuses of §5.32, the index kinds, the constraint operators of §5.7, the licence classes of §5.18, or the signature policies of §5.37. A conforming implementation of this version MUST reject a value outside each of those sets, so a new value is not ignorable.
  • Any change to the version comparison algorithm of §5.6, which is frozen.
  • Any change to the determinism rules of §5.11, which decide the bytes.
  • Any change to the signature envelope of §5.28, which is strictly parsed by construction.

5.38.3 Reserved space #

This version reserves syntactic room in three places, so that a future extension can be additive where it would otherwise not be:

  • The algorithm fields of the files manifest and the index hash object reserve room for a further hash algorithm.
  • The arch qualifier on a dependency reserves room for explicit architecture identifiers, for a multi-architecture system.
  • The sibling-artifact paths of §5.36 reserve room for build attestations and bills of material.

An implementation of this version MUST reject a value in a reserved space rather than guess at it.

5.38.4 Deprecation #

A field this specification requires MUST NOT be removed within a schema_version. When a field becomes unnecessary, a producer continues emitting it and a future version removes it under a new schema_version.

5.39 Conformance

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

5.39.1 Producer #

A conforming producer:

  • emits packages satisfying §5.10 through §5.17: the container, every determinism rule, the internal layout, the payload path constraints, the install destinations, the triplet rule, the entry rules, and the symlink rules;
  • emits a manifest satisfying §5.18, with names, versions, and architectures satisfying §5.3 through §5.8;
  • emits a files manifest satisfying §5.25, covering exactly the regular-file payload entries, with size_installed equal to the sum of its sizes;
  • declares relationships satisfying §5.21, sorted and unique within each field, using the derived-capability names of §5.22 where a capability is machine-derived;
  • declares claims satisfying §5.23, with every target a payload path of its own;
  • declares side effects satisfying §5.24, declaring each that its payload requires and none that it does not;
  • signs packages satisfying §5.28, or emits them unsigned knowing they will be accepted only under a permissive policy.

5.39.2 Repository #

A conforming repository:

  • publishes a descriptor satisfying §5.31 with a valid detached signature, and serves the public key file of every key the descriptor declares, including revoked ones (§5.32);
  • publishes an active index satisfying §5.33 and an archive index satisfying §5.35, each with a valid detached signature, each derived directly from package manifests;
  • increases index_version strictly on every publication (§5.34);
  • retains every version it has ever advertised, and removes a pruned package from both its archive index and its storage (§5.35);
  • serves everything over HTTPS at the URLs its descriptor declares (§5.36).

5.39.3 Consumer #

A conforming consumer:

  • verifies a package by §5.26 in full, including the transaction-wide rule and the path-resolution rule, before installing anything;
  • enforces the decompression bounds of §5.27 continuously, from the index-declared sizes;
  • verifies signatures by §5.30, against a trust set scoped to the originating repository, honouring key status before any cryptography;
  • rejects a package violating any rule of §5.10 through §5.25 — the determinism rules and the payload rules included, on the way in, not only on the way out;
  • enforces the freshness and rollback rules of §5.34 on both indexes, and never lowers a recorded floor except by removing the repository;
  • establishes and maintains trust by §5.37, including the fingerprint comparison, the per-operation warnings, the maximum trusted age, the orphan rules, and the two cross-repository guards;
  • enforces the security descriptor policy of §5.20;
  • invokes side effects by §5.24, once per transaction, by fixed absolute path, with a cleared environment, against the root the transaction acted on;
  • materialises claims by §5.23, and never at a path an installed package owns.

5.39.4 What conformance does not require #

A conforming consumer is not required to resolve dependencies by any particular algorithm, to store its state in any particular form, to recover from an interrupted operation by any particular mechanism, or to offer any particular command surface. Those are its own design, and §5.1 places them outside this chapter deliberately.

What it is required to do is reach the same answer as any other conforming consumer about whether a given package satisfies a given dependency (§5.21), and about which of two versions is newer (§5.6). Those two questions are the ones a producer's declarations depend on, and they are frozen.

Appendix 5.A Limits and Defaults

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Every limit below is a minimum conformance figure: a consumer MUST process a package or document whose characteristics fall within it, and MUST reject one that exceeds it.

A consumer MAY raise a limit through operator configuration, but MUST NOT raise one silently: an operator-tuned value SHOULD be logged and surfaced in diagnostic output.

A producer SHOULD stay well below these figures. They exist to bound a consumer's resource use when processing a maliciously crafted package, not to describe the scale of a well-formed one.

5.A.1 Package structure #

LimitMaximum
Payload entries100,000
.peipkg/manifest.json size16 MiB
.peipkg/files.json size64 MiB
.peipkg/signature size64 KiB
Single payload path component (UTF-8 bytes)255
Complete payload path (UTF-8 bytes)4096
Path nesting depth (components)256
Single claim path (UTF-8 bytes)4096

5.A.2 Manifest arrays #

LimitMaximum
dependencies10,000
optional_dependencies10,000
conflicts10,000
provides10,000
replaces1,000
sd_overrides100,000
Single sd_override decoded sd length64 KiB
Slots per claims field64
Claim paths materialised per role256

The claim-path figure is a materialisation limit, not a manifest limit: it bounds the union computed across every installed package declaring a path for that role, which is the quantity an adversary controls by installing many consumer-only packages.

5.A.3 Identity #

LimitValue
Package name length2 to 64 characters
Virtual name length2 to 128 characters
Architecture identifier lengthat most 16 characters

5.A.4 Documents #

LimitValue
JSON nesting depth64
Integer field rangeunsigned 64-bit

5.A.5 Decompression #

BoundValue
Compressed overrun allowance over size_compressedthe lesser of 1% or 16 MiB
Decompressed overhead allowance over size_installed320 MiB
Absolute decompressed cap4 GiB (default; operator-tunable)

5.A.6 Repository defaults #

DefaultValue
Maximum trusted age30 days
Maximum trusted age producing a warningabove 180 days
Maximum index staleness90 days
Maximum index staleness producing a warningabove 365 days
Revoked key retentionat least 1 year
Repository prioritypositive integer; lower is higher priority
Default signature policy for a new repositoryrequired

Appendix 5.B Enumerated Values

Peios / Peios technical reference / PSPU / Package Format and Repository Protocol

Every set below is closed in this version. A conforming implementation MUST reject a value outside it, and a new value requires a schema_version bump (§5.38).

5.B.1 Architecture identifiers #

IdentifierTripletNotes
x86_64x86_64-linux-peiosprimary target
aarch64aarch64-linux-peiossecondary target
noarchnonearchitecture-independent

Defined in §5.8.

5.B.2 Pre-release rank tokens #

TokenRank
dev0
alpha1
a1
beta2
b2
pre3
rc4
any other alphabetic segment5

Rank 0 sorts lowest. Rank-5 tokens compare lexically against each other. Recognition is case-insensitive. Defined in §5.6.

5.B.3 Constraint operators #

OperatorMeaning
=exactly equal
>strictly greater than
>=greater than or equal
<strictly less than
<=less than or equal
!=not equal

A bare version with no operator means =. Comma is the AND separator. Defined in §5.7.

5.B.4 Side-effect identifiers #

IdentifierDeclared whenInvoked as
depmodthe payload contains kernel modules (MUST)once per affected kernel release, naming it
man-dbthe payload contains man pages (SHOULD)the tool, in quiet mode

Defined in §5.24.

5.B.5 Licence classes #

ClassMeaning
unknownNothing asserted. The meaning of an absent license_class.
freeFree and open-source software.
firmwareRedistributable device firmware: no source, executes on a device rather than the CPU, needed to use hardware the user owns.
proprietaryNon-free software that runs on the CPU.

Defined in §5.18.

5.B.6 Hash algorithms #

AlgorithmIdentifierStatus
SHA-256sha256REQUIRED; the only valid value
BLAKE3blake3RESERVED for a future version

Defined in §5.25.

5.B.7 Signature algorithms #

AlgorithmIdentifierStatus
Ed25519ed25519REQUIRED; the only valid value

Defined in §5.29.

5.B.8 Signing key statuses #

StatusSigns new contentAccepted for verification
activeyesyes
transitioningnountil valid_until
revokednonever, regardless of cryptographic validity

Defined in §5.32.

5.B.9 Index kinds #

KindContent
activethe current version of each package
archiveevery version ever shipped

Defined in §5.33 and §5.35.

5.B.10 Signature policies #

PolicyUnsigned content
requiredrejected
optionalaccepted with a per-operation warning

There is no silently-accept-unsigned policy. Defined in §5.37.

5.B.11 Reserved metadata paths #

PathRequired
.peipkg/manifest.jsonyes
.peipkg/files.jsonyes
.peipkg/signaturein every signed package

The .peipkg/ prefix is reserved; a payload entry MUST NOT use it. Defined in §5.12.

5.B.12 Permitted entry types #

TypeTypeflag
Regular file0 or \0
Directory5
Symbolic link2

Every other type MUST cause the package to be rejected. Defined in §5.12.

5.B.13 Permitted top-level install destinations #

/usr/bin/, /usr/sbin/, /usr/lib/<triplet>/, /usr/lib/debug/, /usr/lib/modules/<release>/, /usr/lib/firmware/, /usr/lib/os-release, /usr/libexec/, /usr/share/, /usr/include/, /usr/src/debug/, /usr/src/dist/, /usr/etc/, /usr/conf/, /var/, /boot/, /hooks/, /++/.

A payload entry MUST NOT install under any other top-level path, unless the package declares itself a special system package and the operator has separately opted in. /lcl/policy is unreachable under every circumstance. Defined in §5.14.

5.B.14 Permitted claim path locations #

The destinations above, plus /run/ and the well-known root-level name /init. Defined in §5.23.

6.1 Scope and Roles

Peios / Peios technical reference / PSPU / Name Resolution Interface

This chapter specifies the Name Resolution Interface (NRI): the protocol by which a program asks the machine's resolver for the records behind a name, the protocol by which the network manager tells the resolver what each interface contributes, and the obligations of the resolver that stands between them.

Three roles participate.

The resolver is the one process on a machine that answers names. It is a stub resolver — a DNS client — not a recursive or authoritative server: it answers synthetically, from the registry, from its cache, or by forwarding to an upstream server chosen by policy. It listens on a Unix socket (§6.4) and on the loopback DNS port (§6.8); it never dials a client. Peios' resolver is resolvd; another implementation MAY stand in its place, and the obligations in §6.11 are what "stand in its place" means.

A client is any process that asks. Most clients never see this protocol: they call getaddrinfo, and the NSS shim (§6.10) speaks it for them. Peios-native tools, the operator command and the network manager speak it directly. A client is not trusted by the resolver beyond what the control object grants it (§6.4).

The network manager is the process that knows what each interface contributes — servers, search domains, addresses, whether it carries the default route. It publishes that to the resolver over the manager's own control socket (§6.9); the resolver subscribes. Nothing about the live network is ever written to the registry to get from one to the other.

This chapter covers:

  • the three doors and the one function behind them (§6.3)
  • the native channel: socket, framing, access (§6.4)
  • the requests and their replies (§6.5)
  • what an outcome and a validation state mean (§6.6)
  • the resolution model: scopes, routing, expansion, synthetic names, the cache (§6.7)
  • the stub door (§6.8)
  • the network manager channel (§6.9)
  • what the hosts shim must and must not do (§6.10)
  • conformance, by role (§6.11)
  • a message reference and the limits (§6.A, §6.B)

What this chapter does not specify: DNSSEC validation and encrypted transports (reserved; a resolver reports unvalidated until it does them), multicast DNS, and per-caller resolution policy. Each has a place held for it in the wire format so that adding it is additive.

6.2 Terminology

Peios / Peios technical reference / PSPU / Name Resolution Interface

Name. A DNS domain name: a sequence of labels. Names compare case-insensitively (RFC 4343). A single-label name has one label; every other name is multi-label. The distinction governs search expansion (§6.7).

Question. A name and a record type (and class, always IN). What a client asks, and the unit the cache is keyed on.

Record. A resource record: name, type, TTL, and data. The protocol is record-level: an answer is records, not addresses shaped for a particular libc structure. lookup (§6.5) is the one convenience that returns addresses, because that is the question every getaddrinfo asks.

Scope. What one interface contributes to resolution: its servers, its search domains, its addresses, whether it claims unmatched names, whether it is exclusive. Scopes come from the network manager (§6.9). The fallback scope is the registry's server list, used only when no interface contributes servers.

Up. A scope is up while its interface's level is link or better (§6.9). Whether it has servers is a separate matter and does not decide it: a scope that is up with no servers still takes every routing step it qualifies for, and a question routed to it is unavailable without a query (§6.7).

Route. The scope a question goes to. Every question goes to exactly one scope (§6.7).

Outcome. How a question ended: found, notfound, or unavailable (§6.6). The three are not interchangeable and no door may collapse two into one.

Source. Where an answer came from: synthetic, hosts, cache, dns, or local (an answer that needed no lookup at all).

Door. A way in: the native socket, the stub listener, or the NSS shim. The doors differ in who can use them and what they can carry; the function behind them does not (§6.3).

Synthetic answer. An answer the resolver gives from what it knows about the machine, before any network: loopback, the machine's own name, static names from the registry, and their reverses.

Control object. The resolver's access-control object, a security descriptor against which every native request is checked (§6.4).

Stub door. DNS itself, on the loopback address 127.0.0.53, for programs that speak DNS directly (§6.8).

6.3 Three Doors, One Function

Peios / Peios technical reference / PSPU / Name Resolution Interface

A resolver MUST offer three doors and MUST answer every question through the same resolution function (§6.7) whichever door it arrived by.

DoorCallersCarries
Native socket (§6.4)Peios tools, the network manager, the NSS shimRecords with TTL, source, validation state; the caller's identity
Stub listener (§6.8)Anything that speaks DNS itself — Go's netgo, musl, hickoryDNS; no caller identity
NSS shim (§6.10)Everything via glibc getaddrinfo and gethostbynamestruct hostent / gaih_addrtuple, rendered from the native door

The rule that matters is the second sentence. A bare printer arriving at the stub door is expanded with the same search domains, routed to the same scope and answered from the same cache as printer arriving on the native socket. A static name from the registry is answered at every door, so a program that never touches libc sees the same printer as one that does. There is no configuration a door reads that another does not, and no file behind any of them.

This is why there is no /etc/hosts: the stub answers static names synthetically, so a direct-DNS resolver gets them without a file. And it is why /etc/resolv.conf is a constant — nameserver 127.0.0.53, options edns0 — shipped by the resolver's package as /usr/etc/resolv.conf, the bottom layer of the /etc merge, and never regenerated: it is a pointer to the answerer, not a copy of the answer. A search list in it would be a second expansion policy the resolver could not see; there is none.

A resolver MUST NOT offer a fourth policy path — an in-process fallback in the shim, a files source, a direct-DNS retry — behind any door. Before the resolver is running, the shim answers localhost itself and nothing else (§6.10).

6.4 The Native Channel

Peios / Peios technical reference / PSPU / Name Resolution Interface

The native door is a SOCK_STREAM Unix socket at a configured path — /run/resolvd/resolv.sock on Peios. Every process MAY connect; what a connection may ask is decided by the control object, not by reaching the socket.

6.4.1 Framing #

Messages in both directions are length-prefixed MessagePack values in the observability query framing (§3.15):

OffsetSizeFieldValue
04lengthLength of the payload in bytes, little-endian
4lengthpayloadA MessagePack map

A request is a map with a required query string naming the request (§6.5). A reply is a map with a required ok boolean; when ok is false it carries error, a string for a person, and nothing else. When ok is true it carries kind — answer, addresses, status — or nothing, for a request with no body to return.

A receiver MUST ignore keys it does not know and MUST reject a message that repeats a key. A resolver MUST refuse a request whose length exceeds the message ceiling (§6.B) without reading the payload, and MUST reply with an error rather than closing silently.

6.4.2 One request per connection #

A connection carries exactly one request and one reply. The client sends, the resolver answers, and either side closes. There is no pipelining and no conversation identifier: the shim that generates most of the traffic is loaded into processes that fork, and a connection held across a fork is how a resolver hands back somebody else's answer. Connecting to a Unix socket is cheap; the cache that would make it cheaper lives in the resolver, where every caller shares it.

A resolver MUST bound how long a connection may take to deliver its request, and MUST drop one that exceeds the bound. A client with a half-sent request holds a few bytes of buffer, never the resolver.

6.4.3 The control object #

Every request names a right (§6.5). Before acting, a resolver MUST perform an access check of that right against its control object using the token of the connecting peer — the peer's real token, as the kernel would judge it, never a numeric credential read from the socket.

The control object's descriptor is Machine\System\Network\Dns ControlSecurity when that value holds a valid self-relative descriptor, else the resolver's compiled default. The default MUST grant RESOLVER_QUERY to Everyone and RESOLVER_ALL_ACCESS to SYSTEM and Administrators.

RightValueGoverns
RESOLVER_QUERY0x0001resolve, lookup, reverse, status
RESOLVER_CONTROL0x0002flush
RESOLVER_ALL_ACCESS0x000F0003Both, with the standard rights

A denied request is answered with an error reply, not a closed connection.

The peer's identity is what identity-aware resolution policy will key on. This version defines no such policy; a resolver MUST NOT invent one.

6.5 Requests

Peios / Peios technical reference / PSPU / Name Resolution Interface

Each request is a map whose query key names it. Field types are the MessagePack types named; an address is a string in its usual textual form; a name is a string in presentation form, with or without a trailing dot.

6.5.1 resolve — RESOLVER_QUERY #

One question.

KeyTypeMeaning
namestringThe name
typeuintThe record type number (1 A, 28 AAAA, 12 PTR, …)
no_cacheboolBypass the cache for this question; default false

Reply kind: answer:

KeyTypeMeaning
outcomestringfound, notfound, unavailable (§6.6)
recordsarray of mapEach: name (string), type (uint), ttl (uint), data (bin, uncompressed wire rdata), text (string, presentation form)
sourcestringsynthetic, hosts, cache, dns, local
serverstring or nilThe upstream that answered, for dns; for cache, the upstream whose answer was cached; nil otherwise
interfacestring or nilThe interface whose scope answered
validationstring§6.6
rcodeuintThe DNS response code, for dns; for cache, the code of the response that was cached; 0 otherwise

records is the answer section only. When the name was expanded (§6.7) the records are at the expanded name, and name on each record says so.

6.5.2 lookup — RESOLVER_QUERY #

The addresses of a name: what getaddrinfo asks. The resolver asks for A and/or AAAA, applies search expansion, follows the CNAME chain and returns what it ends at.

KeyTypeMeaning
namestringThe name
familystringany (default), inet, inet6

Reply kind: addresses:

KeyTypeMeaning
outcomestringfound when at least one family answered; unavailable when none did and one could not; else notfound
canonicalstringThe name the addresses belong to after expansion and CNAME chasing; the name asked, when neither applied
addressesarray of mapEach: address (string), ttl (uint)
sourcestringAs for resolve
validationstring§6.6

6.5.3 reverse — RESOLVER_QUERY #

KeyTypeMeaning
addressstringAn IPv4 or IPv6 address

Equivalent to resolve of the address's reverse-mapping name with type PTR; the reply is kind: answer.

6.5.4 status — RESOLVER_QUERY #

No fields. Reply kind: status:

KeyTypeMeaning
hostnamestringThe machine's name as the resolver knows it
netdboolWhether the network manager channel is connected
scopesarray of mapEach: interface, servers (array of string), domains (array of string), default_route (bool), exclusive (bool), metric (uint), subnets (array of string: the interface's addresses, each as address/prefix, which name the subnets routing step 3 matches against), demoted (array of string)
fallback_serversarray of stringThe registry's server list
cache_entriesuint
countersmapqueries, synthetic, cache_hits, upstream_sent, upstream_answered, upstream_failed, refused, each uint

6.5.5 flush — RESOLVER_CONTROL #

No fields. Drops every cached answer. Reply ok: true with no kind.

6.5.6 Unknown requests #

A resolver MUST answer a query it does not know with an error reply. A client MUST treat an error reply as "not answered", never as notfound.

6.6 Outcomes and Validation

Peios / Peios technical reference / PSPU / Name Resolution Interface

6.6.1 Outcomes #

OutcomeMeaningCached
foundAn answer. Includes a name that exists with no records of the asked type — an empty records with found is NODATA, and is not absence. Yes, by TTL
notfoundThe name does not exist, authoritatively (NXDOMAIN, or a single label with no domain to apply, or .local). Yes, briefly (§6.7)
unavailableNothing that could have answered did: every server timed out, refused, or failed; or no server exists to ask.Never

The third row is the one that carries the weight. A resolver MUST NOT report unavailable as notfound, at any door, and MUST NOT cache it: a source that could have answered and did not is not the same as a name that does not exist, and recording it as one would let an outage be remembered as a fact. Each door renders the distinction in its own terms (§6.8, §6.10).

A resolver MUST answer notfound locally, without a query, for a single-label name with no applicable search domain and for any name under .local (§6.7).

6.6.2 Validation #

Every answer and addresses reply carries validation:

ValueMeaning
unvalidatedThe resolver did not attempt DNSSEC validation.
secureValidated, with a chain of trust to a configured anchor.
insecureValidated as provably unsigned.
bogusValidation failed.

This version of the interface defines no validating resolver, and a resolver that does not validate MUST report unvalidated for every answer from the network. The field exists now so that a client is written against it from the start: when validation arrives, the change is a value, not a field, and a client that wanted only secure answers needs nothing new.

A resolver MUST NOT set the AD bit on a stub-door reply (§6.8) unless its own validation produced secure. An upstream's AD is an assertion by a plaintext peer and MUST NOT be forwarded as one.

6.7 The Resolution Model

Peios / Peios technical reference / PSPU / Name Resolution Interface

This is the function behind every door.

6.7.1 Scopes #

Every interface with a link contributes a scope: servers in the order to try them, search domains, the interface's addresses, whether it claims unmatched names (default_route), whether it is exclusive, and its metric. Scopes arrive from the network manager (§6.9); the resolver holds them and nothing else about the network. The fallback scope is Machine\System\Network\Dns FallbackServers and ExtraSearchDomains.

6.7.2 Routing #

A question goes to exactly one scope. A resolver MUST NOT send a question to several scopes' servers and take the first answer — parallel fan-out is the mechanism of the classic VPN name leak, and it also lets a hostile network win a race for a name it has no claim to.

The order:

  1. An exclusive scope, if any interface at addressed or better is exclusive: it takes every question. Several exclusive scopes: the lowest metric. While an exclusive interface is up no other interface's servers are consulted for anything.
  2. The longest matching search domain. The scope with the longest domain the name ends with; ties by metric.
  3. The subnet, for a reverse-mapping name: the scope whose interface has an address in the same subnet as the address named.
  4. The default-route claimant with the lowest metric.
  5. The lowest-metric scope with servers, when no interface claims the default route.
  6. The fallback scope, only when no interface contributes servers.

When none of these yields a scope with servers, the question is unavailable without a query.

This is DNS routing only. Which interface a packet leaves by is the network manager's and the firewall's.

6.7.3 Search expansion #

A single-label name is never sent upstream as it is. It is expanded with the applicable search domains, in order, and each candidate is asked in turn until one is not notfound. The applicable domains are the exclusive scope's when one is up; otherwise every up scope's, in metric order, followed by the fallback scope's. With no applicable domain the name is notfound without a query.

A multi-label name is never expanded. There is no ndots setting: the rule is fixed at one, because every other value is a leak of local names to the public tree or of public names to the local one.

An answer at an expanded name reports the expanded name (resolved records on the native channel; a CNAME from the bare name at the stub door, §6.8).

6.7.4 Synthetic answers #

Before any network, a resolver MUST answer:

  • localhost and any name under it: 127.0.0.1 and ::1;
  • the machine's hostname: every unicast address of every interface at addressed or better, or loopback when there are none;
  • every name under Machine\System\Network\Dns\Hosts, exactly and case-insensitively: its addresses. A static name wins over DNS;
  • the reverse of loopback (localhost), of a static name's address (the name whose ASCII-lowercased presentation form sorts first in byte order when several names share it), and of the machine's own addresses (the hostname);
  • any name under .local: notfound. mDNS is a separate transport (RFC 6762) and a .local question MUST NOT reach a DNS server. LLMNR MUST NOT be spoken.

A synthetic answer has source synthetic (or hosts) and TTL 0.

6.7.5 The cache #

The cache is keyed by (name, type, scope), the name folded to lower case. The scope is part of the key because the same name may have different answers on different interfaces (split horizon), and because when an interface's servers change or the interface goes away, every answer learned through it MUST be discarded: what a VPN's servers said dies with the VPN.

Positive answers live for their least TTL, capped (§6.B). Negative answers — notfound, and found with no records — live for the lesser of the authority section's SOA record TTL and its MINIMUM field (RFC 2308 §5), capped lower. unavailable is never cached. A hit reports TTLs reduced by the time spent in cache.

6.7.6 Upstream behaviour #

A resolver MUST send each query from a fresh source port, MUST use a random message identifier, and MUST randomise the case of the question name and require the reply to echo it exactly (0x20 encoding). Both the identifier and the case bits MUST come from a cryptographically secure generator: a predictable one hands a spoofer back the entropy these measures exist to add. A reply whose identifier, question or case pattern does not match MUST be ignored, not treated as a failure.

Queries are UDP with EDNS0 advertising the buffer size in §6.B; a truncated reply is retried over TCP to the same server. A server that does not answer within the per-server timeout, or answers SERVFAIL, REFUSED or a format error, is demoted — tried after its peers — for the demotion period; the question moves to the next server. After the attempt limit the question is unavailable. Servers within a scope are tried in the scope's order, healthy ones first; never in parallel.

6.8 The Stub Door

Peios / Peios technical reference / PSPU / Name Resolution Interface

A resolver MUST listen for DNS over UDP and TCP on 127.0.0.53 port 53 and MUST NOT listen on any other address: not ::1, which a local DNS server may legitimately want, and never a routable one. It MUST ignore a datagram or connection whose source is not a loopback address.

The stub door exists for software that does not go through libc — a Go binary built with netgo, a musl program, a Rust resolver library — and it is non-optional: the constant /etc/resolv.conf points at it. It is also anonymous: DNS carries no caller identity, and the door is not governed by the control object. Everything on the machine may ask.

6.8.1 Rendering #

A query is answered by the same function as every other door (§6.7), including search expansion of a single-label question. The reply is rendered as:

OutcomeReply
foundNOERROR, the records in the answer section
notfoundNXDOMAIN
unavailableSERVFAIL

When the answer is at an expanded name, the reply's answer section MUST begin with a CNAME from the question name to the expanded name, so that a client checking names sees a well-formed chain. RA is set; AA is not; AD is not, per §6.6.

A query with an opcode other than QUERY is answered NOTIMP; one with other than exactly one question, or that cannot be decoded, is answered FORMERR when there is enough of a header to echo. A reply larger than the client's advertised buffer (or 512 bytes without EDNS) is truncated with TC set; TCP carries the whole answer.

6.8.2 Binding the port #

Port 53 is below the privileged floor. On Peios the resolver's service SID holds a port reservation for tcp,udp:53, shipped as a registry seed by the resolver's package; the resolver needs no privilege to bind it. A resolver MUST NOT require SeTcbPrivilege or any capability for its listeners.

6.9 The Network Manager Channel

Peios / Peios technical reference / PSPU / Name Resolution Interface

The live facts of the network — which interface has which servers, which carries the default route — never reach the resolver through the registry, whatever the manager also records there. They go from the network manager to the resolver directly, over the manager's own control socket, and they are gone when the manager is.

6.9.1 subscribe #

The resolver connects to the manager's control socket (/run/netd/control.sock on Peios; framing as §6.4) and sends {"query": "subscribe"}, which requires the manager's query right (NETWORK_QUERY). The manager replies at once with a snapshot and then keeps the connection open, sending a fresh snapshot every time the picture changes, until either side closes. This is the one request on that socket that holds a connection.

A snapshot is ok: true, kind: snapshot:

KeyTypeMeaning
hostnamestringThe machine's name as set by the manager; empty when unset
scopesarray of mapOne per managed interface with a link, in metric order

Each scope:

KeyTypeMeaning
ifidstringThe interface's stable identity; the cache scope key
namestringThe interface name
serversarray of stringThe profile's static servers, then the lease's when the profile takes them
domainsarray of stringThe profile's search domains, then the lease's search list or domain
addressesarray of stringEvery unicast address, CIDR form
default_routeboolThe profile's Dns.Default; unset, whether the interface carries a default route
exclusiveboolThe profile's Dns.Exclusive
metricuintThe route metric
levelstringlink, addressed, routed

A snapshot is the whole picture, not a delta. It is a few hundred bytes, and a whole picture cannot be misapplied out of order. The resolver MUST replace its scopes with each snapshot; a scope whose servers differ from before loses its cached answers (§6.7).

6.9.2 Failure and reconnection #

The manager MAY drop a subscriber it cannot write to. The resolver MUST reconnect with backoff whenever the connection is lost or the manager is absent, and MUST keep answering meanwhile: synthetic names need no scope, and the scopes it holds remain valid until replaced. A resolver MUST NOT treat the manager's absence as a reason to refuse questions.

The manager merges the profile's static DNS keys with the lease itself; the resolver reads Profiles\ for nothing. The registry keys the resolver reads are exactly Machine\System\Network\Dns and its subkeys; it MAY watch the parent Machine\System\Network so that the key's creation is seen. When the manager reports no hostname the resolver uses the kernel's.

6.10 The Hosts Shim

Peios / Peios technical reference / PSPU / Name Resolution Interface

The shim is the NSS module glibc's hosts database reaches, and on Peios the only one it can reach: glibc is patched so that hosts, like the identity databases, is not configurable and names peios_net whatever nsswitch.conf says. There is no files source behind it and no /etc/hosts for one to read.

6.10.1 Obligations #

A shim MUST:

  1. Forward every question to the native channel (§6.4) as lookup or reverse, one connection per libc call, and render the reply. It MUST NOT hold a connection across calls.
  2. Answer localhost, and any name under it, and the reverse of a loopback address, itself — with no socket. It is the one name a machine can never be without, and the only one the shim knows.
  3. Answer NSS_STATUS_UNAVAIL for everything else when the resolver cannot be reached. There is nothing behind it.
  4. Report an over-full caller buffer as NSS_STATUS_TRYAGAIN, with *errnop = ERANGE and *h_errnop = NETDB_INTERNAL (-1), so glibc retries with a larger one.

A shim MUST NOT read /etc/hosts, /etc/resolv.conf or any file; MUST NOT speak DNS itself; and MUST NOT cache. Each would be a second policy path the resolver cannot see.

The complete buffer-exhaustion triple is required by the glibc NSS interface: NETDB_INTERNAL tells the caller to examine errnop, including its ERANGE buffer-size indication.

6.10.2 Status mapping #

Resolver outcomeNSS statush_errno
found, with addresses of the asked familySUCCESS—
found, none of that familyNOTFOUNDNO_DATA
notfoundNOTFOUNDHOST_NOT_FOUND
unavailableTRYAGAIN (EAGAIN)TRY_AGAIN
Resolver unreachable, or an error replyUNAVAILNO_RECOVERY

The unavailable row is the one that matters: a shim MUST NOT render it as NOTFOUND. A caller that retries gets its answer when the network comes back; a caller told "no such host" may remember that.

Note

glibc's getaddrinfo reports a module's UNAVAIL to its caller as EAI_NONAME, the same code as a name that does not exist, so through that interface an unreachable resolver still looks like an absent name. That mapping is glibc's; the shim reports UNAVAIL faithfully, and the native channel and resolv keep the distinction.

h_name is the reply's canonical name. TTLs are reported through ttlp where glibc offers it, as the least TTL among the addresses.

6.10.3 Entry points #

_nss_peios_net_gethostbyname4_r, gethostbyname3_r, gethostbyname2_r, gethostbyname_r, gethostbyaddr2_r, gethostbyaddr_r. The module's SONAME is libnss_peios_net.so.2 and it is installed in glibc's configured library directory; it links against libc and the wire codec and nothing else, since it is loaded into every process that resolves a host.

6.11 Conformance

Peios / Peios technical reference / PSPU / Name Resolution Interface

A conforming implementation MUST satisfy every requirement in this chapter. This section collects them by role.

6.11.1 Resolver obligations #

6.11.1.1 Doors #

  1. Offer the native socket, the stub listener and answer the shim, and run one resolution function behind all three (§6.3).
  2. Offer no other policy path: no files, no in-shim DNS, no fallback (§6.3).

6.11.1.2 Native channel #

  1. Frame as §6.4; refuse an oversized request without reading it, with an error reply (§6.4).
  2. Ignore unknown keys; reject duplicate keys (§6.4).
  3. One request per connection; bound the time a request may take to arrive (§6.4).
  4. Access-check every request against the control object with the peer's real token; default Everyone RESOLVER_QUERY, SYSTEM and Administrators all (§6.4).
  5. Answer unknown requests and denied requests with error replies (§6.4, §6.5).

6.11.1.3 Outcomes #

  1. Never report unavailable as notfound; never cache unavailable (§6.6).
  2. Report unvalidated until validating; never forward an upstream's AD (§6.6).

6.11.1.4 Resolution #

  1. Route every question to exactly one scope, in the order of §6.7; never fan out.
  2. Honour an exclusive scope absolutely while it is up (§6.7).
  3. Expand single labels with applicable domains only; never send one bare; never expand a multi-label name; no ndots (§6.7).
  4. Answer the synthetic names of §6.7 before any network; never forward .local; never speak LLMNR.
  5. Key the cache by scope; flush a scope whose servers change or that goes away; cap TTLs as §6.B (§6.7).
  6. Fresh source port, random identifier, 0x20 case with exact echo; ignore mismatched replies (§6.7).
  7. EDNS0 with the §6.B buffer; TCP on truncation; demote failing servers; bounded attempts (§6.7).

6.11.1.5 Stub door #

  1. Listen on 127.0.0.53 only, UDP and TCP; ignore non-loopback sources (§6.8).
  2. Render outcomes as §6.8; CNAME from a bare question to its expanded name; NOTIMP and FORMERR as §6.8; truncate over the buffer.
  3. Bind without privilege (§6.8).

6.11.1.6 Network manager channel #

  1. Subscribe; replace scopes on every snapshot; reconnect with backoff; keep answering while disconnected (§6.9).
  2. Read only Machine\System\Network\Dns from the registry (§6.9).

6.11.2 Network manager obligations #

  1. Answer subscribe with a snapshot and stream one on every change on the same connection (§6.9).
  2. Send whole snapshots, never deltas; merge profile and lease facts itself (§6.9).

6.11.3 Client obligations #

  1. Treat an error reply as "not answered", never as notfound (§6.5).
  2. Be written against validation (§6.6).

6.11.4 Shim obligations #

  1. One connection per libc call; answer localhost alone; UNAVAIL when the resolver is unreachable; ERANGE retry (§6.10).
  2. No files, no DNS, no cache (§6.10).
  3. Map outcomes as §6.10; never render unavailable as NOTFOUND.

Appendix 6.A Message Reference

Peios / Peios technical reference / PSPU / Name Resolution Interface

6.A.1 Native channel requests #

queryRightReply kindDefined in
resolveRESOLVER_QUERYanswer§6.5
lookupRESOLVER_QUERYaddresses§6.5
reverseRESOLVER_QUERYanswer§6.5
statusRESOLVER_QUERYstatus§6.5
flushRESOLVER_CONTROL(none)§6.5

6.A.2 Network manager channel #

queryRightReply kindDefined in
subscribeNETWORK_QUERYsnapshot, streamed§6.9

6.A.3 Rights #

ConstantValueDefined in
RESOLVER_QUERY0x00000001§6.4
RESOLVER_CONTROL0x00000002§6.4
RESOLVER_ALL_ACCESS0x000F0003§6.4

6.A.4 Enumerations #

FieldValuesDefined in
outcomefound, notfound, unavailable§6.6
validationunvalidated, secure, insecure, bogus§6.6
sourcelocal, synthetic, hosts, cache, dns§6.2
familyany, inet, inet6§6.5
levellink, addressed, routed; never absent in a snapshot, since an absent interface contributes no scope§6.9

6.A.5 Paths and addresses (Peios) #

ItemValue
Native socket/run/resolvd/resolv.sock
Stub listener127.0.0.53:53, UDP and TCP
Network manager socket/run/netd/control.sock
Registry keyMachine\System\Network\Dns
Shimlibnss_peios_net.so.2
Compat file/usr/etc/resolv.conf, constant

Appendix 6.B Limits

Peios / Peios technical reference / PSPU / Name Resolution Interface

BoundMainlineDefined in
Message ceiling, native channel65 536 bytes §6.4
Request delivery bound, native channel5 s §6.4
Per-server timeout2 s §6.7
Attempts per question3 §6.7
Demotion period30 s §6.7
Positive TTL cap86 400 s §6.7
Negative TTL cap300 s §6.7
EDNS0 buffer advertised1 232 bytes §6.7
Upstream transactions in flight (synthetic and cached answers are never refused)4 096 §6.7
Native connections delivering a request or awaiting resolution256 §6.4
Stub TCP clients delivering a query or awaiting resolution256 §6.8
Stub UDP queries awaiting resolution256 total; 16 per source address and port §6.8
Cache entries8 192 §6.7
Stub TCP client bound10 s §6.8
Manager reconnect backoff0.5 s doubling to 10 s §6.9

The upstream ceiling leaves synthetic and cached answers unaffected once a query has passed its door's admission limits. Native and stub TCP reply writers have separate 256-frame queues and one-second write deadlines.

An implementation MAY adjust these; it MUST document the values it uses. The relations that must hold: the shim's own timeout exceeds attempts × per-server timeout, so a slow upstream is reported by the resolver as unavailable rather than by the shim as a timeout.

7.1 Scope and Roles

Peios / Peios technical reference / PSPU / Job Submission

This chapter defines the jobs channel: the interface by which a process asks a Peios service manager to run an arbitrary program under its supervision, with an identity the requester is entitled to confer, and to manage that program afterwards.

A job submitted this way is a job in the sense of §4.2 — one process execution, supervised by the manager — whose parent is the process that asked for it rather than a service definition. The manager forks it, installs its token, contains it, records it and reaps it exactly as it does a service's process. What differs is where the definition came from and who is entitled to manage the result.

Two shapes of use are intended, and the chapter is written so that one primitive serves both:

  • A task: a program that runs to completion and reports progress on the way — a backup, a transfer, a migration — submitted by a broker on behalf of the principal it is acting for.
  • A session: a long-lived program managed from outside — a per-logon host process for an interactive session — submitted with a freshly issued logon token and one end of a channel back to its submitter.

Nothing in the protocol distinguishes the two. They are the same message with different field values.

7.1.1 The roles #

The manager is the process that supervises services and listens on the control and notification channels of §4. It listens on the jobs channel too. On Peios this is peinit.

A submitter connects to the jobs channel, submits a job, and MAY manage the jobs it submitted. A submitter is any process the socket's descriptor admits; it holds no relationship with the manager beyond the one its token establishes.

A job is the process the manager started on a submitter's behalf. It speaks the notification channel of §4 about itself, in the same way a service does, and this chapter adds nothing to that role beyond the progress fields defined in §4.19.

A client of the control channel (§4) MAY observe and stop jobs it is entitled to, alongside services. That role is unchanged here; §4.14 defines the commands.

Requirements are stated against the role, not the program. A service that submits jobs is a submitter when it does so; a job that submits jobs of its own is a submitter too.

7.1.2 What this chapter covers #

  • the jobs socket, how it is reached, and who may reach it
  • message framing on it, and how a message carries a token and descriptors alongside its content
  • how the identity a job runs as is established, and the rule that the manager never installs an identity the submitter could not already act as
  • the job definition a submitter sends, and what the manager validates
  • the job view the manager reports, its states and its causes
  • the commands a submitter may issue about its own jobs, and the Security Descriptor that decides who else may
  • passing descriptors into a job, and receiving its output
  • the error vocabulary, the extension rules and the conformance requirements

7.1.3 What this chapter does not cover #

  • The notification channel, including the progress fields a job sends. A job reports on itself exactly as a service does. §4.16–§4.19.
  • Observing and stopping jobs from the control channel. The job-list, job-status and job-stop commands are part of the control channel's command set and are defined in §4.14, using the job view this chapter defines.
  • How the manager supervises the job once it is running — cgroup containment, output capture, reaping, the child setup sequence. That is the manager's own design, documented in its reference manual.
  • How a token is conveyed on a socket, and what the kernel checks when it is. Peer-token capture, KACS_SCM_TOKEN, the impersonation gates and the impersonation-level ratchet are kernel operations, documented in the Peios Kernel TRM §3.5.
  • How a submitter obtained the token it attaches. From a peer it is serving, from a logon it originated (PGSS §2), or from anywhere else — the manager does not ask, because the kernel has already decided whether the submitter may convey it.

7.2 Terminology

Peios / Peios technical reference / PSPU / Job Submission

Terms defined in §4.2 — service, job, activation generation, right, terminal state, datagram — are used here with the same meaning and are not redefined.

Submitted job. A job whose definition arrived on the jobs channel rather than from a service definition. It has no service, no operation, and no activation generation; it belongs to a submitter.

Submitter. The principal that submitted a job: the identity the connection carried when the submit message was read. It is recorded on the job and is the owner of the job's Security Descriptor.

Job identity. The identity a submitted job's process runs as. It is either the submitter's own primary identity or one the submitter attached to the submit message; it is never named by a field.

Attached token. A token conveyed with a message through the kernel's per-message identity facility (KACS_SCM_TOKEN; the Peios Kernel TRM §3.5). The kernel has verified, at the moment of sending, that the sender could act as that identity at the level recorded on the token.

Attached descriptor. A file descriptor conveyed with a message through SCM_RIGHTS, to be given to the job or used as its output sink.

Message. One SOCK_SEQPACKET record on the jobs channel, carrying exactly one JSON object and, optionally, one attached token and any number of attached descriptors. The jobs channel has no frames in the sense of §4.2: the transport delimits messages.

Job view. The JSON object by which the manager reports a job, on either channel. §7.7.

Live job. A submitted job in a non-terminal state. Quotas count live jobs.

7.3 The Jobs Socket

Peios / Peios technical reference / PSPU / Job Submission

The manager MUST listen on a Unix SOCK_SEQPACKET socket at a well-known path. On Peios that path is:

/run/services/peinit/jobs.sock

The socket MUST exist for as long as the manager is serving, and the manager MUST unlink it when it stops. The manager MUST create the listening socket and every accepted connection with close-on-exec set, so that no connection descriptor is inherited by a job it starts.

7.3.1 Why a second socket #

The control channel (§4.4) is an administrator's door: its commands change the state of the system's services, and the descriptor on its socket admits the principals entitled to do that. Submitting a job is a different permission held by a different population. Any principal may reasonably be allowed to run a program under supervision, subject to a quota, without being allowed to stop registryd.

Putting the two on one socket would force one descriptor to serve both populations. Two sockets let the filesystem say who may submit and who may administer, separately, with no policy inside the manager.

7.3.2 Why a sequenced-packet socket #

A message on this channel carries more than bytes. It carries the token a job is to run as, and the descriptors a job is to be given, as ancillary data. The kernel associates ancillary data with the record it was sent with, and on a stream that association is only as precise as the reader's record boundaries. SOCK_SEQPACKET makes one send one message and one identity (the Peios Kernel TRM §3.5), so the manager never has to decide which request an attached token or descriptor belongs to.

The cost is that a message has a maximum size (§7.A) and cannot be split. A definition is small; nothing here needs to be large.

7.3.3 Who may submit #

Being able to connect is the submission permission. The manager MUST NOT perform an access check of its own before accepting a submit; the check is the one the kernel performs against the socket's inode descriptor when the submitter connects, and the manager relies on it entirely.

The manager MUST therefore ensure the socket, and each directory containing it, carries a Security Descriptor admitting exactly the principals it intends to let submit. The default a Peios service manager applies is in §7.A. §4.3's warning applies with full force: a socket that inherits nothing is reachable by nobody, and one in a permissive place is reachable by anything.

What a submitter may do to a job is a separate question, answered by the job's own descriptor (§7.8). Reaching the socket confers the right to submit, and nothing else.

7.3.4 A connection #

A submitter connects, issues one or more messages, and closes. The manager MUST NOT require a submitter to issue any message before another, and MUST NOT hold state across connections beyond the jobs themselves: a connection carries an identity (§7.5) and nothing else.

Messages on one connection MUST be answered in the order they were received. The manager MAY read no further messages from a connection while a response on it is outstanding.

Closing a connection MUST NOT affect any job submitted on it. A job belongs to its submitter's identity, not to the connection that submitted it; a submitter that reconnects — after a restart, say — finds its jobs where it left them.

7.3.5 Limits #

The manager MUST enforce three limits on the channel and one on the submitter, and MUST make their values discoverable to an administrator through the same surface that sets them. The values a Peios service manager uses by default are in §7.A.

Concurrent connections. A connection accepted while the manager is already at its limit MUST be closed at the socket level, without a response, and a submitter MUST treat an immediate close with no response as a refusal rather than a protocol error.

Message size. A message whose content exceeds the limit MUST be answered with REQUEST_TOO_LARGE. The manager MUST detect a message the kernel truncated to fit its receive buffer (MSG_TRUNC) and MUST NOT interpret the truncated content: a truncated JSON object is malformed by construction, but a truncated attachment list is not, and the manager MUST NOT act on a request whose attachments it did not fully receive.

Idle timeout. A connection with no message outstanding MAY be closed once it has been idle for the configured period, without a response. A connection blocked on a wait (§7.8), or on a submit whose job has not yet confirmed exec, is not idle and MUST NOT be closed for idleness.

Live jobs per submitter. The manager MUST bound the number of live jobs one submitter may hold, counted by the submitter's user SID, and MUST answer a submit that would exceed the bound with QUOTA_EXCEEDED, creating nothing. The manager MAY exempt the SYSTEM principal (S-1-5-18). A job that reaches a terminal state stops counting at once, not when its record is dropped (§7.7).

7.4 Messages

Peios / Peios technical reference / PSPU / Job Submission

7.4.1 Content #

Every message in both directions carries exactly one JSON object, serialised compactly, as the whole of the record's content. There is no terminator: the transport delimits messages, and the manager MUST NOT require or emit a trailing newline.

Content is UTF-8. The manager MUST reject content that is not well-formed UTF-8 with MALFORMED_REQUEST.

The rules of §4.5 for what is malformed apply unchanged: an empty message, content that is not valid JSON, and valid JSON that is not an object are all MALFORMED_REQUEST. The manager MUST NOT emit pretty-printed JSON. Timestamps follow §4.5.

7.4.2 Attachments #

A request MAY carry ancillary data. Two kinds are defined:

LevelTypeMeaningApplies to
SOL_KACSKACS_SCM_TOKENThe token the job is to run as. At most one.submit
SOL_SOCKETSCM_RIGHTSDescriptors for the job, or an output sink.submit

The manager MUST receive every message with room for both kinds and for the number of descriptors in §7.A. It MUST detect ancillary data the kernel could not fit (MSG_CTRUNC) and MUST answer the message INVALID_ARGUMENTS without acting on it: a request whose attachments were partly lost does not describe the job the submitter meant.

Attachments on a message whose command does not use them MUST be closed and ignored. A stray descriptor on a status is not an error, in the same spirit as §4.8's rule for fields that do not apply — but it is closed, because nothing else could own it.

The manager MUST close every attached descriptor it does not hand to a job or adopt as an output sink, on every path including error paths. A submitter MUST assume the manager has taken ownership of everything it attached to a submit, whatever the outcome, and MUST NOT rely on the manager leaving a descriptor open.

A response MAY carry ancillary data. One kind is defined:

LevelTypeMeaningCarried by
SOL_SOCKETSCM_RIGHTSThe job's process handle. Exactly one.A submit response for a running job. §7.6

A submitter that receives a response without room for its ancillary data does not receive the handle, and MUST NOT treat that as an error: the response content is complete without it.

7.4.3 Requests #

A request is one JSON object.

{"command": "submit", "image_path": "/usr/bin/peios-backup",
 "arguments": ["sync", "/home/u/docs"], "description": "Backup of docs"}
FieldTypeRequiredMeaning
commandstringalwayssubmit, status, wait, stop or signal.
job_idstringall but submitThe job.
waitboolnoFor stop: block until the job is terminal. §7.8
forstringnoFor wait: ready or terminal. §7.8
signalintegerfor signalThe signal number. §7.8

The submit definition fields are in §7.6.

command MUST be present and MUST be a string naming one of the five commands. Otherwise the manager MUST answer INVALID_COMMAND.

job_id MUST be present and a string for every command but submit. Its absence, a non-string value, or a value that is not a well-formed identifier MUST be answered INVALID_ARGUMENTS.

A field the command does not use MUST be ignored, not rejected. A field the manager does not recognise MUST be ignored (§7.11).

7.4.4 Responses #

Every response carries a status field, exactly "ok" or "error".

The success shape for every command is the job view (§7.7):

{"status": "ok", "job": { … }}

The error shape is §4.9's:

{"status": "error", "code": "QUOTA_EXCEEDED",
 "message": "submitter S-1-5-21-… holds 64 live jobs"}

code MUST be one of the values in §7.10. message is not normative; a submitter MUST NOT parse or branch on it.

7.4.5 Closing after an error #

§4.5's distinction applies. On a sequenced-packet socket a frame-level failure is narrower — the transport cannot lose synchronisation — so only REQUEST_TOO_LARGE and a truncated message close the connection. Every other error is command-level: the manager MUST send the error and keep the connection open.

A submitter MUST NOT assume a connection survives an error response.

7.5 Identity

Peios / Peios technical reference / PSPU / Job Submission

Every submission involves two identities, and the protocol keeps them apart.

The submitter is who asked. The job identity is who the job runs as. They are the same principal in the simplest case and different ones in the case the channel exists for — a broker running a program as the client it is serving.

7.5.1 The submitter #

The manager MUST establish the submitter's identity from the kernel, as §4.6 requires of the control channel: the peer's token, captured when the connection was accepted, used for every message on the connection. A submitter MUST NOT be able to assert who it is, and a change of identity mid-connection has no effect on it.

The submitter's identity is what the manager records on the job as its submitter, what it counts quotas against, and what it names as the owner of the job's descriptor (§7.8). It MUST be the identity the peer thread was acting under when it connected — so a submitter that connects while impersonating is recorded as the principal it was impersonating. A submitter that intends to own the jobs it submits, as itself, connects as itself and conveys any other identity per message.

7.5.2 The job identity #

The job identity is established in exactly one of two ways, and the manager MUST NOT provide a third.

No token attached — the submitter's own primary. The manager MUST open the primary token of the connecting process — identified by the kernel through the peer's process handle (SO_PEERPIDFD), never by a PID the submitter names — duplicate it, and install the duplicate on the job.

The job then has the identity a child of the submitter would have had: its primary token, with no impersonation inherited (the Peios Kernel TRM §3.2). A submitter that was impersonating when it connected therefore produces a job running as its own identity, not the impersonated one, while being recorded as a submitter under the impersonated one. The two differ deliberately, and a submitter that wants the impersonated identity on the job attaches it.

A token attached — the identity conveyed with the message. The manager MUST use the token the kernel delivered with the submit message, duplicate it to a primary token, and install the duplicate on the job.

The kernel gated the attach: it ran the impersonation gates with the submitter as installer and recorded on the token the lower of the socket's level and the source's own (the Peios Kernel TRM §3.5). The attached token is therefore a kernel-verified statement that the submitter could have acted as this identity, at this level, when it sent this message. The manager MUST rely on that statement and MUST NOT re-derive it.

7.5.3 What the manager checks #

The manager MUST refuse, with BAD_TOKEN:

  • more than one attached token on a message;
  • an attached token whose impersonation level is below Impersonation. An Identification-level token cannot pass an access check and an Anonymous one is not an identity to run a process as; neither can become a primary (the Peios Kernel TRM §3.2.4);
  • an attached token the manager cannot duplicate.

The manager MUST NOT refuse a token because of who it names. Whether the submitter may run a job as that identity was decided by the kernel when the token was conveyed, and a second decision inside the manager would be a second copy of the same policy, able only to disagree with the first.

7.5.4 The level carries through #

The duplicated primary carries the attached token's impersonation level, and everything the job does inherits it (the Peios Kernel TRM §3.5.1). A submitter that needs the job to convey Delegation — to a session that will authenticate to remote services on its user's behalf — MUST set its own jobs connection to Delegation before attaching, because the default level on a socket is Impersonation and the attach is clamped to it. A job started at Impersonation cannot be raised afterwards, by anyone.

7.5.5 What the manager never does #

The manager MUST NOT accept a primary token passed as a plain descriptor (SCM_RIGHTS) as a job identity. A descriptor passed that way is not gated by the kernel: whoever holds an fd can pass it, and the recipient learns nothing about whether the sender could act as the identity it names. Every job identity on this channel is one the kernel verified the submitter could act as, and a path that skips the verification would be the only one worth attacking.

The manager MUST NOT install on a job any token it obtained by a route other than the two above. In particular it MUST NOT open a token through a submitter-supplied PID or descriptor, and MUST NOT let a submit field name an identity. There is no identity field, and a manager MUST NOT add one (§7.11).

Note

The consequence for a service that runs programs as its clients is that it needs SeImpersonatePrivilege — to be allowed to convey a client's identity at all — and nothing more. It never holds a primary token for the client, never needs SeAssignPrimaryTokenPrivilege, and never installs anything. The one process that installs primary tokens on other people's behalf is the one that supervises the result.

7.6 Submitting a Job

Peios / Peios technical reference / PSPU / Job Submission

A submit carries the whole definition of the job in the request object. There is no persistent definition, no name, and no policy: a submitted job runs once, as described, and is then gone.

7.6.1 The definition #

FieldTypeRequiredDefaultMeaning
image_pathstringyes—The program to execute. An absolute path.
argumentsarray of stringsno[]Its arguments, after the program name.
environmentobject of string → stringno{}Variables to set. §7.6 below.
working_directorystringno/The working directory. An absolute path.
descriptionstringno""A human-readable label, for views and records.
timeoutintegerno0Seconds the job may run before it is stopped. 0 is no limit.
stop_timeoutintegerno§7.ASeconds between the termination signal and the kill.
readinessstringnononenone, or notify to wait for READY=1.
readiness_timeoutintegerno§7.ASeconds a notify job has to become ready.
success_exit_codesarray of integersno[]Non-zero exit codes that count as success.
descriptorsarray of stringsno[]Names for the descriptors to inject. §7.9
outputboolnofalseWhether the last attached descriptor is an output sink. §7.9
security_descriptorstringno§7.8The job's descriptor, in SDDL. §7.8

Nothing here restarts, depends, probes, or schedules. A program that needs a restart policy, dependencies, a health check or a trigger is a service, and its definition belongs in the registry (§4.1). The manager MUST NOT accept a policy field on a submit, and MUST NOT add one (§7.11).

7.6.1.1 Validation #

The manager MUST answer INVALID_ARGUMENTS, creating nothing, when:

  • image_path is absent, not a string, empty, not absolute, or contains a NUL byte;
  • working_directory is present and not an absolute string, or contains a NUL byte;
  • any element of arguments is not a string or contains a NUL byte;
  • any key of environment is empty, contains =, or contains a NUL byte; or any value contains a NUL byte;
  • any of timeout, stop_timeout, readiness_timeout is present and not a non-negative integer, or stop_timeout is 0;
  • readiness is present and not one of the two values;
  • any element of success_exit_codes is not an integer in 0–255;
  • descriptors does not match the attached descriptors (§7.9);
  • security_descriptor is present and is not a valid descriptor with an owner and a DACL (§7.8);
  • the total size of the resulting argv and environment exceeds what the manager can pass to execve.

The manager MUST NOT validate that image_path exists or is executable. That is decided at execve, by the job's own token against the file's descriptor, and a job whose program cannot be run becomes a failed job (§7.7) rather than a refused submission. Checking in advance would either check with the wrong identity or duplicate the kernel's decision.

7.6.1.2 The environment #

The manager MUST build the job's environment as it builds a service's, with environment in the place a service definition's own variables occupy: above any manager-wide layer an administrator configures, and below the protocol variables the manager sets last. A submitter MUST NOT be able to override NOTIFY_SOCKET, LISTEN_FDS, LISTEN_FDNAMES or LISTEN_PID.

The manager sets no HOME, USER, LOGNAME or SHELL — it knows nothing about the job identity beyond its token, and a submitter that knows the principal's home directory supplies it. A submitter that originated a logon has these from the logon's profile (PGSS §2.9).

7.6.2 What the manager does #

On an accepted submit the manager MUST, in order:

  1. Establish the job identity (§7.5) and refuse with BAD_TOKEN if it cannot.
  2. Check the submitter's quota and refuse with QUOTA_EXCEEDED if the job would exceed it.
  3. Create the job record, with its identifier, in the created state, owned by the submitter with the descriptor of §7.8.
  4. Start the process — install the identity, set up its streams, inject descriptors, execute — as it starts a service's main process.
  5. Answer the submit.

Steps 1 and 2 MUST both precede step 3: a refused submission MUST leave no record, emit no lifecycle event, and consume no quota.

7.6.3 When the submit is answered #

The manager MUST answer a submit when the job has left the created state: when the process has confirmed a successful execve, or when starting it has failed.

It MUST NOT answer earlier. A submitter holding a response holds a job that either exists as a running process or has definitively failed to — never one whose fate is still being decided in the setup between fork and exec.

It MUST NOT answer later. In particular a readiness: notify job is answered when it is running, not when it is ready; a submitter that wants to block until readiness follows with a wait (§7.8).

7.6.4 The response #

The response is the job view (§7.7).

If the job is running, the response MUST carry the job's process handle — a pidfd referring to the job's main process — as an attached descriptor. The handle lets the submitter observe the process's exit with poll or waitid, and signal it, with no further protocol; the kernel applies its ordinary process access rules to whatever the submitter does with it. A submitter that declined to receive ancillary data simply does not get the handle (§7.4).

If the job is failed, the response carries no descriptor, and the view's cause says why (§7.7). The submission itself succeeded — a job was created, recorded and reported — so the response is "status": "ok". Only a submission the manager refused outright, at steps 1–3, is an error response.

Note

A submitter that needs the process handle as a child would have — to waitpid on it, or to be its parent for PR_SET_PDEATHSIG — does not get that, because the job is the manager's child, not the submitter's. Exit status reaches the submitter through the job view, through wait, or through the manager's event stream; the pidfd is for polling, signalling, and passing to something else.

7.7 The Job View

Peios / Peios technical reference / PSPU / Job Submission

The job view is the shape every successful response on this channel carries, and the shape job-status, job-list and job-stop return on the control channel (§4.14), which adds one field to it. It is defined once, here.

{
    "id": "0190a3b2-…",
    "type": "submitted",
    "state": "running",
    "cause": null,
    "submitter": "S-1-5-80-…",
    "identity": "S-1-5-21-…",
    "logon_session": 1042,
    "description": "Backup of docs",
    "image_path": "/usr/bin/peios-backup",
    "pid": 4521,
    "ready": null,
    "exit_code": null,
    "exit_signal": null,
    "status_text": "Backing up /home/u/docs (3/5)",
    "progress": {"current": 812, "total": 2048, "bounded": true,
                 "unit": "items"},
    "created_at": "2026-06-01T12:34:56.123456789Z",
    "started_at": "2026-06-01T12:34:56.223456789Z",
    "ended_at": null
}
FieldTypeMeaning
idstringThe job's identifier.
typestringAlways submitted on this channel. §4.B
statestring§7.B.
causestring or nullWhy the job ended, where the manager decided it. §7.B
submitterstringThe submitter's user SID.
identitystringThe job identity's user SID.
logon_sessionintegerThe job identity's logon session identifier.
descriptionstringAs submitted.
image_pathstringAs submitted.
pidinteger or nullThe process identifier, while there is a process.
readybool or nullFor a notify job: whether READY=1 has arrived. Null for a none job.
exit_codeinteger or nullThe exit code, when the process exited.
exit_signalinteger or nullThe signal, when the process was killed.
status_textstring or nullThe most recent STATUS the job sent (§4.19).
progressobject or nullThe most recent progress the job sent (§4.19).
created_atstringWhen the record was created. §4.5
started_atstring or nullWhen exec was confirmed.
ended_atstring or nullWhen the job became terminal.

A field that does not apply MUST be present and null, never omitted.

These are all the view's fields on this channel. On the control channel each view carries one more, granted: the job rights the caller holds on the job (§4.14). It describes the caller rather than the job, and a submitter on this channel is not given it.

identity and submitter are user SIDs. A client that needs the identity's groups, privileges or integrity does not get them here; they are the job's, not the view's.

progress carries current (integer), total (integer or null), bounded (bool) and unit (string or null), in the representation §4.19 defines. A progress object whose total is null is either unbounded or not yet bounded, and bounded distinguishes them.

7.7.1 States #

StateProcess?Terminal?Meaning
createdNot yetNoThe record exists; exec has not been confirmed. Never visible on this channel — a submit is answered only after leaving it.
runningYesNoExec succeeded and the process is alive.
completedNoYesThe process exited with 0 or a code in success_exit_codes.
failedNoYesThe process exited otherwise, was killed by a signal, or never ran because setup failed.
abandonedYes, unkillablyYesThe process survived the kill. The manager has stopped supervising it.

These are the states of every job the manager runs, not a set invented for this channel; a client that understands them for a service's main job understands them here. A stopped job whose process died to the termination signal is failed with exit_signal set, and cause says the stop was asked for.

Which fields are populated is a function of state, and a client MAY rely on it:

Statepidstarted_atended_atexit_code / exit_signal
runningsetsetnullnull
completednullsetsetexactly one set
failed, process rannullsetsetexactly one set
failed, setup failednullnullsetboth null
abandonedsetsetsetboth null

exit_code and exit_signal are never both set: a process exits or is killed. For abandoned, ended_at records when supervision stopped; nothing exited.

7.7.2 Causes #

cause is set when the manager itself brought the job to its end, or decided it could not start, and null when the process ended of its own accord — a clean exit, a crash, a signal from elsewhere.

ValueStateMeaning
parent_setup_failurefailedThe manager could not prepare the launch: no token, no cgroup, no fork. No process existed.
pre_exec_failurefailedThe child's setup between fork and exec failed, or exec itself did.
readiness_timeoutfailedA notify job did not send READY=1 within readiness_timeout. The manager stopped it.
timeoutfailedThe job ran longer than timeout. The manager stopped it.
explicit_stopfailedA stop or job-stop was issued and the process died to it.
shutdownfailedThe system was shutting down and the manager stopped it.
process_unkillableabandonedThe process survived the kill.

A stop whose process handled the termination signal and exited 0 produces completed with cause explicit_stop: the manager asked, the process agreed, and both facts are recorded.

7.7.3 Retention #

The manager MUST hold a terminal job's record for at least a grace period after it reaches a terminal state, so that a submitter polling for the outcome can retrieve it. The value a Peios service manager uses is in §7.A.

An identifier that never existed and one whose record has been dropped MUST both be answered UNKNOWN_JOB, and a submitter MUST NOT distinguish them (§4.14's rule for operations, applied to jobs).

A job that is terminal stops counting against its submitter's quota at once; its record's retention is for the submitter's benefit, not a cost to it.

7.8 Managing a Job

Peios / Peios technical reference / PSPU / Job Submission

Four commands act on an existing job. Every one is authorised against the job's own Security Descriptor, using the connection's token, with the rights and mapping below. There is no command the manager performs on a job without a check, and no principal exempt from one — the submitter included.

7.8.1 Rights #

RightBitGrants
JOB_QUERY0x0001Read the job view; wait on the job.
JOB_STOP0x0002Stop the job.
JOB_SIGNAL0x0004Send a signal to the job's process.
JOB_ALL_ACCESS0x0007All three.
Generic rightMaps to
GENERIC_READJOB_QUERY
GENERIC_WRITEJOB_STOP | JOB_SIGNAL
GENERIC_EXECUTEJOB_STOP | JOB_SIGNAL
GENERIC_ALLJOB_ALL_ACCESS

The same rights and mapping govern the control channel's job commands (§4.7).

7.8.2 The job's descriptor #

Every job carries a Security Descriptor from the moment its record exists. Unless the submit supplied one, the manager MUST construct:

  • owner and group: the submitter's user SID;
  • a DACL granting JOB_ALL_ACCESS to the submitter, to SYSTEM (S-1-5-18) and to Administrators (S-1-5-32-544);
  • a SACL with one failure-audit ACE for Everyone (S-1-1-0) over JOB_ALL_ACCESS, so that a refused command on the job is recorded by the access check (§4.7);
  • nothing else. In particular the job identity is granted nothing: a process cannot, by virtue of running as U, see or stop a job that runs as U. A submitter that wants the principal to see its own session says so.

A submit MAY supply security_descriptor, in SDDL, and the manager MUST use it as given — it MUST NOT add its default entries to it. The supplied descriptor MUST carry an owner and a DACL, or the submit is INVALID_ARGUMENTS. A submitter that omits itself from the DACL it supplies has locked itself out of its own job, and the manager MUST NOT prevent that: the descriptor is the policy, and a policy the manager second-guessed would not be one.

The descriptor is fixed at submission. This chapter defines no command to change it.

7.8.3 status #

Returns the job view. Requires JOB_QUERY.

7.8.4 wait #

Blocks until a condition holds, then returns the job view. Requires JOB_QUERY.

forReturns when
terminal (default)The job reaches a terminal state.
readyThe job has sent READY=1, or reaches a terminal state, whichever first.

for: ready on a readiness: none job MUST be answered INVALID_STATE: there is nothing to wait for. On a job that is already ready, or already terminal, the manager MUST answer at once.

A connection blocked on a wait is not idle (§7.3). The wait is bounded by nothing but the job: a wait on a session job with no timeout blocks until something stops it. A submitter that needs a bounded wait polls status instead, or waits on the process handle it was given.

If the job's record is dropped while a wait is blocked on it — which can only happen after it became terminal and its grace period elapsed, so only if the manager was unable to answer in time — the wait MUST be answered UNKNOWN_JOB.

7.8.5 stop #

Asks the manager to end the job. Requires JOB_STOP.

The manager MUST send the termination signal to the job's process, wait stop_timeout, and then kill everything remaining in the job's containment. A job that has sent STOPPING=1 MUST NOT receive the termination signal (§4.19); the deadline still applies.

wait defaults to true: the response is sent when the job is terminal. With wait: false the manager MUST respond as soon as the stop has been initiated, with the job view as it then is.

A stop on a job that is already terminal MUST return the job view unchanged, with "status": "ok". It is not an error to stop something that has stopped; it is a no-op, and the view is the honest answer. A second stop on a job already being stopped MUST NOT restart the deadline.

7.8.6 signal #

Sends one signal to the job's main process, and nothing else. Requires JOB_SIGNAL.

signal MUST be an integer naming a signal the platform defines, or the request is INVALID_ARGUMENTS. The manager MUST deliver it to the process through the handle it obtained at fork, so that a recycled PID can never be signalled by mistake, and MUST answer with the job view.

A signal on a job that is not running MUST be answered INVALID_STATE.

This is the raw mechanism, deliberately: SIGKILL through signal kills the main process and leaves whatever it spawned, and produces a failed job with exit_signal set and a null cause. A submitter that wants the job ended uses stop.

7.8.7 What none of them do #

None of these commands moves a job backwards or starts it again. A terminal job stays terminal, its record is dropped after the grace period, and a submitter that wants the program to run again submits it again.

7.9 Descriptors and Output

Peios / Peios technical reference / PSPU / Job Submission

7.9.1 Descriptors into the job #

A submit MAY attach descriptors with SCM_RIGHTS. The manager MUST give them to the job's process, in the order they were attached, in exactly the form §4.20 gives stored descriptors back to a service:

  1. Placed consecutively from descriptor 3, with close-on-exec cleared.
  2. LISTEN_FDS set to the count.
  3. LISTEN_FDNAMES set to the names from descriptors, colon-separated, in the same order.
  4. LISTEN_PID set to the job process's own PID.

All four MUST be absent when nothing is passed.

descriptors names them. It MUST have exactly one entry per descriptor to be injected — the attached descriptors, less the output sink if output is true — or the submit is INVALID_ARGUMENTS. A name MUST be non-empty and MUST NOT contain :, since : is the separator in LISTEN_FDNAMES.

A submitter MAY attach nothing and send an empty descriptors; that is the common case.

Note

This is what makes a session job possible without inventing a channel: the submitter creates a socketpair, keeps one end, and attaches the other, and the session process finds it at descriptor 3 under whatever name the submitter chose. It is also what makes a terminal possible — attach the pseudo-terminal's slave — and every other case where the program needs a handle its submitter already holds. The convention is sd_listen_fds's, so a program written to adopt descriptors from a service manager adopts them from a submitter unchanged.

The manager MUST NOT inspect, validate or restrict what an attached descriptor refers to. What a job may do with a descriptor is decided by the rights cached on it, which the submitter established when it opened it.

7.9.2 The job's output #

A job's standard output and standard error MUST be captured by the manager and recorded exactly as a service's are — the manager's own design says where, and on Peios that is eventd, tagged with the job's identifier. This is unconditional: a submitter cannot turn it off, and cannot redirect the job's streams somewhere the manager does not see.

A submitter MAY additionally ask for a copy. With output: true, the last attached descriptor is not injected into the job; the manager adopts it as an output sink and writes to it every line it reads from the job's streams, as it reads them.

The copy is best-effort, and the record is not:

  • The manager MUST NOT block on the sink, and MUST NOT let a sink that is not being drained slow the job or the manager. It MUST put the sink in non-blocking mode.
  • When a write to the sink would block, the manager MUST drop the line for the sink only, MUST still record it, and MUST count the drop. It MUST report that drops occurred — at least once per job, through its event stream — so a submitter that sees a gap can learn it caused one.
  • The manager MUST close the sink when the job is terminal, after the last line has been written or dropped, and MUST close it at once if writing to it fails with anything other than a would-block condition.

What the manager writes to the sink is each line the job produced, followed by a newline, with no tagging: the submitter attached the sink knowing which job it was for. Lines from the two streams are interleaved in the order the manager read them. A submitter that needs to tell them apart, or needs every line, reads the record instead.

output: true with no attached descriptor is INVALID_ARGUMENTS.

7.10 Errors

Peios / Peios technical reference / PSPU / Job Submission

The code field of an error response MUST be one of these values. The manager MUST NOT emit any other code on this channel, and a submitter MUST treat a code it does not recognise as an unrecoverable error for that request (§7.11).

CodeMeaningCloses?
MALFORMED_REQUESTThe message is not a single well-formed JSON object. §7.4No
REQUEST_TOO_LARGEThe message exceeds the configured maximum, or was truncated. §7.3Yes
INVALID_COMMANDcommand is absent, not a string, or names no command.No
INVALID_ARGUMENTSA field is absent, malformed, or inconsistent with the attachments. §7.6, §7.9No
UNKNOWN_JOBjob_id names nothing the manager holds — it never existed, or its retention has elapsed. §7.7No
ACCESS_DENIEDThe access check against the job's descriptor denied the right. §7.8No
INVALID_STATEThe command is not valid for the job's state, or the manager is shutting down.No
QUOTA_EXCEEDEDThe submitter already holds its maximum of live jobs. §7.3No
BAD_TOKENThe attached token cannot be a job identity. §7.5No
INTERNAL_ERRORThe manager failed while executing the command.No

UNKNOWN_SERVICE, UNKNOWN_OPERATION and OPERATION_TIMEOUT are not emitted here: nothing on this channel names a service or an operation, and a wait has no timeout of its own to expire.

7.10.1 Distinctions a submitter can rely on #

UNKNOWN_JOB versus ACCESS_DENIED. A caller that may not query a job receives ACCESS_DENIED for an identifier that exists, and UNKNOWN_JOB for one that does not. This channel does not hide the existence of a job from a caller that can name it: identifiers are unguessable, and a caller that holds one was told it by something that had it.

BAD_TOKEN is about the token, never about the identity. It says the attached token is unusable — its level, its count, its rights — and never that the submitter may not act as the principal it names. That decision was the kernel's, made before the message arrived, and a submitter that could not convey an identity never reaches the manager with it.

QUOTA_EXCEEDED created nothing. No record, no event, no consumption. A submitter MAY retry once one of its live jobs ends.

INVALID_STATE during shutdown. Once the manager is shutting down it MUST reject submit with INVALID_STATE and MUST continue to answer status, wait, stop and signal; a submitter watching its jobs be stopped has a legitimate reason to keep looking, and a submitter that wants a job stopped sooner than the shutdown will manage has a legitimate reason to ask.

7.10.2 Codes are not extensible without a version #

The manager MUST NOT introduce a new code without the negotiation in §4.21, for the reason §4.10 gives: an unrecognised code silently turns a handled condition into an unhandled one.

7.11 Extension

Peios / Peios technical reference / PSPU / Job Submission

The jobs channel carries no version number, and is extended under the rules of §4.21. They are not repeated; what follows is how they apply here and what this chapter adds.

7.11.1 What may be added #

A request field, including a new definition field on submit. A manager MUST ignore a field it does not recognise, so a submitter MAY send one and MUST NOT depend on its effect.

A job view field. A client MUST ignore a field it does not recognise.

A command, and the error codes and enumerated values that appear only in responses to it, on the terms §4.21 gives: a client that never sends the command is never exposed to them.

7.11.2 What may not be added without a version #

A state, a cause, a progress unit, or a for value outside §7.B. A command changing what an existing command does. A change to the shape of the job view. A new error code on an existing command.

7.11.3 What may never be added #

Two things are excluded from extension by any means, because their absence is a security property of the channel rather than a gap in it:

  • An identity field. A submit MUST NOT be able to name the principal the job runs as. Every job identity is one the kernel verified the submitter could convey (§7.5), and a field that named one would be a path around that verification.
  • A policy field. A submit MUST NOT be able to ask for a restart, a dependency, a health check or a trigger. A job that needs policy is a service, and a service is defined where an administrator controls it.

A revision that wanted either would not be a revision of this channel.

7.12 Conformance

Peios / Peios technical reference / PSPU / Job Submission

7.12.1 A conforming manager #

The socket. Listens on a Unix sequenced-packet socket at the well-known path, close-on-exec on the listener and every accepted connection, and ensures the socket and each directory containing it carry a Security Descriptor admitting exactly the principals meant to submit. Performs no access check of its own before a submit (§7.3).

Messages. Emits and accepts exactly one compact JSON object per record, with no terminator. Receives every message with room for one token and the maximum number of descriptors, answers a truncated message or attachment list without acting on it, and closes every attached descriptor it does not hand on or adopt, on every path (§7.4).

Identity. Establishes the submitter from the kernel once, at accept. Installs on a job exactly one of: the connecting process's own primary token, opened through the kernel's peer process handle; or the token the kernel attached to the submit, duplicated to a primary. Refuses a token below Impersonation level, more than one token, or one it cannot duplicate, with BAD_TOKEN. Accepts no identity by name and no primary token by plain descriptor (§7.5).

Submission. Validates the definition as §7.6 lists and refuses with INVALID_ARGUMENTS; does not check that the program exists. Checks identity and quota before creating any record. Answers a submit when the job has left created and not before or after, with the job view, attaching the process handle when the job is running (§7.6).

The job view. Reports every job in the one shape of §7.7, with every inapplicable field present and null, the state-dependent population rules honoured, and the cause set only where the manager decided the outcome. Holds a terminal record for the grace period and answers a dropped or never-existent identifier UNKNOWN_JOB alike.

Management. Gives every job a descriptor — the default of §7.8 or the one supplied — and checks every command against it with the rights and mapping there, including commands from the submitter. Implements status, wait, stop and signal with the semantics of §7.8: a wait bounded only by the job, a stop that is a no-op on a terminal job and does not restart a running deadline, a signal delivered through the fork-time handle.

Descriptors and output. Injects attached descriptors from 3 upward with LISTEN_FDS, LISTEN_FDNAMES and LISTEN_PID set; records the job's output unconditionally; and, when asked, copies it to a non-blocking sink, dropping for the sink only, counting and reporting the drops, and closing the sink when the job ends (§7.9).

Errors and extension. Emits only the codes in §7.10, ignores unrecognised request fields, and adds nothing from §7.11's closed list — and never an identity or policy field — without a negotiated version.

7.12.2 A conforming submitter #

Connects as the identity it wants to own its jobs, and conveys any other identity per message rather than by impersonating at connect. Attaches at most one token, and sets its connection's level to Delegation before attaching when the job needs it. Sends one compact JSON object per record. Supplies every LISTEN_FDNAMES name it attaches a descriptor for, and none containing :. Assumes the manager owns every descriptor it attached. Treats an immediate close with no response as a refusal. Does not parse message. Accepts null for every nullable field and unrecognised fields in the job view. Treats an unrecognised state, cause, unit or error code as an error for that request rather than guessing. Reads state rather than cause to decide whether a job succeeded. Uses stop to end a job and signal only to signal it. Does not depend on a job's record surviving its grace period.

7.12.3 A conforming job #

Nothing beyond §4.22's requirements of a service on the notification channel. A job that sends progress does so in the form §4.19 defines. A job that adopts descriptors checks LISTEN_PID first.

7.12.4 What conformance is not #

A manager that offers the control channel and not this one is still a conforming manager under §4. This chapter is the contract for one that offers both.

Appendix 7.A Limits and Defaults

Peios / Peios technical reference / PSPU / Job Submission

The values a Peios service manager uses. A manager MAY use different ones; where a value is configurable, it MUST be discoverable to an administrator through the same surface that sets it.

7.A.1 The socket #

BoundValueConfigurableDefined in
Socket path/run/services/peinit/jobs.sockNo§7.3
Socket descriptor, defaultConnect for Authenticated Users (S-1-5-11); full control for SYSTEM and AdministratorsYes§7.3
Concurrent connections64Yes§7.3
Message size65536 bytesYes§7.3
Idle timeout30 secondsYes§7.3
Listen backlog32No§7.3
Live jobs per submitter64; SYSTEM exemptYes§7.3

7.A.2 Attachments #

BoundValueDefined in
Tokens per message1§7.4
Descriptors per message64, including an output sink§7.4
First injected descriptor3§7.9

7.A.3 The definition #

FieldDefaultDefined in
stop_timeout10 seconds§7.6
readiness_timeout30 seconds§7.6
timeout0, no limit§7.6
working_directory/§7.6

7.A.4 Records #

BoundValueDefined in
Terminal record retention60 seconds§7.7

The retention value is the control channel's operation retention (§4.A), for the same reason: a client polling once a second must never find the outcome gone.

7.A.5 Composing with the control channel #

A job's status_text and progress come from datagrams on the notification channel, whose bound is §4.A's 65536 bytes; the job view is not bounded by the jobs channel's message size on the way out, so a status a job can send is always one the manager can return.

The message-size bound applies to a submit and therefore to the sum of its definition: a program with a very large environment or arguments list is bounded here before it is bounded by execve.

Appendix 7.B Wire Vocabulary

Peios / Peios technical reference / PSPU / Job Submission

Every enumerated value that appears on the jobs channel. All are lower snake case. A manager MUST NOT emit a value outside these sets without the negotiation in §4.21, and a submitter MUST treat one it does not recognise as an error for that request rather than mapping it onto a value it knows.

Values shared with the control channel — response status, job type — are §4.B's and are not restated.

7.B.1 Command #

submit, status, wait, stop, signal

7.B.2 Job state #

ValueProcess?Terminal?
createdNot yetNo
runningYesNo
completedNoYes
failedNoYes
abandonedYes, unkillablyYes

created is never reported on this channel (§7.6). It appears here because the control channel's job-list may show it for a job whose submit is still in flight.

7.B.3 Cause #

parent_setup_failure, pre_exec_failure, readiness_timeout, timeout, explicit_stop, shutdown, process_unkillable

cause is null when the process ended of its own accord.

7.B.4 Readiness #

none, notify

Request-only, on submit. The job view reports readiness through ready, not by echoing this.

7.B.5 Wait condition #

terminal, ready

Request-only, the for field of wait.

7.B.6 Progress unit #

bytes, items, percent

The vocabulary of PROGRESS_UNIT (§4.19), reported in the job view's progress.unit. Null when the job sent none.

8.1 Scope and Roles

Peios / Peios technical reference / PSPU / Security Descriptor Editing

This chapter defines the editing channel: the interface by which a program has a person edit the Security Descriptor of an object the program holds, in a dialog drawn by another program, the editor.

The object may be of any kind that has a Security Descriptor — a file, a registry key, a service. The editor knows none of them. What it is told is what a person needs in order to edit the descriptor: what the object is called, what kind of thing it is, what its rights are called, and the descriptor itself. What it answers with is a descriptor.

8.1.1 Roles #

The requester is the program whose object it is. It starts the editor, reads the object's descriptor, tells the editor about it, and applies each descriptor the editor sends back. It holds the object, and whatever right changing the descriptor takes, throughout.

The editor draws the descriptor for a person to change, on the desktop the requester is on, and sends back what they apply. It holds no handle to the object and needs no right over it. It changes nothing itself.

The split is deliberate. The party that can change the object is the one that already holds it, and it applies exactly what it is sent, so an editor serves every kind of object without ever being trusted with one. A requester gains a full editor without containing one.

Note

The arrangement is the one Windows' security editor uses, where the caller implements the object's side (ISecurityInformation: the descriptor, the rights' names, applying) and the editor draws. Here the editor is a separate process rather than a library, so that it serves a requester in any language, and a fault in it is its own.

8.1.2 What this chapter leaves to others #

How the editor draws its dialog, and on which desktop, is the editor's own design and that desktop's. On a GXWI desktop the editor is an app like any other, found by the environment it inherits from the requester.

The layout of a Security Descriptor, of the access masks it carries and of its entries is PCDS §5's. This chapter carries descriptors and says nothing about their contents beyond which components are applied.

How a requester reads and applies a descriptor is its own concern, and depends on the kind of object. A requester SHOULD apply through the same handle it read through, so that what is applied reaches the object that was shown, wherever it has gone in the meantime.

8.2 Terminology

Peios / Peios technical reference / PSPU / Security Descriptor Editing

Security Descriptor, access mask, ACE, DACL, SACL, owner, group, and generic right are used with PCDS §5's meanings and are not redefined.

Requester. The program whose object's descriptor is being edited, and which started the editor. §8.1

Editor. The program that shows the descriptor to a person and sends back what they apply. §8.1

Line. One message on the editing channel: a single compact JSON object, encoded as UTF-8, followed by a line feed (U+000A). §8.3

Request. The first line the requester sends: everything the editor is told about the object. §8.4

Component. One of the five separately applicable parts of a Security Descriptor: the owner, the group, the DACL, the SACL and the label. The label is the SACL's mandatory integrity label alone, which KACS applies apart from the rest of the SACL and with a different right (KACS set-security). On the channel they are named owner, group, dacl, sacl and label.

General right. A right the requester names for a person to grant or deny in one step, such as "Read" or "Full control", standing for an access mask that may have many bits. The requester decides which rights are general and what each stands for. §8.4

Applying. The requester writing components of a descriptor the editor sent onto the object. §8.5

8.3 The Channel

Peios / Peios technical reference / PSPU / Security Descriptor Editing

8.3.1 Starting the editor #

The requester MUST start the editor as a child process of its own, with the editor's standard input and standard output connected to it. It SHOULD start the editor with its own environment, which is how the editor finds the desktop to draw on.

The editor's standard error is not part of the channel. The editor MAY write diagnostics there for an administrator, and the requester MUST NOT read meaning into them.

8.3.2 Lines #

Each direction is a sequence of lines (§8.2): one compact JSON object, UTF-8, then a line feed. A line MUST NOT contain a line feed before its end; compact JSON never needs one.

  • Requester to editor, on the editor's standard input: the request (§8.4), first and exactly once, then one answer for each apply (§8.5), and raise (below) whenever the person asks for the object again.
  • Editor to requester, on the editor's standard output: apply messages (§8.5), and nothing else in this revision.

Every line carries a type member naming what it is, except the request, which is identified by being first.

A party MUST ignore a line whose type it does not recognise, and a member of an object it does not recognise (§8.7). A party MAY refuse a line longer than 1 MiB; a request carrying a descriptor of the largest size PCDS §5.1 permits is well inside it.

8.3.3 Asked for again #

A requester that the person asks to edit an object it already has an editor open on SHOULD NOT start a second editor for it: two dialogs on one object would each apply over the other. It SHOULD instead send

{"type":"raise"}

which needs no answer. An editor that receives it SHOULD bring its dialog to the front and give it the keyboard, as far as the desktop lets it. An editor that does not recognise it ignores it, as any line, so a requester MUST NOT depend on the dialog coming forward. A requester whose write of raise fails has learned the editor has gone, and MAY start a new one.

8.3.4 Ending #

The channel ends when either party closes its end, and either party can tell when the other has.

The editor ends when the person closes or cancels the dialog, and after the person confirms once what it sent has been applied (§8.5). It ends by exiting, which closes its standard output; the requester reading end-of-file there is how it knows. An editor that ends sends nothing more and applies nothing, since it never could.

The requester ends, or gives up on the editor, by closing the editor's standard input. The editor MUST end promptly when it reads end-of-file there, whatever it is doing, and MUST NOT ask the person anything first: the requester can no longer apply what they would say. A requester that exits closes the editor's input whether it means to or not, so the editor goes with it.

The editor SHOULD also arrange to be signalled when its parent exits (on Linux, PR_SET_PDEATHSIG), and end then. A process whose parent exits is not ended by that, and an editor left showing a dialog for an object nobody can apply to is worse than none.

The editor's exit status says nothing about what was applied. The requester knows that from its own answers (§8.5), and MUST NOT infer it from the status.

8.4 The Request

Peios / Peios technical reference / PSPU / Security Descriptor Editing

The request is the first line on the editor's standard input, and the only one that has no type. It is one JSON object:

MemberTypeRequiredMeaning
objectobjectYesWhat the descriptor is the descriptor of.
object.namestringYesWhat the object is called where the person found it: notes.txt, not a full path, unless a path is how they know it.
object.kindstringYesWhat kind of thing it is, in words for the person: File, Folder, Service.
object.containerbooleanNo, falseWhether other objects inherit from it (PCDS §5.6).
object.childrenstringNo, "all"What a container holds, which is what the entries made for it are passed on to: all or containers (see Containers). Ignored unless container is true.
object.parentobjectNoWhat the object is in, which its inherited entries come from (see The parent).
object.parent.namestringYes, in parentWhat the parent is called where the person knows it: /srv.
object.parent.sdstringNoThe parent's descriptor, self-relative and in base64 as sd is.
object.partsarrayNo, emptyThe object's parts that rules can be made for one at a time: object types (see Parts and kinds).
object.parts[].guidstringYesThe object type, as a GUID in its usual text form: bf967aba-0de6-11d0-a285-00aa003049e2.
object.parts[].namestringYesWhat the part is called, for the person.
object.parts[].kindstringYesset, a set of properties; property; or right, an action.
object.parts[].setstringNoFor a property, the GUID of the set it is in.
object.kindsarrayNo, emptyOn a container of typed things, the kinds of thing it holds: inherited object types.
object.kinds[].guid, .namestringYesThe inherited object type, and what that kind of thing is called.
object.part_rightsarrayNo, emptyThe rights a rule for a part can give, as rights gives the object's (see Parts and kinds).
object.part_rights[].name, .maskstring, numberYesWhat the right is called, and its access mask.
object.namingobjectNoHow a part not listed is named (see Parts named by the person).
object.naming.namespacestringYes, in namingThe UUID v5 namespace a part's object type is derived in, as a GUID in its usual text form.
object.naming.nounstringYes, in namingWhat one such part is called, for the person: field.
object.naming.examplestringNoA name the person might give, to show how one is written: source.name.
sdstringYesThe descriptor as it is now: a self-relative Security Descriptor (PCDS §5.1), in base64 with padding (RFC 4648 §4).
readarray of stringsNoThe components sd holds as they are on the object: some of owner, group, dacl, sacl and label (see The descriptor). Left out, every component sd has.
rightsarrayYesThe object's rights by name, in the order a person should see them.
rights[].namestringYesWhat the right is called.
rights[].masknumberYesThe access mask the right stands for, as an unsigned 32-bit integer.
rights[].generalbooleanNo, falseWhether it is a general right (§8.2).
genericobjectYesWhat each generic right stands for on this kind of object (PCDS §5.3).
generic.read, .write, .execute, .allnumberYesThe access mask each generic right maps to.
canobjectNoWhich components the requester is able to change.
can.daclbooleanNo, trueWhether it can change the DACL. It is true when left out because, before this member, a requester always could.
can.ownerbooleanNo, falseWhether it can change the owner.
can.auditbooleanNo, falseWhether it can change the SACL.
can.labelbooleanNo, falseWhether it can apply the label by itself (§8.5).
can.propagatebooleanNo, falseWhether, on a container, it can push what was applied into what is already inside (§8.5, Pushing into what is inside).
can.whystringNoWhy the requester cannot change what can says it cannot, as text for the person.

8.4.1 The descriptor #

The requester SHOULD include the owner, the group and the DACL. It MAY leave out the SACL: reading a SACL takes ACCESS_SYSTEM_SECURITY, which a requester need not have. It SHOULD include the label where it can read it, which takes only READ_CONTROL. A requester that could read the label and not the rest of the SACL sends a SACL holding the label alone, and MUST then say so in read, naming label and not sacl.

The editor MUST NOT take a SACL for the whole SACL unless read names sacl or is left out. Where it is only the label, the editor MUST NOT show it as an object with no auditing, claims or policies, since those are not known.

A descriptor with no DACL component means one with no access list, which PCDS §5.1 treats as granting everyone everything. The editor MUST show it as such and not as an empty list, which grants nobody anything.

8.4.2 The parent #

object.parent names what the object's inherited entries come from, for the person: "Inherited from /srv". An editor that offers to stop inheriting MAY offer to inherit again, but KACS does not pass a parent's entries down again by itself when protection is turned off (PCDS §5.6): the editor works out what is passed down from object.parent.sd, and SHOULD NOT offer to inherit again without it. A requester SHOULD send the parent's descriptor where it can read it.

8.4.3 Parts and kinds #

object.parts lists the object types (PCDS §5.4) a rule can be made for, as an account's sign-in details or the action of resetting its password. object.kinds lists, for a container of typed things, the kinds of thing it holds, which an entry can be passed on to alone. An editor MAY offer rules for parts and kinds only where they are listed, and MUST keep as it found it an entry naming a GUID it is not told of.

object.part_rights names what a rule for a part can give. Left out, an editor offers what a directory's parts take: reading and writing a property, and using an action. A requester whose parts take other rights MUST send them. eventd's fields, for example, take only its Read.

8.4.3.1 Parts named by the person #

Some objects have more parts than can be listed, each one's object type derived from its name. eventd's fields are an example: a payload field needs no registration, and its GUID is derived from its name (eventd TRM §7.3). object.naming tells the editor how such a part is named. Its object type is the UUID v5 (RFC 9562 §5.5) of the name, as UTF-8, in naming.namespace, and it is a property.

An editor MAY then let the person name a part beside those listed. It MUST derive the object type exactly so, and SHOULD show the part by the name given for as long as the dialog is open. A requester SHOULD still list in parts the ones it knows, so that an existing rule for one is shown by name.

8.4.4 The rights #

rights names every right the requester wants a person to see. General rights SHOULD come first, from the one granting the most to the one granting the least, so that a right that includes another is above it. The masks of two rights MAY overlap, and typically do: "Modify" includes "Read".

An editor that shows general rights as boxes to tick SHOULD show as a whole what a mask it cannot express in them — a mask with bits outside every general right it contains, an entry of a type it does not edit, or inheritance flags other than its own — rather than leave it unshown, and SHOULD keep such an entry as it found it.

generic lets the editor read an entry that grants generic rights (PCDS §5.3) as the rights they stand for. The requester MUST give the mapping its kind of object actually uses.

8.4.5 Containers #

On a container, the entries an editor makes apply to the container and are passed on to what it holds. object.children says what that is, and so which inheritance flags (PCDS §5.6) those entries carry:

ValueHoldsFlags of an entry made here
allContainers and other objects, as a folder holds folders and files.Object inherit and container inherit.
containersContainers alone, as a registry key holds keys and nothing else.Container inherit.

An entry with other inheritance flags is one the editor cannot express in its general rights (above), and is shown as a whole and kept.

8.4.6 What the requester can change #

can says what the requester is able to change. It MUST NOT say it can change a component it cannot. can.label is true only where it can apply the label by itself, which takes WRITE_OWNER and, for a level above the caller's own, SeRelabelPrivilege (KACS set-security). It SHOULD find out by asking the system, for example by an AccessCheck of the person's token against the descriptor, or by opening the object for the rights that changing it takes. It SHOULD NOT infer it from the person's group memberships.

can.propagate is true only on a container whose requester holds what is inside it and can walk it: a file explorer for a folder, a registry editor for a key. It says nothing about whether each item inside can be changed, which is found out item by item as the walk goes (§8.5).

A requester that cannot change the DACL still sends it, so that the person can read it. The editor MUST then show the DACL without offering to change it, and SHOULD say why, with can.why if it was given. An editor whose requester can change nothing SHOULD offer only to close.

8.4.7 Validation #

An editor that cannot use the request — one that is not a JSON object, lacks a required member, or carries an sd that is not a Security Descriptor — MUST NOT show a dialog. It SHOULD write why to its standard error, and MUST end (§8.3).

8.5 Applying

Peios / Peios technical reference / PSPU / Security Descriptor Editing

8.5.1 apply #

When the person asks for what they have done to take effect — Apply, or OK — the editor sends:

MemberTypeMeaning
type"apply"
sdstringThe whole descriptor as the person now has it, self-relative and in base64 as in the request (§8.4).
partsarray of stringsThe components of sd that changed since the request, or since the last apply the requester answered applied: some of owner, group, dacl, sacl, label.
propagatebooleanOptional, false when left out. Once parts is applied, push what the object now passes down into what is already inside it (see Pushing into what is inside).

The editor MUST NOT send an apply as the person changes things, only when they ask for it. A descriptor applied part way through a change can take away the access the person needs to finish it: denying Everyone before granting oneself, say.

The editor MUST NOT name dacl in parts unless the request's can.dacl was true or left out, nor owner or group unless can.owner was true, nor sacl unless can.audit was and the request held the whole SACL, nor label unless can.label was. It MUST NOT send propagate true unless can.propagate was, nor without the person having chosen it for this apply. It MUST NOT send an apply while one is unanswered, and SHOULD NOT send one whose parts is empty. It MAY send one whose parts names components that have not changed since they were applied, with propagate true, to push them into what is inside again (after a walk was stopped or some of it failed): applying them again changes nothing.

8.5.2 The label #

The label is in the SACL, and sacl carries it: a changed label goes with a changed SACL. parts MUST NOT name both sacl and label, which KACS refuses to apply together (KACS set-security). label names the label alone, where the rest of the SACL has not changed or cannot be applied: the SACL of sd then holds the label that is to be the object's, or none to remove it, and the requester applies it as a label (LABEL_SECURITY_INFORMATION), which keeps the rest of the object's SACL as it is. A requester that keeps a descriptor whole, as a registry value, puts the label into the SACL it has, in place of the label there.

8.5.3 The answer #

The requester MUST answer every apply with exactly one line, in the order they came:

LineMeaning
{"type":"applied"}Every component in parts was applied. Where propagate was true, it carries how pushing into what is inside went (below).
{"type":"failed","why":"…"}None was, or not all, and why says why, as text for the person. Nothing was pushed into what is inside.

The requester MUST apply the components named in parts, and MUST NOT apply any other component of sd. The rest is as the requester sent it, or as the editor could not read it, and writing it back would at best change nothing and at worst write over what changed meanwhile.

The requester SHOULD apply the named components in one operation where the kind of object allows it. Where it cannot, and some were applied and some were not, it MUST answer failed and SHOULD say which were applied.

why is shown to the person as it stands. It SHOULD say what was in the way in words they can act on — "you are not allowed to" — rather than an error code.

8.5.4 Pushing into what is inside #

Inheritance is eager (PCDS §5.6): what a container passes down reaches the things made inside it afterwards, and the things already there keep what they were given. Where the person changes what a container passes down, the editor SHOULD ask whether to update what is already inside too, and, where they say yes, sends the apply with propagate true.

A requester sent propagate true first applies parts as above. If it answers failed, it pushes nothing. Otherwise, before it answers, it re-propagates everything inside the object as PCDS §5.6 sets out (Re-propagation), for the lists parts names: the DACL for dacl, the SACL for sacl, and for label the SACL as far as it holds labels, read and applied as labels. In particular it MUST:

  • go parents first, re-propagating each item from its parent as just rewritten;
  • leave a list an item protects exactly as it is, and still re-propagate what is inside that item from it as it is;
  • resolve CREATOR OWNER and CREATOR GROUP against each item's own owner and group, and map generic rights through the generic mapping of the kind of thing it holds;
  • change no component of an item but the lists it re-propagates;
  • follow no link out of the tree;
  • carry on past an item it cannot read or change, noting it, and leave what is inside that item as it is.

The editor sends nothing for the items inside: what each gets is worked out by the requester, from the object as it now is.

While it walks, the requester MAY send:

LineMeaning
{"type":"progress","done":n,"at":"…"}done items have been re-propagated, the last being at, by what the person knows it as.

It needs no answer. A requester SHOULD send one now and then on a long walk, and SHOULD NOT send them more often than a person can read.

The editor MAY send, while it waits for the answer to an apply with propagate true:

LineMeaning
{"type":"stop"}Stop the walk after the item in hand.

It needs no answer of its own: the requester stops, and answers the apply as it would have at the end. What was re-propagated stays so. End-of-file on the requester's input from the editor means the same.

The applied answer to an apply with propagate true carries:

MemberTypeMeaning
donenumberHow many items inside were re-propagated, those that protect every list included. Left out, none.
failedarrayThe items that could not be, each {"name":"…","why":"…"}: what the person knows it as, and why, as text for the person. Left out, none.
stoppedbooleanWhether the walk was stopped before the end. Left out, it was not.

Each of these concerns the items inside, not the object: applied still means the object's parts were all applied. The editor SHOULD show how far the walk got, a way to stop it, and afterwards what could not be done, and SHOULD NOT end on an apply-and-close whose walk failed for some items or was stopped, so that the person sees it.

8.5.5 After the answer #

On applied, the editor takes what it sent as the descriptor it is editing from then on: a later apply names only what changed after. If the person had asked to apply and close, the editor ends (§8.3).

On failed, the editor MUST show the person why and MUST NOT end because of it: what they did is still there to be put right or abandoned. What it has not had answered applied it still counts as changed.

8.6 Extension

Peios / Peios technical reference / PSPU / Security Descriptor Editing

The editing channel carries no version number. It grows by addition, under these rules.

8.6.1 What may be added #

A member of the request, or of any line. A party MUST ignore a member it does not recognise, so a requester MAY send one and MUST NOT depend on its effect.

A line type, in either direction, that the other party may ignore and still conform: a party MUST ignore a type it does not recognise (§8.3). A line that needs an answer MUST NOT be added this way, since an editor waiting on an answer from a requester that ignored the question would wait for ever.

8.6.2 What may not be added without a version #

A change to the meaning of an existing member or line. A component name beyond the five of §8.2. An answer to apply other than the two of §8.5.

The fifth, label, was added without a version because nothing can send it to a requester that does not know it: an editor names it only for a requester that said can.label, which an older requester never says.

Pushing into what is inside was added the same way. An editor sends propagate only to a requester that said can.propagate. progress and stop are lines that need no answer. What applied carries about the walk is members of it, and applied still means only that the object's parts were applied.

object.part_rights and object.naming were added as optional members of the request. An editor that does not know naming offers only the parts listed. An editor that does not know part_rights offers a directory's rights for a part, which may not be rights the object's parts take. For eventd's fields they aren't, so such a rule gives nothing.

raise (§8.3) was added as a line that needs no answer. An older editor ignores it, and its dialog stays where it is.

8.6.3 What may never be added #

A way for the editor to apply. Nothing on the channel may let the editor change the object itself, or ask the requester to apply anything but a descriptor the person asked to apply. That the editor holds nothing and is trusted with nothing is what lets a requester open it on any object, and the requester applying exactly what it is sent is the whole of the contract between them.

Pushing into what is inside keeps to this. The editor sends no descriptor for anything but the object, and propagate asks only for what PCDS §5.6 says re-propagation of the object's descriptor is, which the requester works out itself from the object as it applied it. The editor cannot choose what any item inside gets.

8.7 Conformance

Peios / Peios technical reference / PSPU / Security Descriptor Editing

8.7.1 A conforming requester #

Starting. Starts the editor as its own child with the editor's standard input and output connected to it, and sends the request as the first line (§8.3).

The request. Sends every required member, a self-relative descriptor in padded base64, the general rights from the most to the least, and the generic mapping its kind of object actually uses. On a container, says in object.children what it holds. Says in can only what it can in fact change, having asked the system. Says in read which components it sent where that is not everything sd has, as a SACL holding only the label (§8.4).

Applying. Answers every apply with exactly one line, in order. Applies the components parts names and no others, and the label as a label, keeping the rest of the SACL. Answers applied only when all of them were, and otherwise failed, with a reason for the person (§8.5).

Pushing into what is inside. Says can.propagate only on a container it can walk. Asked to, after applying, re-propagates everything inside as PCDS §5.6 sets out, for the lists parts names: parents first, protected lists left, nothing changed but those lists, no link followed, past an item it cannot change. Stops after the item in hand on stop or end-of-file, and answers applied with how many were done, which could not be and why, and whether it was stopped (§8.5).

Lines. Ignores a line type and a member it does not recognise (§8.3). Infers nothing from the editor's exit status or standard error (§8.3).

8.7.2 A conforming editor #

Starting. Shows no dialog for a request it cannot use, and ends (§8.4).

Showing. Shows a descriptor without a DACL as granting everyone everything, never as an empty list. Shows what it cannot express in the general rights as a whole, and keeps such entries as it found them. Gives the entries it makes on a container the inheritance flags of what the container holds. Shows a DACL the requester cannot change without offering to change it. Takes a SACL for the whole SACL only where read says it is (§8.4).

Applying. Sends an apply only when the person asks, with the whole descriptor and the components that changed, never dacl, owner, group, sacl or label it was not told it could, never sacl and label together, and never while one is unanswered. Shows a failed answer's reason and stays (§8.5). Sends propagate only to a requester that said can.propagate, and only where the person chose it (§8.5).

Changing nothing. Holds no handle to the object and changes nothing itself (§8.1, §8.6).

Ending. Ends when the person closes or cancels, after an applied answer to an apply-and-close, and promptly on end-of-file on its standard input, without asking the person anything. SHOULD also end when its parent exits (§8.3).

Appendix 8.A Message Reference

Peios / Peios technical reference / PSPU / Security Descriptor Editing

DirectionLineDefined in
Requester → editorThe request (first line, no type)§8.4
Editor → requester{"type":"apply","sd":…,"parts":[…],"propagate":…}§8.5
Requester → editor{"type":"progress","done":…,"at":…} (while pushing into what is inside; no answer)§8.5
Editor → requester{"type":"stop"} (while pushing into what is inside; no answer)§8.5
Requester → editor{"type":"applied","done":…,"failed":[…],"stopped":…} (the last three only after pushing)§8.5
Requester → editor{"type":"failed","why":…}§8.5
Requester → editor{"type":"raise"} (the object asked for again; no answer)§8.3

8.A.1 An example #

A file explorer opens the permissions of a file. The person adds Everyone with Read and presses Apply, which fails, then changes their mind and closes the dialog. Lines are shown on several lines here for reading. On the channel each is one line, and the descriptors are shortened.

→ {"object":{"name":"notes.txt","kind":"File","container":false,
             "parent":{"name":"/home/dana","sd":"AQAEgBQAAAAkAAAA…"}},
   "sd":"AQAEgBQAAAAkAAAAAAAAADQAAAAB…",
   "read":["owner","group","dacl","label"],
   "rights":[{"name":"Full control","mask":2032127,"general":true},
             {"name":"Read","mask":1179785,"general":true}],
   "generic":{"read":1179785,"write":1179926,"execute":1179808,"all":2032127},
   "can":{"owner":true,"audit":false,"label":true}}
← {"type":"apply","sd":"AQAEgBQAAAAkAAAAAAAAADQAAAAC…","parts":["dacl"]}
→ {"type":"failed","why":"you are not allowed to"}

The editor shows why and stays open. The person closes it, and the requester reads end-of-file on the editor's output.

The requester could read the label but not the rest of the SACL, so read names label and not sacl, and the SACL in sd holds the label alone. Had the person raised the label, the editor would have sent "parts":["label"].

8.A.2 Pushing into what is inside #

The same explorer opens a folder's permissions, saying it can push into what is inside. The person gives Finance Modify on the folder and everything in it, presses OK, says yes to updating what is already inside, and stops partway.

→ {"object":{"name":"finance","kind":"Folder","container":true}, …,
   "can":{"owner":true,"audit":true,"label":true,"propagate":true}}
← {"type":"apply","sd":"AQAEnBQAAAAkAAAA…","parts":["dacl"],"propagate":true}
→ {"type":"progress","done":120,"at":"/srv/finance/2025/q3.xlsx"}
→ {"type":"progress","done":480,"at":"/srv/finance/2026/budget"}
← {"type":"stop"}
→ {"type":"applied","done":512,
   "failed":[{"name":"/srv/finance/archive","why":"you are not allowed to"}],
   "stopped":true}

The folder's DACL was applied, and 512 items inside were updated before the walk stopped. The editor shows that, and what could not be done, and stays open though OK was pressed. The person can push the rest with another apply of the same parts.

9.1 Scope and Roles

Peios / Peios technical reference / PSPU / File Choosing

This chapter defines the choosing channel: the interface by which a program has a person choose a file, either an existing one to read or a place and a name to write to, in a dialog drawn by another program, the chooser.

The chooser knows nothing of what the file is for. It is told what to call the dialog, what the file is for in words, where to start, and what kinds of file to offer. What it answers with is a path, or that the person chose nothing.

9.1.1 Roles #

The requester is the program that wants a file. It starts the chooser, says what it wants, and, given a path, opens, reads, creates or writes the file itself.

The chooser shows the person's folders and lets them pick a file or type a name, and answers with the path. It runs as the same person as the requester, and, as a small file manager, lets them make a folder and rename and delete as themselves. It never opens the chosen file, and hands the requester nothing but a path.

The split mirrors §8's. The party that will use the file is the one that opens it, with its own token, so the chooser is trusted with nothing it could misuse, and serves any requester without knowing what files are for.

9.1.2 What this chapter leaves to others #

How the chooser draws its dialog, and on which desktop, is the chooser's own design and that desktop's. On a GXWI desktop the chooser is an app like any other, found by the environment it inherits from the requester.

What the person may do with files and folders is KACS's to decide, as it is for any program of theirs. This chapter says only that the chooser asks the system rather than guessing (§9.6).

What a requester does with the file, and in what format, is its own concern.

9.2 Terminology

Peios / Peios technical reference / PSPU / File Choosing

Requester. The program that wants a file chosen, and which started the chooser. §9.1

Chooser. The program that shows the person's folders and answers with the file they choose. §9.1

Line. One message on the choosing channel: a single compact JSON object, encoded as UTF-8, followed by a line feed (U+000A). §9.3

Request. The first line the requester sends: everything the chooser is told about what is wanted. §9.4

Answer. The one line the chooser sends: the path chosen, or that nothing was. §9.5

Mode. Whether an existing file is to be opened (open) or a place and a name to be saved to (save). §9.4

Filter. A kind of file the requester offers, named for the person and given as a list of extensions. §9.4

9.3 The Channel

Peios / Peios technical reference / PSPU / File Choosing

9.3.1 Starting the chooser #

The requester MUST start the chooser as a child process of its own, with the chooser's standard input and standard output connected to it. It SHOULD start the chooser with its own environment, which is how the chooser finds the desktop to draw on, and the person's home folder.

The chooser's standard error is not part of the channel. The chooser MAY write diagnostics there for an administrator, and the requester MUST NOT read meaning into them.

9.3.2 Lines #

Each direction is a sequence of lines (§9.2): one compact JSON object, UTF-8, then a line feed. A line MUST NOT contain a line feed before its end.

  • Requester to chooser, on the chooser's standard input: the request (§9.4), first and exactly once, and nothing else in this revision.
  • Chooser to requester, on the chooser's standard output: the answer (§9.5), at most once, and nothing else in this revision.

The answer carries a type member naming what it is. The request is identified by being first.

A party MUST ignore a line whose type it does not recognise, and a member of an object it does not recognise (§9.7). A party MAY refuse a line longer than 1 MiB.

9.3.3 Ending #

The chooser ends once it has answered, and when the person closes or cancels the dialog, which it answers as cancelled first. It ends by exiting, which closes its standard output.

The requester ends, or gives up on the chooser, by closing the chooser's standard input. The chooser MUST end promptly when it reads end-of-file there, without answering and without asking the person anything: nobody is left to use the answer. A requester that exits closes the chooser's input whether it means to or not, so the chooser goes with it. The chooser SHOULD also arrange to be signalled when its parent exits (on Linux, PR_SET_PDEATHSIG), and end then.

A requester that reads end-of-file on the chooser's output with no answer MUST treat it as cancelled. It MUST NOT infer anything from the chooser's exit status.

9.4 The Request

Peios / Peios technical reference / PSPU / File Choosing

The request is a JSON object, sent once, as the first line.

MemberTypeRequiredMeaning
modestringyesopen for an existing file to read; save for a place and a name to write to
titlestringyesWhat the dialog is called, for the person: "Export a key"
purposestringnoWhat the file is for, in a sentence, for the person to read
folderstringnoAn absolute path to the folder to start in
namestringnoThe name to suggest, in save mode: "Services.json"
filtersarraynoThe kinds of file to offer, in order, each an object below

Each filter is an object with:

MemberTypeMeaning
namestringWhat the kind is called, for the person: "Registry documents"
extensionsarray of stringsThe extensions it covers, without the dot: ["json"]

9.4.1 What the chooser does with it #

The chooser MUST start in folder if it is given, absolute, and a folder the person may list, and otherwise in the person's home folder.

If filters is given, the chooser MUST offer them in the order given, with the first chosen at first, and MAY offer every file as well. In open mode it SHOULD show only folders and the files of the kind chosen. A filter matches a file by its extension, without regard to case.

In save mode the chooser MUST offer name, if given, as the name to save as, and the person MAY change it. If the person types a name without an extension while a filter is chosen, the chooser SHOULD add the filter's first extension.

The requester MUST NOT rely on the person choosing a file of a kind it offered: the person may choose any file. A requester that needs a kind checks what it opens.

A chooser that receives a request it cannot use, because a required member is missing or mode is unknown, MUST NOT show a dialog, and ends without answering.

9.5 The Answer

Peios / Peios technical reference / PSPU / File Choosing

The chooser sends at most one answer, and ends after sending it.

LineMeaning
{"type":"chosen","path":…}The person chose the file at path, an absolute path
{"type":"cancelled"}The person chose nothing: they cancelled or closed the dialog

9.5.1 What a path promises #

In open mode, the path names a file that existed when the person chose it. The requester opens it, and MUST handle its having gone, or changed, since.

In save mode, the path names a place the person chose to write to, in a folder that existed when they chose it. If a file was there, the person has already said to save over it: the chooser MUST ask before it answers with the path of a file that exists. The requester creates or replaces the file, and MUST handle being refused, since what the person may do can change, and the chooser could not open the file to find out.

The chooser MUST NOT open, create or change the chosen file to choose it, and MUST NOT answer with a path it has not shown or the person has not typed. In neither mode does a path grant anything: the requester uses it as the person, with its own token.

9.6 The Chooser

Peios / Peios technical reference / PSPU / File Choosing

The chooser shows one folder at a time, and lets the person move among folders. Beyond choosing, it MAY let them make a folder, and rename and delete files and folders: a small file manager, so that a person saving a file can make somewhere to put it.

9.6.1 As the person #

The chooser runs as the same person as the requester, started as its child. Everything it does to files and folders is that person's act, which KACS checks as for any program of theirs. The chooser MUST NOT hold or use any authority beyond theirs.

9.6.2 Asking before offering #

The chooser MUST NOT guess what the person may do from who they are. Before it offers an act, it SHOULD find out whether the person may do it, by asking the system: an AccessCheck of the person's token against the descriptor of the folder or the file. What the person may not do it SHOULD show disabled, with the reason in words, rather than hide it or offer it to fail. Where the descriptor cannot be read, it MAY offer the act and let the system decide.

A folder the person may not list MUST be shown as such, with the reason, and not as an empty folder.

9.6.3 Deleting #

The chooser MUST ask the person before it deletes anything, and say when a folder will be deleted with everything in it.

9.7 Extension

Peios / Peios technical reference / PSPU / File Choosing

The choosing channel carries no version number. It grows by addition, under these rules.

9.7.1 What may be added #

A member of the request or of the answer. A party MUST ignore a member it does not recognise, so a requester MAY send one and MUST NOT depend on its effect.

A line type from the chooser that the requester may ignore and still conform: a requester MUST ignore a type it does not recognise (§9.3), and goes on waiting for the answer.

9.7.2 What may not be added without a version #

A change to the meaning of an existing member or line. A mode beyond open and save. A second answer, or a line from the requester after the request.

9.7.3 What may never be added #

A way for the chooser to hand over a file. Nothing on the channel may carry an open file, its contents, or anything but a path from the chooser to the requester. The requester opening what it is pointed at, as the person, is what lets any program use a chooser it does not trust.

9.8 Conformance

Peios / Peios technical reference / PSPU / File Choosing

9.8.1 A conforming requester #

Starting. Starts the chooser as its own child with the chooser's standard input and output connected to it, and sends the request as the first line (§9.3).

The request. Sends mode and title, a folder that is absolute if any, and filters with extensions without the dot (§9.4).

The answer. Uses a chosen path as the person, opening the file itself, and handles its having gone or the act being refused. Treats end-of-file without an answer as cancelled (§9.3, §9.5). Infers nothing from the chooser's exit status or standard error, and does not rely on the person choosing a file of a kind offered (§9.4).

Lines. Ignores a line type and a member it does not recognise (§9.3).

9.8.2 A conforming chooser #

Starting. Shows no dialog for a request it cannot use, and ends (§9.4). Starts in the folder given, or the person's home (§9.4).

Choosing. Offers the filters in order, the first chosen (§9.4). In save mode, asks before answering with a file that exists (§9.5). Never opens, creates or changes the chosen file to choose it (§9.5).

The answer. Sends at most one, a chosen with an absolute path or a cancelled, and ends. Answers cancelled when the person closes or cancels the dialog (§9.3, §9.5).

Ending. Ends promptly, without answering or asking, when its standard input closes (§9.3).

As the person. Acts on files and folders only as the person, and holds nothing beyond their authority. Asks the system what they may do before offering it, and shows what they may not, with the reason. Shows a folder they may not list as such. Asks before deleting (§9.6).

Lines. Ignores a member it does not recognise (§9.3).

Appendix 9.A Message Reference

Peios / Peios technical reference / PSPU / File Choosing

DirectionLineDefined in
Requester → chooserThe request (first line, no type)§9.4
Chooser → requester{"type":"chosen","path":…}§9.5
Chooser → requester{"type":"cancelled"}§9.5

9.A.1 An example #

A registry editor exports a key. The person goes into a folder, makes a folder called Backups inside it, opens it, keeps the suggested name and saves. Lines are shown on several lines here for reading. On the channel each is one line.

→ {"mode":"save","title":"Export Services",
   "purpose":"The key and everything under it is written to a registry document.",
   "folder":"/home/dana","name":"Services.json",
   "filters":[{"name":"Registry documents","extensions":["json"]}]}
← {"type":"chosen","path":"/home/dana/Backups/Services.json"}

The chooser ends, and the registry editor writes the file.

10.1 Scope and Roles

Peios / Peios technical reference / PSPU / Local Principal Administration

This chapter specifies PLPS, the protocol on which a program administers a local principal store: the users and groups a machine holds for itself, their memberships, their credentials and their claims. It is spoken to the daemon that owns the store, never to the store's file.

Two roles participate.

The store daemon owns the store. It is the store's only reader and only writer, it decides who may administer it, and it applies each request or refuses it. On Peios it is lpsd, which is also the machine's local principal source on PSI (§2): the store it administers here is the one it answers for there.

The administrator client is a program that asks: a command-line tool such as lps, a desktop app such as Principals Manager, or a provisioning tool. It holds no state of its own and no privilege of its own; everything it does is a request the store daemon decides whether to honour. Any program MAY be an administrator client, and a third party writing one is the case this chapter is written for.

10.1.1 Why a daemon, and not the file #

The store holds every local principal's credential verifier, so its descriptor admits the store daemon and nothing else. A tool that edited the file would need that descriptor widened to whatever it runs as, undoing the one thing it exists to do. Going through the daemon keeps one writer, makes "an administrator may do this" a check on the caller's token rather than a file permission, and lets the daemon validate every change before anything is written.

This chapter covers:

  • the channel: where it is, who may connect, and the one exchange each connection carries (§10.3)
  • message framing, shared with PGSS Logon (§10.4)
  • the requests: principals (§10.5), groups and membership (§10.6), claims (§10.7), and SSH keys and credential policy (§10.8)
  • the replies, and how a request is refused (§10.9)
  • the obligations binding on each role (§10.10)
  • the self socket, on which any principal reads their own account and sets their own display name (§10.11)

This chapter does not cover:

  • How the store is kept on disk, or how a credential verifier is derived — the store daemon's own design.
  • Signing in, and a principal changing their own password or adding or removing their own SSH keys. Those are PGSS Logon (PGSS §2.20 and PGSS §2.23), and reach the store through PSI (§2.21, §2.23).
  • Looking other principals up. Anyone may ask who a name or a SID is, and who is in a group, on the authority's identity socket (PGSS §2.14); a client that only reads about principals in general SHOULD read there rather than on the admin socket, since that socket answers everyone. A principal reading their own account, including what the identity socket does not carry — their credential policy and SSH keys — reads it on the self socket (§10.11).
  • What policy applies to a principal at sign-in, such as its privileges. That is the authority's (PGSS §2.13).

10.2 Terminology

Peios / Peios technical reference / PSPU / Local Principal Administration

Store. The set of local principals and local groups one store daemon holds, with their memberships, credentials, claims and SSH keys.

Principal. A user or service account the store holds. It has a name, unique in the store and matched without regard to case, and a RID (relative identifier): the last sub-authority of its SID. A RID is never reissued, so a deleted principal's SID never names anyone else.

Domain SID. The SID every principal and local group of the store is under, S-1-5-21-A-B-C. A principal's SID is the domain SID followed by its RID.

Local group. A group the store holds, named and numbered as a principal is, from the same RID space.

Well-known group. A group whose SID is fixed by the system rather than issued by any store: Everyone, Authenticated Users, and the BUILTIN groups such as Administrators. The store holds none of them as objects, but it records memberships of the BUILTIN groups (§2.19).

Group name, as written. Where a request names a group (§10.6), the store daemon resolves it, never the client: a well-known group by its name (Administrators, with or without BUILTIN\), a local group by its name, or any group by its SID written out (S-1-5-32-544). A client MUST pass what the person wrote, and MUST NOT resolve it itself; a client with its own table of well-known names would be a second implementation of the machine's identity scheme, free to disagree with the first.

Effective Unix ID. The POSIX identifier a principal or group projects to: the authority's base for the store's range plus the store's relative number (§2.20). Replies here carry the effective number, so that what an administrator is shown is what the principal will appear as. Zero means the store daemon cannot say.

Credential policy. Which credentials a principal may sign in with (§10.8): a password, an SSH public key, either, none required, or none accepted.

10.3 The Channel

Peios / Peios technical reference / PSPU / Local Principal Administration

10.3.1 The socket #

The store daemon listens on a Unix stream socket. On Peios it is /run/lpsd/admin.sock.

The socket's descriptor SHOULD admit exactly those the store daemon would (§10.3, Who may administer), so that a caller it would refuse is refused at connect rather than after sending a request. That descriptor is load-bearing rather than a convenience: the store daemon reads the caller's token from the connection, and a socket open to everyone would let anyone hold a connection open against its timeouts.

A client MUST treat a connect refused for permission as the store daemon refusing it, exactly as Denied (§10.9).

10.3.2 One request a connection #

Each connection carries one exchange: the client sends one request, the store daemon sends one reply, and the connection is finished. There is no pipelining, no conversation identifier and nothing to demultiplex. The store daemon MUST close the connection after replying, and MUST NOT read a second request from it.

A client MUST open a connection for each request. It MUST NOT hold one open waiting to use it later: the store daemon closes a connection that sends nothing (below).

Every request is answered exactly once, with its own reply (§10.A) or with Failed (§10.9). A client MUST treat a connection closed without a reply as the request's outcome being unknown: a change MAY have been applied. It SHOULD read the store again before telling a person the change failed.

10.3.3 Who may administer #

The store daemon decides who may administer the store from the caller's token, read from the connection, and it MUST decide before reading the request. On Peios a caller may administer the store if its token's user is SYSTEM, or if it carries BUILTIN\Administrators enabled. A group present but deny-only grants nothing, and MUST NOT be treated as membership.

SYSTEM is admitted because a machine's first account is created by a boot-time service, before any person exists to be an administrator.

There is nothing finer. A caller that may administer the store may make every request on this socket; one that may not, may make none. In particular a principal may not change their own profile here. What a principal may do about their own account is served elsewhere: their display name and a view of their account on the self socket (§10.11), and their password and SSH keys through PGSS Logon (PGSS §2.20 and PGSS §2.23).

A store daemon refusing a caller MUST reply Failed with Denied and then close the connection.

10.3.4 Timeouts #

A store daemon SHOULD bound how long it waits for the request and how long it spends sending the reply. On Peios each is five seconds.

A client SHOULD wait for a reply far longer than that. A request that sets a password makes the store daemon derive a verifier, and a store daemon that also answers PSI may serve a sign-in first; on Peios lps waits thirty seconds.

10.3.5 Credentials on the wire #

Add and SetPassword (§10.5) carry a password in the clear. On a kernel-mediated local socket, anything able to read those bytes could as well read the memory of the process that typed them; what must never travel is anything a verifier could be recomputed from, and nothing here does. Both parties SHOULD wipe a buffer that held a password once they are finished with it.

10.4 Message Framing

Peios / Peios technical reference / PSPU / Local Principal Administration

10.4.1 Header #

Every message begins with PGSS Logon's 12-byte header (PGSS §2.6), unchanged, with this chapter's magic:

OffsetSizeFieldValue
04magicPLPS (50 4c 50 53)
42version1
62msg_typeSee §10.A
84total_lenHeader plus body, in bytes

The magic MUST be checked on every message, and MUST end the exchange when wrong. PLPS shares PGSS Logon's codec and some of its structures, so a socket plugged into the wrong daemon would otherwise decode several fields before going wrong.

The high bit of msg_type marks a message sent by the store daemon, which is the authority for its own store. A party MUST check the type it received against the one it expected, not only against that bit.

10.4.2 Size limit #

A message MUST NOT exceed 2 MiB (2,097,152 bytes) in total, larger than PGSS Logon's limit so that Principals can carry a whole store's listing (§10.5). A party MUST reject a header declaring more without reading the body.

10.4.3 Encoding #

Integers, strings, byte strings, arrays and length-framed structures are encoded exactly as PGSS §2.6 specifies, and a body is one length-framed structure. The two length mechanisms are the ones that section warns against confusing: a string or byte string is a u32 byte count and its bytes; a structure or array element is a u32 byte count of the whole structure, and a decoder skips to that end after the fields it knows.

Booleans are a u8: zero is false, anything else is true.

SIDs are byte strings carrying the binary SID (PCDS §4), at most 68 bytes.

An optional string is a u8 presence flag followed by a string: the string is meaningful only where the flag is non-zero. It exists where "leave this alone" and "set this to empty" are both requests a client makes, and PGSS's empty-means-absent rule could not tell them apart.

A claim is encoded as PSI encodes one (§2.13): a length-framed structure of name, flags, value_type and values. Its value types and flags are PCDS §5.9's, and are closed here as they are on PSI (§2.A).

10.4.4 Extending #

The rules of PGSS §2.6 bind this chapter: fields are appended only, an appended field is optional with a stated default, and a new value in an enumeration is a breaking change requiring a version bump.

A decoder that reaches the end of a structure where an appended field would have been MUST substitute that field's default. This chapter states each default where the field is defined; a client and a store daemon of different ages then degrade rather than fail.

10.5 Principals

Peios / Peios technical reference / PSPU / Local Principal Administration

Every request naming a principal names it by name, a string of at most 256 bytes, matched without regard to case. A store daemon MUST answer NotFound (§10.9) for a name it holds no principal by.

10.5.1 List #

msg_type = 0x0001. Empty body. Answered with Principals.

10.5.2 Principals #

msg_type = 0x8001. Every principal in the store, as an array of at most 4096 length-framed entries:

FieldEncodingDefault if absent
namestring, 256 bytes
ridu32
enabledboolean
groupsu32: how many groups it is listed in
unix_idu32: its effective Unix ID (§10.2)0

groups counts listed memberships only, not the primary group.

10.5.3 Show #

msg_type = 0x0002. Body: name. Answered with Principal.

10.5.4 Principal #

msg_type = 0x8002. One principal in full:

FieldEncodingDefault if absent
namestring, 256 bytes
ridu32
enabledboolean
sidSID
groupsarray of at most 128 group references
unix_idu32: effective0
primary_groupone length-framed group referencean empty reference
homestring, 4096 bytesempty
shellstring, 4096 bytesempty
display_namestring, 256 bytesempty
claimsarray of at most 64 claims (§10.4)none
permitted_logon_typesu32, as on Add0, not stated
credential_policyu8 (§10.8)not said

credential_policy is closed, as §10.8 says: a client MUST treat a value it does not know as a reply it cannot read. A client given no credential_policy MUST NOT show one; the store daemon did not say.

A group reference is a length-framed structure:

FieldEncodingDefault if absent
sidSID
namestring, 256 bytes: what this machine calls the groupempty
unix_idu32: its effective Unix ID, or zero0

The store daemon names each group, a well-known one included, and gives its number where it knows it, so a client never builds a SID or resolves a name itself. An empty name means the store daemon knows no name for the SID; zero means it cannot say the number.

10.5.5 Domain #

msg_type = 0x0003. Empty body. Answered with DomainIs.

10.5.6 DomainIs #

msg_type = 0x8003. Body: sid, the store's domain SID (§10.2).

Domain is the smallest request there is, changes nothing, and is refused to anyone who may not administer the store (§10.3). A client MAY send it to find out whether its caller may administer the store, before offering to change anything.

10.5.7 Add #

msg_type = 0x0004. Creates a principal. Answered with Created carrying its RID.

FieldEncodingDefault if absent
namestring, 256 bytes
credential_kindu8: 0 none, 1 a password
secretbyte string, 4096 bytes
enabledboolean
groupsarray of at most 128 length-framed strings: groups as written (§10.2)
permitted_logon_typesu320, not stated
primary_groupoptional string (§10.4), 256 bytes: a group as writtenabsent
homeoptional string, 4096 bytesabsent
shelloptional string, 4096 bytesabsent
display_nameoptional string, 256 bytesabsent

credential_kind says plainly whether the principal has a password, rather than an empty secret meaning none, so that a client cannot make a principal that needs no credential by accident. secret is present either way, and empty for 0. A store daemon MUST refuse an empty secret with kind 1 as Invalid, and MUST refuse a kind it does not know.

A principal made with kind 0 has the credential policy NoCredential (§10.8): it signs in with nothing collected. One made with kind 1 has the policy Password.

permitted_logon_types is the set of PGSS §2.16's, as PSI carries it (§2.13). Zero is not stated, which the authority reads as its default: every kind of sign-in a person could use, and never a service sign-in.

The principal gets the store's next RID. Where primary_group, home, shell or display_name is absent, it gets what the store daemon chooses (on Peios, Authenticated Users, /home/<name>, /bin/sh and no display name). A present field is checked as SetProfile and SetPrimaryGroup check it, and a store daemon MUST refuse the whole request if it refuses any field, creating nothing: a principal is made whole or not at all.

A store daemon that predates the last four fields ignores them, and makes the principal with its own choices. A client cannot tell that from the reply; one that must know reads the principal back with Show.

A store daemon MUST refuse a name it already holds, a principal's or a local group's, as Exists, and a name it will not accept as Invalid with the reason. Which names it accepts is its own. On Peios a name is 1 to 256 bytes of printable ASCII, may not contain @ \ / : ,, and may not be a well-known group's name.

10.5.8 Remove #

msg_type = 0x0005. Body: name. Deletes the principal. Answered with Done.

Its RID is not reissued, so its SID keeps appearing in the descriptors of whatever it owned, naming nobody. A client SHOULD say so before removing one, and SHOULD offer disabling instead.

10.5.9 SetEnabled #

msg_type = 0x0006. Body: name, then enabled, a boolean. Answered with Done. A disabled principal keeps everything, and cannot sign in.

10.5.10 SetPassword #

msg_type = 0x0007. Body: name, then secret, a byte string of at most 4096 bytes, never empty. Sets the principal's password. Answered with Done.

It does not change the principal's credential policy (§10.8). A principal whose policy accepts no password is not given one by this request: a client that means the password to be used MUST also send CredentialPolicy.

10.5.11 SetProfile #

msg_type = 0x000d. Changes part of a principal's profile, leaving the rest alone. Answered with Done.

FieldEncoding
namestring, 256 bytes
homeoptional string (§10.4), 4096 bytes
shelloptional string, 4096 bytes
display_nameoptional string, 256 bytes

An absent field is left as it is. A present empty display_name clears it. A store daemon MAY refuse a value it will not accept as Invalid; on Peios home and shell must be absolute paths.

10.5.12 Rename #

msg_type = 0x0015. Changes what a principal is called. Answered with Done.

FieldEncoding
namestring, 256 bytes: the principal
new_namestring, 256 bytes: what it is to be called

Its SID, RID and Unix ID stay, so everything that names it by SID, every descriptor and every token already minted, still does. Its home directory stays too: a store daemon MUST NOT change home on a rename. A client that means the home directory to follow sends SetProfile as well.

A store daemon MUST refuse a new_name another principal or a local group holds as Exists, and applies the rules for a name as Add does. A new_name differing from the principal's name only in case is a rename; one equal to it is Done and changes nothing.

10.5.13 SetLogonTypes #

msg_type = 0x0016. Sets which kinds of sign-in a principal may be used for. Answered with Done.

FieldEncoding
namestring, 256 bytes
permitted_logon_typesu32, as on Add

Zero returns the principal to not stated, the authority's default. There is no value meaning "no sign-in at all"; a client disables the principal for that. A store daemon MUST keep bits for logon types it does not know as they are given, as it does on Add: the authority checks the types it knows.

10.5.14 Last administrator #

A store daemon MUST refuse, as Invalid, a request that would leave the store with no enabled principal in BUILTIN\Administrators who can sign in: removing or disabling the last one, removing the last one from that group (§10.6), setting the last one's permitted_logon_types to a set that permits none of interactive, remote interactive or network sign-in, or leaving the last one nothing to sign in with (§10.8): a CredentialPolicy it would not have something to sign in with under, or a KeyRemove of the last key it signs in with. A machine left so has nobody to administer it.

10.6 Groups and Membership

Peios / Peios technical reference / PSPU / Local Principal Administration

10.6.1 GroupList #

msg_type = 0x000a. Empty body. Answered with Groups.

10.6.2 Groups #

msg_type = 0x8007. Every local group in the store, as an array of at most 4096 length-framed entries:

FieldEncodingDefault if absent
namestring, 256 bytes
ridu32
unix_idu32: its effective Unix ID (§10.2)
sidSID
membersu32: how many principals list it
descriptionstring, 1024 bytesempty

The well-known groups are not in it: the store holds none of them as objects (§10.2). members counts listed memberships, not principals whose primary group it is; the identity socket's MEMBERS counts both (PGSS §2.16).

A group's description says what it is for, on one line, for a person to read; empty is none. Anyone may read it, on the identity socket's DESCRIPTION (PGSS §2.16), where a store daemon that is a principal source answers it.

10.6.3 GroupCreate #

msg_type = 0x000b. Creates a local group, with the store's next RID. Answered with Created carrying it.

FieldEncodingDefault if absent
namestring, 256 bytes
descriptionstring, 1024 bytesempty

A store daemon MUST refuse a name it already holds, a principal's or a group's, as Exists, and applies the same rules to a group's name as to a principal's (§10.5). It MAY refuse a description it will not keep as Invalid; on Peios one may hold no control characters, and is kept with the spaces at its ends removed.

A store daemon that predates description ignores it, and makes the group with none, as Add describes (§10.5).

10.6.4 GroupRename #

msg_type = 0x0017. Body: name, a local group's, then new_name, as Rename (§10.5). Changes what the group is called. Answered with Done.

Its SID, RID and Unix ID stay, and so does every membership of it, since a membership names the group by SID. A store daemon MUST answer NotFound where it holds no local group by name, and refuses new_name as Rename does.

10.6.5 GroupDescribe #

msg_type = 0x0018. Sets a local group's description. Answered with Done.

FieldEncoding
namestring, 256 bytes: a local group's
descriptionstring, 1024 bytes; empty clears it

A store daemon MUST answer NotFound where it holds no local group by name. The well-known groups' descriptions are the authority's, and cannot be set here.

10.6.6 GroupDelete #

msg_type = 0x000c. Body: name, a local group's. Deletes it. Answered with Done.

A store daemon MUST refuse, as Invalid, deleting a group any principal lists or has as its primary group: a principal would be left in a group that no longer exists. A client removes the members first.

A store daemon MUST answer NotFound where it holds no local group by name, a well-known group's name included.

10.6.7 GroupAdd and GroupRemove #

GroupAdd, msg_type = 0x0008, puts a principal in a group; GroupRemove, msg_type = 0x0009, takes it out. Both are answered with Done.

FieldEncoding
namestring, 256 bytes: the principal
groupstring, 256 bytes: the group as written (§10.2)

name MUST name a principal: groups do not nest, and a store daemon MUST answer NotFound for a group's name there.

group MAY name a local group, a BUILTIN group, or any group by its SID. A membership of a BUILTIN group is recorded here on the machine's behalf, and asserted at sign-in under PSI's membership-scope exception (§2.19); it is how a principal becomes an administrator. A membership of a group in another domain is recorded as given.

Both are idempotent: adding a membership already held, or removing one not held, is Done and changes nothing. A principal MAY be listed in at most 128 groups; a store daemon MUST refuse a 129th as Invalid.

Removing a principal from BUILTIN\Administrators is refused if it is the last enabled one there (§10.5).

A store daemon MUST answer NotFound for a group that names nothing it can resolve.

10.6.8 SetPrimaryGroup #

msg_type = 0x000e. Body: as GroupAdd. Makes group the principal's primary group: the one it projects to as its POSIX group ID. Answered with Done.

A primary group is a membership (§2.13): the principal is in it whether or not it is also listed.

10.7 Claims

Peios / Peios technical reference / PSPU / Local Principal Administration

A principal's claims are named, typed attributes that the authority puts on the tokens it mints for them, and that conditional ACEs are evaluated against (PCDS §5.9). Principal carries them (§10.5).

10.7.1 SetClaim #

msg_type = 0x000f. Sets a claim, replacing one of the same name. Answered with Done.

FieldEncoding
namestring, 256 bytes: the principal
claimone claim (§10.4)

Claim names match without regard to case. A store daemon MUST refuse, as Invalid, a claim PSI could not carry to the authority (§2.13): a name empty or longer than 255 bytes or containing a NUL, a flag or value type it does not recognise, more than 64 values, or a string or octet value longer than 1024 bytes. It MUST refuse a 65th claim on one principal.

10.7.2 RemoveClaim #

msg_type = 0x0010. Removes a claim. Answered with Done.

FieldEncoding
namestring, 256 bytes: the principal
claim_namestring, 255 bytes

Removing a claim the principal does not hold is Done and changes nothing.

10.8 Keys and Credential Policy

Peios / Peios technical reference / PSPU / Local Principal Administration

A principal's credential policy says which credentials it may sign in with. Its SSH public keys are what it may sign in with by key (PGSS §2.D). The two are separate on purpose: adding a key does not let anyone sign in with it until the policy says keys are accepted, so enrolling a key and allowing key sign-in are two decisions, and neither happens as a side effect of the other.

10.8.1 Credential policies #

ValuePolicySigns in with
0Deniednothing: every sign-in is refused
1Passwordits password
2SshPublicKeyone of its SSH keys
3PasswordOrKeyeither
4NoCredentialnothing collected (§10.5, Add)

The enumeration is closed (§10.4). A store daemon MUST refuse a value it does not know.

10.8.2 KeyList #

msg_type = 0x0011. Body: name. Answered with Keys.

10.8.3 Keys #

msg_type = 0x8008. The principal's credential policy and keys:

FieldEncoding
policyu8
keysarray of at most 32 length-framed keys

A key:

FieldEncoding
idbyte string, 16 bytes: the store daemon's, never all zero
fingerprintstring, 128 bytes: as ssh-keygen -l writes one
labelstring, 128 bytes
createdu64: seconds since the Unix epoch

Detail (§10.5) answers the credential policy too, as a trailing optional field; KeyList answers it from every store daemon.

10.8.4 KeyAdd #

msg_type = 0x0012. Enrols an SSH public key. Answered with Done.

FieldEncoding
namestring, 256 bytes
public_keystring, 16384 bytes: one line of an OpenSSH authorized_keys file
labelstring, 128 bytes, no control characters

A store daemon MUST refuse, as Invalid, a key it cannot read or does not support, a key the principal already has, a 33rd key, and a label too long or carrying a control character. It does not change the policy.

10.8.5 KeyRemove #

msg_type = 0x0013. Body: name, then id, a byte string of 16 bytes. Removes the key. Answered with Done, or NotFound for an id the principal has no key by. It is refused as the last-administrator rule says (§10.5).

10.8.6 CredentialPolicy #

msg_type = 0x0014. Body: name, then policy, a u8. Sets the principal's credential policy. Answered with Done. It is refused as the last-administrator rule says (§10.5).

A principal has something to sign in with when its policy is NoCredential; Password and it has a password; SshPublicKey and it has a key; or PasswordOrKey and it has either. Denied never does.

10.9 Replies and Refusal

Peios / Peios technical reference / PSPU / Local Principal Administration

Every request has exactly one successful reply, listed in §10.A, and any request may instead be answered with Failed.

10.9.1 Created #

msg_type = 0x8004. Body: rid, a u32. Answers Add and GroupCreate: the two requests that allocate a RID, and so have something to report.

10.9.2 Done #

msg_type = 0x8005. Empty body. Answers every other request that changes the store.

10.9.3 Failed #

msg_type = 0x8006. The request was refused.

FieldEncoding
failureu32: why, from the table below
reasonstring, 512 bytes: why, in words
ValueFailureMeans
1DeniedThe caller may not administer the store (§10.3).
2NotFoundNothing in the store by the name, or the key, given.
3ExistsThe name asked for is already held.
4InvalidWell-formed, and asking for something the store will not do.
5InternalThe store could not be read or written.

The enumeration is closed (§10.4). failure is for a client that branches on it; reason is for a person, and a client SHOULD show it as it is rather than interpret it.

A store daemon MUST answer a request it cannot decode, or whose msg_type this chapter does not define, with Invalid.

There is no failure for "you may not see that". Administering is not authenticating, and a caller already allowed to administer the store has nothing to learn from a refusal that it could not learn by listing.

10.9.4 A change is applied whole, or not at all #

A store daemon MUST NOT acknowledge a change, with Created or Done, before it has been made durable. A change that cannot be saved MUST be undone and answered Internal; a refused request MUST leave the store as it was.

Every change acknowledged is also announced to the authority, as PSI's Changed (§2.17), so that what it answers next reflects it. A client here is told nothing when somebody else changes the store: one that shows the store SHOULD read it again after its own changes and from time to time.

10.10 Conformance

Peios / Peios technical reference / PSPU / Local Principal Administration

10.10.1 A conforming store daemon #

The channel. Decides from the caller's token, before reading the request, whether the caller may administer the store, admitting SYSTEM and a token carrying BUILTIN\Administrators enabled, and treating a deny-only group as no membership; refuses anyone else with Denied (§10.3). Serves one request a connection, answers it exactly once, and closes (§10.3).

Framing. Checks the magic and the version of every message, refuses one over 2 MiB unread, and substitutes the stated default for an appended field a peer did not send (§10.4).

Requests. Answers each with its own reply (§10.A) or Failed, and a request it cannot decode or does not define with Invalid (§10.9). Resolves a group as written itself (§10.2). Answers NotFound for a principal, group or key it does not hold, Exists for a name already held, and Invalid, with the reason, for anything it will not do (§10.5 to §10.9).

Its rules. Refuses to leave the store with no enabled administrator who can sign in (§10.5); to delete a group anyone is in or has as their primary group (§10.6); a claim PSI could not carry (§10.7); and a key it cannot read, or holds already (§10.8). Gives a principal made without a password the policy NoCredential, and one made with one Password (§10.5). Makes a principal whole or not at all, refusing an Add if it refuses any field of it (§10.5). Keeps a renamed principal's SID, RID, Unix ID and home directory (§10.5), and a renamed group's SID, RID, Unix ID and memberships (§10.6). Holds one name for one principal or local group, matched without regard to case (§10.5, §10.6). Never reissues a RID (§10.2).

Changes. Acknowledges a change only once it is durable, undoes one it cannot save, leaves the store untouched on a refusal, and announces every change to the authority (§10.9).

10.10.2 A conforming administrator client #

The channel. Opens a connection for each request and holds none open (§10.3). Treats a connection refused for permission as Denied, and a connection closed without a reply as an outcome unknown, reading the store again before saying the change failed (§10.3).

Requests. Passes a group as the person wrote it, resolving nothing itself (§10.2). Sends credential_kind 0 only where a principal is meant to need no credential (§10.5). Sends CredentialPolicy alongside SetPassword where the password is meant to be used (§10.5). Reads a principal back with Show where it must know that an Add's profile was applied (§10.5). Shows no credential policy where the store daemon gave none (§10.5).

Refusals. Shows a refusal's reason as it is (§10.9).

Reading. Reads principals for display on the identity socket where it need not administer them (§10.1), and reads the store again after its own changes, since nothing tells it of anyone else's (§10.9).

10.10.3 The self socket #

The obligations of a store daemon serving the self socket, and of its clients, are collected in §10.11.

10.11 The Self Socket

Peios / Peios technical reference / PSPU / Local Principal Administration

The admin socket (§10.3) is for administrators, and every request on it names the principal it is about. A principal also needs to see their own account — what they are called, what they sign in with, which SSH keys they have — and to set what they are called, without being an administrator. The self socket serves that: any authenticated principal may connect to it, and no request on it names anybody.

Changing a credential is not here. A display name is not a credential, so setting it asks for no proof; a password or an SSH key is one, so changing either goes through PGSS Logon, where the principal's current password is asked for (PGSS §2.20 and PGSS §2.23).

10.11.1 The socket #

The store daemon listens on a second Unix stream socket. On Peios it is /run/lpsd/self.sock.

Its descriptor MUST admit every authenticated principal to connect. On Peios it is D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;0x100082;;;AU): SYSTEM and Administrators with full access, and Authenticated Users with exactly what a connect needs (FILE_WRITE_DATA, READ_ATTRIBUTES and SYNCHRONIZE), the mask Peios grants them on /run/logon.sock. The store daemon sets it when it creates the socket and reads it back; the socket's directory admits nobody the socket is for, so a socket that inherited it would be closed to everyone it exists to serve.

10.11.2 The subject is the peer #

The subject of every request is the user of the connected peer's token, read by the store daemon from the connection when it accepts it. No request carries a name, a SID or any other way to point at a principal, and the store daemon MUST NOT answer about, or change, any principal but that one.

A peer whose user the store does not hold — SYSTEM, a service identity, a principal another source holds — is refused with NotFound and a reason saying so. It is never shown, or allowed to change, somebody else's account.

10.11.3 Framing #

Messages are PLPS's (§10.4): the same header, magic, encoding and Failed and Done replies (§10.9). The requests are in a range of their own, 0x0040 upward, with their reply at 0x8040. Each socket MUST refuse the other's requests with Invalid: the self socket is not a second way to administer the store, and the admin socket does not answer about its caller.

A request on the self socket MUST NOT exceed 4096 bytes. The store daemon MUST refuse, from its header and without reading the body, one that declares more, answering Invalid. Replies are bounded by PLPS's ceiling as on the admin socket.

10.11.4 One request a connection, never served blocking #

Each connection carries one exchange, as on the admin socket (§10.3).

The admin socket admits only administrators, so a store daemon MAY serve it a connection at a time. The self socket admits everyone, and a store daemon that also answers PSI (§2) MUST NOT let a connection on it delay anything else: a principal who connected and sent nothing would otherwise stall every sign-in behind them. A store daemon MUST serve the self socket without blocking on any one connection, MUST bound how long a connection may remain open from accept to the last byte of its reply, and MUST bound the connections open at once and the connections any one user, by the token's user SID, holds. A caller over a bound is refused, with Failed where that will not block, and the connection closed.

On Peios lpsd holds each connection non-blocking in a table its poll loop drives, and drops one at 5 seconds after accept. It holds at most 32 at once and 4 from any one user.

10.11.5 ShowSelf #

msg_type = 0x0040. Empty body. Answered with Self.

10.11.6 Self #

msg_type = 0x8040. The caller's own account.

FieldEncoding
namestring, 256 bytes
sidbyte string, 68 bytes
display_namestring, 256 bytes; empty when unset
enabledboolean
policyu8: the credential policy (§10.8)
has_passwordboolean: whether the account has a password at all
keysarray of at most 32 length-framed keys

A key:

FieldEncoding
idbyte string, 16 bytes: as Keys gives it (§10.8)
fingerprintstring, 128 bytes: as ssh-keygen -l writes one
algorithmstring, 64 bytes: the key's type, such as ssh-ed25519
labelstring, 128 bytes
createdu64: seconds since the Unix epoch

has_password is separate from policy because a policy can name a password the account has not got, and a principal adding or removing a key must prove a password (PGSS §2.23). A client SHOULD offer key changes only where the account has one.

This is what Show and KeyList give an administrator about the same principal, less what is the administrator's to manage. Nothing in it is a verifier, a key's private half or anything a credential could be recomputed from.

10.11.7 SetDisplayName #

msg_type = 0x0041. Answered with Done.

FieldEncoding
display_namestring, 256 bytes; empty clears it

The store daemon MUST validate it exactly as it validates SetProfile's display_name (§10.5), refusing what that refuses with Invalid. It MUST refuse with Denied a principal that is disabled: a session that outlived its account's disabling is not a way to change the account.

The change is applied as any other (§10.9): made durable before Done, undone and answered Internal if it cannot be saved, and announced to the authority as PSI's Changed (§2.17) so that its identity channel answers with the new name at once. A request that changes nothing — the name is already that — MAY be answered Done without writing.

10.11.8 Conformance #

A store daemon serving the self socket admits every authenticated principal to connect to it; answers every request about the peer's token's user and nobody else, and refuses a peer it does not hold with NotFound; refuses the admin socket's requests with Invalid, and a request over 4096 bytes from its header; never blocks on one connection, and bounds each connection's time, the connections open, and each user's connections; validates SetDisplayName as SetProfile, refuses it for a disabled principal, and makes, undoes and announces it as §10.9 requires.

A client of the self socket names nobody, since there is no field to; opens a connection for each request; treats a connect refused for permission as Denied; and changes credentials through PGSS Logon, not here.

Appendix 10.A Message Reference

Peios / Peios technical reference / PSPU / Local Principal Administration

10.A.1 Messages #

msg_typeMessageDirectionAnswered withDefined in
0x0001Listclient → store daemonPrincipals§10.5
0x0002Showclient → store daemonPrincipal§10.5
0x0003Domainclient → store daemonDomainIs§10.5
0x0004Addclient → store daemonCreated§10.5
0x0005Removeclient → store daemonDone§10.5
0x0006SetEnabledclient → store daemonDone§10.5
0x0007SetPasswordclient → store daemonDone§10.5
0x0008GroupAddclient → store daemonDone§10.6
0x0009GroupRemoveclient → store daemonDone§10.6
0x000aGroupListclient → store daemonGroups§10.6
0x000bGroupCreateclient → store daemonCreated§10.6
0x000cGroupDeleteclient → store daemonDone§10.6
0x000dSetProfileclient → store daemonDone§10.5
0x000eSetPrimaryGroupclient → store daemonDone§10.6
0x000fSetClaimclient → store daemonDone§10.7
0x0010RemoveClaimclient → store daemonDone§10.7
0x0011KeyListclient → store daemonKeys§10.8
0x0012KeyAddclient → store daemonDone§10.8
0x0013KeyRemoveclient → store daemonDone§10.8
0x0014CredentialPolicyclient → store daemonDone§10.8
0x0015Renameclient → store daemonDone§10.5
0x0016SetLogonTypesclient → store daemonDone§10.5
0x0017GroupRenameclient → store daemonDone§10.6
0x0018GroupDescribeclient → store daemonDone§10.6
0x8001Principalsstore daemon → client§10.5
0x8002Principalstore daemon → client§10.5
0x8003DomainIsstore daemon → client§10.5
0x8004Createdstore daemon → client§10.9
0x8005Donestore daemon → client§10.9
0x8006Failedstore daemon → client§10.9
0x8007Groupsstore daemon → client§10.6
0x8008Keysstore daemon → client§10.8

Any request may be answered with Failed instead.

10.A.1.1 On the self socket #

msg_typeMessageDirectionAnswered withDefined in
0x0040ShowSelfclient → store daemonSelf§10.11
0x0041SetDisplayNameclient → store daemonDone§10.11
0x8040Selfstore daemon → client§10.11

Done and Failed (§10.9) answer here too. Each socket refuses the other's requests with Invalid.

10.A.2 Protocol constants #

ConstantValueDefined in
Socket path/run/lpsd/admin.sock on Peios§10.3
Self socket path/run/lpsd/self.sock on Peios§10.11
Largest self-socket request4096 bytes§10.11
MagicPLPS (50 4c 50 53)§10.4
Version1§10.4
Header size12 bytes§10.4
Maximum message size2,097,152 bytes§10.4
Requests a connection1§10.3

10.A.3 Field limits #

FieldLimit
A principal's or group's name256 bytes
A password4096 bytes, never empty
home, shell4096 bytes
display_name256 bytes
A group's description1024 bytes
A principal's listed groups128
Principals in Principals4096
Groups in Groups4096
A SID68 bytes
Claims on a principal64
A claim's name255 bytes
A claim's values64
A string or octet claim value1024 bytes
SSH keys on a principal32
An SSH public key16384 bytes
A key's label128 bytes
A key's fingerprint128 bytes
A key's algorithm64 bytes
A refusal's reason512 bytes

10.A.4 Enumerations #

EnumerationDefined in
Failure§10.9
Credential policy§10.8
Logon typesPGSS §2.16, carried as PSI carries them (§2.13)
Claim value types and flagsPCDS §5.9, closed as on PSI (§2.A)

1.1 Overview

Peios / Peios technical reference / PKM / Introduction

The Peios kernel is a Linux kernel with Peios compiled into it. Peios contributes three things to the tree it is built from: a patch series of around fifty patches against existing kernel files, a new security/pkm subtree, and a new fs/stratafs subtree. None of it is loadable. CONFIG_SECURITY_PKM and CONFIG_STRATAFS_FS are both boolean options, so what they build is linked into vmlinux; there is no module to insert and none to unload.

security/pkm builds as PKM, the Peios Kernel Module — the name predates the decision to build in, and the subsystem still carries it. PKM registers as a Linux Security Module, provides the syscalls in the PKM range, and holds three of the four subsystems described here. The fourth, stratafs, is an ordinary in-tree filesystem that sits beside PKM rather than inside it, and reaches PKM through a kernel-private header that exports no symbols to modules.

The four subsystems are peers.

KMES, the Kernel Mediated Event Subsystem, is the sole event emission path in Peios. Kernel subsystems and userspace processes alike emit events exclusively through KMES; it stamps each event with trusted metadata, buffers it in per-CPU ring buffers, and delivers it to userspace consumers through shared memory.

KACS, the Kernel Access Control System, is the security core: SIDs, tokens, security descriptors, privileges, impersonation, process protection, binary signature verification, and the AccessCheck algorithm that ties them together. KACS also projects Peios identities onto Linux credentials so that unmodified Linux subsystems make decisions consistent with Peios policy.

stratafs is the layered filesystem. It composes an ordered stack of strata — ordinary directories, independently owned — into a single mounted tree, resolving each name in the highest-precedence stratum that holds it, routing each modification to the stratum that will accept it, and copying an object up into the designated create stratum when its own stratum will not. It stores nothing of its own, and delegates every access decision to KACS.

LCS, the Layered Configuration Subsystem, is the kernel half of the Peios registry. It owns the data model — hives, keys, values, and the precedence-ordered layers the name refers to — along with access control, watches, transactions, and the syscall and ioctl surface through which processes read and write configuration. It holds no storage of its own, delegating that to userspace sources over the Registry Source Interface.

This manual describes each subsystem in its own chapter, in the order above. They are peers, but not independent: KMES stamps events with identities it obtains from KACS, KACS emits its audit trail through KMES, LCS enforces access with KACS security descriptors, and stratafs consults KACS for every delegation decision. Cross-references between chapters mark these seams.

1.1.1 What this manual is #

This is a technical reference manual: an exhaustive description of the kernel as it is actually built. It documents observed behaviour — formats, algorithms, limits, failure modes — in plain indicative prose. It is not a standard, and it makes no conformance demands.

The contracts the kernel shares with other parties are specified elsewhere, in the Peios Core Specification Anthology: the binary structures that cross subsystem boundaries (GUIDs, SIDs, security descriptors) in PCDS, and the protocols the kernel speaks with userspace services and the formats they exchange — the registry source interface, the registry backup format, the event stream consumer protocol — in PSPK. Where a chapter touches one of those contracts, it references the specification rather than restating it, and describes only what this kernel adds: how the contract is implemented, and the behaviour on this side of the boundary.

Constants, ABI tables, and catalogues are collected in appendices at the end of the chapter they belong to, so that a chapter's reference material sits beside the prose that explains it.

For kernel build-time and initialization requirements, see Kernel invariants.

1.2 Kernel invariants

Peios / Peios technical reference / PKM / Introduction

The boot chain — kernel-direct tokens, peinit at PID 1, the authd handoff — relies on a handful of kernel-level invariants to be safe. Things like "PKM is in the LSM stack and no incompatible LSM is", "the kernel rejects unsigned kernel modules", "physical memory is restricted to what is appropriate". If these invariants are violated, the security model the rest of these docs describes is no longer guaranteed.

This page covers the invariants, where each is enforced, and the consequences of failure. They are not user-facing settings in the sense of "things you might change" — they are kernel build-time and init-time configurations that the deployment requires. But knowing what they are helps with reasoning about the trust chain and diagnosing the rare case where something doesn't fit the expected model.

1.2.1 The LSM stack invariant #

The Linux Security Module framework lets the kernel host multiple security modules in a defined order. Peios's KACS is implemented as an LSM called PKM (Peios Kernel Module). For PKM to function correctly:

  • PKM must be in the LSM stack.
  • MAC (Mandatory Access Control) LSMs must not be in the stack — specifically SELinux, AppArmor, SMACK, and TOMOYO.
  • The BPF LSM must not be active.

The reasoning: MAC LSMs and PKM both want to make authoritative access decisions, and the LSM framework's composition rules don't merge their decisions cleanly. A system with SELinux and PKM would have both modules trying to decide each access; the result would be that either both must agree (over-restrictive) or one supersedes the other (whichever the LSM order puts first), neither of which is the right semantic. Peios's model is that PKM is the sole authoritative module for the operations it covers.

1.2.1.1 What the kernel verifies #

The kernel verifies at PKM initialisation that no MAC LSM is active and that BPF LSM is not in use. If any are present, PKM refuses to activate. The kernel boots, but the access control is now broken — PKM is supposed to be enforcing, and it isn't. This is detected and reported as an init-time error.

Specifically, PKM refuses to activate if any of selinux, apparmor, smack, tomoyo, or bpf LSMs are present in the active stack.

1.2.1.2 The required stack #

The LSM stack Peios expects is, in order:

landlock, lockdown, integrity, pkm
  • commoncap is implicit (Linux always includes it; not listed in CONFIG_LSM explicitly).
  • landlock is permitted — it's a process-local sandboxing LSM that processes opt into; it doesn't conflict with PKM.
  • lockdown is permitted — it gates specific kernel-debugging interfaces; orthogonal to PKM.
  • integrity is permitted — Linux's IMA/EVM mechanisms can run alongside PKM for the use cases they cover.
  • pkm is the Peios module.

yama is not built. Its ptrace_scope decides attach ahead of PKM, and its escape hatch is CAP_SYS_PTRACE, which PKM maps to SeDebugPrivilege — so with it present, a process descriptor that grants debugging never gets a say. PKM's own ptrace gating is the whole authority.

Distributors building a Peios kernel must include this stack. Building a kernel with SELinux enabled and active would produce a system where PKM cannot activate — boot would fail with a clear error.

1.2.2 Module signing #

CONFIG_MODULE_SIG_FORCE=y is required. This flag tells the kernel to refuse to load any kernel module that is not signed by a key the kernel recognises.

The reasoning: a kernel module runs with full kernel privileges. An attacker who can load arbitrary kernel modules bypasses every layer of PIP and KACS — they can read memory directly, modify any data structure, disable any check. The defence against this is to refuse to load unsigned modules.

CONFIG_MODULE_SIG_FORCE=y is the kernel-level switch that enforces it. With the flag set, unsigned modules are rejected at load time; a malicious or buggy administrator with SeLoadDriverPrivilege cannot load a malicious module unless they have the signing key, and it is not on a running system.

The module-signing key is deliberately a different key from the TCB key that signs binaries, and is not chained to it. Both are compiled into the image, so neither can rotate independently and neither can be an offline root — the TCB key signs binaries at build time. Chaining them would mean that compromising the TCB key also yielded the ability to load kernel modules. Keeping them independent contains binary signing and module loading separately. Both are ML-DSA-65.

The flag is verified at kernel build time. A kernel built without it doesn't enforce the rule and is not a valid Peios kernel for security purposes; the PIP threat model relies on this enforcement.

User-visible effect: kernel modules from the Peios distribution work (they are signed); modules built ad-hoc on a running system do not load. Third-party kernel modules (a vendor driver, say) must be signed by a key the kernel trusts, which in practice means going through the Peios signing infrastructure to add them.

1.2.3 The module-loading helper #

CONFIG_MODPROBE_PATH="/bin/modprobe" is required, and the binary it names must be TCB-signed.

When the kernel needs a module it does not have — mounting a filesystem whose driver is not built in, opening a socket for an unknown protocol family, resolving a crypto algorithm by name — it does not load it itself. It spawns a userspace helper to do it, and runs that helper at its own authority.

Two things follow. The path must be right: the upstream default is /sbin/modprobe, which does not exist on Peios, and left at that default every kernel-initiated load fails to find the helper while reporting nothing that names the cause.

And the path is a writable sysctl, which makes it an escalation vector — whoever can write /proc/sys/kernel/modprobe gets code of their choosing executed with the kernel behind it. The defence is not to protect the sysctl but to make redirecting it useless: KACS refuses any exec the kernel initiates on its own behalf unless the binary carries at least PeiosTcb trust. Pointing the sysctl elsewhere gains nothing without a TCB-signed binary to point it at.

This is the one place a signature gates execution rather than just labelling it. Everywhere else an unsigned binary runs and simply carries no integrity tier, because the process that asked for it bounds what it can do; a kernel-initiated exec has no such process behind it.

User-visible effect: a module the kernel asks for on your behalf loads only if the distribution's modprobe is in place and properly signed. A refusal is recorded as a kacs_exec event with reason umh-not-tcb, rather than appearing as an unexplained module-load failure.

1.2.4 Memory access restrictions #

CONFIG_STRICT_DEVMEM=y is required. This flag restricts /dev/mem and /dev/kmem access — physical-memory mapping pseudo-devices — to specific I/O regions, denying read or write access to actual system RAM.

The reasoning: a process that can read physical memory bypasses PIP. The DACL might say "this process cannot read the memory of authd"; the kernel's PIP check might agree; but if the process can mmap /dev/mem and read the corresponding physical pages, neither check matters. The defence is to restrict the device.

With CONFIG_STRICT_DEVMEM=y:

  • /dev/mem is readable only for the kernel's defined I/O regions (memory-mapped I/O, the VGA framebuffer, etc.).
  • General RAM is not readable through /dev/mem.
  • /dev/kmem is unavailable.

This closes the physical-memory bypass for PIP. Combined with CONFIG_MODULE_SIG_FORCE, the two flags eliminate the kernel-level escapes from PIP that don't require an actual kernel compromise.

The flag is build-time; the kernel image either has it or doesn't. A kernel without it is not a valid Peios kernel for security purposes.

1.2.5 What the kernel will not have #

The Peios kernel is built with specific things excluded:

  • CONFIG_BPF_LSM=n — the BPF LSM is not built. BPF programs running as security modules would conflict with PKM and bypass its checks.
  • MAC LSMs disabled — SELinux, AppArmor, SMACK, TOMOYO are all disabled. Their config flags are off.

These exclusions are part of the kernel image. A distribution that includes these LSMs in its kernel image is not running Peios's intended security model.

1.2.6 What about user-space hardening #

A few things are explicit in the Peios environment that aren't strictly kernel invariants but are operational expectations:

  • peinit is signed at TCB level. If the binary peinit is exec'd from is not signed at TCB, the kernel's verification at exec sets pip_type = None. peinit then runs unprotected, and any other process can interfere with it. This is operationally untenable; a properly-built Peios image has peinit signed.
  • authd, loregd, eventd, lpsd are signed at TCB level. Same reasoning. These are the TCB daemons; they need PIP protection to function in their role.
  • No kernel modules are loaded other than signed ones. Per CONFIG_MODULE_SIG_FORCE.

These are not enforced at boot in the sense of "the boot will fail" if violated — they are operational expectations. A Peios system where peinit is unsigned will boot, run, and "work" in a superficial sense; it just won't have the security properties Peios documents.

1.2.7 The invariant verification #

The kernel's init code does several checks at start:

  1. Verify PKM is in the LSM stack and no incompatible LSM is present. PKM refuses to activate if violated.
  2. Verify CONFIG_MODULE_SIG_FORCE is set. (This is a build-time flag; at runtime the kernel just behaves consistently with the build.)
  3. Verify CONFIG_STRICT_DEVMEM is set. (Same; build-time.)
  4. Construct the SYSTEM and Anonymous tokens. As covered in Bootstrap tokens.
  5. Attach the SYSTEM token to init. Init becomes the first process holding it.
  6. Start userspace. init execs the configured first program — prelude, the initramfs PID 1, which runs the initramfs and then execs the real init (peinit, in a normal Peios image). See The initramfs stage.

By the time userspace starts, the invariants are either satisfied (boot proceeds normally) or violated (boot fails or the security model is broken). The verification is internal; from the operator's perspective, the system either comes up cleanly or doesn't.

1.2.8 Failure modes #

If an invariant fails:

  • MAC LSM detected at PKM init. PKM refuses to activate. The kernel boots but the access control is effectively absent. peinit's exec verification (looking for a TCB signature) returns "valid" but no PIP is set because PKM isn't running. The system is unsafe.
  • CONFIG_MODULE_SIG_FORCE=n. Modules can be loaded without signing. An attacker with SeLoadDriverPrivilege can load arbitrary kernel modules and bypass everything. PIP is no longer meaningful.
  • CONFIG_STRICT_DEVMEM=n. /dev/mem can read physical memory. Any process with READ_CONTROL on /dev/mem (or whose DACL grants it) can bypass PIP by reading memory directly.

Each failure mode is a security regression. The boot chain depends on the invariants holding. A deployment that needs to be sure its security model is operating correctly verifies these as part of the boot validation — typically via a post-boot tool that reads /proc/sys/kernel/lsm, checks build-time flags via the kernel config, etc.

For most Peios deployments built from the official kernel image, the invariants hold by construction. The verification is mostly relevant for custom kernels built from source — anyone modifying the kernel image needs to know which flags they cannot change.

1.2.9 Why these invariants #

Two themes underlie all of these:

The trust chain is rooted in kernel integrity. SYSTEM token, PIP, signed binaries — every layer of the model assumes the kernel is honest. If the kernel can be modified at runtime (unsigned modules), or its memory can be read or written from userspace (/dev/mem), the foundation crumbles.

PKM must be the sole authoritative LSM. Trying to compose PKM with MAC LSMs would give either over-restrictive or inconsistent semantics; the Peios access model is built on PKM's decisions, and that requires PKM to make the decisions alone.

The invariants are what make these themes operational: the kernel is built and initialised in ways that protect itself from runtime modification and that ensure PKM is in charge.

1.2.10 Where to go next #

For the tokens the verified kernel constructs at init, read Bootstrap tokens.

For the signing scheme module signing shares its keys with, read Binary signing.

For the protection model these invariants defend, read Process integrity protection.

2.1 Overview

Peios / Peios technical reference / PKM / KMES

The Kernel Mediated Event Subsystem is the sole event emission path in Peios. Kernel subsystems and userspace processes alike emit events exclusively through KMES — there is no alternative path. KMES stamps each event with trusted metadata at emission time, buffers it in per-CPU shared memory ring buffers, and delivers it to userspace consumers that map those buffers directly. It does not persist, index, or query events, and it imposes no schema or naming convention on them; those are consumer concerns, handled by eventd.

KMES serves a similar role to ETW in the Windows kernel and auditd in Linux, but is compatible with neither at the wire, format, or API level. The design — structured events with a fixed binary header and a msgpack payload, shared memory delivery, one emission path for kernel and userspace — was chosen for unified observability with kernel-trusted metadata.

The consumer-facing contract — the event header layout, the mapped ring buffer regions, and the protocols a consumer follows to drain, sleep, and survive buffer swaps — is specified in PSPK's KMES event stream chapter. This chapter describes the kernel side: how events are constructed and stamped (§2.2), the in-kernel emission API (§2.3), the syscall surface (§2.4), how the ring buffers are organised and written (§2.5), self-configuration through the registry (§2.6), behaviour under failure (§2.7), and the emission policy that decides which of the kernel's own event types are written (§2.8).

2.1.1 Terminology #

An event is an indivisible record: a packed binary header carrying KMES-intrinsic metadata, followed by a msgpack-encoded payload whose structure is defined by the emitter. Header and payload are produced, stored, and consumed together as one contiguous byte sequence; neither is meaningful alone. KMES treats the payload as opaque.

The stamp fields are the header fields KMES populates itself at emission time: the timestamp, sequence number, CPU identifier, and origin class, plus three identity GUIDs captured from KACS — the effective token GUID (the token governing the emitting thread's access rights, which is the impersonation token when the thread is impersonating), the true token GUID (the process's primary token, regardless of impersonation), and the process GUID (assigned by KACS at fork and unchanged across exec). The null GUID — sixteen zero bytes — stamps an identity field whose value is unavailable.

The sequence number is a per-CPU, per-boot monotonic 64-bit counter. Each CPU counts independently; the counter starts at zero when PKM loads and is incremented before its value is taken, so the first event on each CPU carries sequence number 1 and sequence 0 is never assigned. A gap in one CPU's sequence indicates lost events. The pair (cpu_id, sequence) uniquely identifies an event within a boot; there is no global sequence.

The origin class is a header byte identifying the emission path: userspace (0), KMES itself (1), KACS (2), or LCS (3). Values 4–255 are unassigned.

The event type is a length-prefixed UTF-8 string in the header identifying the kind of event. KMES imposes no structure on it and compares nothing against it; types are consumer vocabulary.

A ring buffer is a per-CPU shared memory region — producer metadata page, consumer metadata page, and a data region — created and managed by KMES and mapped by consumers. A consumer is a userspace process that maps one or more ring buffers and drains events from them, typically with one thread per CPU. Boot-time ring buffers are the ordinary per-CPU buffers created at module load using compiled-in defaults; they are the live consumer-facing buffers from the first instant, not a separate class.

2.2 Event Model

Peios / Peios technical reference / PKM / KMES

2.2.1 Structure #

An event is a packed binary header followed immediately by its msgpack payload, written and delivered as a single contiguous byte sequence with no padding or alignment gaps anywhere. The header is never represented as a C struct in the kernel; it is serialised field by field, in order, directly into the ring buffer. The field-by-field layout — offsets, sizes, and endianness — is part of the consumer contract and is defined in the PSPK event stream specification. In summary: all fields before the event type string sit at fixed offsets, the event type string begins at offset 77 with its u16 length at offset 75, the header is exactly 77 + type_len bytes, the payload occupies the bytes from header_size to event_size, and the next event begins at offset event_size from the start of the current one. All multi-byte header integers are little-endian; the identity GUIDs are copied as opaque 16-byte values.

event_size, header_size, and type_len are structural fields computed by KMES during construction. The emitter supplies only the event type string and the payload, and KMES copies both verbatim.

2.2.2 Intrinsic stamps #

KMES populates four intrinsic stamp fields at emission time, unconditionally; the emitter cannot supply them.

  • timestamp — wall clock time (CLOCK_REALTIME, via ktime_get_real_ns()) at the moment KMES accepts the event, in nanoseconds since the Unix epoch.
  • sequence — the emitting CPU's per-boot counter, incremented and then read, so the first event on each CPU gets 1.
  • cpu_id — the CPU on which the ring buffer write occurs, which identifies the per-CPU buffer holding the event.
  • origin_class — for syscall emission, set unconditionally to 0 (userspace); the caller cannot influence it. For kernel emission, the value the calling subsystem passed, written to the header without validation — kernel emitters are trusted to pass an assigned value.

The timestamp is captured before the sequence number is assigned, so two events with the same timestamp on the same CPU are ordered by sequence.

2.2.3 Identity stamps #

The three identity GUIDs are captured by calling KACS accessors during the preemption-disabled ring buffer write phase, after the sequence number is taken and before the header is built. All three accessors are safe with preemption disabled — each is a handful of pointer dereferences and a 16-byte copy, with no allocation and no sleeping. The stamps therefore reflect the thread's identity at the moment of the ring buffer write, not at syscall entry.

  • kacs_effective_token_guid() reads the thread's subjective credentials (current_cred()), so during impersonation — which installs the impersonation token via override_creds() — it yields the impersonation token's GUID. Otherwise it equals the true token GUID.
  • kacs_primary_token_guid() reads the real credentials (current_real_cred()), which impersonation leaves untouched, so it always yields the process's primary token GUID.
  • kacs_process_guid() reads the process GUID KACS assigned when the process's security state was created at fork. Threads created with CLONE_THREAD share the process state, and no exec path writes the field, so the GUID is stable for the process's lifetime.

All three accessors return the null GUID when there is no task context (!current or not in_task()), and the token accessors also return it when the token cannot be resolved. A fully null identity triple therefore indicates emission before KACS initialisation, from a kernel thread, or from interrupt or softirq context. No event is ever stamped with the identity of a task that merely happened to be interrupted.

For batch emission, the timestamp and the three identity GUIDs are captured once, before the per-event loop, and shared by every event in the batch: the batch executes with preemption disabled on one CPU, so the emitting thread's identity cannot change mid-batch. Each event still receives its own sequence number.

2.2.4 Ordering #

Cross-CPU ordering is by timestamp. Events with identical timestamps from different CPUs were genuinely concurrent and have no defined relative order. Within one CPU, sequence is the reliable ordering primitive, monotonic even across wall-clock discontinuities; events with identical timestamps on the same CPU are ordered by sequence.

2.2.5 Payload #

The payload is a single msgpack value. KMES neither interprets nor modifies it — buffering and delivery are content-blind — and it performs no payload validation at all for kernel emitters, which are trusted callers inside PKM.

Payloads arriving through the syscall interface are validated before acceptance by an iterative (non-recursive) msgpack walker implemented in Rust, operating on the kernel's staged copy of the payload:

  • The payload is exactly one well-formed top-level msgpack value. Trailing bytes after it are rejected, as is the never-used 0xc1 type byte. A zero-length payload is rejected — an empty byte sequence is not a msgpack value — so syscall events always carry a payload. (Kernel emitters can emit header-only events with event_size == header_size.)
  • Nesting depth is bounded by the configured MaxNestingDepth (§2.6). Depth counts from 1 at the top-level value; each child of an array or map sits one deeper than its container. A non-empty container at the maximum depth is invalid, because its children would exceed the limit; an empty array or map at the maximum depth is valid, consuming no depth. Map keys and values each occupy a child slot, so a map contributes twice its entry count of children.
  • The walker's own stack is 256 frames, matching the upper bound of the MaxNestingDepth range; a configured depth outside 1–256 causes every payload to be rejected rather than any to be waved through.
  • Length prefixes inside msgpack are big-endian, per the MessagePack specification — the one big-endian ingredient in an otherwise little-endian event.

A rejected payload fails the syscall with EINVAL and nothing is written to the ring buffer.

The event type string is validated as UTF-8 on the syscall path by the same staged-copy pass; kernel emitters' type strings are trusted and copied as given. Types are compared by consumers as raw bytes — no case folding or normalisation is applied anywhere.

2.2.6 Size limits #

Three structural bounds apply to every event, and one configurable policy bound applies to syscall emitters only:

  • The event type length fits the header's u16 type_len field and is nonzero. Syscall emitters cannot express an overlong type — the ABI length field is already u16 — but the kernel emission API takes a size_t and enforces the bound itself.
  • The total event size (header + payload) fits a u32, checked with overflow-safe arithmetic on the declared lengths.
  • The total event size does not exceed 50% of the per-CPU ring buffer capacity — a fixed ratio, not configurable, protecting a CPU's event history from a single giant event. An event of exactly half the capacity is accepted. Capacity is always a power of two, so the halving is exact.
  • Syscall events are additionally bounded by the registry-configurable MaxEventSize (§2.6), rejected with ENOSPC when exceeded. Kernel emitters are exempt from this policy limit.

2.3 Emission API

Peios / Peios technical reference / PKM / KMES

The emission API is the internal kernel interface through which PKM subsystems emit events — an ordinary function call inside the module, not a syscall. Userspace emission goes through the syscall interface (§2.4). There are two entry points: single emission (pkm_kmes_emit_kernel) and batch emission (pkm_kmes_emit_kernel_batch). Both return nothing: kernel emission is fire-and-forget, and the emitting subsystem is never notified of a drop.

Neither entry point consults the emission policy. Whether a type is switched on is decided by the emitter before it builds the event, with the check described in §2.8; an event that reaches the emission API is written.

2.3.1 Single emission #

A kernel emitter passes an origin class, an event type (pointer and length), and a payload (pointer and length). It does not choose a CPU or buffer: KMES writes the event to the ring buffer of the CPU the calling code is executing on.

The entire emission path runs with preemption and bottom halves disabled — from before the current CPU is determined until after the ring buffer write — which guarantees the emitting thread cannot migrate mid-write and preserves the single-writer-per-buffer invariant. For kernel emitters this covers the full path, timestamp capture through ring write; the payloads are small and trusted, and the window is a few hundred nanoseconds.

Bottom halves matter because rings are written from softirq context as well as from tasks: the packet engine reports from the network receive path, and RCU callbacks run in softirq. With only preemption disabled, a softirq taken on interrupt exit could write the same CPU's ring between a task writer's sequence increment and its write_pos publication, interleaving two events in one reservation. Every ring writer — single, kernel batch and the emit syscalls — therefore holds bottom halves off across its reservation and write. A writer that runs with interrupts already disabled needs no more: neither a softirq nor a hard interrupt can reach its CPU.

Emission from hard interrupt or NMI context is refused. Bottom halves cannot be held off there, and such a writer could interrupt one holding the ring, so the event is dropped before any ring is touched: no sequence number is consumed, no ring drop is counted, and KMES tallies it in a private counter and logs the first such drop once. No kernel emitter runs in hard interrupt context.

The consumer wake that follows a write takes the futex hash bucket lock, which the futex core takes with bottom halves enabled; a softirq taking it while a task on the same CPU held it would spin forever. A writer outside task context therefore does not wake consumers itself: the write still advances the futex counter, and the wake is handed to a work item on the ring, which holds a reference to the ring until it has run in process context.

Construction proceeds in order: capture the wall clock timestamp; increment the CPU's sequence counter and take the new value; capture the three identity GUIDs from KACS; build the packed header; write header and payload contiguously into the ring. The ordering consequences of these steps are described in §2.2.

KMES trusts kernel emitters. It does not validate the origin class, the type string's encoding, or the payload — only the structural checks below run. A null event-type pointer is not checked on the single-emission path; passing one faults.

2.3.2 Structural checks and drops #

Every kernel emission is checked for: nonzero event type length; type length within u16; total event size within u32 (overflow-checked); and total event size within 50% of the per-CPU ring capacity.

A failing event is not written — but its sequence number has already been consumed, so the drop is visible to consumers as a gap in that CPU's sequence, and an internal per-CPU dropped-event counter is incremented. That counter also aggregates events discarded by overwrite when the buffer wraps; it is not exposed in the ring buffer metadata and is readable only by the KUnit test harness.

Two further situations discard kernel events entirely outside this accounting:

  • Before KMES initialisation completes, emission is a silent no-op: no sequence number is consumed, the drop counter is not incremented, and no gap is visible. Events emitted in this window are simply gone. KMES tallies them in a private counter and logs the total once when the rings come up.
  • If the ring's recorded CPU identity does not match the executing CPU, the ring selection itself is untrustworthy, so every emission path — single, kernel batch and syscall — returns early without consuming a sequence number or counting a drop.

2.3.3 Ring buffer full #

Each per-CPU buffer is circular. When it is full, KMES overwrites the oldest events to make room; the write position advances unconditionally, and emission never blocks and never fails from buffer pressure. Consumers detect the overwritten events as sequence gaps, and a consumer whose read position has been overtaken re-anchors to the oldest surviving event, as specified in the PSPK event stream chapter. The overwrite mechanics are described with the write protocol in §2.5.

2.3.4 Batch emission #

The batch API emits multiple events in one operation: an origin class applied to the whole batch, an array of event descriptors (type pointer and length, payload pointer and length each), and a count. There is no upper bound on the kernel batch count — unlike the syscall batch, which caps at 256 entries — so the non-preemptible window is bounded only by the caller's restraint.

The batch executes as one section with preemption and bottom halves disabled:

  1. One wall clock timestamp is captured; every event in the batch shares it.
  2. The three identity GUIDs are captured once and shared.
  3. Each event, in order: structural checks, sequence assignment, header build, ring write. The batch structural check is slightly stronger than the single-emission one — it also rejects a null type pointer, a null payload pointer with a nonzero length, and a header size beyond u32. A failing event is dropped exactly as in single emission (sequence consumed, gap visible, counter incremented) and the batch continues with the next event — kernel emitters are trusted, and an individual structural failure indicates a kernel bug rather than hostile input. This differs deliberately from the syscall batch, which stops at the first failure so the untrusted caller learns which entry was bad.
  4. After the loop, provided at least one event was actually written, the new tail position and then the new write position are published with release stores — one publication for the whole batch — and the consumer wake flag is checked once, incrementing the futex counter if a consumer is asleep. A batch in which every event failed publishes nothing and performs no wake check.
  5. Preemption and bottom halves are re-enabled, and only then is the futex wake performed, outside the window — or, outside task context, handed to the ring's work item.

Deferring publication gives batch atomicity: consumers observe either none of the batch or all of it, since the data is fully written before the single write_pos release store. During the batch, the overwrite check runs against an internal running write offset — the consumer- visible write_pos stays untouched until the end. The tail position is likewise kept local and published only at the end.

2.3.5 Write atomicity #

Individual event writes are atomic from the consumer's perspective: an event's bytes are fully written into the data region before the write_pos release store makes them reachable, so a consumer bounded by write_pos can never observe a partially written event. The memory-ordering contract this rests on is part of the PSPK event stream specification; the kernel-side implementation of the write protocol is described in §2.5.

2.4 Syscall Interface

Peios / Peios technical reference / PKM / KMES

KMES exposes three syscalls in the PKM syscall range (1090–1099): kmes_emit (1090) emits a single event from userspace, kmes_attach (1091) attaches the caller as a consumer of one per-CPU ring buffer, and kmes_emit_batch (1092) emits multiple events in one operation. All three follow the standard Linux convention — they return −1 and set errno on failure — and their numbers, entry struct layout, error tables, and privilege masks are collected in §2.A.

Before KMES initialisation completes, all three syscalls fail with ENOMEM. Before KACS initialisation, the emit syscalls fail closed with EPERM, since privilege checks cannot be performed.

2.4.1 kmes_emit #

Emits one event. The origin class is set to 0 (userspace) unconditionally — the caller cannot choose it — and the event is written to the ring buffer of the CPU the calling thread is executing on at write time.

2.4.1.1 Privilege gate #

The caller's effective token has to hold SeAuditPrivilege, enabled; otherwise the syscall fails with EPERM. A successful gate records SeAuditPrivilege as used on the token, as a KACS standalone privilege gate; a failed gate records nothing. If recording the used state itself fails, the syscall also fails with EPERM.

2.4.1.2 Rate limiting #

Callers without enabled SeTcbPrivilege are rate limited per process by a token bucket: the refill rate and the burst capacity both equal the configured MaxEmitRatePerProcess (§2.6). The bucket is allocated together with the process's KACS security state at fork, initialised to full capacity, and freed when the process's security state is released at exit. Refill is computed against the monotonic clock, so wall-clock jumps do not affect it. When MaxEmitRatePerProcess changes at runtime, the new rate and capacity take effect immediately — rates are read live on every operation — and each bucket's current token count is clamped down to the new capacity.

A token is reserved up front and refunded if the syscall subsequently fails, so validation failures cost nothing; the refund is clamped to capacity, which means a refund landing just after a rate decrease can forfeit the token. An empty bucket fails the reserve with EAGAIN, consuming nothing. Callers holding enabled SeTcbPrivilege bypass the bucket entirely, and the exemption records SeTcbPrivilege as used.

Rate state is per-process rather than per-SID: per-SID limiting would penalise unrelated services sharing a SID (LocalService, for instance). A process that forks to reset its limit is bounded by RLIMIT_NPROC.

2.4.1.3 Validation #

Validation runs in order and stops at the first failure; the errno reflects the first failing check.

  1. The privilege gate and rate reservation, above.
  2. event_type_len is nonzero — EINVAL otherwise.
  3. The declared total event size (77 + type_len + payload_len) is computed from the length fields alone, without dereferencing either userspace pointer, with overflow-checked arithmetic — overflow is EINVAL.
  4. The declared size is within MaxEventSize — ENOSPC otherwise.
  5. The declared size is within 50% of the ring capacity — ENOSPC otherwise. At this stage the check runs against the first live ring's capacity; it is repeated against the actual target CPU's ring inside the write phase, so a capacity swap racing the syscall can surface ENOSPC after all other validation has passed.
  6. The event type and payload are copied into a kernel staging buffer (EFAULT if a pointer is inaccessible, ENOMEM if allocation fails). Everything after this point — validation and the ring write — operates on the kernel copy, closing the TOCTOU window in which userspace could rewrite the payload after validation.
  7. The event type is validated as UTF-8 — EINVAL otherwise.
  8. The payload is validated as msgpack within MaxNestingDepth (§2.2) — EINVAL otherwise.

2.4.1.4 Preemption #

Validation runs with preemption enabled — the userspace copies can fault, and msgpack validation of a large payload takes microseconds. Preemption is disabled only around the ring buffer write: determining the CPU, stamping, writing, publishing write_pos, and checking need_wake. The cpu_id and identity GUIDs therefore reflect the thread's state at write time, not at syscall entry. On success the syscall returns 0 and the event is immediately visible to consumers.

2.4.2 kmes_emit_batch #

Emits up to 256 events in one call, sharing the privilege check, the timestamp, the identity capture, and the single write_pos publication across the batch. The 256-entry cap bounds the preemption-disabled write window to roughly 50–100 microseconds for typical event sizes.

The caller passes an array of 32-byte entry descriptors (layout in §2.A; the descriptor padding bytes are documented as reserved-must-be-zero in the ABI header but are not validated), a count, and an emitted_out pointer.

Processing order:

  1. The SeAuditPrivilege gate, as for kmes_emit.
  2. count is within 1–256 — EINVAL otherwise.
  3. count tokens are reserved from the rate bucket in one critical section, so concurrent threads cannot both pass the check — EAGAIN if unavailable, and nothing is emitted. SeTcbPrivilege exempts as before.
  4. Zero is stored to *emitted_out before any per-entry work — EFAULT if unwritable, with nothing emitted.
  5. The descriptor array is copied from userspace (EFAULT/ENOMEM).
  6. Each entry, in order, goes through the same staging pipeline as kmes_emit — declared-size arithmetic, MaxEventSize, 50% capacity, userspace copy, UTF-8, msgpack. Staging stops at the first failing entry.
  7. The validated prefix is emitted in one preemption-disabled write phase: one timestamp, one identity capture, a sequence number per event, origin class 0 throughout, and a single deferred publication that makes the whole prefix visible atomically.
  8. Unused tokens (count minus events emitted) are refunded — only events actually emitted are charged.

On full success the syscall returns 0 and writes count to *emitted_out. If entry N fails, entries 0 through N−1 are emitted, N is written to *emitted_out, and the syscall returns −1 with the errno of the failing entry. Failed entries never consume sequence numbers, so batch validation failures leave no consumer-visible gap. The final emitted_out store is a second write to userspace; if it faults, the syscall reports EFAULT even though the prefix was already emitted.

Every staged entry is held in kernel memory simultaneously until the write phase completes, so a batch's transient allocation is bounded by count × MaxEventSize — up to 1 GB at the maximum settings — rather than by a single event.

2.4.3 kmes_attach #

Attaches the caller as a consumer of one per-CPU ring buffer, returning a file descriptor. The consumer contract built on this fd — the mapped region layout, the drain and notification protocols, and the re-attach protocol across buffer swaps — is specified in the PSPK event stream chapter; the TRM side of the mechanics is §2.5.

The caller's effective token has to hold SeSecurityPrivilege, enabled — EPERM otherwise — and a successful gate records SeSecurityPrivilege as used. cpu_id is a logical CPU index using the same numbering as the ring metadata and event headers.

The slot array is allocated at KMES initialisation and sized by nr_cpu_ids, then filled by walking for_each_possible_cpu. Those two quantities are not the same thing: the array size bounds a valid cpu_id, while the ring count is however many of those slots got a ring. They agree only when the possible-CPU mask is dense. An index at or beyond the array size fails with EINVAL; so does an index inside it whose slot holds no live ring.

A consumer learns the array size by calling kmes_attach with cpu_id set to KMES_ATTACH_QUERY_SLOTS (0xFFFFFFFF). The call takes the same privilege gate, writes the slot count through capacity, returns 0, and opens no descriptor. Enumeration then walks 0 to slots-1 and skips the indexes that answer EINVAL.

Counting up until the first EINVAL — which is what this interface used to ask for — is wrong on a sparse mask: it stops at the first hole, and every ring above it becomes permanently unreachable, filling and overwriting with no consumer able to attach.

CPUs that were possible but offline at initialisation have rings and are attachable; hotplug beyond the initial set is not handled (§2.7).

On success the current ring capacity is written to *capacity — the consumer computes its mmap size as 8192 + 2 × capacity — and the fd is returned. The fd is opened O_RDWR | O_CLOEXEC and supports exactly two operations: mmap() and close(). The fd is installed before the capacity write-back; if that write faults, the fd is closed again and the syscall returns EFAULT, but another thread of the process can have observed the fd in the interim.

Repeated attaches to the same CPU are permitted and return a new fd each time; all fds for one CPU share the same ring — producer metadata, consumer metadata, and data region — so multiple direct consumers can drain one buffer concurrently, each keeping its own read position in its own memory. KMES stores no per-consumer state: the fd's private data is a reference to the ring, nothing more. When events arrive and need_wake is set, KMES increments that buffer's futex counter and wakes all waiting threads.

2.5 Ring Buffers

Peios / Peios technical reference / PKM / KMES

2.5.1 Organisation #

KMES maintains one ring buffer per logical CPU, created for every CPU in the kernel's possible-CPU set when the module initialises — before LCS exists, using the compiled-in default capacity — and buffering events from that first instant. These boot-time buffers are ordinary buffers: same layout, same overwrite semantics, same generation model, immediately attachable. If LCS never becomes available they simply remain the live buffers indefinitely.

Each ring is an independent, reference-counted object holding its capacity, generation, sequence counter, write and tail positions, futex counter, dropped-event counter, two metadata pages, and the data region. There is no shared state between rings on the write path: each CPU writes only to its own ring, using plain non-atomic fields under preemption disablement, and the only ordering machinery is a set of release stores at publication points. Fds taken by consumers hold references, so a ring — including a superseded generation — survives until the last consumer releases it.

The data region is a vzalloc allocation of exactly capacity bytes, zeroed once at creation and never scrubbed afterwards; overwritten regions retain stale bytes, which is why the consumer contract forbids reading beyond an event's event_size. Capacity is always a power of two — enforced at every entry point — so position wrap is a bitwise AND with capacity - 1. The permitted range is 64 KB to 256 MB, with a 4 MB default.

2.5.2 The two producer metadata pages #

Producer metadata exists twice. The kernel writes its working copy to a private page allocated with the ring, and mirrors every store to a second, consumer-visible page. The consumer-visible page is shmem-backed and allocated lazily at the first kmes_attach for the ring; shmem backing is what makes the notification futex work, since a shared (inode-keyed) futex needs a page-backed mapping. Every producer store — write_pos, tail_pos, futex_counter, generation — is a release store performed to both pages; the static fields are initialised at ring creation and re-stamped when the shared page appears. The mmap handler exposes only the shared page. The field offsets on the page are part of the consumer contract, defined in the PSPK event stream chapter.

2.5.3 Consumer mapping permissions #

The producer metadata page and both aliases of the data region are read-only to consumers. The consumer metadata page is read-write: ordinary stores and authorised process_vm_writev writes can set need_wake. A consumer can remove write permission from that page with mprotect and restore it later.

A write-enabling mprotect covering any producer or data byte fails with EACCES, including requests spanning the consumer page and a protected region. Write faults and writable page pins must not upgrade the protected pages. These boundaries follow the original mapping offsets when mprotect splits a VMA; a new VMA start does not move the consumer page. Process access gates for remote writes still apply.

2.5.4 Wrap handling #

The consumer's data region is double virtual mapped — the same physical pages appear twice consecutively — so a consumer reads an event that crosses the physical end of the buffer as one contiguous byte sequence. The producer side has no such mapping: kernel writes into the vzalloc region are wrap-aware, splitting a byte-range copy that crosses the boundary into two memcpy calls and masking scalar stores per byte. The contiguity guarantee is a property of the consumer's view, produced by the mmap layout rather than by the writer.

2.5.5 Write protocol #

Each ring has exactly one writer — its CPU — and the write path takes no locks and performs no cross-CPU atomics. For a single kernel emission: capture the timestamp; take the next sequence number; if the event fails a structural check, count the drop and stop (§2.3); otherwise make room, write the event at write_pos & (capacity - 1), publish the new write_pos (old value plus event size) with a release store, and check need_wake. The release store is what makes the event atomic from the consumer's side: the bytes are complete before the position that makes them reachable moves.

Making room is the overwrite walk. While the live span (write_pos - tail_pos) plus the incoming event exceeds capacity, KMES reads the event_size of the event at the tail and advances tail_pos past it, counting each overwritten event in the internal dropped-event counter, and publishes the advanced tail with a release store before the new data lands on top of it. Walking the tail is sequential and possibly cache-cold — the tail can be megabytes from the write position — a cost accepted in preference to maintaining an index of event offsets on the hot write path.

The walk carries a corruption guard: if the size field read at the tail is zero, larger than the capacity, or larger than the live span, KMES abandons the walk and resynchronises by jumping tail_pos straight to write_pos, discarding the entire surviving window in one step (the discarded span is not itemised in the drop counter) and emitting a tracepoint.

For a batch, KMES first walks the reservations for every accepted event using local write and tail positions. It publishes the resulting tail with one release store before writing any event bytes, so a concurrent consumer can detect that the record it was reading has been overwritten. Publishing the tail only after the writes would allow the consumer's tail re-check to accept torn data.

After writing the events, KMES publishes the final write position with one release store and checks need_wake once, provided at least one event was written. New batch records become available at that final write-position store; the tail still transitions once per batch rather than once per overwritten event.

2.5.6 Notification #

The wake path reads the consumer page's need_wake byte — the single consumer-writable byte KMES ever reads, treated as a boolean and trusted for nothing else. If it is zero, notification costs that one read. If set, KMES increments the futex counter with a release store and wakes every thread waiting on it. The futex is a shared, inode-keyed futex on the shmem producer page at the counter's offset — a consequence a consumer must match: a FUTEX_PRIVATE_FLAG wait on the mapped address is never woken. Before any consumer has attached, the shared page does not exist and the wake is skipped entirely, though the private counter still advances.

The futex_wake call itself is issued after preemption is re-enabled, outside the write window; only the counter increment happens inside it. Waking a thread that is already awake is a harmless no-op, which is also why the consumer's relaxed clearing of need_wake is safe.

2.5.7 Capacity swaps #

A valid BufferCapacity configuration change replaces every ring. New rings are allocated first, at the old generation plus one, fully initialised before any CPU can see them. The switch itself runs under stop_machine: with every CPU quiesced, each ring's surviving events are migrated to its replacement, the per-CPU live pointers are switched, and each old ring's published generation is bumped to the new value — signalling consumers on the old mapping to re-attach. If an old ring's need_wake is set, its futex counter is bumped inside the quiesced section and the wake is issued after it, so consumers asleep on a dead generation do not sleep forever.

Migration copies the surviving span in sequence order, re-compacted contiguously from position zero: the new ring starts with tail_pos = 0 and write_pos equal to the bytes copied, and the sequence and dropped-event counters carry over, so sequence numbers are continuous across a swap. When the new capacity is smaller than the surviving span, the oldest events are skipped from the tail forward until the suffix fits — loss is bounded to the oldest prefix, traced but not counted as drops. Old positions are meaningless in the new ring; a consumer re-locates by sequence number, per the PSPK protocol.

If allocating the new rings fails, the old rings stay live at their size, no generation changes, and the failure is reported through a kmes.buffer.swap.failed event (§2.6). A migration abort — a corrupt size field encountered inside the quiesced section — abandons the swap the same way and is reported the same way, with -EIO as the error. There is no automatic retry; the next configuration write or reboot tries again. A superseded generation's pages stay valid for as long as any consumer keeps them mapped, so during and after a swap old and new rings coexist until the last old fd closes.

2.6 Self-Configuration

Peios / Peios technical reference / PKM / KMES

KMES reads four operational parameters from the registry under Machine\System\KMES\. Compiled-in defaults carry it from module load until LCS becomes available; from then on a persistent kernel-internal watch keeps it current. The key names, types, defaults, and ranges are in §2.A.

At no point does KMES wait for configuration. The defaults are always sufficient, and if LCS never appears KMES runs on them indefinitely.

2.6.1 Reading and validating #

Value names are matched with LCS's value-name comparison rules — Unicode Simple Case Folding, case-preserving and case-insensitive. Names in the subtree that do not fold to one of the four canonical names are unknown keys: they are counted and ignored.

A REG_DWORD value carries exactly four little-endian payload bytes and a REG_QWORD exactly eight. A value whose type tag is right but whose payload length is wrong is not a malformed number — it is classified as a wrong-type value, and reported as such.

Values are never clamped or silently corrected. A value outside its range, of the wrong type, of the wrong payload length, absent, or (for BufferCapacity) not a power of two is rejected outright and the previously active value is retained — the compiled-in default, or the last accepted value. The registry write itself succeeds, because the source does not enforce kernel semantics; the registry therefore shows what was written while the event log shows what KMES is actually using. Validation happens twice: once when the change plan is built, and again in C before the plan is applied, so an out-of-range field reaching the second gate fails the whole application with EINVAL.

The capacity swap runs first, and a BufferCapacity change that cannot be applied rolls back the capacity and nothing else: MaxEventSize, MaxNestingDepth, and MaxEmitRatePerProcess still commit in the same pass, against the capacity that is actually live, so the stored configuration describes the running system rather than the requested one. The situation that makes a swap fail — memory pressure — is exactly the one in which an administrator raising MaxEventSize and BufferCapacity together needs the MaxEventSize change to survive. A MaxEmitRatePerProcess change additionally reconfigures every live rate bucket, clamping any bucket holding more tokens than the new capacity (§2.4).

A valid BufferCapacity different from the current one triggers a ring buffer swap (§2.5).

2.6.2 Self-configuration events #

KMES reports its own configuration handling through KMES, with origin class 1. These events are best-effort diagnostics: emission runs before the configuration is applied and its result is discarded, so a failed emission neither rolls back a valid application nor activates an invalid value. Each event's payload is built by a small in-kernel msgpack writer into a 768-byte buffer; a payload that would exceed it is silently skipped.

kmes.config.value.rejected reports one missing or invalid value. Its payload follows the event-field rules of PGSS §6: each dotted field path is a nested msgpack map, one per segment, and a field that does not apply is absent rather than nil. Every field lives under a single top-level config map of five keys, in order:

  • config.key.path — always Machine\System\KMES.
  • config.name — the canonical value name.
  • config.expected.type, config.expected.min and config.expected.max — the registry type code and range from the key's definition.
  • config.received.kind — one of missing, wrong-type or out-of-range. A malformed payload length reports wrong-type. A REG_DWORD and a REG_QWORD value out of range both report out-of-range, since config.expected.type already gives the width. config.received.type (the actual registry type code) is present exactly when the kind is wrong-type, and config.received.value (the number received) exactly when it is out-of-range. A missing value carries the kind alone.
  • config.value — the value KMES continues to use, read before any part of the plan was applied.

One read reports at most four of these events, which is exactly the number of configuration keys. A plan that would need more is rejected before anything is applied, and the entire configuration read is abandoned.

On a first boot where the KMES key exists but is empty, all four keys are missing, so the read emits four kmes.config.value.rejected events and retains all four defaults.

kmes.buffer.swap.failed reports a valid BufferCapacity change that could not be applied. Its payload is two maps: buffer, holding buffer.capacity-requested (the capacity asked for) and buffer.capacity (the capacity kept), and outcome, holding outcome.errno as a negative signed integer — -ENOMEM when the replacement rings could not be allocated, which is the usual case. The swap is system-wide, so no CPU or ring is named. Every failure is reported, not only allocation: a swap abandoned because migration met a corrupt size field reports -EIO, and one the kernel could not quiesce the CPUs for reports what stop_machine returned.

kmes.config.applied records a read of Machine\System\KMES that reached the commit, after the rejection reports and the commit itself. Its payload is three maps: config, holding config.key.path and config.counts with the four counts applied, retained-missing, retained-invalid and ignored-unknown; buffer, holding buffer.capacity; and emission, holding emission.rate-limit. The capacity and rate are read back after the commit, so they are what is in force rather than what the plan asked for. A read whose capacity swap failed still reaches the commit — the other three settings commit without it — so it is recorded as applied too, with the capacity kept, beside the kmes.buffer.swap.failed that says why. MaxEventSize and MaxNestingDepth in force are not in the record: the catalogue has no field for either yet.

kmes.config.refresh.failed records a re-read, after the watch saw a change, that failed: the source did not answer, answered with something malformed, or the plan was refused (more than four rejection reports, or a configuration the second gate rejects). Its payload is config, holding config.key.path, and outcome, holding outcome.errno. The configuration in force stays as it was. The record is essential: the registry and the running kernel disagree until the next change, and it is the only sign. A failed capacity swap is not a failed read and does not produce one. A read that fails during the bootstrap refresh is not recorded this way: it fails that refresh, which is traced and leaves the machine-root fallback armed to try again (LCS §5.10.4). The emission policy reports a failed walk of Machine\Generic\Events with the same record (§2.8).

2.6.3 Bootstrap and watching #

  1. PKM loads. KMES initialises with compiled-in defaults and creates per-CPU rings at the default capacity. They are live immediately.
  2. The first Machine-hive source registers, making LCS usable. KMES enumerates every value under Machine\System\KMES\.
  3. Valid values are applied. A BufferCapacity differing from the current one drives a swap; a matching or absent one changes nothing.
  4. KMES arms a persistent watch on the key through LCS's internal watch mechanism — a kernel-internal registration, not a userspace fd-based watch. Delivery is filtered to value-set and value-deleted notifications on the key itself, so changes in keys below Machine\System\KMES do not trigger a re-read.
  5. If the key does not exist yet, the fallback watch is armed on the Machine hive root and fires on subkey creation at any depth. When it fires, KMES re-runs the whole bootstrap: discover the key, read it, and re-arm the targeted watch. Deleting the key afterwards does not re-arm the fallback.
  6. On subsequent changes — administrator edit, or a Group Policy push at a higher-precedence layer — the watch fires and KMES re-reads, validates, and applies or rejects.

2.6.4 Access to the configuration #

The configuration keys inherit the Machine hive root security descriptor, which grants KEY_ALL_ACCESS to SYSTEM and Administrators and KEY_READ to Authenticated Users, so unprivileged processes cannot change KMES's operational parameters. Enforcement is LCS's, not KMES's — KMES reads values that LCS has already decided the caller was entitled to write. Domain policy at a higher-precedence layer provides defence against a compromised local administrator, since creating a layer above precedence 0 requires SeTcbPrivilege.

The boot-time capacity is the compiled-in default and is not separately configurable: making it so would need a channel to deliver a value to the kernel before the registry exists. Once LCS is available, capacity changes go through the ordinary swap.

2.7 Failure Modes

Peios / Peios technical reference / PKM / KMES

KMES has no external trust boundary on the write path: kernel emitters are trusted, and userspace emitters are validated at the syscall boundary (§2.4). Its failure semantics are correspondingly simpler than a subsystem like LCS that spans the kernel-userspace boundary in both directions.

2.7.1 Ring overrun #

When events are emitted faster than consumers drain them, the buffer fills and KMES overwrites the oldest events. The write path is never blocked and emission never fails from buffer pressure. Consumers see the loss as gaps in the per-CPU sequence, and a consumer whose read position has been overtaken re-anchors to the oldest surviving event.

Overrun is a normal operating condition under load, not an error: the system degrades by keeping recent events, losing old ones, and telling consumers that it did.

Two bulk-loss cases sit outside that model. The tail resynchronisation guard (§2.5) can discard an entire surviving window at once when a size field reads back implausibly, and a shrinking capacity swap drops the oldest events that do not fit the new ring. Neither itemises the discarded events in the drop counter, though both are traced.

2.7.2 Event drop #

An event is dropped without reaching the buffer when a structural limit is exceeded — an event type length that cannot be encoded in the header's u16 field, or an event larger than half the ring capacity — and, for syscall emitters only, when the event exceeds MaxEventSize or the payload fails msgpack validation.

The two paths differ in what a consumer sees. For kernel emitters the sequence number is consumed before the structural checks run, so the drop appears as a sequence gap; the emitting subsystem is not notified, since emission is fire-and-forget. For syscall emitters validation completes before the write phase, so no sequence number is consumed and no gap appears — the drop is visible only to the caller, as the syscall's error return.

Events emitted before KMES initialisation completes are discarded with no sequence consumed and no counter incremented, and are therefore invisible to consumers in both ways.

The internal per-CPU dropped-event counter aggregates structural drops and overwrite losses together. It is not exposed in the ring metadata and is reachable only through the KUnit test interface.

2.7.3 Consumer crash #

A crashed consumer's mappings are cleaned up by the ordinary kernel path when its file descriptors close on process exit, and the rings themselves are reference-counted, so they survive until the last reference goes. KMES is unaffected and keeps writing regardless of whether any consumer is attached — a system with no consumers behaves identically to one with consumers, events simply being stamped, buffered, and eventually overwritten. A restarted consumer re-attaches and sees every surviving event, with the outage visible as a sequence gap.

2.7.4 Buffer swap failure #

If replacement rings cannot be allocated, or the migration meets a corrupt ring, the existing rings stay live at their current size, the capacity change is not applied, no generation changes, and consumers are unaffected. A kmes.buffer.swap.failed event records the requested and retained capacities as buffer.capacity-requested and buffer.capacity, and the error as outcome.errno (§2.6). KMES does not retry; the next configuration write or a reboot triggers another attempt.

2.7.5 Configuration that cannot be read #

A re-read of Machine\System\KMES that fails keeps the configuration in force, and a walk of the emission policy that fails keeps the mask in force; each is recorded as kmes.config.refresh.failed (§2.6, §2.8). Nothing retries on its own: the next change under the key reads it again.

2.7.6 LCS unavailable #

If no source ever registers, KMES runs indefinitely on compiled-in defaults with the boot-time rings live and the configuration watch never armed. This is a valid operating mode, not a failure — the only consequence is that the parameters cannot be tuned.

2.7.7 Clock discontinuity #

Timestamps come from CLOCK_REALTIME, so an NTP adjustment can move them forward or backward and consumers sorting by timestamp will see an apparent reordering. Sequence numbers are unaffected: they are independent monotonic counters, never derived from the clock, and remain the reliable ordering primitive within a CPU. KMES neither detects nor compensates for discontinuities. Cross-CPU ordering near a jump is best-effort — an inherent cost of wall-clock timestamps, accepted for their readability and cross-boot comparability. Rate limiting is immune, being driven by the monotonic clock.

System suspend and hibernate are special cases of the same thing. Ring contents survive suspend to RAM, and are restored from the hibernate image on resume; in both cases the clock jumps forward by the sleep duration, so consumers see a wall-clock gap with no sequence gap.

2.7.8 CPU topology #

The set of rings is fixed at initialisation from the kernel's possible-CPU set, and topology changes are not handled dynamically.

A CPU that was possible but offline at initialisation already has a ring: if it comes online later, its events go to that ring, and consumers can attach to it throughout. A CPU whose logical index was outside the possible-CPU set has no ring, and kmes_attach rejects that index; KMES neither creates rings for such CPUs nor publishes topology-change notifications. A CPU taken offline through cpu_online keeps its ring, and a consumer draining it simply sleeps indefinitely, unable to distinguish a quiet CPU from a departed one. Hot-add is the realistic case — hypervisors adding vCPUs to a running guest — while hot-remove is rare outside mainframes.

Anything that fixes this is additive: a topology-change notification, whether a generation bump meaning "re-enumerate", a dedicated descriptor, or a status field in the metadata page, fits the existing attach-per-CPU design without changing the ring format, the event format, or the emission API.

One structural caveat applies to attach discovery. The index bound is the count of rings successfully created, while rings are indexed by logical CPU id. On a system whose possible-CPU mask has holes, the two differ: the "attach with incrementing indexes until EINVAL" loop stops early, and rings at high logical indexes are unreachable.

Each logical CPU — each hardware thread under SMT — gets its own ring, so ring memory scales with threads rather than cores: at the 4 MB default a 64-core, 128-thread machine holds 512 MB of ring buffers. This follows from events being emitted per logical CPU and cpu_id naming the logical CPU.

2.7.9 Memory bounding #

Ring memory in steady state is num_cpus × BufferCapacity for the fixed CPU count fixed at initialisation, plus two metadata pages per CPU and one shmem page per ring that has ever been attached. During a capacity swap old and new rings coexist until every mapping of the old generation is released.

Transient allocation during emission is bounded by the event size for a single emit and freed as soon as the event is written. A batch is different: every entry is staged in kernel memory simultaneously, so a batch holds up to count × MaxEventSize — 256 entries at a 4 MB maximum event size — until its write phase completes.

Each kmes_attach creates one file descriptor, bounded by RLIMIT_NOFILE and by the SeSecurityPrivilege requirement. No KMES-specific global memory cap exists; the capacity configuration and standard Linux resource limits are the bounds.

2.7.10 Allocation and timing choices #

The data region is a plain vzalloc allocation of 4 KB pages, and the mmap handler inserts pages one at a time, so hugepage backing is not available to it. The difference is substantial for TLB coverage: a 4 MB ring needs 1024 standard pages against 2 hugepages, and because the double mapping doubles the virtual range, 2048 standard pages against 4 hugepages. Allocation is not NUMA-aware: pages come from the general allocator with no attempt to place a ring on the node local to its CPU, so writes on a CPU whose ring landed on a remote node cross the interconnect — roughly 100-150 ns against about 70 ns for a local node. Timestamps use the full ktime_get_real_ns() rather than ktime_get_real_fast_ns(), which avoids the timekeeper seqlock and costs roughly 15-25 ns less per event at the price of being up to one tick stale when a timer interrupt is updating the timekeeper concurrently. Headers are built field by field on every event with no precomputed per-CPU template, msgpack validation is scalar, and each staged syscall event takes its own kvmalloc rather than drawing on a per-CPU staging buffer.

None of these choices affects the ring format, the event header, or the consumer protocol.

2.8 Emission Policy

Peios / Peios technical reference / PKM / KMES

An event type that is switched off is never written. PGSS §6.9 defines the policy: a tree of keys under Machine\Generic\Events, one per event-type segment, any of which may hold an Enabled REG_DWORD of 0 or 1, where the deepest value on a type's path decides and the type's tier decides when there is none. This section is how the kernel applies that policy to the event types it writes itself. Userspace components apply it to their own types through libpeios, peios-rs and libp-go; KMES does not enforce the policy on what userspace emits.

2.8.1 The kernel's event types #

The kernel writes a closed set of event types, fixed when it is built. They are generated from the kernel's own evman fragments (pkm/evman/*.evman) by tools/gen-kmes-event-table.py into a C header and a matching Rust module, each type with an id, its type string and its tier. The generator has a --check mode, so a type cannot change tier in its fragment without the kernel's table following.

The policy is cached as a 64-bit mask with one bit per kernel type: bit i set means type i is switched on. A walk of the registry computes a whole new mask and publishes it with a single store, and an emitter reads it with a single load, so a decision is a load and a bit test and no lock is taken on any emission path. A mask is never published half-computed.

An emitter asks before it builds a payload, with pkm_kmes_event_enabled(id), and Rust emitters through the generated module's enabled(). An essential type's tier is a compile-time constant, so the check for it folds to on when the emitter is compiled and never reads the mask: no policy can switch an essential type off, whatever the tree holds. The policy and an event type's own gating — a SACL, an alarm mask, a REPORT level — combine by AND, the policy first because it is the cheaper of the two.

Every kernel emitter makes the check, and makes it first: before it resolves the subject, looks up a path, sizes the payload or allocates anything. A type that is switched off therefore costs one check and nothing else, and the emitter carries on as though the record had been written. Each emitter names its type with the generated table's string rather than one of its own, so the type written and the type the policy decided on cannot drift apart.

Note

kacs.impersonation.reverted is the kernel's one verbose type, so on a machine whose policy says nothing about it, it is not written. An administrator who wants each revert recorded sets Enabled to 1 on Machine\Generic\Events\kacs\impersonation\reverted, or on a key above it.

2.8.2 Before the policy can be read #

From the moment PKM initialises until a registry source for the Machine hive has registered and the first walk has finished, the mask holds the tier defaults: every essential and standard type on, every verbose and debug type off. That is PGSS §6.9's rule for an emitter that cannot read the policy, and the kernel writes events from long before any source exists, so it is the policy the kernel runs on for the whole of early boot. No event is ever held back to wait for the registry.

A verbose type the registry switches on is therefore not written before the walk, and a standard type the registry switches off is. A boot-time event that must be written whatever the policy says has the essential tier and nothing else.

If Machine\Generic\Events does not exist, no Enabled value can be found anywhere and the tier decides for every type: the kernel publishes the tier defaults. A bootstrap refresh that finds the key gone after it existed does the same, so deleting the key undoes the policy it held.

2.8.3 The walk #

One walk resolves every kernel type at once. It reads Enabled on Machine\Generic\Events itself, then descends the trie of the kernel types' segments — kacs, kacs\audit, kacs\audit\access and so on — looking up only the keys some kernel type's path passes through. Each key that exists costs one RSI_LOOKUP and one RSI_QUERY_VALUES.

A key that does not exist ends the walk down that path, and nothing beneath it is asked for: an empty Events key costs one query and one lookup per root. A key that is a link is not followed and ends the walk the same way.

Only an Enabled value that is a REG_DWORD holding 0 or 1 is a setting. A value of another type, of the wrong length, or holding any other number is ignored as though it were absent, and the setting found above it stands. Nothing reports the ignored value. Value names compare ignoring case, as the registry does.

From what it read the walk computes the mask by PGSS §6.9's procedure: for each type, the Enabled on the deepest existing key on its path decides; with none, the tier decides; an essential type is on. The mask is then published.

A walk that fails — the source does not answer in time, or answers with something malformed — publishes nothing. The mask in force stays, whether that is the tier defaults or the last mask a walk computed, and a kmes.config.refresh.failed record names Machine\Generic\Events and the error.

2.8.4 Noticing a change #

At bootstrap, after the KMES, layer, port reservation and network policy keys, LCS discovers Machine\Generic\Events and walks it. If it exists it gets an internal watch of its own; if not, the machine root fallback watch stays armed (LCS §5.10.4), and creating the key — Generic and Events two levels below Machine, with or without Generic already there — re-runs the bootstrap, which then walks it and watches it.

The watch covers the whole subtree at any depth, but it passes on only what can change a kernel type: a change on Events itself, a change beneath one of the kernel's five roots (kacs, kmes, lcs, ntfe, stratafs), and among value changes only those to a value named Enabled. A vendor writing under Events\org\…, which PGSS §6.9 lets an emitter ignore, costs the kernel nothing.

What the watch passes on is coalesced. The first change of a burst schedules one re-walk 50 ms later; changes that arrive before it runs join it rather than postponing it, and a change that arrives while a walk is running schedules the next one. A change to the policy applies within about 50 ms plus one walk of the write that made it — or, when a walk is already in progress, after that walk and one more — however busy the tree is, which is inside PGSS §6.9's one second.

A change is not retroactive. An event already written stays written, and a decision made before the walk publishes stays made.

2.8.5 The shipped seed #

The kernel package ships a registry seed, event-policy.reg in /usr/share/regim/, that creates Machine\Generic\Events and sets no Enabled anywhere: with nothing set, the tiers decide, which is the policy a machine ships with. The key is there so the policy has a home for an administrator to write beneath. Like every seed it is inert until an image names it in [registry] autoapply, and until then every emitter decides by tier just the same.

The seed gives the key the SACL PGSS §6.9 asks for, one that audits writes: S:(AL;CI;0x10002;;;WD), a SYSTEM_ALARM ACE for Everyone, inherited by every key beneath, over KEY_SET_VALUE | DELETE. A handle opened for writing anywhere in the policy tree carries that continuous-audit mask, so each change to what is recorded can itself be recorded. Only the SACL is set; the owner and DACL stay inherited from the Machine hive.

Appendix 2.A KMES ABI Reference

Peios / Peios technical reference / PKM / KMES

Every name, value, offset and size in this appendix is generated from pkm/uapi/pkm/kmes.h by pkm/tools/gen-kmes-abi.py, with struct layouts measured by compiling a probe against the real header. Regenerate it whenever the ABI changes; do not edit it by hand. The names here are the ones a program actually compiles against.

What a compiler cannot measure -- the error vocabulary of each syscall, the privilege each requires by name, what the configuration keys do, and the implementation bounds that are not in the header -- is in the notes appendix, §2.B, which this generator does not touch.

2.A.1 Syscall numbers #

Signatures are read from the SYSCALL_DEFINE sites in pkm/kmes/. The PKM UAPI exports the uppercase constants in <pkm/syscall.h>. Peios glibc 2.44-5 and later exports the standard lowercase SYS_kmes_* aliases in <sys/syscall.h>; each alias has the same number as its PKM constant.

NumberPKM constantglibc aliasSignature
1090SYS_KMES_EMITSYS_kmes_emitkmes_emit(const char __user *event_type, u16 event_type_len, const void __user *payload, u32 payload_len)
1091SYS_KMES_ATTACHSYS_kmes_attachkmes_attach(unsigned int cpu_id, u64 __user *capacity)
1092SYS_KMES_EMIT_BATCHSYS_kmes_emit_batchkmes_emit_batch(const struct kmes_emit_entry __user *entries, u32 count, u32 __user *emitted_out)

2.A.2 Structure layouts #

Offsets and sizes are measured, not declared.

2.A.2.1 struct kmes_emit_entry #

Total size 32 bytes.

OffsetSizeTypeField
08__u64event_type
82__u16event_type_len
106__u8[6]_pad0
168__u64payload
244__u32payload_len
284__u8[4]_pad1

2.A.3 Constants #

Grouped as the header groups them.

Event origin class — kmes_event_header.origin_class.

ConstantValueNotes
KMES_ORIGIN_USERSPACE0
KMES_ORIGIN_KMES1
KMES_ORIGIN_KACS2
KMES_ORIGIN_LCS3
KMES_ORIGIN_NTFE4net/ntfe: ntfe.verdict.reported, ntfe.policy.published, ntfe.policy.rejected

Ring-slot discovery.

Ring slots are indexed by logical CPU id and the array is sized by the kernel's nr_cpu_ids, so a slot inside the array holds no ring when that CPU is not possible. Counting up from 0 until SYS_KMES_ATTACH returns -EINVAL therefore stops at the first hole and misses every ring above it, leaving those CPUs' events permanently unreachable.

Call SYS_KMES_ATTACH with cpu_id KMES_ATTACH_QUERY_SLOTS to learn the slot count instead. It writes the count through the capacity argument, returns 0, and opens no descriptor. Enumerate 0 .. count-1 and treat -EINVAL as "this slot holds no ring", not as the end of the array.

The sentinel is outside the index space for good: nr_cpu_ids is bounded by CONFIG_NR_CPUS, which cannot reach 2^32-1.

ConstantValue
KMES_ATTACH_QUERY_SLOTS0xFFFFFFFF

Largest entry count a single SYS_KMES_EMIT_BATCH call accepts.

ConstantValue
KMES_BATCH_MAX_ENTRIES256

Runtime configuration registry location and keys.

Type values match the LCS REG_* constants: REG_DWORD is 4 and REG_QWORD is 11. They are repeated here so <pkm/kmes.h> remains standalone.

ConstantValue
KMES_CONFIG_ROOT_HIVE"Machine"
KMES_CONFIG_ROOT_SYSTEM_KEY"System"
KMES_CONFIG_ROOT_KMES_KEY"KMES"
KMES_CONFIG_KEY_BUFFER_CAPACITY"BufferCapacity"
KMES_CONFIG_KEY_MAX_EVENT_SIZE"MaxEventSize"
KMES_CONFIG_KEY_MAX_NESTING_DEPTH"MaxNestingDepth"
KMES_CONFIG_KEY_MAX_EMIT_RATE_PER_PROCESS"MaxEmitRatePerProcess"
KMES_CONFIG_TYPE_REG_DWORD4
KMES_CONFIG_TYPE_REG_QWORD11
KMES_CONFIG_BUFFER_CAPACITY_TYPE11
KMES_CONFIG_BUFFER_CAPACITY_DEFAULT4194304
KMES_CONFIG_BUFFER_CAPACITY_MIN65536
KMES_CONFIG_BUFFER_CAPACITY_MAX268435456
KMES_CONFIG_MAX_EVENT_SIZE_TYPE4
KMES_CONFIG_MAX_EVENT_SIZE_DEFAULT65536
KMES_CONFIG_MAX_EVENT_SIZE_MIN1024
KMES_CONFIG_MAX_EVENT_SIZE_MAX4194304
KMES_CONFIG_MAX_NESTING_DEPTH_TYPE4
KMES_CONFIG_MAX_NESTING_DEPTH_DEFAULT32
KMES_CONFIG_MAX_NESTING_DEPTH_MIN4
KMES_CONFIG_MAX_NESTING_DEPTH_MAX256
KMES_CONFIG_MAX_EMIT_RATE_PER_PROCESS_TYPE4
KMES_CONFIG_MAX_EMIT_RATE_PER_PROCESS_DEFAULT10000
KMES_CONFIG_MAX_EMIT_RATE_PER_PROCESS_MIN100
KMES_CONFIG_MAX_EMIT_RATE_PER_PROCESS_MAX1000000

Privilege requirements.

Values mirror the corresponding KACS privilege bits while keeping this header standalone.

ConstantValue
KMES_EMIT_REQUIRED_PRIVILEGE0x0000000000200000 (1ULL << 21)
KMES_ATTACH_REQUIRED_PRIVILEGE0x0000000000000100 (1ULL << 8)

On-wire event header.

Every event in a ring begins with a fixed 77-byte header, followed by event_type_len bytes of type string and then the msgpack payload. Events abut at event_size stride, so a header is not generally aligned; it crosses the ABI as raw bytes, not a C struct. Its fields, in order:

__u32  event_size            total event byte length (header + type + payload)
__u32  header_size           byte offset from the event start to the payload
__u64  timestamp_ns
__u64  sequence
__u16  cpu_id
__u8   origin_class          one of KMES_ORIGIN_* above
__u8   effective_token_guid[16]
__u8   true_token_guid[16]
__u8   process_guid[16]
__u16  event_type_len        length of the type string following the header

The three GUIDs are 16-byte Microsoft GUID binary values (Data1/Data2/Data3 little-endian, Data4 raw), captured from KACS at emission time; the null GUID (16 zero bytes) means identity was unavailable (KACS not initialised, or no process context). KMES copies them opaquely.

Read each field at its KMES_EVENT_*_OFFSET below. header_size locates the payload: it is KMES_EVENT_HEADER_BASE_SIZE + event_type_len. A future revision may grow the header, so consumers must use header_size, not the end of the type string, to find the payload.

ConstantValue
KMES_EVENT_SIZE_OFFSET0
KMES_EVENT_HEADER_SIZE_OFFSET4
KMES_EVENT_TIMESTAMP_NS_OFFSET8
KMES_EVENT_SEQUENCE_OFFSET16
KMES_EVENT_CPU_ID_OFFSET24
KMES_EVENT_ORIGIN_CLASS_OFFSET26
KMES_EVENT_EFFECTIVE_TOKEN_GUID_OFFSET27
KMES_EVENT_TRUE_TOKEN_GUID_OFFSET43
KMES_EVENT_PROCESS_GUID_OFFSET59
KMES_EVENT_TYPE_LEN_OFFSET75

Byte width of each identity GUID in the event header.

ConstantValue
KMES_EVENT_GUID_SIZE16

Byte size of the fixed event header — the offset at which the type string begins.

ConstantValue
KMES_EVENT_HEADER_BASE_SIZE77

Ring-buffer metadata layout.

An attached ring is mmap'd as:

page 0          producer metadata (read-only to the consumer)
page 1          consumer metadata (read-write)
ring data       the event bytes, mapped twice back-to-back so an event
                that wraps the buffer end is still contiguous.

The producer metadata page begins with KMES_RING_MAGIC.

ConstantValue
KMES_RING_MAGIC"KMESRING"
KMES_RING_VERSION1
KMES_METADATA_PAGE_SIZE4096
KMES_METADATA_TOTAL_SIZE8192
KMES_MAPPING_PRODUCER_OFFSET0
KMES_MAPPING_CONSUMER_OFFSET4096
KMES_MAPPING_DATA_OFFSET8192

Field offsets within the producer metadata page.

ConstantValue
KMES_PRODUCER_MAGIC_OFFSET0
KMES_PRODUCER_VERSION_OFFSET8
KMES_PRODUCER_CPU_ID_OFFSET12
KMES_PRODUCER_CAPACITY_OFFSET16
KMES_PRODUCER_DATA_OFFSET_OFFSET24
KMES_PRODUCER_GENERATION_OFFSET32
KMES_PRODUCER_WRITE_POS_OFFSET64
KMES_PRODUCER_TAIL_POS_OFFSET72
KMES_PRODUCER_FUTEX_COUNTER_OFFSET128

Field offset within the consumer metadata page.

ConstantValue
KMES_CONSUMER_NEED_WAKE_OFFSET0

2.A.4 Tracepoint diagnostic codes #

From uapi/pkm/trace.h. These are a diagnostic contract for ftrace, perf and eBPF consumers, letting a tool decode a kmes: event's reason, op or state field without recompiling against a specific kernel. No KMES syscall accepts or returns them, and values are append-only.

kmes_drop reason — why the KMES ring machinery lost an event.

RING_FULL is a normal overwrite; TAIL_RESYNC is the silent corruption- recovery path that discards ALL pending events; VALIDATE is a kernel- emit size/type reject at the ring boundary; BATCH_STRUCT_INVALID is a per-entry structural reject in the kernel batch path. Emitted by kmes:kmes_drop. No event payload bytes.

ConstantValueNotes
KMES_DROP_RING_FULL0capacity overwrite; oldest event dropped
KMES_DROP_TAIL_RESYNC1corrupt ring header; pending events silently discarded
KMES_DROP_VALIDATE2single kernel-emit size/type reject
KMES_DROP_BATCH_STRUCT_INVALID3kernel-batch entry structurally invalid

kmes_swap reason — a bounded ring capacity swap lifecycle marker.

BEGIN and COMPLETE/FAILED are whole-topology (cpu field is U16_MAX); MIGRATE_SKIP is per-CPU and carries the skipped byte count in ret. Emitted by kmes:kmes_swap.

ConstantValueNotes
KMES_SWAP_BEGIN0capacity change accepted, rings allocating
KMES_SWAP_COMPLETE1swap committed across all CPUs
KMES_SWAP_MIGRATE_SKIP2shrink: old event too large, skipped (ret=bytes)
KMES_SWAP_FAILED3swap aborted; ret is the errno

kmes_rate reason — the per-process token-bucket backpressure signal.

THROTTLE is an -EAGAIN emit rejection; RECONFIGURE marks an admin rate change clamping all buckets. Emitted by kmes:kmes_rate.

ConstantValueNotes
KMES_RATE_THROTTLE0emit denied -EAGAIN; tokens < requested
KMES_RATE_RECONFIGURE1max emit rate reconfigured for all buckets

kmes_wake reason — consumer wakeup machinery.

NOTE arms a pending wake; FUTEX is the actual futex wake of blocked consumers. Emitted by kmes:kmes_wake.

ConstantValueNotes
KMES_WAKE_NOTE0wake armed; futex counter incremented
KMES_WAKE_FUTEX1blocked consumers woken

kmes_ring_lifecycle reason — a generation-stable ring object transition.

ret is the outcome. Emitted by kmes:kmes_ring_lifecycle.

ConstantValueNotes
KMES_RING_ALLOC0ring backing allocated (or -ENOMEM)
KMES_RING_FREE1ring backing released
KMES_RING_PRODUCER_PAGE2producer shmem/meta page attached
KMES_RING_CONSUMER_FD3consumer anon-inode fd created

kmes_ingress_reject reason — why an emit request was rejected before the ring.

OVER_MAX/OVER_CAP_HALF/SIZE_OVERFLOW are declared-size rejects; EMIT_OVERSIZE is a staged event too large at ring-write time; BATCH_PARTIAL marks a batch that validated fewer entries than requested. Emitted by kmes:kmes_ingress_reject.

ConstantValueNotes
KMES_INGRESS_OVER_MAX0event_size exceeds configured max_event_size
KMES_INGRESS_OVER_CAP_HALF1event_size exceeds ring_capacity/2
KMES_INGRESS_SIZE_OVERFLOW2declared header/event size overflow
KMES_INGRESS_EMIT_OVERSIZE3staged event exceeds live capacity/2 at emit
KMES_INGRESS_BATCH_PARTIAL4batch staged fewer entries than requested

kmes_validate reason — the C-boundary result of the Rust staged-event validator.

The Rust side collapses its structural checks into one nonzero return; only visible type/payload lengths and ret are recorded here. Emitted by kmes:kmes_validate.

ConstantValueNotes
KMES_VAL_EINVAL0Rust msgpack/structural validation rejected

Appendix 2.B KMES ABI Notes

Peios / Peios technical reference / PKM / KMES

§2.A is generated from pkm/uapi/pkm/kmes.h and holds only what a compiler can measure. This appendix holds the rest.

The split is structural rather than editorial. gen-kmes-abi.py overwrites §2.A wholesale on every run, so anything written there is lost the next time the ABI changes.

Layouts that form part of the consumer contract — the event header, the producer and consumer metadata pages, and the mapped region — are specified normatively in the PSPK event stream specification. §2.A gives their offsets as the header defines them; the specification governs.

2.B.1 Syscall parameters #

SyscallParameterTypeMeaning
kmes_emitevent_typeconst char *Event type string.
event_type_lenu16Its length in bytes.
payloadconst void *MessagePack payload.
payload_lenu32Its length in bytes.
kmes_emit_batchentriesstruct kmes_emit_entry *Array of event descriptors.
countu32Number of entries, 1 to KMES_BATCH_MAX_ENTRIES.
emitted_outu32 *Receives the number of events emitted.
kmes_attachcpu_idunsigned intRing slot index, or KMES_ATTACH_QUERY_SLOTS to query the slot count.
capacityu64 *Receives the ring buffer capacity in bytes.

kmes_emit and kmes_emit_batch return 0 on success; kmes_attach returns a file descriptor. All three return -1 and set errno on failure.

The PKM syscall range is 1090–1099; KMES uses the first three.

2.B.2 Privilege requirements #

kmes.h gives these as bit masks so it can stand alone. The names belong to the KACS privilege catalogue, and the bit index is what the two agree on.

OperationRequired privilegeBit
kmes_emit, kmes_emit_batchSeAuditPrivilege21
Rate-limit exemption on bothSeTcbPrivilege7
kmes_attachSeSecurityPrivilege8

Holding a privilege is not enough: it must be enabled, and KMES marks it used before proceeding. A failure to record the used state is itself an EPERM, because an unrecorded privilege use is an audit gap.

SeTcbPrivilege is checked but not required — an emitter that holds it enabled is exempt from the per-process rate limit, and one that does not is throttled.

2.B.3 Implementation bounds #

These are properties of the implementation rather than of the ABI, so they are not in kmes.h and a program must not compile against them. They bound what the interface will accept.

QuantityValueWhere
Maximum event size, structural50% of ring capacitykmes/kmes.c
MessagePack validator nesting stack256kmes/kmes_validate.rs
Self-configuration payload buffer768 byteskmes/kmes.c
Self-configuration audit intents per read4kmes/kmes.h
Self-configuration parameter name64 byteskmes/kmes.h

The structural 50% bound is independent of MaxEventSize and applies to kernel emitters too. An event may satisfy the configured maximum and still be refused because the ring is small.

The validator's 256-frame stack is why MaxNestingDepth has a maximum of 256. The two bounds are enforced independently: the configuration range check refuses a larger value at apply time, and the validator refuses every event outright if it is somehow handed one, rather than silently accepting nesting it cannot track.

2.B.4 Configuration keys #

Registry path Machine\System\KMES\. The type codes, defaults and ranges are in §2.A; what each key does is here.

KeyEffect
BufferCapacityPer-CPU ring size in bytes. Must be a power of two. Changing it swaps every ring; see §2.6.
MaxEventSizeLargest event a syscall emitter may produce.
MaxNestingDepthDeepest MessagePack container nesting the validator will accept.
MaxEmitRatePerProcessToken-bucket rate, events per second, per process.

MaxEventSize, MaxNestingDepth and MaxEmitRatePerProcess apply only to syscall emitters. A kernel emitter is not rate-limited and its payload is not parsed as MessagePack, but it is still checked structurally — non-empty type string within PKM_KMES_MAX_KERNEL_TYPE_LEN, a payload pointer if the length is non-zero, no size arithmetic overflow, and the 50% bound. A kernel event that fails is dropped with KMES_DROP_VALIDATE, not emitted.

A key that is missing, of the wrong type, or out of range does not fail the read: the previous value is retained and the disagreement is recorded as an audit intent. At most four such intents are carried out of one read, so a configuration with five bad keys reports four.

2.B.5 Error codes #

2.B.5.1 kmes_emit #

ErrnoCondition
EPERMSeAuditPrivilege not held or not enabled, or recording its used state failed.
EAGAINPer-process rate limit exceeded.
EINVALZero event type length, event type not valid UTF-8, declared size arithmetic overflowed, payload not valid msgpack, or nesting depth over MaxNestingDepth.
EFAULTEvent type or payload pointer inaccessible.
ENOSPCEvent exceeds MaxEventSize or 50% of ring capacity.
ENOMEMStaging buffer allocation failed, or KMES not initialised.

2.B.5.2 kmes_emit_batch #

ErrnoCondition
EPERMAs kmes_emit.
EAGAINFewer than count tokens available.
EINVALcount is 0 or over KMES_BATCH_MAX_ENTRIES, or the failing entry hit one of kmes_emit's EINVAL conditions.
EFAULTemitted_out, the entry array, or the failing entry's type or payload pointer inaccessible.
ENOSPCThe failing entry exceeds MaxEventSize or 50% of ring capacity.
ENOMEMKernel allocation failed, or KMES not initialised.

2.B.5.3 kmes_attach #

ErrnoCondition
EPERMSeSecurityPrivilege not held or not enabled, or recording its used state failed.
EINVALcpu_id at or beyond the ring array size, or its slot holds no live ring.
EFAULTcapacity pointer inaccessible.
ENOMEMKernel allocation failed, or KMES not initialised.

A KMES_ATTACH_QUERY_SLOTS call takes the same EPERM, EFAULT and ENOMEM conditions and cannot return EINVAL.

The ring array is sized by nr_cpu_ids, not by the number of rings allocated. On a machine with a sparse possible-CPU mask the two differ, and a slot inside the array with no live ring returns EINVAL exactly as an index beyond the array does. A consumer therefore enumerates against the slot count from KMES_ATTACH_QUERY_SLOTS and skips the EINVAL slots rather than stopping at the first one; see §2.4.

2.B.6 Build configuration #

KMES is built by CONFIG_SECURITY_PKM, a boolean option, so it is linked into vmlinux rather than loaded. CONFIG_RUST=y is required: the MessagePack validator is Rust. The whole subsystem is staged into the kernel tree as security/pkm/kmes by pkm/kernel/stage-sources.sh, which also stages <trace/events/kmes.h> so the tracepoints resolve.

The three syscall numbers are added to the syscall table by kernel/patches/arch/syscall-table-pkm.patch, which patches both arch/x86/entry/syscalls/syscall_64.tbl and the copy of it that ships under tools/perf/. They are registered common in the table, but the kernel is built without CONFIG_X86_X32_ABI, so the only entry point that exists is x86-64: an x32 invocation (the number with bit 30 set) fails ENOSYS like any other x32 syscall on this kernel.

CONFIG_SECURITY_PKM_KUNIT compiles in the in-kernel test harness.

3.1 Overview

Peios / Peios technical reference / PKM / KACS

The Kernel Access Control System is the security core of Peios: an LSM within PKM providing identity-based access control in the Linux kernel. It is the sole identity-based authorization mechanism for managed objects. Every identity-based decision — a file open, a registry read, an IPC connection, a signal, a token operation — passes through one evaluation function, AccessCheck.

This chapter covers tokens (§3.2), the Process Security Block (§3.3), privileges (§3.4), impersonation (§3.5), binary signature verification (§3.6), Process Integrity Protection (§3.7), the AccessCheck algorithm in full (§3.8), file enforcement (§3.9), and how Peios identity is projected onto the Linux credential model (§3.10).

The kernel-private storage and call-site inventory is in §3.E: the LSM security blobs, every registered KACS hook, and the direct metadata entry points used by patched file-descriptor operations.

Two bodies of material that KACS owns conceptually live elsewhere in the documentation. The binary structures — SIDs, security descriptors, ACLs, ACEs, access masks, conditional ACE bytecode — are specified in PCDS, because userspace tooling constructs and interprets them and has to agree with the kernel byte for byte. The signing format a third party would use to sign a binary with a PIP level is specified in PSPK; this chapter describes only the verification side. What remains here is the kernel's own behaviour: how it holds identity, and how it decides.

3.1.1 Terminology #

A token is a per-thread identity object held in the kernel's credential structure, carrying a user SID, group SIDs, a privilege bitmask, an integrity level, an impersonation level, and metadata. Identity fields — SIDs, type, integrity level — are immutable; policy fields — enabled privileges, enabled groups, default owner, group and DACL — are atomically adjustable. Every thread has a token, and there is no such thing as a null token.

A primary token defines a process's baseline identity and is inherited on fork, reached through task->real_cred. An impersonation token temporarily overrides it for access decisions on one thread only, reached through task->cred.

A LogonSession is a kernel object representing one authentication event: a session ID, a logon type, a user SID, an authentication package, a logon time, and a logon SID. Tokens reference their session by ID.

A privilege is a system-wide right carried on a token. Some influence AccessCheck; others gate a standalone operation.

An impersonation level controls how far an identity can travel: Anonymous, Identification, Impersonation, or Delegation. It is a ratchet carried by every token, primary or impersonation, and only ever goes down (§3.5.1).

An integrity level is a vertical trust classification on tokens and objects. Numerically it is the mandatory label SID's single sub-authority compared as an unsigned integer, so any S-1-16-<n> with exactly one sub-authority is valid. In practice five standard levels form a strict total order — Untrusted (0), Low (4096), Medium (8192), High (12288), System (16384) — and non-standard values such as S-1-16-8448 appear only in SDs authored for Windows interop. Mandatory Integrity Control is the constraint evaluated before the DACL, blocking write access, and optionally read and execute, when the caller's level is below the object's label.

Process Integrity Protection is a two-dimensional trust model — type against trust level — protecting processes and objects from insufficiently trusted callers. Unlike MIC, it revokes rights that a privilege would otherwise have granted.

The Process Security Block is a per-process structure carrying PIP identity, process mitigations, and process restrictions. It is never affected by impersonation, which is the point of keeping it separate from the token.

FACS, the File Access Control Shim, is the part of KACS that replaces Linux DAC with security-descriptor evaluation on files. It enforces the handle model: AccessCheck runs at open time and the granted mask is cached on the file description, with later operations checked against the cached mask.

An object type is the category of a protected resource. Each defines a GenericMapping table translating generic rights into object-specific ones.

The TCB — the components whose correct behaviour is necessary for system security — is the Linux kernel, PKM, and the core trusted userspace daemons: peinit, authd, and loregd.

3.1.2 Relationship to MS-DTYP #

KACS is not a port of another system's security model. Tokens, security descriptors, AccessCheck, structured SIDs, and per-thread impersonation were chosen because they solve what Peios needs solved: coherent identity, rich per-object access control, scoped delegation, and integrated audit.

Those same primitives are the ones Active Directory uses, and Peios is built to join AD domains as a first-class member — exchanging security data with domain controllers, authenticating through Kerberos, and enforcing policy distributed by Group Policy. That imposes binary format compatibility, which PCDS specifies: an SD written by a Windows domain controller and replicated through Samba is evaluated by KACS without translation.

Format compatibility does not imply evaluator compatibility in every corner. Given the same token, descriptor, and desired mask, KACS generally reaches the same decision MS-DTYP describes, which is what makes policy authored in an AD environment behave predictably here. Where it deliberately does not, §3.B records every departure and why.

3.2.1 The Token Model

Peios / Peios technical reference / PKM / KACS / Tokens

A token is a kernel object representing a thread's identity and security policy: the user's SID, group memberships, privileges, integrity level, impersonation state, claims, confinement settings, and metadata. Every KACS-mediated access control decision evaluates the thread's effective token.

Every live userspace thread has one. KACS-mediated authorization never evaluates a null token — credentials that are blank, uncommitted, kernel-only, asynchronous, or otherwise outside a meaningful userspace evaluation context may carry no token at all, and an LSM hook that reaches KACS with such a credential fails closed rather than evaluate a meaningless identity.

3.2.1.1 Relationship to Linux credentials #

Tokens are independently allocated, reference-counted kernel objects. A struct cred holds a pointer to the token in its LSM security blob, not the token data itself.

That indirection is architecturally load-bearing. Linux credentials are immutable once committed: after commit_creds() a struct cred cannot be modified. Had token data been embedded in the credential, every token mutation — toggling a privilege, adjusting a group — would have required allocating a whole new credential. Keeping the token behind a pointer lets token-internal mutations use the token's own synchronisation and leave the credential alone.

Several thread credentials within a process may reference one token object through real_cred, and a mutation to a shared token is visible to every thread sharing it. At fork the child receives an independent deep copy, so mutations after fork are invisible across the process boundary.

3.2.1.2 Primary and effective tokens #

The real_cred/cred split on task_struct carries two roles.

real_cred is the task's objective identity, used when other tasks evaluate access to this task. Its LSM blob points at the primary token — the process's baseline identity, inherited from the parent at fork.

cred is the task's subjective identity, used when this task evaluates access to other objects. Its blob points at the effective token: normally the primary token, or an impersonation token installed by a server thread acting for a client.

With no impersonation in play, real_cred and cred are the same credential and resolve to the same token. Impersonation swaps cred to a new credential pointing at a different token; reverting restores cred to real_cred.

3.2.1.3 Evaluation context #

Token evaluation — AccessCheck, privilege checks — happens only where a meaningful subject authority exists. In task context that authority is the effective token reached through current_cred().

Linux credential substitution is authoritative for deferred work. When a kernel path runs under credentials installed by override_creds() or another subjective-credential mechanism, the token pointer in that credential's blob travels with it and is evaluated normally. KACS does not strip authority merely because the current task carries a kernel-thread, workqueue, or io_uring-worker flag — the credential is what counts, not the worker flag.

Authorization already cached on an object handle, such as the granted mask on a file description, continues to use that cached authority for post-open operations. User-originated asynchronous work that has neither captured credentials nor cached handle authority reaches KACS without a token-bearing credential and fails closed.

Kernel-originated infrastructure work running under the boot SYSTEM credential evaluates as SYSTEM. There is no separate worker-flag-based kernel authority identity.

3.2.2 Token Structure

Peios / Peios technical reference / PKM / KACS / Tokens

Token fields fall into three mutability classes. Fixed fields are set at creation and never change — every security-critical identity field is fixed. Adjustable fields can be modified at runtime through the adjustment operations (§3.2.5). One-way fields can be set or tightened but never cleared or loosened.

3.2.2.1 Identity core (fixed) #

FieldTypeDescription
user_sidSIDThe token's primary identity.
user_deny_onlyboolWhen true, the user SID matches only deny ACEs, never allow ACEs. Set at creation by CreateToken or FilterToken. True whenever write_restricted is true.
groupsSID_AND_ATTRIBUTES[]Group memberships. The set of SIDs is fixed at creation; the per-group attribute flags are adjustable.
restricted_sidsSID_AND_ATTRIBUTES[]?Secondary SID list for restricted tokens, null on unrestricted ones. Set at creation by CreateToken or FilterToken. AccessCheck treats the list as presence-based: a restricting SID participates whenever it is present, and neither SE_GROUP_ENABLED nor SE_GROUP_USE_FOR_DENY_ONLY affects restricted-pass matching.
write_restrictedboolWhen true, the restricted SID check applies only to write access. Set at creation by CreateToken or FilterToken.

The logon SID — S-1-5-5-X-Y, tying the token to its LogonSession — is not stored as a token field at all. It is derived from the session on every read, and materialised once in groups carrying SE_GROUP_LOGON_ID, which is where AccessCheck finds it.

The group SID set is fixed at creation — adjustment never adds or removes a SID. Individual groups can be enabled or disabled by modifying SE_GROUP_ENABLED, within two limits: a mandatory group (SE_GROUP_MANDATORY) cannot be disabled, and a deny-only group (SE_GROUP_USE_FOR_DENY_ONLY) cannot be re-enabled.

A token holds at most 1024 group entries including the kernel-injected logon SID, so CreateToken accepts at most 1023 caller-supplied groups.

3.2.2.2 Token type (fixed) #

FieldTypeDescription
token_typeenumPrimary or Impersonation.
impersonation_levelenumAnonymous, Identification, Impersonation, or Delegation. On every token it is the ceiling on what may be derived from it: an impersonation token acts at this level, and a primary token bounds everything captured from, conveyed by, or duplicated out of the process carrying it (§3.5.1).

3.2.2.3 Integrity (fixed) #

FieldTypeDescription
integrity_leveluintNumeric integrity level. Standard values are 0 (Untrusted), 4096 (Low), 8192 (Medium), 12288 (High), 16384 (System), but any unsigned integer is valid and is compared numerically against object labels.
mandatory_policyflagsNO_WRITE_UP (0x0001) and NEW_PROCESS_MIN (0x0002). Per-token MIC enforcement policy, set at creation.

mandatory_policy is immutable: a process cannot change its own MIC constraints at runtime, neither loosening nor tightening them. This is a deliberate departure from MS-DTYP, where the mandatory policy is runtime-modifiable — which reduces MIC to a constraint that stops only processes not actively trying to bypass it. Immutability is what makes MIC a real boundary here, and it is also what allows the impersonation integrity ceiling (§3.5) to be enforced unconditionally.

3.2.2.4 Privileges (adjustable) #

Each privilege on a token has four independent states.

Present — the privilege exists on the token. A present privilege can be removed permanently, but no privilege can be added after creation. Enabled — the privilege is currently active; only present privileges can be enabled or disabled. Enabled by default — the creation-time enabled state, which adjustment can restore. Used — the privilege has been exercised during this token's lifetime; monotonic, and never cleared once set.

The lifecycle runs: present and disabled, to enabled, to used, then optionally disabled, then optionally removed permanently. Removal clears the privilege from the present, enabled, and enabled-by-default states together.

The four states are encoded as four 64-bit bitmasks, one bit per defined privilege, which makes a privilege check constant-time and a multi-privilege operation atomic.

3.2.2.5 Elevation (one-way) #

FieldTypeDescription
elevation_typeenumDefault (non-elevated), Full (elevated), or Limited (filtered).

A token is created as Default. Only KACS_IOC_LINK_TOKENS sets Full or Limited, when a linked pair is established, and once set neither reverts to Default on that token object. The role is sticky: relinking can replace the partner but never converts Full to Limited or the reverse. DuplicateToken and FilterToken produce new token objects whose elevation_type starts again at Default, because a new token is not part of any linked pair.

Linked pairs are associated at the LogonSession level rather than stored on individual tokens; §3.2.6 describes the pairing mechanism.

3.2.2.6 Default object security (adjustable) #

FieldDescription
owner_sid_indexIndex into [user_sid, groups...] selecting the default owner SID for new objects: 0 is the user SID, 1..N are groups[0..N-1]. The referenced SID is the user SID or a group carrying SE_GROUP_OWNER.
primary_group_indexIndex into [user_sid, groups...] selecting the default primary group. The referenced SID is the user SID or any group SID on the token.
default_daclThe DACL applied to objects this token creates when no explicit descriptor is supplied.

Storing indices rather than SID copies keeps the owner and primary group consistent with the group array they name.

3.2.2.7 Metadata (fixed) #

FieldTypeDescription
token_idLUIDUnique identifier for this token instance.
token_guidUUID128-bit identifier for this token instance, generated by the kernel at creation and immutable. Used by KMES for identity stamping and event correlation; readable through KACS_TOKEN_CLASS_GUID under TOKEN_QUERY.
auth_idLUIDThe LogonSession LUID, linking the token to the authentication event that produced it.
sourceTOKEN_SOURCEWho minted the token: an 8-character name plus a LUID.
created_attimestampWhen the original token was minted by CreateToken. Copied unchanged by DuplicateToken, FilterToken, and NEW_PROCESS_MIN, so it tracks original minting rather than duplication.
expirationtimestampWhen the token becomes invalid; zero means no expiry. Not enforced by AccessCheck.
originLUIDThe originating LogonSession for derived tokens, such as S4U or network logon.

3.2.2.8 Mutation tracking (adjustable) #

FieldDescription
modified_idCounter incremented on any token adjustment.

Marking a privilege used is audit and accounting state rather than an access-decision input, so it stays monotonic but does not bump modified_id. Setting the elevation type is the one other mutation that leaves the counter alone.

The counter is intended as a cache invalidation key — a modified_id that has changed since a cached decision was taken means the decision is stale. Nothing currently reads it for that purpose: it is maintained on every adjustment and reported through the statistics query class, and no cache anywhere invalidates on it.

3.2.2.9 Interactivity scope (adjustable) #

FieldDescription
interactivity_scope0 for services, which have no interactive environment; 1 and above for interactive and remote user environments. Changing it requires SeTcbPrivilege.

3.2.2.10 Claims and security attributes (fixed) #

FieldTypeDescription
user_claimsCLAIM_ATTRIBUTES[]Name-value pairs from the user's directory object, fed into conditional ACE evaluation.
device_claimsCLAIM_ATTRIBUTES[]Name-value pairs from the machine's directory object.

3.2.2.11 LCS registry credentials (fixed) #

FieldTypeDescription
lcs_scope_guidsGUID[]Ordered private registry scope GUIDs used by LCS private hive routing. LCS checks the list in order before falling back to global hives.
lcs_private_layersstring[]Registry layer names visible to this token even when disabled globally, using LCS layer-name syntax and matching rules.

These are KACS-owned credential material belonging to LCS. They are fixed at creation and copied by duplication and filtering, and attaching them is authorized by the same trusted-minting gate as the rest of CreateToken — only a caller holding SeCreateTokenPrivilege can create a token carrying them.

3.2.2.12 Device identity (fixed) #

FieldTypeDescription
device_groupsSID_AND_ATTRIBUTES[]?The machine's group memberships, for compound identity.
restricted_device_groupsSID_AND_ATTRIBUTES[]?Filtered device groups for restricted tokens.

3.2.2.13 Confinement (fixed) #

FieldTypeDescription
confinement_sidSID?Places the token in a default-deny sandbox; null means unconfined. When set, AccessCheck switches to default-deny and access requires an explicit grant to this SID or to a SID present in confinement_capabilities.
confinement_capabilitiesSID_AND_ATTRIBUTES[]Declared access capabilities for a confined process, empty if none. The attributes field is carried for wire-format uniformity only: AccessCheck treats capability membership as presence-based and consults neither SE_GROUP_ENABLED nor SE_GROUP_USE_FOR_DENY_ONLY when matching confinement SIDs.
isolation_boundaryboolAdds namespace filtering on top of confinement, making objects outside the boundary invisible rather than merely denied. Requires confinement_sid. Settable at creation but not enforced.
confinement_exemptboolEscape hatch: confinement restrictions are not evaluated at all.

ALL_APPLICATION_PACKAGES participates only when it is present in confinement_capabilities. KACS never synthesises it, and equally never rejects an otherwise valid confined token merely because it is present — strict confinement is expressed by the caller omitting it. Deciding which capabilities a package token receives is authd's job and policy tooling's, not the kernel's.

3.2.2.14 Audit (fixed) #

FieldTypeDescription
audit_policyu32Per-token audit overrides as a bitmask, fixed at creation — no adjustment operation exists.
FlagValueDescription
OBJECT_ACCESS_SUCCESS0x0001Audit successful object access.
OBJECT_ACCESS_FAILURE0x0002Audit failed object access.
PRIVILEGE_USE_SUCCESS0x0004Audit successful privilege exercises: the privilege contributed requested bits that survive into the final granted result.
PRIVILEGE_USE_FAILURE0x0008Audit failed privilege exercises: the privilege contributed requested bits during evaluation, but they do not survive into the final result.

The policy is additive. It forces audit events that system-wide policy would not generate, and cannot suppress events that system-wide policy requires. It follows impersonation: when service A impersonates client B and B's token has a category enabled, operations during impersonation are audited under B's identity. The creation default is 0.

The audit records these categories produce describe the effective token in their subject.token fields: the user SID as sid, the groups as groups with their attribute words as the parallel group-attributes, the integrity level as integrity, token_id as id, auth_id as auth-id, the token type as type (primary or impersonation), the impersonation level as impersonation (0 for a primary token), and the projected Linux UID as uid (§3.C).

3.2.2.15 Credential projection (fixed) #

FieldDescription
projected_uidLinux UID for the user SID, precomputed by authd when the token is minted (PSPU §2); 65534 for the anonymous identity.
projected_gidLinux primary GID, precomputed the same way; 65534 for the anonymous identity.
projected_supplementary_gidsLinux supplementary GIDs, one per group SID, precomputed the same way.

authd computes these at token creation and they are stored on the token; KACS never resolves a SID-to-UID mapping at runtime. Projection reflects all groups regardless of enabled state, so adjusting groups does not trigger recalculation. §3.10 covers how the projection is used.

3.2.2.16 Token security (adjustable) #

FieldDescription
security_descriptorThe token's own SD, controlling who may query, adjust, duplicate, or impersonate it.

3.2.2.17 Internal #

FieldDescription
refcountReference count; the token is freed when the last reference drops. Not exposed to userspace.

3.2.3 Token Lifecycle

Peios / Peios technical reference / PKM / KACS / Tokens

3.2.3.1 Fork and clone #

Every process and thread creation path goes through clone(), and KACS branches on CLONE_THREAD.

Without CLONE_THREAD — fork or vfork — a new process is created and the child receives an independent deep copy of the parent's primary token. If the parent is impersonating, the impersonation token is not inherited: the child's effective credential is set from real_cred. After the fork, mutations to either token are invisible to the other.

With CLONE_THREAD a new thread is created. Threads share the parent's real_cred by reference and therefore share one primary token object, so a privilege adjustment on it is visible to every thread. Each thread keeps independent impersonation state through its own cred. If the cloning thread is impersonating at the moment of the clone, the impersonation token does not become the new thread's primary or effective identity — the new thread starts with the shared primary token as both, and may impersonate independently later.

3.2.3.2 Exec #

The primary token survives execve() unchanged, with the single exception of NEW_PROCESS_MIN below. If the calling thread is impersonating, impersonation is reverted before the new program runs; a new program always starts with the primary token as its effective identity.

Token assignment for services happens between fork and exec: peinit forks, installs the service's token on the child, and the child then execs the service binary.

3.2.3.2.1 NEW_PROCESS_MIN #

When a token's mandatory_policy includes NEW_PROCESS_MIN, the kernel replaces the primary token at exec time if the executable carries an explicit, lower integrity label.

  1. The executable's integrity label is read from the mandatory label ACE in its descriptor's SACL. A file with no label — no SACL, or no non-inherit-only mandatory label ACE — is unlabelled, and the token survives exec unchanged. This is deliberately not the access-check rule, under which an unlabelled object counts as Medium (§3.8.3): an image that labels nothing would otherwise demote every process on it, PID 1 first, to Medium — which is exactly what happened before PEI-530, leaving the TCB below every High-integrity user and unable to convey their identity.
  2. If the file's level is lower than the token's, a new token is created following DuplicateToken semantics — new token_id, new token_guid, modified_id initialised to the new token_id, elevation_type reset to Default — with integrity_level set to the file's label. Every other field — the impersonation level included — is copied from the source, and the original token is dropped.
  3. If the file's level is greater than or equal to the token's, nothing happens and the token survives exec unchanged.

The mechanism only ever lowers integrity, so a child's level is always at most its parent's, and only to a level the image itself claims. The flag itself is immutable on the token, which is what prevents a process from opting out before exec.

3.2.3.3 Self-installing a primary token #

KACS_IOC_INSTALL commits a new primary token on the calling process. The operation is process-wide: the kernel replaces the primary token for the entire thread group, not just the calling thread.

The handle has to carry TOKEN_ASSIGN_PRIMARY, the token has to be a primary token, and the caller's real token has to hold SeAssignPrimaryTokenPrivilege enabled — an impersonating thread's effective token does not satisfy the gate, and the privilege is marked used on success. Unless that real token also holds SeTcbPrivilege enabled, the token being installed has to carry the same user SID and the same LogonSession as the caller's, or the call fails with EPERM; this is what stops a non-TCB holder of SeAssignPrimaryTokenPrivilege from installing an arbitrary higher-privilege token on itself, and SeTcbPrivilege bypassing it is how peinit installs service tokens with a different user SID and LogonSession.

A thread that is not impersonating switches both real_cred and cred to the new primary token. A thread that is impersonating has only real_cred replaced, and its active impersonation stays in cred until it reverts or execs — so reverting after an install restores the thread to the new primary token, not the old one.

The calling thread installs immediately. Sibling threads converge in their own context through queued in-kernel credential work, with no atomic all-threads-at-once swap: during a brief transition window some siblings may still observe the old primary token until they run their queued work. No completion barrier is exposed to the caller.

Successful installations are ordered per process. Siblings converge to the latest successful installation in that order; older queued work must not restore a superseded token, including when retried after an allocation failure. Concurrent callers need not complete their syscalls in installation order. Once exec commits its primary token, delayed pre-exec installation work cannot replace it.

A new thread joins the same convergence rule even if it was not yet visible when an installation selected the siblings to update. This does not change a process fork's independent copy of the calling thread's primary token.

If the installed token's user SID differs from the outgoing primary token's, the process security descriptor is regenerated from the default template for the new token. Otherwise the existing process descriptor is preserved.

3.2.3.4 Bootstrap tokens #

Two tokens are created by PKM during kernel initialisation, before any userspace process exists. Neither involves a syscall — the kernel allocates the objects directly.

The SYSTEM token is hardcoded with user SID S-1-5-18 (Local System); groups S-1-5-32-544 (BUILTIN\Administrators), S-1-1-0 (Everyone), S-1-5-11 (Authenticated Users) and other well-known SIDs; every defined privilege present and enabled; integrity level System; token type Primary; impersonation level Delegation, the top of the ratchet; elevation type Default; token source PeiosKrn; projected UID 0; and auth_id set to SYSTEM_LUID. It is assigned to the kernel's init task and inherited by PID 1 at exec. Its audit_policy is PRIVILEGE_USE_SUCCESS, so every privilege SYSTEM spends — at a capability gate and in an access check — is recorded as kacs.audit.privilege.used (§3.4), and tokens derived from it carry the same policy. The Anonymous token and the query-only SYSTEM token have an audit policy of 0.

The Anonymous token is a global singleton with user SID S-1-5-7; Everyone (S-1-1-0) as its only group; no privileges; integrity level Untrusted; logon type Network; token type Impersonation; impersonation level Anonymous; elevation type Default; token source PeiosKrn; and auth_id set to ANONYMOUS_LOGON_LUID. It is effectively immutable, having no privileges and no groups to adjust. A peer token captured at Anonymous level references this global object, whereas KACS_IOC_DUPLICATE targeting Anonymous level creates a fresh independent token of the same shape, because the DuplicateToken contract requires a new object.

LUIDValueDescription
SYSTEM_LUID999 (0x3E7)The SYSTEM LogonSession, created at kernel init.
ANONYMOUS_LOGON_LUID998 (0x3E6)The Anonymous LogonSession, created at kernel init for Anonymous impersonation tokens.

LogonSessions created dynamically by authd receive auto-generated LUIDs starting at 1000; 999 and 998 are never assigned dynamically.

The SYSTEM token carries SeBackupPrivilege and SeRestorePrivilege present and enabled at boot. FACS passes backup and restore intent flags into AccessCheck, which grants read and write regardless of file DACLs — subject still to PIP. Once peinit has launched the TCB services and early boot is complete, it disables these on child service tokens through FilterToken.

3.2.3.5 External token replacement #

A privileged process being able to replace the primary token on another running process — peinit downgrading a pre-authd service from SYSTEM to a purpose-built token, for instance — is designed but not built. Nothing in the kernel implements it today.

The design uses task_work_add() to queue a credential swap on each task in the target thread group, each task executing the swap in its own context to preserve RCU safety, with an impersonating thread having only real_cred replaced and its impersonation left intact. The gates would be SeAssignPrimaryTokenPrivilege on the caller's real token, TOKEN_ASSIGN_PRIMARY (0x0001) on the token fd, PROCESS_SET_INFORMATION on the target process's descriptor, and two constraints — the new token's user SID matching the target's current one, and the new token belonging to the same LogonSession — both bypassed by SeTcbPrivilege. The process descriptor gate puts the target's owner in control of who can change its identity, while the SID and LogonSession constraints stop a non-TCB holder of SeAssignPrimaryTokenPrivilege assigning an arbitrary token to a process it can reach. SeTcbPrivilege bypassing both is how peinit would assign tokens with different user SIDs and LogonSessions to child services. Per-thread queuing would leave a brief window with some threads on the new token and some on the old, which is acceptable only because replacement is always a downgrade.

In its absence, the mitigation for pre-authd services is privilege removal: peinit uses FilterToken to copy the SYSTEM token with the dangerous privileges permanently deleted and assigns that filtered token to the service at launch. The service keeps the SYSTEM SID but permanently lacks the ability to exercise those privileges.

3.2.4 Token Creation

Peios / Peios technical reference / PKM / KACS / Tokens

Three operations create tokens, each for a different purpose: CreateToken mints one from nothing, DuplicateToken copies one, and FilterToken produces a strictly weaker copy.

3.2.4.1 CreateToken #

Mints a new token from scratch. The caller supplies the security-meaningful content; the kernel generates the internal bookkeeping and validates the structural invariants. The operation is gated by SeCreateTokenPrivilege.

The caller supplies user_sid, groups with their attributes, privileges as privs_present plus privs_enabled, owner_sid_index, primary_group_index, default_dacl, integrity_level, mandatory_policy, token_type, impersonation_level, auth_id referencing an existing LogonSession, expiration (0 for none), audit_policy, source as a name plus LUID, user_claims, device_claims, lcs_scope_guids, lcs_private_layers, device_groups, restricted_sids, restricted_device_groups, confinement_sid, confinement_capabilities, confinement_exempt, isolation_boundary, write_restricted, user_deny_only, projected_uid, projected_gid, projected_supplementary_gids, origin, and interactivity_scope. The wire format is in §3.A.

Including the well-known implicit groups is the caller's responsibility — Everyone (S-1-1-0), Authenticated Users (S-1-5-11), and whatever else the principal's authentication context implies, such as S-1-5-4 Interactive, S-1-5-6 Service, or S-1-5-15 This Organization. The kernel injects none of these. The logon SID is the sole kernel-generated group.

The kernel generates token_id as a LUID, token_guid, modified_id initialised to token_id, created_at as the current time, elevation_type always Default, the token's own default security descriptor (§3.2.7), and logon_sid, derived from the LogonSession ID as S-1-5-5-{id >> 32}-{id & 0xFFFFFFFF}.

The logon SID is injected into the groups array carrying SE_GROUP_MANDATORY | SE_GROUP_ENABLED_BY_DEFAULT | SE_GROUP_ENABLED | SE_GROUP_LOGON_ID, appended after the caller's groups. Callers do not include it themselves. Because the injected entry is appended, owner_sid_index and primary_group_index are interpreted relative to the caller-supplied groups — 0 for the user SID, 1..N for the caller's groups — and not against the array with the logon SID in it.

Validation covers, in turn:

  • that the caller holds SeCreateTokenPrivilege;
  • that every SID is structurally well-formed;
  • that the owner SID is the user SID or a group carrying SE_GROUP_OWNER, resolved to owner_sid_index;
  • that the primary group SID is the user SID or a group on the token, resolved to primary_group_index;
  • that auth_id references an existing LogonSession;
  • that user_deny_only is true whenever write_restricted is;
  • that confinement_sid is present whenever isolation_boundary is;
  • that the wire format's elevation_type field is 0, since the kernel always sets Default itself;
  • and that the caller's group count plus the injected logon SID fits the 1024-entry limit.

The optional LCS registry credential extension, when present, has to use the version and layout of §3.A, and carries at most 256 scope GUIDs and at most 256 private layer names, with no nil scope GUID, no duplicate scope GUIDs, no empty or overlong layer names, and no duplicate layer names under LCS case-insensitive matching. Malformed LCS credentials fail the whole call closed.

CreateToken accepts all four impersonation levels for a Primary token, including Identification and Anonymous, and preserves the chosen level and user identity. The level limits what may be derived from the token; it does not prevent the process from acting as its own primary identity. A peer capture takes the lower of the primary and socket levels: Identification conveys identity without authority to act as it, while Anonymous conveys the anonymous identity.

A minter chooses this ceiling deliberately (§3.5.1). authd uses Delegation for verified evidence and Impersonation for attested evidence; peinit's SYSTEM token uses Delegation. Each client may lower its level per connection. Older kernels refused Primary tokens below Impersonation as a migration guard; callers must not use an ignored or default Anonymous field when they intend peers to impersonate the identity.

The kernel does not authenticate the user, look up SIDs in the directory, resolve SID-to-UID mappings, or check that the principal exists at all. Holding SeCreateTokenPrivilege is what makes the caller trusted, and that trust is total.

The call returns a token file descriptor. Since CreateToken takes no desired-access parameter, the returned handle always carries a cached access mask of TOKEN_ALL_ACCESS.

3.2.4.2 DuplicateToken #

Creates an independent copy of an existing token, requiring TOKEN_DUPLICATE access on the source.

Two things may change during duplication. The token type may go from primary to impersonation or the reverse. The impersonation level is chosen by the caller and is a ratchet: whatever the types involved, the new level has to be equal to or lower than the source's, and asking for more fails with EINVAL. A primary minted at Delegation can be duplicated to an impersonation token at any level; a primary at Impersonation cannot yield a Delegation-level token; an Identification-level token cannot be duplicated up to Impersonation or Delegation. The level only ever goes down — reaching a higher one again means minting a fresh token with CreateToken.

Duplicating to Primary is how an impersonated identity becomes a process — the server's impersonation token, converted, is what a service manager installs on a child it launches as that client. The result keeps the identity and the level, so a primary made from a client captured at Impersonation is itself capped at Impersonation and so is every process and token descended from it. The kernel refuses a Primary result below Impersonation: a captured Identification or Anonymous identity must not become an acting process through duplication. This restriction applies even when the source is itself Primary. It is separate from privileged CreateToken, which may deliberately mint a primary with a low derivation ceiling.

On the new token, token_id and token_guid are fresh, modified_id is initialised to the new token_id, and elevation_type resets to Default because the copy belongs to no linked pair. token_type and impersonation_level are as the caller specified, within the rules above. The token's own descriptor is a fresh default (§3.2.7): no custom descriptor can be supplied at duplication time, and changing it afterwards means using WRITE_DAC on the new handle.

Everything else is copied from the source: user_sid, user_deny_only, logon_sid; groups with all per-group attributes; restricted_sids and write_restricted; the privilege present, enabled, enabled-by-default and used states; integrity_level and mandatory_policy; auth_id, origin, source, created_at, expiration, audit_policy; interactivity_scope; default_dacl, owner_sid_index, primary_group_index; user_claims, device_claims, device_groups, restricted_device_groups; lcs_scope_guids and lcs_private_layers; confinement_sid, confinement_capabilities, confinement_exempt; and the three projection fields.

One target is not a copy at all. Duplicating to Impersonation at Anonymous level discards the source entirely and returns a fresh token of the boot Anonymous shape — user SID S-1-5-7, Everyone as its only group, no privileges, Untrusted integrity, and LogonSession 998 rather than the source's. None of the copied-field rules above apply to it. Assuming Anonymous is an identity boundary rather than a level change, so the operation constructs the minimal identity instead of narrowing the caller's.

The original token is unaffected.

3.2.4.3 FilterToken #

Creates a restricted copy, requiring TOKEN_DUPLICATE access on the source. Filtering only ever weakens: there is no parameter that grants anything.

It can remove privileges, deleting them permanently from the new token by clearing them from the present, enabled, and enabled-by-default states at once. It can set groups to deny-only, giving them SE_GROUP_USE_FOR_DENY_ONLY so they block access through deny ACEs but never grant it through allow ACEs — permanently, with no way back. It can add restricted SIDs, a secondary list that makes AccessCheck evaluate the DACL twice, granting access only when the normal SIDs and the restricted SIDs independently both pass. And it can enable write-restricted mode, limiting that second evaluation to write operations so reads use the normal list alone; enabling it forces user_deny_only true on the new token.

Input validation is all-or-nothing — a single malformed entry means no token is created. The deny-only list uses zero-based group indices into the source's group array, and a duplicate or out-of-range index is invalid. The restricting SID blob has to parse exactly as the declared packed SID list, with no truncated or trailing bytes. If the source token is already restricted and the intersection of its restricted SID list with the supplied list is empty, the request is invalid and nothing is created.

On the new token, token_id and token_guid are fresh, modified_id is initialised to the new token_id, and elevation_type resets to Default. The privilege states are the source's modified by the removal list, except that used resets to 0 — unlike duplication, which carries it across. groups keeps the source's SIDs with attributes modified per the deny-only list, adding and removing nothing. restricted_sids is the supplied list, or its intersection with the source's when the source was already restricted. write_restricted is sticky: set if requested or if the source had it. user_deny_only is true when write-restricted is enabled and otherwise copied. user_sid, logon_sid, integrity_level, mandatory_policy, token_type and impersonation_level are copied, as are auth_id, origin, source, created_at, expiration, audit_policy, default_dacl, owner_sid_index, primary_group_index, the claims and device group arrays, the LCS credentials, the confinement fields, and the projection fields. The token's descriptor is a fresh default.

3.2.5 Token Adjustment

Peios / Peios technical reference / PKM / KACS / Tokens

A live token's privileges, groups, default object-creation metadata, and interactive session metadata can be adjusted at runtime. These are distinct operations with separate access rights and constraint models. All of them mutate the token object in place using atomic operations, all become visible immediately to every thread sharing the token, and all bump modified_id.

3.2.5.1 AdjustPrivileges #

Requires TOKEN_ADJUST_PRIVILEGES on the token, and has three modes.

Enable and disable flips individual bits in privileges_enabled for privileges present on the token. A privilege that is not present cannot be enabled, and disabling one that is already absent is a no-op. The operation activates existing privileges; it never grants new ones.

Reset to defaults restores privileges_enabled to match privileges_enabled_by_default, returning every privilege to its creation-time state in one operation. It is encoded as a single kacs_priv_entry with luid = 0 and attributes = KACS_PRIV_RESET_ALL_DEFAULTS. The reset touches only privileges_enabled: it does not restore privileges that were removed from privileges_present.

Remove permanently deletes a privilege, clearing its bit in privileges_present, privileges_enabled, and privileges_enabled_by_default together. Removing an already-absent privilege is a no-op. The deletion is irreversible; nothing re-adds a privilege to a token.

Duplicate privilege indices in one request are invalid. The kernel validates every entry before applying any change, so an invalid entry fails the whole operation with no state change at all. The caller receives a report of each adjusted privilege's previous state.

3.2.5.2 AdjustGroups #

Requires TOKEN_ADJUST_GROUPS on the token, and has two modes.

Enable and disable flips SE_GROUP_ENABLED on individual groups, within two constraints. A group carrying SE_GROUP_MANDATORY, SE_GROUP_USE_FOR_DENY_ONLY, or SE_GROUP_LOGON_ID cannot be adjusted in either direction — not enabled, not disabled, whatever its current state; naming one in a request fails the whole call. And the user SID, when it appears in the group list, cannot be disabled, which is the one constraint that is direction-scoped.

Reset to defaults restores every group to its creation-time enabled state. It restores only that state — it does not clear SE_GROUP_USE_FOR_DENY_ONLY from a group that FilterToken marked deny-only afterwards.

Groups are addressed by zero-based index into the token's groups array. A count of 0 is invalid, as is a count above 1024, as are duplicate indices in one request. Reset is encoded as a single kacs_group_entry of { index = 0xFFFFFFFF, enable = 0 }.

The caller receives the previous enabled state of every group as a 1024-bit mask encoded as sixteen 64-bit words in ascending order: word 0 covers group indices 0–63, word 1 covers 64–127, and bit i % 64 of word i / 64 corresponds to group index i. Since group arrays cap at 1024 entries, the mask is complete for any valid token.

3.2.5.3 AdjustInteractivityScope #

Requires TOKEN_ADJUST_INTERACTIVITY_SCOPE on the token and SeTcbPrivilege on the caller's real token. It changes the token's interactivity_scope to a new u32 and nothing else: the field is metadata, and changing it grants or removes no privilege, group, access right, label, claim, default owner, default primary group, default DACL, or any other authorization state.

3.2.5.4 AdjustDefault #

Requires TOKEN_ADJUST_DEFAULT on the token, and covers three fields.

The default DACL applied to new objects is replaced by an RCU pointer swap, with the old DACL freed after a grace period. The owner SID index changes which SID becomes the default owner of new objects, and has to reference the user SID or a group carrying SE_GROUP_OWNER. The primary group index changes the default primary group, and has to reference the user SID or any group SID on the token. Both index updates are atomic.

All three affect future object creation only; existing objects are untouched. None can escalate anything, because the caller is choosing among SIDs already on their own token.

audit_policy is fixed at creation, and no adjustment operation changes it.

3.2.6 Linked Tokens and Elevation

Peios / Peios technical reference / PKM / KACS / Tokens

At logon, authd may create two tokens for one principal and link them. The elevated token, elevation_type = Full, carries the user's complete identity: every group active, every assigned privilege present and enabled. The filtered token, elevation_type = Limited, carries the same user SID with administrative groups set to deny-only and dangerous privileges stripped, produced from the elevated token by FilterToken.

Both tokens belong to the same LogonSession, are both primary tokens, and carry the same user SID. The filtered token is installed as the LogonSession's default; the elevated token exists but is not directly reachable by unprivileged processes.

A token never assigned a linked-pair role has elevation_type = Default. Once KACS_IOC_LINK_TOKENS sets a token object to Full or Limited, that role is sticky on that object. If the pair is later replaced or destroyed the token has no active partner and KACS_IOC_GET_LINKED_TOKEN returns an error, but the token goes on reporting its last assigned elevation type.

3.2.6.1 What KACS does #

KACS provides pairing, storage, and query restriction — three mechanisms, no policy.

Linked pair association registers the pair on the LogonSession, so given either token the system can retrieve its partner. The pairing lives at the LogonSession level and is not stored on the token objects.

Establishing a pair is a TCB operation: the caller holds SeTcbPrivilege on its primary token — an impersonating thread's effective token does not satisfy it — and holds TOKEN_DUPLICATE on both of the token handles being linked. The two handles have to name distinct token objects, and both have to belong to the LogonSession named in the request, which itself has to be published. The ioctl ignores the handle it was issued on entirely; only the two named handles matter.

Elevation type classification puts elevation_type on each token so a consumer can tell which side it is holding.

Identification-level query restriction governs what an unprivileged caller gets when it queries a token's partner: a deep clone at Identification impersonation level. The clone follows DuplicateToken semantics — a new token object, a new token_id, modified_id initialised to it, a fresh default descriptor — except that it preserves the partner's elevation_type, and it is always returned through a TOKEN_QUERY-only handle. The caller can inspect the elevated token but cannot use it for an access decision. A caller holding SeTcbPrivilege receives a full handle to the actual linked token instead.

Returning a copy through TOKEN_QUERY is a deliberate exception to the normal access-right model, where TOKEN_DUPLICATE would be expected. It holds because the returned copy is always Identification-level: functionally a query result rather than a usable token.

3.2.6.2 What KACS does not do #

The elevation decision itself — whether this user should be allowed to use their elevated token right now — is entirely authd's. KACS does not gate elevation, verify credentials, or display prompts. It stores the pair and restricts unprivileged access to it.

Beyond enforcing the LogonSession, token-type and same-user invariants, KACS does not verify that the filtered token really is a FilterToken-derived reduction of the elevated one. That correspondence is authd's to get right.

3.2.6.3 Lifecycle #

Both tokens in a pair share a LogonSession, and that session is not destroyed while any token fd, credential, pair slot, or other reference keeps one of its token objects live. When the last external reference is released and only the linked-pair's own references remain, KACS destroys the LogonSession, removes the linkage, and drops the pair's references to both tokens. After that cleanup no token object from the session remains live purely because it was linked.

Stale-role tokens can exist before final destruction — for instance when KACS_IOC_LINK_TOKENS replaces a LogonSession's active pair while an old token object is still held by an fd or credential. Fork produces them too: the deep copy a child receives preserves the parent's elevation type, so a forked child can hold a Full or Limited token that was never linked to anything. Once a token is no longer the active member of the pair, querying its linked token returns an error, because the partner relationship no longer exists for it. Such survivors keep their sticky Full or Limited elevation type: they are stale-role tokens with no active partner, not Default tokens.

3.2.7 LogonSessions and Revocation

Peios / Peios technical reference / PKM / KACS / Tokens

3.2.7.1 LogonSessions #

A LogonSession is a lightweight kernel object identified by a LUID, carried on tokens as auth_id. Every token references one.

authd creates a LogonSession through a KACS syscall at authentication time, before creating the token. The object holds the LogonSession ID, the logon type (Interactive, Network, Service, and so on), the user SID, the authentication package name such as Kerberos or Negotiate, and a creation timestamp. The logon SID, S-1-5-5-X-Y, is derived from the LogonSession ID. Several tokens may share one session — linked pairs, and tokens derived by duplication.

When the last token referencing a session is freed, the kernel destroys the session object and emits a kacs.session.destroyed event through KMES. The last token usually goes when a credential is freed, which happens in an RCU callback where no record can be built, so the session is queued, still referenced, and a kernel work item writes the event moments later and then releases it. The session is gone at once — it no longer resolves — but its event follows the teardown rather than coming from it, and its header names the kernel worker that wrote it. The event is for whatever keeps per-session state outside the kernel — an audit pipeline, an accounting tool, an authority that caches credentials per session. authd keeps none and does not subscribe.

The record describes the session under object.session: its LUID as id (the subject.token.auth-id of every record made under it), the user SID as user.sid, the logon type as logon-type, the authentication package as auth-package, and the creation time as logon-time. The logon type is written by name — interactive, network, batch, service, network-cleartext, new-credentials or remote-interactive — and the time in realtime nanoseconds, though the kernel holds it to whole seconds.

There is one rollback path for the case where authd creates a session but no token ever becomes live for it: kacs_destroy_empty_logon_session, which requires SeTcbPrivilege and succeeds only when the session exists, has zero live tokens, has no linked-token state, and has no other in-flight kernel references. On success it destroys the object and emits the same kacs.session.destroyed event as normal cleanup. A nonexistent session fails with -ENOENT; one with any live token, linked-token state, or in-flight reference fails with -EBUSY.

A second enumeration surface exists alongside /proc: /sys/kernel/security/kacs/sessions lists every live session, one line each, giving the session ID, user SID, logon type, authentication package, and creation time. Each line reads

logon_session_id=<u64> user_sid=<hex> logon_type=<u32> auth_package=<hex> created_at=<u64>

with the user SID's binary form and the package name's bytes in lowercase hexadecimal, and created_at in seconds since the Unix epoch. securityfs itself is unclassified by FACS, so its objects sit deny-missing until the mount is given a synthesising policy (§3.9.5); only then can the file be opened at all. On Peios, peinit gives it one at boot that lets Authenticated Users open and traverse. Reading it is access-checked against a synthetic descriptor granting read to SYSTEM and Administrators, and is PIP-checked.

AccessCheck never consults auth_id, and the logon SID influences a decision only because it is materialised as an ordinary group SID on the token. Two enforcement decisions elsewhere in the kernel do read session state, though. Installing a primary token denies a non-TCB caller whose target token belongs to a different LogonSession (§3.2.3). And the CAP_SYS_BOOT mapping selects between SeShutdownPrivilege and SeRemoteShutdownPrivilege by inspecting the session's logon type (§3.10). interactivity_scope is metadata in the same way: the kernel stores it and returns it on query, and no kernel security mechanism evaluates it.

3.2.7.2 Expiration #

The expiration field carries a timestamp, and AccessCheck does not enforce it. It is informational.

Token lifetime is governed by reference counting instead: a token exists as long as at least one reference — a process credential or an open file descriptor — exists.

3.2.7.3 Revocation #

KACS has no token revocation primitive. There is no "invalidate token X" syscall, and no syscall destroys a LogonSession while tokens still reference it. kacs_destroy_empty_logon_session is only authd's rollback for a session that never acquired live tokens.

Terminating a LogonSession is therefore userspace coordination. On Peios authd does it, when asked over /run/logon.sock (PGSS Logon §2.22):

  1. A caller asks authd to end the session, and authd decides whether it may: its own session as a person, anyone else's by an access check against local policy. SYSTEM's session (999), Anonymous's (998) and every session of logon type Service are never ended this way; a service is stopped through peinit.
  2. authd enumerates processes whose primary token carries the target auth_id, by walking /proc, opening each process with pidfd_open, opening its primary token with kacs_open_process_token on that pidfd, and reading TokenStatistics, which includes auth_id. No dedicated enumeration syscall exists or is needed. A thread's impersonation token is not consulted, so a service impersonating a member of the session is not one of its processes.
  3. authd sends each SIGTERM with pidfd_send_signal, waits up to a grace period (5 seconds) for them to exit, and sends SIGKILL to the survivors.
  4. It walks again, since a process may have forked meanwhile, for at most three rounds.
  5. The processes terminate, dropping their token references.
  6. The last reference drops and the session object is cleaned up.

Each token handle authd opens in the walk is itself a reference to the session, and is closed as soon as auth_id has been read.

Token file descriptors can be passed between processes over IPC, so a reference held by a process outside the target session survives that session's process termination. The walk finds processes running under the session, not every process holding one of its tokens, so such a reference keeps the session alive after authd has ended every process in it, and authd reports nothing about it. A process that has exited but not been reaped likewise keeps its token, and the session, until it is reaped.

Kernel-side invalidation — a dead flag on the LogonSession object checked during AccessCheck, so that access checks against its tokens fail immediately — is not implemented.

3.2.8 Token Access Rights

Peios / Peios technical reference / PKM / KACS / Tokens

Tokens are securable objects: each has its own security descriptor, and reaching a token means passing an AccessCheck against it.

3.2.8.1 Obtaining a token file descriptor #

Opening directly. A syscall takes a pidfd — not a raw PID — and a desired access mask. The kernel finds the target's primary token, evaluates the caller's token against that token's descriptor, and returns a token fd with the granted mask cached on it. A separate variant opens a thread's impersonation token. Opening another process's token additionally requires PROCESS_QUERY_INFORMATION on the target process's descriptor.

Opening through /proc. /proc/<pid>/token and /proc/<pid>/task/<tid>/token open a process's primary token and a thread's effective token as a query-only handle: the fd always carries TOKEN_QUERY and nothing else. Opening another process's file makes both of the syscall's checks — PROCESS_QUERY_INFORMATION on the process's descriptor, with PIP dominance, and TOKEN_QUERY on the token's own descriptor — so the path reads nothing the syscall would refuse. A process opens its own without either check.

The peer-token socket option (getsockopt(SOL_KACS, KACS_SO_PEER_TOKEN)) is the exception: it takes no desired-access mask, and the fd it returns always carries the fixed rights TOKEN_QUERY | TOKEN_IMPERSONATE | TOKEN_DUPLICATE. A token delivered in a KACS_SCM_TOKEN control message carries the same three. TOKEN_DUPLICATE is what lets a server turn a client's identity into a primary token for a process it launches on the client's behalf (§3.2.4); it is safe to hand out because the level ratchet means the copy can never act above the level the client chose (§3.5.1).

Receiving over IPC. A token fd can be passed over a Unix socket with SCM_RIGHTS. What the recipient may do is bounded by the mask cached on the fd when it was originally opened, not by the recipient's own identity.

Implicit self-access. A thread has implicit access to its own effective token for query operations, but this is not a kernel bypass. It follows from the default token descriptor, which grants TOKEN_QUERY and the adjustment rights to the token's own user SID. The AccessCheck still runs; it simply succeeds while the descriptor continues to grant the right. Explicitly mutating a token's own descriptor later can revoke self-query by removing that grant.

3.2.8.2 Token-specific rights #

RightValueGrants
TOKEN_ASSIGN_PRIMARY0x0001Install as a process's primary token. Also requires SeAssignPrimaryTokenPrivilege on the caller's token.
TOKEN_DUPLICATE0x0002Duplicate the token, or create a restricted copy with FilterToken.
TOKEN_IMPERSONATE0x0004Install as a thread's impersonation token.
TOKEN_QUERY0x0008Read token information: SIDs, groups, privileges, integrity, claims, source, statistics, GUID, elevation type.
TOKEN_ADJUST_PRIVILEGES0x0020Enable, disable, or permanently remove privileges.
TOKEN_ADJUST_GROUPS0x0040Enable or disable groups.
TOKEN_ADJUST_DEFAULT0x0080Change the default DACL, owner SID, and primary group SID.
TOKEN_ADJUST_INTERACTIVITY_SCOPE0x0100Change the interactivity scope. Also requires SeTcbPrivilege.

Bit 0x0010, TOKEN_QUERY_SOURCE, is subsumed by TOKEN_QUERY: a holder of 0x0008 can query source information too. The bit is not reused for anything else, for format compatibility with MS-DTYP.

TOKEN_ALL_ACCESS is 0x000F01FF — the union of the token-specific rights with STANDARD_RIGHTS_REQUIRED (DELETE | READ_CONTROL | WRITE_DAC | WRITE_OWNER, 0x000F0000). The named rights alone OR to 0x01EF; the reserved TOKEN_QUERY_SOURCE bit adds 0x0010 to reach 0x01FF.

3.2.8.3 Generic mapping #

Generic rightMaps to
GENERIC_READTOKEN_QUERY | READ_CONTROL
GENERIC_WRITETOKEN_ADJUST_PRIVILEGES | TOKEN_ADJUST_GROUPS | TOKEN_ADJUST_DEFAULT | WRITE_DAC
GENERIC_EXECUTETOKEN_IMPERSONATE
GENERIC_ALLTOKEN_ALL_ACCESS (0x000F01FF)

3.2.8.4 Standard rights #

READ_CONTROL reads the token's own descriptor, WRITE_DAC modifies its DACL, and WRITE_OWNER changes its owner. DELETE has no practical effect on a token and is present only for uniformity across standard rights.

3.2.8.5 The default token descriptor #

A newly created token receives a descriptor owned by the creating process's user SID, with a DACL granting:

  • the token's own user SID TOKEN_QUERY | TOKEN_ADJUST_PRIVILEGES | TOKEN_ADJUST_GROUPS | TOKEN_ADJUST_DEFAULT;
  • the creator TOKEN_ALL_ACCESS;
  • SYSTEM (S-1-5-18) TOKEN_ALL_ACCESS;
  • Administrators (S-1-5-32-544) TOKEN_QUERY.

The Administrators grant is read-only: it lets an administrator see who any process runs as, its groups and privileges, and which logon session it belongs to, which is what a machine-wide process listing needs. Duplicating, impersonating, adjusting or rewriting the token still needs the token's own grants.

Self-access is deliberately limited to the adjustment operations that cannot escalate. TOKEN_DUPLICATE, TOKEN_IMPERSONATE, and WRITE_DAC are not granted to the token's own subject.

That limit needs protecting in the case where the creator and the token's own user SID are the same, which would otherwise hand the subject TOKEN_ALL_ACCESS through the creator ACE. When the two SIDs are identical the creator ACE is omitted — and the descriptor also gains a non-inherit-only OWNER RIGHTS ACE suppressing the owner's implicit READ_CONTROL | WRITE_DAC grant while preserving READ_CONTROL. Without it, owner-implicit WRITE_DAC would let the subject rewrite its own DACL and reintroduce exactly the escalation that omitting the creator ACE was meant to close.

3.2.8.6 Check-at-open #

The check-at-open model applies to tokens exactly as it does to files, for the token-specific rights. AccessCheck runs once, when the token fd is obtained; the granted mask is cached on the fd; and each of the token ioctls verifies against that cached mask with no re-evaluation.

The standard rights are the exception. Reading and writing a token's own descriptor passes no cached mask at all and runs a live AccessCheck on every call, so READ_CONTROL, WRITE_DAC and WRITE_OWNER are re-evaluated per operation rather than snapshotted at open. A descriptor change therefore takes effect immediately for those three rights, while already-opened handles keep their cached token-specific rights.

3.3.1 The PSB Model

Peios / Peios technical reference / PKM / KACS / The Process Security Block

The Process Security Block is a per-process structure carrying properties that describe what the process is, independent of the principal running it.

Its PIP identity fields, pip_type and pip_trust, are determined by the binary loaded at exec (§3.6). Its other fields — process mitigations and process restrictions — are process policy, set by whoever launches the process. Neither category is token identity, and neither is determined by the principal.

Keeping the PSB separate from the token is the point. The token describes identity and travels with impersonation: when a thread impersonates a client, the effective token changes. The PSB describes the process, and impersonation never touches it.

The PSB is never affected by impersonation. Impersonation changes who the thread is acting as. It does not change what the process is.

The canonical PSB reference lives on the task's LSM security blob, task_struct->security, separate from the credential that holds the token pointer. A mirrored, non-authoritative reference is also kept in credential security blobs, for the benefit of Linux hooks that receive only a credential. For any task-attached credential that mirror refers to the same PSB as the task blob, and it never defines a different process identity.

Credentials are swapped during impersonation, and that swap may change the credential's token pointer — but the canonical PSB is untouched and any credential-level mirror continues to refer to it.

3.3.2 PSB Fields

Peios / Peios technical reference / PKM / KACS / The Process Security Block

3.3.2.1 Process identity (fixed at fork) #

FieldTypeDescription
process_guidUUID128-bit identifier for this process instance, generated by the kernel at fork and immutable for the process's lifetime. It is not copied from the parent — every process receives a new one. Used by KMES and kernel-internal consumers for identity stamping and event correlation.

The process GUID is distinct from the PID. PIDs are recycled; process GUIDs are unique within a boot, and globally unique in practice. It is the stable correlation key that lets KMES attribute events to a process across its whole lifetime.

3.3.2.2 Protection (set at exec, fixed) #

FieldTypeDescription
pip_typeu32Process Integrity Protection type. Determined by the binary's cryptographic signature at exec.
pip_trustuintTrust tier within a PIP type; higher values can reach lower ones. Determined by the signer's identity.

PIP fields are signing-based. At exec the kernel verifies the binary's signature and derives both fields from the signer, using the algorithm and key model of §3.6.

Both are plain unsigned integers rather than enumerations, and are compared numerically. Three type values are conventional — None (0), Protected (512) and Isolated (1024) — but only two are producible: the key table validator accepts a key only at exactly Protected with PeiosTcb trust (8192) and rejects the whole table otherwise, so Isolated is unreachable and a signed binary is always Protected/8192. Neither None nor Isolated is defined as a named constant in the public ABI at all. The parent process cannot influence the determination at all — even a compromised peinit running as SYSTEM cannot forge PIP protection for an unsigned binary. The public verification key is compiled into the kernel image, and the kernel only ever verifies; it never signs.

This is a deliberate departure from MS-DTYP, where the parent sets a protection level at process creation and the kernel validates the binary's signature against it. Peios removes the parent-controlled half entirely: one input, one answer.

3.3.2.3 Process mitigations (one-way) #

FieldDescription
lsvLibrary Signature Verification. Only signed shared libraries load. When the process has pip_type != None the library's trust level has to be at or above the process's PIP trust; when pip_type = None any valid signature suffices and trust is not compared.
wxpWrite-XOR-Execute Protection. No page is simultaneously writable and executable; W+X mappings and transitions between writable and executable are rejected.
tlpTrusted Library Paths. Shared libraries load only from approved directory prefixes. Weaker than LSV, since it trusts the path rather than the binary.
cfifForward-Edge Control Flow Integrity. Hardware indirect-branch tracking — Intel IBT, ARM BTI — is locked on and cannot be disabled by the process. Not settable: see below.
cfibBackward-Edge Control Flow Integrity. The hardware shadow stack, Intel CET, is locked on and cannot be disabled by the process.
piePosition-Independent Executable Requirement. Non-PIE binaries are rejected at exec, so that ASLR is actually effective.
smlSpeculation Mitigation Lock. Speculation mitigations are locked on and cannot be disabled by the process.

Mitigations are inherited from the parent at fork and can be set by syscall. They are one-way: once set they are never cleared, and exec does not reset them — a mitigation set by the launcher persists regardless of which binary is loaded.

Do not infer an additional service policy from peinit launch: the source-checked peinit 0.0.12 path does not make a per-service PSB mitigation request. Inherited and self-applied flags are separate from that launcher behavior.

Setting a bit is activation-backed. Before a mitigation bit moves from clear to set, KACS either activates the underlying protection for the target process or verifies that the process already satisfies the invariant. If any requested mitigation cannot be activated or verified, the whole operation fails closed without mutating any bit from that request. Once committed, later operations that would disable the protection or make the process violate the invariant are rejected, and re-requesting an already-set mitigation never weakens what is already committed.

For the runtime memory mitigations, activation covers existing state as well as future transitions. Enabling wxp fails if the process already has a mapping that is simultaneously writable and executable, or otherwise already violates the invariant in a way KACS can observe. Enabling tlp fails if the process already has a file-backed executable mapping whose kernel-resolved path is missing, unresolvable, outside the approved prefix cache, or otherwise TLP-denied. Enabling lsv fails if the process already has a file-backed executable mapping whose signing material is missing, invalid, or below the required PIP trust. Anonymous executable mappings are governed by wxp alone; tlp and lsv apply only to file-backed ones.

For the architecture-backed mitigations, activation goes through the architecture's kernel interface to place the process in the protected state and prevent later process-controlled disablement. Enabling cfif, cfib, or sml fails closed when the platform cannot make the protection true for the target.

sml also accepts a second route: a platform that reports speculation as unconditionally not-affected satisfies activation by that fact alone, with nothing to enable.

cfif cannot currently be committed at all. Activation against a live task returns ENODEV unconditionally, because the kernel exposes no userspace control surface for IBT or BTI, so the bit fails closed on every request. cfib works, through shadow-stack enable-and-lock, with one restriction: enabling it on a task other than the caller fails.

Two mitigations are event-gated rather than retroactive: pie is enforced at subsequent exec and no_child_process at subsequent process creation. They still follow the one-way commit rule, and have to be set before the event they are meant to constrain.

All of this is distinct from PIP. Mitigations are policy set by the launcher; PIP is a property of the binary determined by the kernel.

The mitigations compose deliberately: LSV ensures only signed libraries load, WXP blocks code injection, CFIF blocks forward-edge code reuse through indirect calls and jumps, CFIB blocks return-oriented programming, and PIE makes ASLR effective. Together they make exploitation dramatically harder than any one of them alone.

3.3.2.4 UI access (one-way) #

FieldDescription
ui_accessPermits interaction with higher-integrity UI elements. Reserved for future desktop functionality. Set by syscall, typically by peinit between fork and exec, and fixed thereafter.

3.3.2.5 Process restrictions (one-way) #

FieldDescription
no_child_processOnce set, the process creates no child processes — fork, or clone without CLONE_THREAD. New threads are unaffected, and the flag is never cleared.

Unlike the exec-time fields, this one can be set at two points. The parent's code, running in the freshly forked child, can set it before exec, so the new binary loads with the restriction already in place. Or a process can restrict itself at any time during its life — after it has finished spawning its own workers, for instance.

3.3.2.6 The TLP cache #

The approved directory prefixes for TLP live in a global kernel cache rather than on individual PSBs: the tlp flag decides whether a process is subject to enforcement, while the paths themselves are machine-wide.

The cache is an array of absolute directory prefix byte strings evaluated against kernel-resolved Linux path bytes, holding at most 64 entries of at most 4096 bytes each. Every prefix begins with / and ends with / — so that /usr/lib/ does not match /usr/libevil — and contains no embedded NUL byte. An empty, relative, NUL-containing, or non-slash-terminated prefix is invalid and is rejected without mutating the existing cache, which is staged and swapped under a mutex so a rejected update cannot leave it partly written.

The cache has no production writer. The only code that populates it is a test helper compiled in solely under the KUnit configuration: there is no syscall, no securityfs node, and no registry path that fills it. In a shipping build the cache is therefore permanently empty — and since an empty cache matches no path, enabling tlp on a process denies every file-backed executable mapping it subsequently attempts.

At mmap(PROT_EXEC) time, a process with TLP enabled has the mapped file's current kernel-resolved backing path checked against every approved prefix. If the path cannot be resolved, if no prefix matches, or if the cache is empty, the mapping is rejected.

3.3.2.7 Reading the PSB #

/proc/<pid>/psb gives a process's PSB as one line of text, in this order:

pip_type=<u32> pip_trust=<u32> mitigations=0x<hex> process_guid=<uuid>

mitigations is the committed mitigation bitfield in at least three hexadecimal digits, including ui_access and no_child_process, with the bit values of §3.A. process_guid is the 16 bytes in order, formatted 8-4-4-4-12.

A process reads its own without any check. Reading another process's needs PROCESS_QUERY_LIMITED on its process descriptor, with SeDebugPrivilege rescuing a descriptor denial as it does for every process right, and does not need PIP dominance. That a process is protected is not a secret every other refusal keeps; it is what a caller needs to see to understand those refusals.

The PSB is otherwise written only: kacs_set_psb sets mitigations and returns nothing about them.

3.3.2.8 Identity virtualization (reserved) #

FieldDescription
virtualizationPer-process state for setuid compatibility redirection. Not active, and implementations may omit the field until it is.

3.3.3 Process Security Descriptors

Peios / Peios technical reference / PKM / KACS / The Process Security Block

Every process carries a security descriptor controlling who may operate on it, stored on the PSB alongside the PIP and mitigation fields. It replaces Linux's UID-based process access control — a patchwork of UID comparisons and capabilities — with a single descriptor evaluation.

3.3.3.1 Process access rights #

RightValueMeaning
PROCESS_TERMINATE0x0001Send signals whose default action is termination.
PROCESS_SIGNAL0x0002Send informational signals whose default action is to ignore: SIGCHLD, SIGURG, SIGWINCH.
PROCESS_VM_READ0x0010Read process memory — ptrace peek, /proc/<pid>/mem, process_vm_readv.
PROCESS_VM_WRITE0x0020Write process memory — ptrace poke, /proc/<pid>/mem, process_vm_writev. Includes debugger attach.
PROCESS_DUP_HANDLE0x0040Extract file descriptors from the process through pidfd_getfd.
PROCESS_SET_INFORMATION0x0200Change priority, CPU affinity, I/O priority, resource limits, process group membership where Linux permits it, timer slack, memory-placement policy or pages, and mutable /proc/<pid> task state — sched, autogroup, timens_offsets, timerslack_ns, coredump_filter, oom_adj, oom_score_adj, make-it-fail, fail-nth, latency and clear_refs, plus write intent on the coupled uid_map, gid_map, projid_map and setgroups seq files.
PROCESS_QUERY_INFORMATION0x0400Inspect the process's token; read the detailed /proc/<pid>/* files — cmdline, status, io, limits, sched, autogroup, timens_offsets, personality, syscall, latency, timers, timerslack_ns, mounts, mountinfo, mountstats, coredump_filter, oom_adj, oom_score_adj, loginuid, make-it-fail, fail-nth, seccomp_cache, ksm_merging_pages and ksm_stat — plus read intent on the coupled uid_map, gid_map, projid_map and setgroups seq files; query Linux compatibility capability state through capget(pid); and query detailed scheduler, CPU-affinity and I/O-priority state.
PROCESS_SUSPEND_RESUME0x0800Send signals whose default action is to stop or continue.
PROCESS_QUERY_LIMITED0x1000Read basic process information: PID, process group ID, session ID, image name, state, CPU and memory usage — stat, statm, comm, wchan, schedstat, cpuset, cgroup, cpu_resctrl_groups, oom_score, sessionid, patch_state, stack_depth, arch_status and psb. This is what ps and top show, it covers /proc/<pid>/stat, and it is the right required for pidfd_open() and for kill(pid, 0) existence probes.
READ_CONTROL0x20000Read the process's own descriptor.
WRITE_DAC0x40000Modify the process's DACL.
WRITE_OWNER0x80000Change the descriptor's owner.

Three /proc entries are not where the right names suggest. maps, fd and environ are not gated by PROCESS_QUERY_INFORMATION: they keep their upstream PTRACE_MODE_READ_FSCREDS gating, which maps to PROCESS_VM_READ — reading a process's memory map is treated as reading its memory, which is defensible but is not what the right's name implies. And cgroup sits in the PROCESS_QUERY_LIMITED set rather than the detailed one.

psb is the process's PSB, read under PROCESS_QUERY_LIMITED with no PIP dominance (§3.3.2). It is the one /proc entry whose check is KACS's own rather than the metadata rule's.

3.3.3.2 Signal classification #

Each Linux signal maps to a process access right according to its default action.

Signal 0 is not delivered at all. A kill(), tkill(), or tgkill() call with signal 0 is an existence and permission probe, requiring PROCESS_QUERY_LIMITED on the target plus PIP dominance.

PROCESS_TERMINATE — default action terminate, or terminate with a core dump:

Signal#DefaultNotes
SIGHUP1TerminateSession hangup
SIGINT2TerminateCtrl-C
SIGQUIT3Terminate + coreQuit request
SIGILL4Terminate + coreIllegal instruction
SIGTRAP5Terminate + coreDebug trap
SIGABRT6Terminate + coreAbort
SIGBUS7Terminate + coreBus error
SIGFPE8Terminate + coreFloating point exception
SIGKILL9TerminateForced kill, cannot be caught
SIGUSR110TerminateUser-defined
SIGSEGV11Terminate + coreSegfault
SIGUSR212TerminateUser-defined
SIGPIPE13TerminateBroken pipe
SIGALRM14TerminateAlarm timer
SIGTERM15TerminateGraceful termination request
SIGSTKFLT16TerminateStack fault
SIGXCPU24Terminate + coreCPU time exceeded
SIGXFSZ25Terminate + coreFile size exceeded
SIGVTALRM26TerminateVirtual timer
SIGPROF27TerminateProfiling timer
SIGIO29TerminateI/O possible
SIGPWR30TerminatePower failure
SIGSYS31Terminate + coreBad syscall

PROCESS_SUSPEND_RESUME — default action stop or continue:

Signal#DefaultNotes
SIGSTOP19StopForced stop, cannot be caught
SIGTSTP20StopTerminal stop, Ctrl-Z
SIGTTIN21StopBackground read from terminal
SIGTTOU22StopBackground write to terminal
SIGCONT18ContinueResume a stopped process

PROCESS_SIGNAL — default action ignore:

Signal#DefaultNotes
SIGCHLD17IgnoreChild status change
SIGURG23IgnoreUrgent socket data
SIGWINCH28IgnoreWindow resize

The real-time signals, SIGRTMIN through SIGRTMAX (32–64), default to terminate and therefore require PROCESS_TERMINATE.

3.3.3.2.1 What bypasses the check #

This classification applies only to signals sent by userspace through kill(), tkill(), and tgkill(). Kernel-generated signals — hardware faults such as SIGSEGV, SIGBUS and SIGFPE, SIGCHLD from a child exiting, SIGPIPE from a broken pipe — are delivered by the kernel and bypass the process descriptor check entirely, because the task_kill LSM hook does not fire for kernel-originated delivery.

Terminal-generated job control signals are kernel-originated under that rule and bypass the check the same way: SIGINT, SIGQUIT and SIGTSTP from the tty driver's isig handling, and SIGHUP on hangup. This is intentional. Authorization for keyboard-driven signals is possession of the controlling terminal, which was gated by the terminal's file descriptor at open time — so Ctrl-C reaches the whole foreground process group even when a member of it is more privileged or more PIP-trusted than whoever holds the terminal. A process that cannot accept that exposure must not attach to an untrusted controlling terminal.

The si_uid in a delivered signal's siginfo_t is the sender's projected UID (§3.10), captured at send time. Like every projected credential surface it is informational only and is not an authorization input; si_pid carries the same caveat and is subject to PID reuse besides.

3.3.3.3 Generic mapping #

Generic rightMaps to
GENERIC_READPROCESS_QUERY_INFORMATION | PROCESS_VM_READ | READ_CONTROL
GENERIC_WRITEPROCESS_SET_INFORMATION | PROCESS_VM_WRITE | WRITE_DAC
GENERIC_EXECUTEPROCESS_TERMINATE | PROCESS_SUSPEND_RESUME | PROCESS_QUERY_LIMITED
GENERIC_ALLevery process right above, together with READ_CONTROL, WRITE_DAC and WRITE_OWNER

3.3.3.4 The default process descriptor #

Every process receives a default descriptor at creation:

Owner: <creator's primary token user SID>
Group: <creator's primary token primary group SID>
DACL:
  ALLOW  <process's own user SID>   GENERIC_ALL
  ALLOW  BUILTIN\Administrators     GENERIC_ALL
  ALLOW  SYSTEM                     GENERIC_ALL
  ALLOW  Everyone                   PROCESS_QUERY_LIMITED

A process can therefore do anything to itself; Administrators and SYSTEM have full control over every process; everyone can see basic process information, which is what makes ps and top work for all users; and detailed inspection — token, memory, environment — is restricted to the process itself, administrators, and SYSTEM.

A service can modify its own descriptor at runtime with kacs_set_sd, which requires WRITE_DAC — granted to the process itself by the default DACL. Requesting a custom descriptor at launch through a service definition is not implemented: the only descriptor creation path always builds the default template, so every process starts from it and any deviation is a subsequent write.

3.3.3.5 How PIP relates to it #

PIP and the process descriptor are complementary, and both checks have to pass. The descriptor controls who may operate on the process; PIP controls what trust level is required for invasive access to a protected one. AccessCheck evaluates the caller's token against the target's descriptor for the requested right, and PIP evaluates the caller's trust against the target's pip_type and pip_trust for operations crossing the process boundary.

The two are genuinely independent. A process may have a permissive descriptor granting Administrators GENERIC_ALL and still be PIP-protected, so administrators pass the descriptor check and are stopped only by insufficient PIP trust.

The converse — a process with no PIP protection carrying a restrictive descriptor that denies even administrators — holds with one qualification. When a descriptor check denies access and PIP was not the deciding factor, an enabled SeDebugPrivilege on the caller grants the access anyway and is marked used. The privilege therefore rescues a descriptor denial while remaining unable to cross a PIP boundary, which is exactly the split §3.4.2 describes for it.

3.3.4 PSB Lifecycle

Peios / Peios technical reference / PKM / KACS / The Process Security Block

3.3.4.1 Fork #

The child receives a copy of the parent's PSB with a single exception: process_guid is not copied, and the child is given a new kernel-generated one. Everything else — the PIP fields, the mitigations, and any active restrictions — is inherited, so a Protected process's children start Protected and PIP propagates across fork.

The child also receives a new default process descriptor. Its owner is the forking thread's primary token's user SID, not the impersonation token's, even when the thread is impersonating at the time. The DACL follows the default template.

3.3.4.2 Exec #

The PIP fields are reset at exec from the new binary's cryptographic signature. A Protected parent that execs an unsigned binary loses PIP protection: protection follows the binary, not the lineage.

The mitigation flags — lsv, wxp, tlp, cfif, cfib, pie, sml, ui_access — are not reset. They persist across exec unchanged, so a mitigation set between fork and exec survives whatever binary is subsequently loaded. no_child_process persists in the same way: a process restricted from creating children stays restricted no matter what it execs.

process_guid is not reset either, because it identifies the process — the scheduling entity — rather than the binary.

The process descriptor is not reset. Exec preserves it unchanged. It was initialised at fork from the forking thread's primary token and reflects the process creation context, or a later explicit management context, rather than the binary being executed. Primary token installation and the other explicit process-descriptor mutation paths replace or modify it only under their own rules (§3.2.3).

3.3.4.3 Clone with CLONE_THREAD #

Threads share the process's PSB. Thread creation is unaffected by no_child_process, which blocks new processes only.

3.3.4.4 Relationship to AccessCheck #

The PSB is not an input to AccessCheck in the general case. AccessCheck takes a token and a descriptor and evaluates access; most PSB fields are invisible to it.

PIP is the exception. The pipeline includes a PIP enforcement step reading pip_type and pip_trust, which come from the PSB rather than from any token: the enforcement layer extracts them and passes them to AccessCheck as explicit parameters.

The asymmetry between MIC and PIP follows from that. MIC uses the effective token, so impersonation changes how it evaluates — which is safe because the integrity ceiling on impersonation (§3.5.2) prevents escalation. PIP uses the PSB, so impersonation cannot change how it evaluates — necessary because there is no impersonation gate constraining the PIP dimensions, making the PSB the only safe source.

The process mitigations and no_child_process do not interact with AccessCheck at all. Each is enforced at its own enforcement point, independently of the access control pipeline.

3.4.1 The Privilege Model

Peios / Peios technical reference / PKM / KACS / Privileges

Some operations do not fit the subject-object model at all. Rebooting the machine, loading a kernel module, changing the system clock, creating a token — these affect the system itself rather than a specific protected resource, so there is nothing to attach a security descriptor to. They still need authorization.

Privileges fill that gap. A privilege is the right to perform a particular system operation, carried on the token beside the principal's identity. Where a descriptor says "this principal may read this file", a privilege says "this principal may shut down the system". The descriptor lives on the object; the privilege lives on the subject.

3.4.1.1 Lifecycle #

A privilege is assigned by policy when authd creates the token, resolving the principal's assignments from security policy once, at creation. There are no runtime grants: a privilege absent at creation can never be added later.

The privilege then sits on the token in whatever enabled state it was created with. The kernel accepts any enabled set that is a subset of the present set, and takes the creation-time enabled set as the enabled-by-default set. authd issues every privilege it grants already enabled, on the reasoning that a privilege the holder had to enable before it worked would be a grant in name only — so the present-but-disabled resting state exists in the model and is reachable through AdjustPrivileges, but is not where privileges normally start.

A privilege that has been disabled is re-activated by explicitly enabling it through AdjustPrivileges.

When the privilege is exercised, the kernel checks that it is both present and enabled — a single mask test against both words — permits the operation, and records it as used.

For a standalone gate, "exercised" means the gate accepted that bit, and the used bit is meant to be recorded even when a later independent check — a process descriptor, PIP, or a malformed-input test — denies the operation afterwards. Where the shared privilege helper performs the check, it marks the bit immediately, and the impersonation gate does the same. Three gates mark later and therefore record nothing when a subsequent check fails:

  • token creation marks after the token has been constructed and its descriptor allocated, so a malformed specification leaves the bit unset;
  • primary token installation marks after the same-user and same-LogonSession gate;
  • and the CAP_SYS_BOOT mapping marks after the remote-shutdown origin gate.

Used-state for the AccessCheck-influencing privileges follows the provenance rules of §3.8 instead.

Recording the used bit is not merely bookkeeping. Every gate treats a failure to record it as a failure of the operation itself and returns EPERM or EACCES.

Afterwards the privilege may be disabled, returning to rest, or removed permanently, which clears it from the present, enabled, and enabled-by-default states while preserving the used bit for audit.

3.4.1.2 Two enforcement categories #

Standalone operation gates are the majority. They authorize specific operations that AccessCheck does not mediate — rebooting, loading modules, debugging processes — and the kernel simply checks whether the calling thread's token holds the privilege present and enabled before allowing the operation.

AccessCheck-influencing privileges alter the outcome of AccessCheck itself, causing it to grant rights the object's DACL would not grant on its own. They are evaluated inside the pipeline alongside DACL rules, integrity policy, and confinement. There are five: SeSecurityPrivilege grants ACCESS_SYSTEM_SECURITY for SACL access (and doubles as a standalone gate for the audit-related Linux capabilities); SeTakeOwnershipPrivilege grants WRITE_OWNER as a post-DACL fallback; SeBackupPrivilege grants all read access; SeRestorePrivilege grants all write access plus WRITE_DAC, WRITE_OWNER, DELETE and ACCESS_SYSTEM_SECURITY; and SeRelabelPrivilege loosens MIC's constraint on WRITE_OWNER for non-dominant callers. §3.8 gives the exact mechanics.

3.4.1.3 Intent gating #

SeBackupPrivilege and SeRestorePrivilege are intent-gated. Other AccessCheck-influencing privileges are self-scoping — SeSecurityPrivilege only matters when ACCESS_SYSTEM_SECURITY is requested — but backup and restore grant such broad categories of access that evaluating them unconditionally would apply them to every AccessCheck on the system.

AccessCheck therefore takes a privilege_intent parameter. A caller passes BACKUP_INTENT for a backup-context operation and RESTORE_INTENT for a restore-context one, and the corresponding privilege is evaluated only when its flag is present. Without the flag, these privileges are invisible to the pipeline.

Intent gating also keeps backup and restore inside the pipeline rather than short-circuiting it, which matters because later stages — PIP in particular — have to be able to constrain privilege-granted access.

3.4.1.4 Assignment #

Privileges are assigned by security policy, not by identity. Membership of the Administrators group confers no privilege by itself. Groups and privileges are orthogonal: groups determine which objects you can reach through DACLs, privileges determine which system operations you can perform.

An administrator defines policy — that members of Backup Operators receive SeBackupPrivilege and SeRestorePrivilege, say. At authentication authd resolves the principal's group memberships, evaluates the policy against them, and creates the token carrying the result. The kernel neither verifies nor evaluates that policy; it trusts authd as a TCB component. The token then carries those privileges for its whole lifetime.

3.4.1.5 Auditing #

Every exercise sets the token's monotonic used state for that privilege, and every standalone gate emits an ftrace event.

KMES audit events, of type kacs.audit.privilege.used, are emitted only when the token's audit_policy opts in through PRIVILEGE_USE_SUCCESS or PRIVILEGE_USE_FAILURE, and they come from two places, told apart by operation.name.

3.4.1.5.1 In an access check #

An access check records the five AccessCheck-influencing privileges, with operation.name access-check. The event fires when the privilege's provenance bits intersect both the mapped desired mask and the final granted mask. For SeSecurityPrivilege and SeTakeOwnershipPrivilege that intersection is genuinely counterfactual — ACCESS_SYSTEM_SECURITY is pre-decided by the privilege, and take-ownership contributes only when WRITE_OWNER was not already granted — so an event means the privilege was load-bearing. Backup and restore seed their bits unconditionally, without asking whether the DACL would have granted the same access, so their events also fire for accesses the DACL alone would have permitted.

A MAXIMUM_ALLOWED request short-circuits this accounting entirely, recording no used bits and emitting no kacs.audit.privilege.used events for any privilege.

3.4.1.5.2 At a capability gate #

A privilege that satisfies a Linux capability check is recorded too, with operation.name linux-cap, the capability in linux.cap and the privilege it mapped to in privilege.name, under the token's PRIVILEGE_USE_SUCCESS policy. The volume-management gate the mount paths consult in place of CAP_SYS_ADMIN records the same way with operation.name volume-mount and no linux.cap. Such a record carries no object, masks or contribution.

Each gate is recorded once per process and effective token: the first use of a capability by a process under a token is recorded, and later uses are not, until the process acts under a token with another GUID, when every gate counts as unused again. The set belongs to the process, not to the token, whose used flags derived tokens copy and every process sharing it shares.

Only successful uses are recorded, and only those of a user process's own capability checks: a check made with CAP_OPT_NOAUDIT is a probe and records nothing, nor does a kernel thread's check, a check from interrupt context, or a check against another process's credentials. A check that consumes no privilege — the capabilities every process holds and the ones none may hold — records nothing either.

A capability check can run with spinlocks held, where a record cannot be built, so the gate notes the use and the record is written as the process returns to user space, or as it exits. The record therefore follows the system call that spent the privilege rather than appearing within it.

The boot SYSTEM token carries PRIVILEGE_USE_SUCCESS (§3.2.3), so on a stock system these records come from SYSTEM and the services running on its token: they answer who used SeTcbPrivilege, and for what.

3.4.2 Privilege Catalogue

Peios / Peios technical reference / PKM / KACS / Privileges

The complete set of Peios privileges, with the bit each occupies in the token's four 64-bit privilege words. Format-compatible privileges sit at their standard Windows LUID positions in bits 2–35; custom Peios privileges are allocated downward from bit 63, so that a privilege defined by a future AD release cannot collide with one of ours.

Enforcement classes are: kernel standalone, enforced at a specific operation boundary independently of AccessCheck; AccessCheck, evaluated inside the pipeline; AccessCheck + standalone, both; application-level, checked by a userspace service rather than the kernel; and reserved, allocated for format compatibility with no enforcement point.

3.4.2.1 Identity and token management #

PrivilegeBitMaskEnforcement
SeCreateTokenPrivilege20x4Kernel standalone
SeAssignPrimaryTokenPrivilege30x8Kernel standalone
SeImpersonatePrivilege290x20000000Kernel standalone

SeCreateTokenPrivilege mints tokens from scratch, and only TCB components — authd and peinit — carry it. SeImpersonatePrivilege lets a service impersonate a principal other than itself, and every service that handles requests on behalf of users needs it; it is checked in exactly one place, the impersonation identity gate (§3.5.2).

SeAssignPrimaryTokenPrivilege gates installing a token as a process's primary identity. Installation is self-directed: KACS_IOC_INSTALL acts on the calling process and fans out to the sibling threads of its own thread group. There is no mechanism for installing a token on a different process (§3.2.3). The kernel also requires the new token to carry the same user SID and the same LogonSession as the outgoing one, unless the caller additionally holds SeTcbPrivilege (§3.2.3). The privilege is also consulted as a deny gate on the exec and credential projection paths.

3.4.2.2 Access control #

PrivilegeBitMaskEnforcement
SeSecurityPrivilege80x100AccessCheck + standalone
SeTakeOwnershipPrivilege90x200AccessCheck
SeBackupPrivilege170x20000AccessCheck + standalone
SeRestorePrivilege180x40000AccessCheck + standalone
SeRelabelPrivilege320x1_0000_0000AccessCheck + standalone
SeChangeNotifyPrivilege230x800000Kernel standalone
SeCreateSymbolicLinkPrivilege350x8_0000_0000Kernel standalone

SeSecurityPrivilege reads and writes an object's SACL, and also gates CAP_AUDIT_CONTROL, CAP_MAC_ADMIN and CAP_AUDIT_READ through the capability mapping, the KMES ring buffer attach, and supplying a SACL at object creation.

SeTakeOwnershipPrivilege takes ownership of any object regardless of its permissions. It is the only privilege here with no standalone enforcement point at all — it exists purely inside AccessCheck.

SeBackupPrivilege and SeRestorePrivilege read and write any object regardless of the DACL. Inside AccessCheck they are intent-gated (§3.4.1), but both are also used as plain standalone gates outside it: restore on the descriptor replacement path when the cache is invalid and on owner assignment to a SID the subject does not hold, and both on the registry key backup and restore paths.

SeRelabelPrivilege changes an object's integrity label, punching WRITE_OWNER through MIC for non-dominant callers and removing the at-or-below-own-level restriction when a label is written.

SeChangeNotifyPrivilege bypasses traverse checking — without it, reaching a file requires FILE_TRAVERSE on every intermediate directory. The bypass has one exception the name does not suggest: it is suppressed when the access carries MAY_CHDIR, so an explicit chdir takes a full FILE_TRAVERSE check whether or not the caller holds the privilege. The privilege additionally gates open_by_handle_at, which has nothing to do with traversal. It is checked once per intermediate directory on every path resolution, and each check takes the token's mutation lock for a snapshot and then performs a used-bit update, so the cost is O(depth) locked operations per path walk.

SeCreateSymbolicLinkPrivilege creates symbolic links, and is required in addition to FILE_ADD_FILE on the parent directory.

3.4.2.3 System operations #

PrivilegeBitMaskEnforcement
SeTcbPrivilege70x80Kernel standalone
SeLockMemoryPrivilege40x10Kernel standalone
SeIncreaseQuotaPrivilege50x20Kernel standalone
SeLoadDriverPrivilege100x400Kernel standalone
SeSystemProfilePrivilege110x800Kernel standalone
SeSystemtimePrivilege120x1000Kernel standalone
SeProfileSingleProcessPrivilege130x2000Kernel standalone
SeIncreaseBasePriorityPrivilege140x4000Kernel standalone
SeManageVolumePrivilege280x10000000Kernel standalone
SeShutdownPrivilege190x80000Kernel standalone
SeDebugPrivilege200x100000Kernel standalone
SeAuditPrivilege210x200000Kernel standalone
SeRemoteShutdownPrivilege240x1000000Kernel standalone

SeTcbPrivilege is the catch-all for system operations with no more specific privilege, and only TCB services need it. It has by far the widest reach of any privilege: fourteen Linux capability mappings, several token operations, the mount policy paths, the central access policy cache, LogonSession creation and destruction, removal of mandatory resource attribute ACEs during descriptor merge, a KMES rate limit exemption, the LCS source authentication path, and the upgrade of a linked-token query from an Identification-level copy to the real token at full access.

The PEI-549 block-layer change also accepts enabled SeManageVolumePrivilege or SeTcbPrivilege, with successful privilege-use marking, for BLKRRPART (partition-table rescan), BLKPG (partition management) and BLKFLSBUF (buffer-cache flush). The caller still needs a usable block-device handle. Administrators membership alone, or a present but disabled privilege, does not pass this gate. Other CAP_SYS_ADMIN checks, including BLKROSET, retain their existing TCB requirement. This change has native VM qualification; older published kernels may still require SeTcbPrivilege for all three.

SeShutdownPrivilege shuts down or reboots the machine, mapped through CAP_SYS_BOOT. SeRemoteShutdownPrivilege is required in addition when the request originates from a Network, NetworkCleartext, NewCredentials, or RemoteInteractive logon.

SeLoadDriverPrivilege loads and unloads kernel modules through CAP_SYS_MODULE. SeDebugPrivilege attaches to and inspects any process regardless of its descriptor, and does not bypass PIP (§3.7).

  • SeSystemtimePrivilege changes the clock,
  • SeIncreaseBasePriorityPrivilege raises scheduling priority and sets CPU affinity for other processes,
  • SeIncreaseQuotaPrivilege overrides resource limits,
  • SeLockMemoryPrivilege locks pages in physical memory,
  • and SeAuditPrivilege writes events to the audit log — it is what KMES requires for userspace event emission.

SeProfileSingleProcessPrivilege attaches perf_event_open() to a specific other process. It respects PIP dominance, and own-task profiling requires nothing. SeSystemProfilePrivilege covers system-wide profiling — per-CPU events, all-task sampling, kernel-mode events — and does not respect PIP at the per-sample level, since system-wide samples include PIP-protected tasks. It is an operator-class privilege.

Those two and SeLoadDriverPrivilege share one mapping: CAP_PERFMON is satisfied by any of the three, and every one the caller holds is marked used. The two profiling privileges are otherwise disjoint tiers.

3.4.2.4 Network #

No privilege. Binding a port is an access check against the port's reservation — a security descriptor keyed by protocol and port range — not a privilege test; see Port reservations. Bit 63 was SeBindPrivilegedPortPrivilege until that object existed; the bit is retired and not reused.

3.4.2.5 Directory and domain operations #

PrivilegeBitEnforcement
SeSyncAgentPrivilege26Application-level
SeEnableDelegationPrivilege27Application-level
SeMachineAccountPrivilege6Application-level

SeSyncAgentPrivilege reads every object in the directory regardless of per-object permissions, for AD replication agents. SeEnableDelegationPrivilege marks a principal as trusted for delegation. SeMachineAccountPrivilege adds computer accounts to the domain. None has a kernel definition, which is consistent with their being application-level — but none has a userspace definition in the tree either, so at present nothing anywhere enforces them.

3.4.2.6 Reserved #

Allocated for format compatibility so that tokens from Active Directory environments carry them without information loss. None is defined in the kernel and none has an enforcement point.

PrivilegeBitReservation rationale
SeCreatePagefilePrivilege15Absorbed into SeTcbPrivilege.
SeCreatePermanentPrivilege16No Linux equivalent.
SeSystemEnvironmentPrivilege22Gated by descriptors on efivar files under FACS.
SeUndockPrivilege25Server operating system.
SeCreateGlobalPrivilege30Peios has no per-LogonSession object namespaces.
SeTrustedCredManAccessPrivilege31Reserved for future secrets infrastructure.
SeIncreaseWorkingSetPrivilege33Linux does not gate memory residency hints.
SeTimeZonePrivilege34Linux does not gate timezone changes.

3.4.2.7 Unallocated and unnamed bits #

SeCreateJobPrivilege is allocated bit 62 and is unused: no kernel definition exists for it and nothing consults it. Submitting a supervised job is not a privilege at all — it is governed by the file Security Descriptor on the service manager's jobs socket, checked by the kernel when a process connects (the Peinit TRM). The bit is nevertheless included in the boot SYSTEM token's privilege set, which covers bits 2–35 together with 62 and 63, so the SYSTEM token holds bit 62 present and enabled with no name attached to it and no gate that consults it.

Nothing validates a privilege mask against the allocated set. Token creation checks only that the enabled set is a subset of the present set, and adjustment accepts any bit index from 0 to 63. Bits 0, 1, and 36–61 — positions the catalogue does not allocate at all — can therefore be set at creation and disabled or removed afterwards without error, and are simply inert.

3.4.2.8 Default grants #

SeChangeNotifyPrivilege is granted to every principal, as an authd policy decision rather than a kernel one: the issuer's floor grants it to Everyone. SeCreateSymbolicLinkPrivilege is not granted by default despite being the other traditional default-grant privilege — authd deliberately omits it from the floor, and no shipped seed grants it. Either can be removed from a specific token by FilterToken.

3.5.1 Impersonation Levels

Peios / Peios technical reference / PKM / KACS / Impersonation

Impersonation lets a server thread temporarily assume a client's identity, so that access control decisions on that thread evaluate the client's token instead of the server's.

The client controls how far its identity can travel by setting an impersonation level on the connection before it is established, and the server cannot escalate beyond the level the client chose. There is no API that bypasses that choice.

Anonymous. The server cannot identify the caller at all. The connection carries no identity information: both token inspection and impersonation yield a token whose user SID is Anonymous (S-1-5-7), which carries Everyone as an enabled group and does not carry Authenticated Users.

Identification. The server can identify the caller — read SIDs, query groups, inspect privileges — but cannot act as them. An Identification-level token is barred from AccessCheck against resources: a server thread impersonating one and attempting to open a file simply fails the check, with EACCES — the same refusal the kacs_access_check syscall reports for that token.

Impersonation. The server can act as the caller for all local operations, including ones that cross local IPC boundaries. If service A impersonates client B at this level and connects to local service C, C sees B's identity — identity cascades freely across local services. This is the default.

Delegation. Locally identical to Impersonation. The distinction activates at the network boundary, where a Delegation-level token carries authorization for the server to forward the client's identity to services on other machines through Kerberos. KACS enforces the level; authd is what acts on it.

The level is set by the client through a KACS syscall on the socket before connect(), and defaults to Impersonation.

3.5.1.1 The level is a ratchet #

Every token carries an impersonation level, and on every token it means the same thing: identity derived from this token may act at no more than this level. An impersonation token acts at its level. A primary token's level is the ceiling on everything the process carrying it can convey — what a peer captures at connect, what KACS_SO_PASS_TOKEN attaches, what KACS_IOC_DUPLICATE produces in either direction.

The level only ever goes down:

  • Socket capture and KACS_SCM_TOKEN attach store the lower of the socket's level and the source token's own;
  • DuplicateToken refuses a result above its source, whatever the types involved;
  • fork, exec, NEW_PROCESS_MIN, FilterToken and primary installation copy it unchanged.

The one way to a higher level is a fresh token from CreateToken, which takes SeCreateTokenPrivilege. authd mints logon tokens at Delegation and the bootstrap SYSTEM token is Delegation, so an identity starts at the top and each hop can only lower it.

This is what makes the client's choice hold across any number of hops and process boundaries. A client that connects at Impersonation is captured at Impersonation; a server that converts that token to a primary and launches a process with it (§3.2.4) produces a process capped at Impersonation; and nothing that process captures, conveys or duplicates can ever be Delegation — so authd, which reads the level to decide whether to forward the client's credentials, is never shown a Delegation flag the client did not grant.

3.5.2 Impersonation Gates

Peios / Peios technical reference / PKM / KACS / Impersonation

When a server thread attempts to impersonate a client's token, two independent checks decide whether it proceeds at the requested level. Both have to pass. If either fails the effective level is reduced to Identification — the movement is only ever downward.

Both gates are evaluated against the server's primary token (real_cred), never its effective token. A server already impersonating another client has its gates judged against its own service identity, so a previous impersonation cannot influence the next one.

3.5.2.1 The identity gate #

The identity gate asks whether this server may impersonate this particular user's identity. Impersonation at Impersonation or Delegation level is permitted if either of two conditions holds.

Same user, same restriction status — the server's primary token and the client's token carry the same user SID, and both are restricted or both unrestricted.

SeImpersonatePrivilege — the server's primary token holds it, enabled.

If neither holds, the level is silently capped to Identification. No error is returned: the call succeeds, and the resulting token is merely at Identification level.

There is one hard denial. A restricted server impersonating an unrestricted client of the same user is rejected outright with -EPERM rather than capped, because that is precisely how a sandboxed process would escape by impersonating its parent's unrestricted token. The reverse direction, unrestricted server to restricted client, is a harmless downgrade and takes the ordinary cap-to-Identification path.

MS-DTYP includes a third condition — an origin LogonSession check letting the session that created a token impersonate it without the privilege. KACS drops it. A service needing to impersonate a different user holds SeImpersonatePrivilege, and there are no hidden paths.

3.5.2.2 The integrity ceiling #

The integrity ceiling asks whether the client's token sits at an integrity level the server is allowed to assume. To act at Impersonation or Delegation level, the client token's integrity level has to be less than or equal to the server primary token's. A Medium-integrity server can impersonate Low or Medium clients; against a High-integrity client the level caps to Identification.

The installed token may keep the client's literal integrity label as identity metadata after the cap, but that preserved label authorizes nothing, because Identification-level tokens are barred from AccessCheck entirely.

The ceiling exists because MIC evaluates the effective token's integrity level for tokens that can act. Without it, a server could impersonate a higher-integrity token and gain write access to higher-integrity objects — integrity escalation through impersonation.

The ceiling is enforced unconditionally, regardless of privilege. SeImpersonatePrivilege bypasses the identity gate and never the ceiling. MS-DTYP allows the privilege to bypass every check including this one; KACS does not, because mandatory_policy is immutable here (§3.2.2) and MIC is consequently a real boundary. Letting a privilege punch through would give back exactly what that immutability buys.

3.5.2.3 Composition #

The two gates are independent, both are evaluated, and the effective level is the minimum any constraint permits: start from the level the client set on the socket, cap to Identification if the identity gate fails, cap to Identification if the integrity ceiling fails, and the result is the effective impersonation level.

3.5.3 Impersonation Lifecycle

Peios / Peios technical reference / PKM / KACS / Impersonation

3.5.3.1 The sequence #

The client connects. It optionally sets the maximum impersonation level on the socket with setsockopt(SOL_KACS, KACS_SO_IMPERSONATION_LEVEL) — the default is Impersonation; the option may be changed at any time and bounds every capture made from then on — and calls connect(). The kernel's LSM hook fires on the Unix stream connection.

Identity is captured, both ways. The hook examines the client thread's effective credential together with the socket's maximum level, derives the identity at the lower of that level and the effective token's own (§3.5.1), and stores the result on the accepted end. It also stores, on the connecting end, the identity the listener captured when listen() was called — so a client can read KACS_SO_PEER_TOKEN on its own socket immediately after connect() and verify who it reached, at Identification level unless the listener set its own level. The listener's identity is what listen() saw; a process that inherits a listener it did not create (from a descriptor store after a restart, or from a broker) replaces it with its own using setsockopt(SOL_KACS, KACS_SO_RESTAMP), which is self-gated and fails with EINVAL on a socket that is not listening. Clients that connect before the restamp see the previous holder, which is honest: that is who was listening. Squatting a name therefore buys nothing against a client that checks — the squatter's own identity is what it conveys. At Anonymous, a token whose user SID is S-1-5-7, whose enabled groups include Everyone, and which does not carry Authenticated Users is stored on the socket's LSM blob, and the client's real identity is never recorded. At Impersonation or Delegation, the thread's effective token is stored — and if the connecting thread is itself impersonating, the impersonated identity is what flows through, which is how identity cascades across local services. At Identification, the effective token is stored but tagged at that level.

The server reads the peer token with getsockopt(SOL_KACS, KACS_SO_PEER_TOKEN) on the accepted connection, which opens a token fd carrying TOKEN_QUERY | TOKEN_IMPERSONATE | TOKEN_DUPLICATE for the identity in that end's conveyed-identity register — at this point, the identity captured at connect (see Per-message identity below for how the register moves). It impersonates by installing that fd with KACS_IOC_IMPERSONATE: the kernel evaluates both gates against the server thread's primary token, computes the effective level, and constructs a new credential carrying the impersonation token at that level. libpeios fuses the two steps as peios_token_impersonate_peer for the handler that impersonates, works and reverts on one thread.

Access control follows the impersonation token. Every subsequent AccessCheck on the thread evaluates it, and MIC uses its integrity level. PIP continues to read the PSB, unchanged.

The server reverts with kacs_revert(), restoring the thread's credential to real_cred and its service identity with it.

3.5.3.2 Anonymous #

Any thread may impersonate the Anonymous identity without passing either gate. Assuming Anonymous is always a downgrade — the token has no access beyond what is explicitly granted to S-1-5-7 or Everyone — so no privilege is needed, no identity gate runs, and no integrity ceiling applies.

Anonymous tokens carry the Anonymous SID as the user SID, no privileges, and Untrusted integrity. The socket path constructs that minimal shape rather than preserving any part of the caller's real identity.

3.5.3.3 Double impersonation #

A thread already impersonating that installs another token with KACS_IOC_IMPERSONATE causes the kernel to revert internally and then re-impersonate. Because the gates are evaluated against the primary token, the previous impersonation has no bearing on the new one.

3.5.3.4 Interaction with MIC and PIP #

MIC reads the effective token for tokens permitted to act, which is safe precisely because the integrity ceiling makes acting impersonation safe. A thread cannot act at Impersonation or Delegation level with a client token whose integrity exceeds the server primary token's. When the ceiling caps the level to Identification, the installed token may still preserve the client's literal label as metadata, but it authorizes no resource access because Identification-level tokens are barred from AccessCheck. An acting impersonation token therefore preserves or lowers the server's integrity, and a higher literal label can only ever exist on a non-acting Identification-level token.

PIP reads the PSB, because there is no equivalent ceiling for it. PIP operates on pip_type and pip_trust, which are orthogonal to integrity level. Reading them from the effective token would let a process impersonate a token carrying higher PIP values and acquire protection it has not earned.

3.5.3.5 SeImpersonatePrivilege #

The privilege permits a service to impersonate arbitrary clients — those with different user SIDs. Without it a process can impersonate only tokens matching its own user SID and restriction status.

It is checked against the server's primary token, so a thread already impersonating one client is judged on its real service identity. It has to be enabled at the moment of the call. And it bypasses the identity gate only, never the integrity ceiling.

3.5.3.6 Delegation and the network boundary #

Locally, Impersonation and Delegation behave identically. The distinction activates at the network boundary, where a Delegation-level token carries authorization for Kerberos credential forwarding to services on other machines.

KACS tracks the level on the token and the socket, and authd checks it when it needs to perform Kerberos authentication for an impersonating thread. KACS itself has no Kerberos awareness and no network awareness: the level is a flag that authd interprets.

3.5.3.7 Per-message identity #

The connect-time capture is the first entry in a per-end conveyed-identity register: the peer identity associated with the data that end has consumed so far. Identity can also travel with the data itself, as a KACS_SCM_TOKEN ancillary message under the SOL_KACS level, and each such message the reader's position passes advances the register. The anchor is the read position, never arrival: a token still queued behind unread data is not yet visible, so a handler can never be told an identity for a request it has not read. KACS_SO_PEER_TOKEN returns a fresh fd to an immutable snapshot each time; two calls may name different tokens, but a token never changes.

Sending. A sender conveys identity in one of two ways, and the receiver cannot tell them apart, because both mean the same thing — the kernel verified that the sender could be this identity when it sent these bytes:

  • Automatically, with KACS_SO_PASS_TOKEN set on the sending end: every send carries the sender's effective token, derived at the lower of the end's impersonation level and the token's own. The derivation is cached against the effective token it was made from, so a run of sends under one identity costs one derivation. A thread that impersonates client A, writes to a pooled connection, reverts and writes again conveys A for the first write and its own identity for the second, with no change to the library doing the writing.
  • Explicitly, by attaching a token fd in a KACS_SCM_TOKEN cmsg. The kernel gates the attach exactly as it would gate impersonating that token: the fd must carry TOKEN_IMPERSONATE, a primary token is first derived to an impersonation token at the lower of the end's level and its own, and the two-gate model runs with the sender as installer. An attach the gates would cap below the token's own level fails with EPERM — an explicit act fails loudly where an install would silently downgrade. Attaching a token is therefore never more than impersonating it, sending, and reverting; it just avoids the install. A router forwarding on behalf of many clients re-attaches each client's token to the messages it forwards, and each downstream receiver gets a kernel-verified identity through the router.

Anonymous-level tokens attach without a gate, as assuming Anonymous is always a downgrade. At most one token per message.

Receiving. Every skb a send produces carries the conveyed token, so a large stream write conveys the same identity on all of its fragments. A stream read stops at an identity boundary rather than gluing bytes conveyed under different identities into one return, which keeps the register exact even on a byte stream; on SOCK_SEQPACKET one send is one message and one identity, which is why it is the recommended type for Peios-native protocols. As the reader's position passes conveyed data, the register moves to that identity. MSG_PEEK neither advances the register nor consumes; a read() or splice() that consumes data advances it just as recvmsg does, because the register is kept at the point of consumption, not where ancillary data is copied out.

A KACS_SCM_TOKEN cmsg — carrying a fresh TOKEN_QUERY | TOKEN_IMPERSONATE | TOKEN_DUPLICATE fd — is delivered with a read when the receive call supplied room for it and the read's identity differs from the register the reader had before the read; otherwise nothing is delivered, and MSG_CTRUNC is set only when a token was due and no room was supplied. A handler that never reads ancillary data still has the register: recvmsg, then getsockopt(KACS_SO_PEER_TOKEN), gives the identity of what it just read. Parsing the cmsg is the path for pipelined or concurrent consumers that need the token bound to a particular message.

SOCK_DGRAM sockets carry no register — many senders share one receiving socket — so a datagram's token is delivered with every datagram that carries one and is never retained.

3.5.3.8 Supported transports #

Peer-token capture works on two AF_UNIX socket types. SOCK_STREAM is the connection-oriented byte stream, and SOCK_SEQPACKET is connection-oriented with message boundaries and uses the same identity capture model. Both follow the same lifecycle: capture at connect(), read with KACS_SO_PEER_TOKEN, impersonate, revert — with per-message identity layered on top as described above. SOCK_DGRAM supports per-message identity only: KACS_SO_PASS_TOKEN and KACS_SCM_TOKEN work, since a send needs no connection, and KACS_SO_IMPERSONATION_LEVEL bounds them as on any socket, but there is no register.

Where the register does not apply, KACS_SO_PEER_TOKEN reports which case it is. On a datagram socket, or any non-Unix family, the option fails with EOPNOTSUPP. A socketpair() end is connected but starts with an empty register — no connect happened to capture anything — so the option fails with ENODATA until the peer conveys an identity, after which the register works as on any connection. A socket that is not yet connected fails with ENOTCONN. Pipes and FIFOs are not sockets at all, so getsockopt itself fails with ENOTSOCK before KACS is reached.

For all of these, the universal fallback is explicit token fd impersonation through KACS_IOC_IMPERSONATE, which works regardless of how the token fd was obtained — the peer-token option, an SCM_RIGHTS transfer, or any other path.

3.5.3.9 Audit records #

Every KACS_IOC_IMPERSONATE writes a kacs.impersonation.started record through KMES (§3.C). Its subject is the server's token, the one that acted, and its object is the client token: object.token.sid, .type, .integrity, .auth-id and .restricted, with the level the client token offered as object.token.impersonation and the level the gate allowed as object.token.impersonation-permitted. The thread that took the identity on is emitter.thread.tid, since impersonation is per thread. Where the gate needed SeImpersonatePrivilege and used it, the record carries privilege.name and privilege.held.

A level the gate lowered is not a failure: the record says outcome.success with impersonation-permitted below impersonation. A lowered level installs a new token, and the record's object.token.guid and object.token.id are always the installed token's — the client's own when the level was not lowered, the new one when it was — so they match the header of the records the thread writes next.

A refused impersonation is recorded too, with outcome.errno and, where the kernel knows why, outcome.reason: primary-token for a primary token (EINVAL), restriction-escape for a restricted server reaching for an unrestricted token of its own user (EPERM), no-impersonate-access for a handle without TOKEN_IMPERSONATE (EACCES), and projection-failed when the identity could not be projected onto the thread's credentials. A refusal before the gate answered carries no impersonation-permitted.

Ending an impersonation writes kacs.impersonation.reverted, a verbose type, so only where the emission policy (§2.8) switches it on. The record names the token given up in object.token.guid and object.token.sid, with the thread's token after the revert as subject. operation.name says why: revert for kacs_revert, exec for the revert every exec performs, and replaced when a second impersonation ends the first. A revert with no impersonation in force writes nothing, nor does the revert and re-apply around a primary-token install, which leaves the impersonation in place, nor does a thread that exits while impersonating. Both records are best effort: a record that cannot be built does not change the outcome of the impersonation or the revert.

3.6 Binary Signature Verification

Peios / Peios technical reference / PKM / KACS

Binary signing is the foundation of PIP trust determination and of Library Signature Verification. The kernel verifies cryptographic signatures on executable files to establish their trust level, and signing is the only mechanism by which a binary acquires PIP protection — there is no runtime API that can confer it.

This section describes verification. The signature format itself, and what a signer has to produce, are specified in PSPK's Binary Signing and PIP chapter. The kernel only ever verifies; it holds no private key and contains no signing primitive.

3.6.1 The key table #

The kernel carries its verification keys in a dedicated data section of the kernel image, as an array of 1960-byte entries — a raw 1952-byte ML-DSA-65 public key, then a u32 little-endian PIP type, then a u32 little-endian PIP trust — terminated by an all-zero entry.

Before any cryptographic work, the whole table is walked and validated. A table with no all-zero terminator is rejected with EINVAL, and so is a table containing any entry whose tier is not exactly Protected (512) with PeiosTcb trust (8192). Both rejections fail every verification on the system, which is what makes the single-tier constraint absolute rather than conventional: a key at any other tier does not merely fail to be honoured, it disables signing entirely.

The consequence is that a verified binary is always Protected/8192. The Isolated type (1024) is reserved and unreachable, and the multi-key, multi-tier model the table layout anticipates would need a change to the validator, not merely an added key.

A build configured for KUnit compiles in a different, hard-coded key at the same tier, taken from the test vector header. Such a kernel trusts a publicly known key.

/sys/kernel/security/kacs/signing_keys lists the table: one line for each entry before the terminator, in table order, reading

key_sha256=<hex> pip_type=<u32> pip_trust=<u32>

where the key is named by the SHA-256 of its raw 1952-byte public key, in lowercase hexadecimal, and the type and trust are the entry's own, in decimal. Entries are listed as they are, whether or not the validator would accept them, so a table that disables signing shows why. The keys are public, so reading the file is not access-checked; whoever can open it can read it. Like every securityfs object, it can be opened only once the mount has a synthesising policy, which on Peios lets Authenticated Users in.

3.6.2 Finding a signature #

Verification begins by recording the file's current size. Everything that follows is bounded by that snapshot, and the size is re-read before the attempt returns — a file that changed size mid-verification invalidates the whole result.

The first four bytes decide the path. A file matching \x7fELF takes the ELF path; a file shorter than four bytes, or with different magic, goes straight to the xattr.

On the ELF path the kernel parses the header, locates the section header table and the section-name string table, and scans sections in index order for one named exactly .peios.sig. The match is over all eleven bytes including the terminating NUL, so a longer name with that prefix does not match.

Finding the section commits the ELF path. The moment a section header with that name is found, the xattr is no longer consulted — whatever happens next. A wrong section type, a size other than 3310, a range outside the file, an allocation failure, a short read, a bad version byte, or a hash failure all yield "unsigned" rather than falling back. This is deliberate: without it, an attacker could craft a malformed ELF section to force fallback to whichever path they could more easily control.

Several structural ELF failures commit the path too, before any .peios.sig section has been seen: a file shorter than an ELF header, a class other than ELFCLASS64, a byte order other than little-endian, an unexpected ELF version, a section header entry size other than 64, an absent or out-of-range section-name string table index, and a section header table or string table lying outside the recorded size. A 32-bit or big-endian ELF therefore cannot carry an xattr signature at all — it is committed to the ELF path and then fails on it. The one structural case that does not commit is e_shnum == 0, so an ELF with no section headers falls through to the xattr normally.

The xattr path reads security.peios.sig and requires exactly 3310 bytes; any other size is treated as unsigned. The read bypasses the LSM xattr hooks, so FACS does not mediate the verifier's own read of the signature.

3.6.3 Hashing and verification #

The message signed is a 32-byte SHA-256 content hash, computed differently depending on where the signature was found. For an ELF section source, the hash covers the file with the section's contents replaced by zeros — the Elf64_Shdr entry describing it is hashed verbatim, along with everything else. For an xattr source the hash covers the entire file with no exclusions, and that applies to ELF files reaching the xattr path as well as to non-ELF ones. Hashing proceeds in 4 KB chunks, with the zero run emitted in SHA256_BLOCK_SIZE pieces.

The kernel then verifies the 3309-byte signature against each key in the table, in order, returning on the first success. There is no key identifier in the blob, so key selection is exhaustive trial and the cost is one ML-DSA verification per key. The trust tier is a property of which key verified, never of anything the signer encoded.

Verification uses the kernel crypto signature API with the mldsa65 algorithm. That API exposes no context parameter, so the empty FIPS 204 context is structurally guaranteed rather than checked — a signature made under a non-empty context simply fails to verify.

3.6.3.1 When verification cannot be performed #

"Did not verify" and "could not be verified" are different answers and are kept apart. The per-key verifier is tri-state: verified, did not verify, or a negative errno meaning the check could not be made — an unavailable ML-DSA transform, or a key the transform will not accept.

A negative stops the search rather than trying the remaining keys: the failure is in the machinery, and every remaining key would meet the same one. It then propagates out of the exec path, which refuses the exec with EACCES.

That asymmetry with the ordinary unsigned path is the point. An unsigned binary runs with no integrity label, which is legitimate. Treating an unverifiable one the same way removes PIP from every process the system executes, and the result is indistinguishable from a correctly working system that has no signed binaries — so nothing surfaces it until signing is deployed, at which point it looks like the signing rollout broke something.

On the LSV path the same condition denies the mapping, which already fails closed.

3.6.3.2 The boot-time probe #

A late_initcall allocates the transform once and, if it cannot, emits pr_err and a kacs.signature.crypto.failed KMES event carrying signature.crypto-stage boot-probe and the error as outcome.errno, negative. The condition is then visible at boot rather than inferred from every process running without an integrity label.

It cannot refuse to start, and two things rule that out rather than one:

  • At LSM init the algorithm is not yet registered, so crypto_alloc_sig returns ENOENT on every boot. A probe there would fire always.
  • A non-zero return from an LSM's init function is only WARN'd (security/lsm_init.c, lsm_init_single). The hooks are never added, so "refuse to initialise" means running with no KACS at all — worse than the failure it would be preventing.

Enforcement therefore lives at exec, where refusing one exec is recoverable in a way losing the integrity boundary is not.

The probe is not IS_ENABLED(CONFIG_CRYPTO_MLDSA). That option is an unconditional select under SECURITY_PKM, which is a bool, so a config test would always pass and catch nothing. Only calling the allocator sees the ordering failure.

3.6.4 PIP determination at exec #

At execve() the kernel looks up and verifies the signature as above. A verified binary takes pip_type and pip_trust from the matched key; no signature, an invalid or unstable one, a bad signature, or no matching key all yield None/0.

Exec proceeds in every case where the question could be answered. PIP is additive protection, not an execution gate: it determines trust level, not permission to run. The one exception is a signature that could not be verified at all, described above, which refuses the exec — because there the question was not answered, so there is no basis on which to assign a trust level. An attacker who replaces a signed binary's signature with garbage costs it PIP protection but can still execute it, subject to FACS. The asymmetry with LSV — which does block unsigned libraries — is intentional. Exec is permissive; mmap(PROT_EXEC) is restrictive.

Determination is transactional with exec success, in three phases. The pending value is cleared at the top of every bprm_creds_from_file invocation, staged into the task's security blob, and committed to the process state only from bprm_committed_creds. An exec that fails between staging and commit leaves the process state untouched, and the stale pending value is cleared by the next exec or at task teardown.

Because the hook fires once per binfmt iteration and each iteration re-stages, a #! script's PIP comes from the interpreter — the last file processed — not from the script. A TCB-signed interpreter runs at TCB level whatever script it executes. Symlinks need no special handling either: the LSM hooks receive the already-resolved target file, so a symlink inherits its target's level by construction.

Once committed, the values are fixed for the lifetime of the process image, inherited at fork, and re-derived at the child's exec.

3.6.5 Content pinning #

A binary that verifies to a nonzero tier has its backing inode pinned as KACS-verified executable content before the exec result is committed, and LSV pins on success too, before allowing the mapping. Pinning closes the gap between verifying one byte image and modifying the same inode afterwards.

If pinning fails at exec, the PIP result is downgraded to None/0 rather than the exec being failed or the tier being kept — the process runs unprotected. Under LSV a pin failure denies the mapping instead.

A pinned inode rejects in-place content mutation even when the size would be preserved: ordinary, positioned and append writes; ftruncate() and pathname truncate(); and every fallocate mode, including allocation-only ones, because the pin check runs before and independently of the mode-support test. File ioctls that mutate content, ranges, or allocation are rejected, and so are ioctls the kernel cannot classify — unknown ioctls fail closed on a pinned inode. Mutation or removal of the security.peios.sig xattr is rejected as well.

The pin is conservative and one-way. It is set once and cleared only when the inode is allocated or freed, never while the inode is live. Updating verified executable content therefore means replacing the inode — write a new file and rename() over it — rather than modifying it in place.

Unsigned, invalid, unstable, bad-signature and no-match attempts never pin.

3.6.6 Library Signature Verification #

With the lsv mitigation enabled, mmap() with PROT_EXEC on a file-backed mapping verifies the backing file. An unsigned file, an invalid or unstable one, a bad signature, or no matching key all deny the mapping with EACCES. On success the library's tier is compared against the loading process's: the image has to dominate the process, so a Protected/PeiosTcb process can load only PeiosTcb-or-above libraries. With one key in the table this reduces to "is it signed with the TCB key?"

The hash covers the entire file, not the mapped region — the whole file is read and hashed even when only part of it is being mapped, so the signature covers code in sections this particular mapping does not touch.

mprotect() adding PROT_EXEC to a mapping that was not already executable runs the same checks in a fixed order: WXP, then TLP, then LSV. Anonymous mappings have neither a path nor a signature, so TLP and LSV skip them and only WXP applies.

Enabling lsv on a running process also re-validates its existing executable mappings before the bit is committed (§3.3.2), so the mitigation cannot be turned on over already-mapped unsigned code.

3.6.7 Firmware verification #

A firmware load is not an exec, so none of the above would see it — and firmware is code that runs on a device with DMA into host memory, which makes a tampered blob a complete compromise. KACS therefore implements the kernel_read_file and kernel_post_read_file LSM hooks for READING_FIRMWARE (kacs/firmware.c).

The signature is the ordinary PIP blob, found by the lookup above. Firmware is never ELF, so in practice it is the security.peios.sig xattr, which peipkg stamps at install from the <file>.peios.sig sidecar the package carries (PSPU §5.16). The bar is the PeiosTcb tier — the same floor a usermodehelper exec must clear, since both are the kernel choosing to run code it did not ship. A verified signature at any other tier counts as no signature.

For a whole-file read, which is the common case, the post-read hook is handed the exact bytes the loader will decompress and give to the device, and those are what is hashed — no second read, no TOCTOU. A partial read (a driver pulling a window out of a large blob) only passes through the pre-read hook, where nothing has been read yet, so that path verifies the file through the reader probe instead. The hash covers the compressed bytes as they sit on disk, so verification precedes decompression.

Policy is CONFIG_SECURITY_PKM_FIRMWARE_SIG_ENFORCE, overridable for one boot with kacs_fwsig=enforce or kacs_fwsig=log on the kernel command line; there is no runtime switch. Under enforce an unsigned, tampered or under-tier file is refused with -EPERM; the loader logs that against the path it was refused at, moves on to its remaining search paths, and reports the driver an ordinary load failure. Under log the verdict goes to a rate-limited warning and the kacs:kacs_firmware_load tracepoint and the load proceeds. 2026.8 ships in log mode: the firmware set is being signed for the first time, and a blob the packaging missed has to show up as a warning rather than as a device that silently stops probing. Enforcement becomes the default once a release has run clean.

CONFIG_FW_UPLOAD — a device's own sysfs update interface — is not covered; it is selected by several drivers and cannot be turned off, and the drivers that expose it are the place to gate it if that ever matters. The test.fwsig build stage exercises both modes through CONFIG_TEST_FIRMWARE's trigger_request, which pulls a named file through the real loader path with no hardware.

3.6.8 Revocation #

There is none. A signed binary later found to be malicious cannot be invalidated short of removing it from the filesystem or replacing the kernel image with a different key. No hash blocklist, no per-key revocation, and no revocation state of any kind exists.

3.6.9 Interaction with other mechanisms #

WXP is orthogonal: it prevents pages being writable and executable at once, while LSV prevents unsigned executable pages. A process with both can execute only signed code in read-only pages.

FACS runs independently. A signed binary in a directory the caller cannot reach is still unreachable — the open is denied before signing is consulted. Signing determines the PIP level of a binary that is already being executed; it never grants access to one.

PIP object protection consumes the result: once a process carries a tier from its binary's signature, trust labels on objects are enforced through the AccessCheck pipeline against the pip_type and pip_trust held in the PSB (§3.7).

3.7 Process Integrity Protection

Peios / Peios technical reference / PKM / KACS

PIP protects objects from insufficiently trusted processes through trust label ACEs evaluated inside AccessCheck (§3.8.7). It also protects processes — their memory, their execution, their metadata — from other processes, which is what this section covers.

Every process-to-process operation passes two independent checks, and both have to succeed.

The process descriptor check is an ordinary AccessCheck of the caller's token against the target's process descriptor (§3.3.3). It answers who may operate on the process, and it is where per-operation granularity lives — different rights for signals, memory, and metadata.

The PIP dominance check is a direct comparison of the two processes' PSB fields. It answers what trust level is required. It does not use AccessCheck, does not read a descriptor, and does not involve the DACL pipeline at all: it is a standalone arithmetic test.

The two are complementary. Object PIP protection stops a non-dominant process opening authd's private key file; process PIP protection stops the same process reading the key straight out of authd's memory with ptrace, or killing authd with a signal.

3.7.1 The dominance test #

pip_dominates(caller_psb, target_psb) -> bool:
    if target_psb.pip_type == None:
        return true   // Unprotected target — any caller dominates.
    return caller_psb.pip_type  >= target_psb.pip_type
       AND caller_psb.pip_trust >= target_psb.pip_trust

Both axes are plain unsigned integers compared numerically, not closed enumerations — the dominance layer would happily order tiers the signing layer cannot currently produce (§3.3.2). The early return for an unprotected target is what keeps ordinary processes universally accessible whatever trust values a caller happens to carry.

Dominance is binary. A caller that does not dominate has no process access at all, whichever operation was attempted; the descriptor provides the granularity and PIP is the all-or-nothing gate above it.

That asymmetry with the object model is deliberate. Object access has natural categories — read, write, execute. Process access does not: a caller that can ptrace a process can read its memory, inject code, and effectively become it. Partial process access is not a meaningful boundary.

3.7.2 SeDebugPrivilege #

SeDebugPrivilege bypasses the descriptor check and never the dominance check. This holds at every enforcement point, and it is enforced structurally in two independent places: inside the descriptor evaluation a PIP-label denial short-circuits before the debug rescue is reached, and the standalone dominance test runs afterwards with no privilege escape of any kind.

3.7.3 Where dominance is enforced #

ptrace, in every mode. A single successful attach is equivalent to full compromise of the target — read and write memory and registers, single-step, inject signals, redirect execution — so a non-dominant caller is refused whatever the mode. The Linux __ptrace_may_access path is patched to return the LSM's answer directly, so native UID and capability rules no longer grant where KACS denies. Direct memory access through /proc/<pid>/mem, process_vm_readv and process_vm_writev routes through the same check, so one hook covers every memory-access vector. This is what makes in-memory secrets genuinely unreachable: a compromised administrator cannot read an HSM daemon's key material out of its address space.

PTRACE_TRACEME inverts the roles — the nominated tracer is the subject and the caller is the target — and requires PROCESS_VM_WRITE on the caller's own descriptor plus dominance by the nominated tracer.

Mode combinations are validated: the mutually exclusive PIDFD_OPEN, GETFD and PROC_QUERY flags cannot be combined, and a request that is neither a read nor an attach, or claims to be both, is rejected as malformed.

Signal delivery, uniformly regardless of signal type. Lifecycle management of PIP-protected processes therefore has to go through a process that dominates them — in practice peinit, which runs at the highest tier.

Signalling within the same process security state is not a boundary operation, and the exemption is structural: it is a pointer comparison of the two processes' security state, tested before any descriptor or dominance evaluation. It does not depend on the default descriptor's self ACE, which is what makes raise(), abort() and pthread_kill() work for restricted and confined tokens whose AccessCheck against their own descriptor would fail.

Multi-target sends are evaluated per target by Linux's own iteration, so the signal reaches the permitted subset and the call succeeds if at least one delivery happened. POSIX's same-session SIGCONT exception is deliberately absent — the patched check_kill_permission returns the KACS answer before reaching the switch that carried it — so SIGCONT needs PROCESS_SUSPEND_RESUME plus dominance like every other job-control signal, whatever the session.

Kernel-originated signals bypass the whole check, as described in §3.3.3.

pidfd_open(), a boundary information query rather than a memory or attach operation, needs PROCESS_QUERY_LIMITED plus dominance. pidfd_getfd() maps to PROCESS_DUP_HANDLE plus dominance — extracting a descriptor from another process is a boundary crossing in its own right.

/proc metadata. Entries that are already ptrace-gated or memory-open-gated are covered automatically by the ptrace hook. The rest would leak information about a protected process, so the non-ptrace-gated entries carry their own descriptor requirement plus dominance; §3.3.3 gives the mapping. The one exception is /proc/<pid>/psb, which needs PROCESS_QUERY_LIMITED and no dominance: a protected process's PIP type, trust and mitigations are readable to whoever its descriptor lets query it (§3.3.2). Entries stricter than a metadata query, such as /proc/<pid>/stack, keep their native hardening and are not brought under the metadata rule.

Denying access prevents reading inside /proc/<pid>/ but does not hide the PID: the directory name is still visible through getdents. Visible-but-inaccessible is the accepted position.

/proc is not FACS-managed — it is a virtual filesystem with no backing store and no xattrs — so enforcement there happens through direct kernel checks rather than an object-backed FACS path.

Capability metadata. capget() on the current process, or on a thread sharing its security state, is not a boundary operation. Against another process it is a detailed information query needing PROCESS_QUERY_INFORMATION plus dominance.

Resource limits, scheduler and placement. Read-only prlimit needs PROCESS_QUERY_INFORMATION plus dominance; a limit change needs PROCESS_SET_INFORMATION plus dominance.

  • setpgid() needs PROCESS_SET_INFORMATION;
  • getpgid() and getsid() need PROCESS_QUERY_LIMITED;
  • the scheduler, affinity and I/O priority queries need PROCESS_QUERY_INFORMATION;
  • and the memory-placement mutations Linux routes through task_movememory need PROCESS_SET_INFORMATION.

Setting nice, scheduler parameters and I/O priority all need PROCESS_SET_INFORMATION too. Self-directed versions of all of these are not boundary operations and skip both checks.

CPU affinity is per-thread, so changing the caller's own thread or a sibling in the same process is not a boundary operation. Changing a thread in a different process needs PROCESS_SET_INFORMATION plus dominance plus SeIncreaseBasePriorityPrivilege — and the privilege is checked and marked used before the descriptor and dominance call, so the SeDebugPrivilege rescue cannot substitute for it. KACS does not relax the kernel's native affinity validity rules: an invalid or disallowed mask still fails.

Token opens. kacs_open_process_token and kacs_open_thread_token need PROCESS_QUERY_INFORMATION plus dominance. Reading a process's security identity is as sensitive as reading its memory.

Performance monitoring. Target-specific perf_event_open() on another process can leak execution timing, branch prediction behaviour, cache access patterns and instruction traces — side channels that reveal cryptographic keys. It needs SeProfileSingleProcessPrivilege plus PROCESS_QUERY_INFORMATION plus dominance, with the privilege again checked first so the debug rescue cannot stand in for it. Own-task profiling is not a boundary operation and needs no privilege. System-wide profiling, pid == -1, samples every task on a CPU including protected ones, so it needs the operator-class SeSystemProfilePrivilege. Cgroup perf mode stays under Linux's native model. Because the target task is resolved after the stock security_perf_event_open hook fires, this rule is enforced through a target-resolved syscall patch rather than that hook — there is no security_perf_event_open registration at all.

3.7.4 The PeiosTcb floor on kernel-initiated execs #

One place PIP gates execution rather than merely labelling it. It is not a dominance test — there is no caller to compare against — but a threshold: a binary the kernel execs on its own behalf must carry at least PeiosTcb trust, or the exec fails with EACCES.

The case that motivates it is request_module(). When the kernel needs a module it does not have — get_fs_type() on a mount, socket() for an unknown protocol family, the crypto API resolving a name — it spawns CONFIG_MODPROBE_PATH as a usermode helper and runs it at the kernel's own authority. That path is a writable sysctl, which makes redirecting /proc/sys/kernel/modprobe a classic escalation: point it somewhere attacker-controlled and the next module request executes it with the kernel behind it. The floor makes the redirection worthless on its own, because the attacker would also have to produce a TCB-signed binary.

This is the only exec KACS refuses on integrity grounds. Everywhere else an unsigned binary runs and simply carries no tier, because a requesting process's own authority bounds what it can do. A kernel-initiated exec has no such process behind it, so there is no lesser authority to fall back to.

The floor also refuses when no tier could be derived at all, not only when one was derived and graded too low. Treating "could not establish trust" differently from "is not trusted" would leave the check bypassable by whatever prevented the derivation from running.

Nothing upstream identifies such an exec by the time the LSM sees it. The helper child is created by user_mode_thread(), so it never carries PF_KTHREAD — kernel_execve() rejects kernel threads outright — and security_kernel_module_request() fires in the requesting task, before the child exists. So kernel/umh.c is patched to mark the child after commit_creds() and before kernel_execve(), and the mark is read in the bprm_creds_from_file hook. It lives in the KACS task blob rather than costing a task_struct flag.

The mark is never cleared. A helper that re-execs — an interpreter for a #! helper — stays under the floor rather than escaping it on the second exec; the task exists only to be that helper.

A refusal emits kacs_exec with reason umh-not-tcb, so it is visible rather than presenting as an unexplained module-load failure.

3.7.5 Trust raising under a tracer or supervisor #

Dominance is tested when a tracer attaches and when a seccomp filter is installed, and not again. That leaves one way to end up controlling a process one does not dominate: be attached to it, or supervising its syscalls through seccomp user notification, when it execs a binary whose signature would raise its label. Linux marks exactly these execs — LSM_UNSAFE_PTRACE when the task is traced, LSM_UNSAFE_NO_NEW_PRIVS under no_new_privs, which every unprivileged seccomp filter requires — and uses them to withhold setuid gains. KACS applies the same rule to the label: the exec goes ahead, but the staged label is capped at the process's current one whenever the new label would not be dominated by it. The binary runs with the protection it can be given, as a setuid binary runs without its setuid under strace.

There is no privilege exception. SeDebugPrivilege never bypasses dominance, and a TCB tracer already dominates every label, so nothing is lost by capping. An exec that does not raise the label — the common case, and every unsigned binary — is untouched. A cap emits kacs_exec with reason pip-capped-unsafe carrying the label that was kept.

3.7.6 Raw physical memory #

A process able to read /dev/mem could map any process's physical pages and bypass virtual memory protections entirely, PIP included.

The defence is CONFIG_STRICT_DEVMEM, which restricts /dev/mem to I/O regions and denies RAM access. It is not merely a recommended build option: the LSM refuses to initialise unless both CONFIG_STRICT_DEVMEM and CONFIG_MODULE_SIG_FORCE are enabled, so a kernel configured without them does not boot with KACS at all. The same initialisation gate refuses to coexist with SELinux, AppArmor, Smack, TOMOYO or the BPF LSM.

Placing a restrictive descriptor on /dev/mem and /dev/kmem as a secondary defence is not implemented; nothing in the kernel handles those paths specially.

3.7.7 Limits of the guarantee #

PIP operates inside the kernel's trust boundary, and three things sit outside it.

Kernel compromise. A loaded module runs with unrestricted access to all memory and kernel structures. PIP is enforced by the kernel, so a compromised kernel voids it, and SeLoadDriverPrivilege is the ceiling of every guarantee here. CONFIG_MODULE_SIG_FORCE is hard-required as noted above, and module signing is itself ML-DSA-65. Stripping SeLoadDriverPrivilege from every token but peinit's and the device manager's is the other half of that defence, and is policy rather than kernel behaviour — nothing in the kernel strips it. The device manager holds it because loading drivers for the hardware that appears is its job; module signature enforcement is what keeps the privilege from meaning more than "load a module Peios built".

Hardware access. DMA-capable devices read and write physical memory directly, bypassing the CPU's virtual memory system. An IOMMU mitigates this, and configuring one is a kernel responsibility outside KACS.

Hypervisor-level isolation. PIP does not offer guarantees equivalent to hypervisor-based memory isolation. The threat model ceiling is a non-compromised kernel.

3.7.8 Impersonation #

PIP reads the PSB, never the effective token. A Protected service impersonating a client still evaluates its own PSB for every process boundary check, so the client's identity has no bearing on it. In the other direction, an unprotected process impersonating a token created for a protected one gains nothing — its PSB is still None. Since nothing constrains the PIP dimensions the way the integrity ceiling constrains impersonation (§3.5.3), the PSB is the only safe source.

3.7.9 Coredumps #

A crashing PIP-protected process is a potential secret leak, so its dumps must not be readable by non-dominant processes.

The implemented strategy is to disable them: a process with a nonzero pip_type has its dumpable flag cleared at exec, and prctl(PR_SET_DUMPABLE, 1) is refused for as long as the process remains protected. Requests that keep or make it non-dumpable are allowed, and no alternative dumpable-setting path is left ungated. If a later exec assigns None/0, normal Linux exec-time dumpability rules apply to the new image.

The alternative — a signed, high-trust crash handler receiving dump data from the kernel and writing it under a restrictive descriptor, so that diagnostics survive without bypassing isolation — is not implemented. The two are not mutually exclusive; disabling dumps is the minimum viable position.

3.8.1 AccessCheck Overview

Peios / Peios technical reference / PKM / KACS / AccessCheck

AccessCheck is the function that connects tokens to security descriptors. Given a token — who is asking — a descriptor — what the rules are — and a desired access mask — what they want to do — it returns a verdict: which of the requested rights are granted, and whether the request as a whole succeeds.

It is a pipeline, evaluating several layers of policy in a fixed order. Each layer can grant or constrain access and the layers interact: integrity policy can block what the DACL would allow, privileges can override what the DACL denied, and confinement can revoke what privileges granted. The order is not incidental — it is the specification.

3.8.1.1 Two API variants #

AccessCheck is the common case. It returns the granted access mask, whether the request succeeded, a continuous audit mask derived from SACL alarm ACEs, and a CAAP staging mismatch flag. When an object type list is supplied, success requires every listed node to pass and the returned mask is the intersection across them. The staging mismatch flag is set when the staged scalar result differs from the effective scalar result, when any per-node staged grant differs from the effective per-node grant, or when staged auditing differs from effective auditing.

AccessCheckResultList is the per-property variant and requires an object type list. It returns a separate verdict for each node, so a denial on one property fails that property alone rather than the whole request. It returns the same continuous audit mask and staging mismatch flag, with the flag set when the staged scalar result differs from the effective one, when any node's staged granted mask differs from that node's effective granted mask, or when staged auditing differs from effective auditing — the scalar comparison is not mode-branched. Directory services use it, because one operation there may touch several properties with independent access rules. Privilege-use auditing in this variant takes the same per-node view: a privilege counts as successfully used if its contributed bits survive on any node's final granted mask.

Both variants share one evaluation pipeline. Only the collection of results differs.

3.8.1.2 The three state values #

Every access check tracks three masks.

decided records which bits have been resolved. It enforces first-writer-wins within the DACL walk: once a bit is decided, no later ACE in the same walk changes its outcome. Pipeline layers that operate on top of the DACL result — restricted token intersection, confinement intersection, PIP revocation, CAAP intersection — may still revoke granted bits. Those layers narrow the result; they do not re-open decided bits for re-evaluation through the DACL.

granted records which bits resolved to yes. During the walk it is a subset of decided. Afterwards the later layers may remove bits from it, and the final value is what the caller receives.

privilege_granted records which bits in granted came from a privilege rather than from the DACL. It exists for two reasons: audit accuracy, so that privilege-granted access is distinguishable from DACL-granted access, and the restricted token merge, where privilege-granted bits are restored after the intersection so that privileges bypass the restricted pass. PIP may revoke privilege-granted bits.

With an object type list present, each node carries its own decided and granted pair.

3.8.2 The DACL Walk

Peios / Peios technical reference / PKM / KACS / AccessCheck

The DACL is an ordered list of ACEs, walked from first to last, comparing each ACE's SID against the calling token's identity. The governing principle is first-writer-wins: once a bit has been resolved, granted or denied, no later ACE changes the outcome for that bit.

An allow ACE whose SID matches the token grants the rights it carries that have not yet been decided, leaving already-decided bits untouched. A deny ACE whose SID matches denies its not-yet-decided rights — marking them decided but not granted — and likewise leaves decided bits alone.

3.8.2.1 SID matching #

An ACE's SID matches the token when it equals the user SID or a group SID on the token, subject to attribute filtering.

For allow ACEs, only groups that are enabled and not deny-only match. For deny ACEs, both enabled groups and deny-only groups match: a deny-only group always participates in deny matching whatever its enabled state. A group with neither SE_GROUP_ENABLED nor SE_GROUP_USE_FOR_DENY_ONLY participates in no matching at all.

The user SID follows the same rule — when user_deny_only is set on the token, it matches deny ACEs and not allow ACEs.

3.8.2.2 Skipping and mapping #

ACEs carrying INHERIT_ONLY exist purely to propagate to child objects and are skipped by the walk.

At the top of the walk each ACE's access mask is mapped through MapGenericBits using the same GenericMapping applied to the caller's request. The mapping works on a local copy — the ACE itself is never mutated. Mapping ACE masks at evaluation time is a deliberate departure from MS-DTYP, and it is what makes GENERIC_ALL work in central access policy recovery ACEs (§3.8.8).

3.8.2.3 Absent and empty DACLs #

If the descriptor has no DACL — SE_DACL_PRESENT unset — every valid right not already decided by an earlier pipeline stage is granted. The valid rights are bounded by MapGenericBits(GENERIC_ALL, mapping) rather than by a raw 0xFFFFFFFF.

If the DACL is present but holds zero ACEs, the walk grants nothing. The only access an owner gets in that case comes from the implicit rights mechanism below.

3.8.2.4 Owner implicit rights #

By default the owner of an object receives READ_CONTROL and WRITE_DAC whatever the DACL says. These are granted before the walk begins, as the first action inside EvaluateDACL, and because first-writer-wins governs the walk, no deny ACE encountered later can override them.

EvaluateDACL takes a skip_owner_implicit parameter. The confinement pass sets it, because confinement is an absolute intersection with no owner bypass.

The grant is suppressed entirely if any non-inherit-only access-control ACE in the DACL targets the OWNER RIGHTS SID (S-1-3-4). This is a pre-scan performed at the start of EvaluateDACL, before the main loop, and it checks only for the SID's presence — it does not evaluate any conditional expression on the ACE.

During the walk proper, S-1-3-4 is treated as an ordinary SID matching the owner, at both allow and deny polarity. It obeys the same rules as any other SID: an allow ACE matches only through an enabled, non-deny-only group, and not through the user SID of a user_deny_only token; a deny ACE matches through a group that is enabled or deny-only, and through the user SID unconditionally.

Note that the implicit grant above is a separate rule and remains presence-based. It is bounded by the pre-scan rather than by polarity.

The implicit grant is also bounded twice over: by the object type's valid rights, and by what has already been decided. A pre-decision from MIC, PIP or a privilege therefore suppresses it — a non-dominant owner does not receive WRITE_DAC through this route.

3.8.2.5 MAXIMUM_ALLOWED #

When the caller includes MAXIMUM_ALLOWED (bit 25), AccessCheck runs the full pipeline and returns the complete set of rights that would be granted. The bit is stripped from the desired mask before evaluation begins.

Two things change. The walk runs to completion with no short-circuit, and the returned mask is whatever the pipeline accumulated rather than being filtered to the requested bits.

MAXIMUM_ALLOWED can be combined with specific rights: MAXIMUM_ALLOWED | READ_CONTROL asks both "can I read the descriptor?" as a success or failure and "what else could I get?" as a mask. A pure MAXIMUM_ALLOWED request carrying no specific bits always succeeds.

Otherwise — when the desired mask is fully decided — the walk may stop early.

First-writer-wins applies to MAXIMUM_ALLOWED requests exactly as it does to targeted ones. MS-DTYP treats the two differently; KACS does not, which is what stops "what can I do?" and "can I do this?" disagreeing on a DACL that is not in canonical order.

3.8.3 Mandatory Integrity Control

Peios / Peios technical reference / PKM / KACS / AccessCheck

MIC is a mandatory constraint that restricts which rights the DACL is allowed to grant, along a vertical trust hierarchy. It is evaluated before the DACL walk, in the pre-SACL phase.

Every token carries an integrity level, and every object may carry a mandatory label — a SYSTEM_MANDATORY_LABEL_ACE in its SACL. MIC compares the two: a caller below the object's level is blocked from whole categories of access whatever the DACL says.

The default is no-write-up. A lower-integrity process can read and execute a higher-integrity object but cannot write to it, and the object's label may additionally block reads or execution for callers beneath it.

An object with no mandatory label ACE in its SACL — or no SACL at all — is treated as Medium integrity with no-write-up, so Low and Untrusted processes cannot write to unlabelled objects.

A caller whose level is greater than or equal to the object's label dominates it, and MIC pre-decides nothing: the DACL handles authorization normally.

3.8.3.1 What MIC does and does not touch #

MIC constrains what the DACL can grant. It does not revoke what privileges have already granted, because it mutates only decided and never touches granted or privilege_granted.

ACCESS_SYSTEM_SECURITY is outside its reach for a structural reason: the bits MIC can decide are bounded by MapGenericBits(GENERIC_ALL, mapping), which does not include it. The right is privilege-granted rather than DACL-granted, so MIC never blocks it. PIP is stricter and does revoke it for non-dominant callers — explicitly ORing it into the set of bits it can take away — which is the mechanism by which objects stay protected even from administrators.

SeRelabelPrivilege has one specific interaction: it lets the DACL grant WRITE_OWNER even when an integrity mismatch would otherwise block it, so a privileged administrator can take ownership of a higher-integrity object as the first step in modifying it. The bit granted this way is recorded under its own provenance and is deliberately not part of privilege_granted, so it is not restored after the restricted merge and is not preserved by the CAAP error escape hatch.

Enforcement is gated on the token's mandatory_policy: with NO_WRITE_UP set — the default — the rule applies, and with it clear MIC is effectively disabled for that token. The field is fixed at creation (§3.2.2), which is what makes MIC a boundary rather than a suggestion.

3.8.3.2 Labels #

An object's SACL may carry more than one mandatory label ACE. Only the first non-inherit-only one is used; inherit-only labels do not apply to the object carrying them.

The SID in a mandatory label ACE has the Mandatory Label authority (S-1-16) and exactly one sub-authority, and that sub-authority value is the integrity level, compared as an unsigned integer. Any S-1-16-X is therefore valid.

SIDLevelName
S-1-16-00Untrusted
S-1-16-40964096Low
S-1-16-81928192Medium
S-1-16-1228812288High
S-1-16-1638416384System

Peios tooling and authd use these five, but intermediate values such as S-1-16-2048 or S-1-16-8448 are valid and compared numerically, which is what allows Windows-originated descriptors carrying non-standard levels to be evaluated without translation.

A label ACE whose SID falls outside the S-1-16 authority — wrong identifier authority, or the wrong sub-authority count — is malformed, and so is one that is not a plain single-SID ACE. Either causes AccessCheck to reject the whole descriptor with an error rather than ignore the label.

3.8.3.3 Policy bits #

BitValueMeaning
SYSTEM_MANDATORY_LABEL_NO_READ_UP0x00000001Non-dominant callers receive no read-mapped rights from the DACL.
SYSTEM_MANDATORY_LABEL_NO_WRITE_UP0x00000002Non-dominant callers receive no write-mapped rights from the DACL.
SYSTEM_MANDATORY_LABEL_NO_EXECUTE_UP0x00000004Non-dominant callers receive no execute-mapped rights from the DACL.

Unknown bits in a label mask are ignored.

3.8.3.4 The algorithm #

EnforceMIC(ace, token, mapping, &decided):

    if not (token.mandatory_policy & NO_WRITE_UP):
        return

    token_dominates = (token.integrity_level >= ace.integrity_level)

    if token_dominates:
        return

    // Non-dominant: start with read + execute, strip per label policy.
    allowed = MapGenericBits(GENERIC_READ, mapping)
            | MapGenericBits(GENERIC_EXECUTE, mapping)

    if ace.mask & SYSTEM_MANDATORY_LABEL_NO_READ_UP:
        allowed &= ~MapGenericBits(GENERIC_READ, mapping)
    if ace.mask & SYSTEM_MANDATORY_LABEL_NO_WRITE_UP:
        allowed &= ~MapGenericBits(GENERIC_WRITE, mapping)
    if ace.mask & SYSTEM_MANDATORY_LABEL_NO_EXECUTE_UP:
        allowed &= ~MapGenericBits(GENERIC_EXECUTE, mapping)

    // READ_CONTROL and SYNCHRONIZE are always allowed regardless of the
    // object type's GenericMapping and the label's up-strip policy — a
    // non-dominant caller can always read the descriptor and synchronize
    // on the object. Applied after the strips because a file GENERIC_READ
    // mapping folds these bits in, so NO_READ_UP would otherwise take them.
    allowed |= READ_CONTROL | SYNCHRONIZE

    // SeRelabelPrivilege: let WRITE_OWNER through MIC.
    if token.privilege_enabled(SeRelabelPrivilege):
        allowed |= WRITE_OWNER

    all_bits = MapGenericBits(GENERIC_ALL, mapping)
    decided |= all_bits & ~allowed

3.8.4 Restricted Tokens

Peios / Peios technical reference / PKM / KACS / AccessCheck

A restricted token carries a secondary SID list, the restricting SIDs. The second pass runs whenever that list or the restricted device group list is non-empty, and AccessCheck evaluates the DACL twice.

The normal pass evaluates the DACL against the token's ordinary identity — user SID and group SIDs — exactly as usual. The restricted pass evaluates the same DACL again with SID matching drawn only from the restricting SID list; the token's normal groups are invisible to it. Conditional membership operators — Member_of, Member_of_Any, and their device variants — take the same restricted view, seeing only the restricting SIDs plus any virtual groups injected from that list.

The restricting SID list is presence-based. A SID participates whenever it appears in the list, and SE_GROUP_ENABLED and SE_GROUP_USE_FOR_DENY_ONLY on those entries are ignored both for restricted-pass SID matching and for restricted-pass non-device conditional membership. This matches Windows restricted-token behaviour, where restricting SIDs are always enabled for access checks.

Access is granted only for rights both passes agree on — the intersection. The restricting list acts as a ceiling: the principal can never receive more access than the restricting SIDs would independently justify.

3.8.4.1 Write-restricted tokens #

In the write-restricted variant the intersection applies only to write-category bits, and read and execute access comes from the normal pass alone. What counts as write is whatever the object type's GenericMapping maps GENERIC_WRITE onto.

3.8.4.2 Privileges bypass the restricted pass #

Rights granted by privileges are added back after the intersection. Privileges are system-level grants from security policy rather than from the object's DACL: token restriction reduces the identity-based access surface, and privilege-based grants are orthogonal to it. The bits restored are the post-PIP privilege-granted set together with the take-ownership grant.

3.8.4.3 Owner rights and virtual groups in the restricted pass #

The restricted pass evaluates owner implicit rights independently. If the object's owner SID appears in the restricting SID list, the pass grants READ_CONTROL and WRITE_DAC, subject to the same OWNER RIGHTS suppression pre-scan as the normal pass; if the owner SID is not a restricting SID, no implicit rights are granted.

Two virtual groups are injected on the same basis. S-1-3-4 (OWNER RIGHTS) is injected when the object's owner SID is among the restricting SIDs, and S-1-5-10 (PRINCIPAL_SELF) when self_sid is. This keeps the restricted pass consistent in its handling of these well-known SIDs rather than letting them leak the unrestricted identity.

Restricted device groups, when the token has them, are swapped in for the restricted pass so that Device_Member_of and its relatives evaluate against the restricted set rather than the unrestricted device groups.

3.8.5 Object ACEs and Property-Level Access

Peios / Peios technical reference / PKM / KACS / AccessCheck

An object ACE carries a GUID identifying the property or property set its rule applies to, which is what enables per-property access control on objects with internal structure — Active Directory objects, most obviously. An object ACE with no GUID, meaning ACE_OBJECT_TYPE_PRESENT is unset, behaves exactly like a basic ACE.

3.8.5.1 Object type lists #

To request property-level access the caller supplies an object type list: a tree of GUIDs representing the properties being asked for. Each node carries its own decided and granted pair and is resolved independently.

With no list supplied, object ACEs with GUIDs apply globally, as if they were basic ACEs. With a list supplied, every ACE that behaves like a basic ACE — ordinary basic ACEs, and object ACEs without an ObjectType GUID — applies to every node in the tree. An object ACE whose GUID does not appear anywhere in the supplied tree is silently skipped.

3.8.5.2 Propagation #

Decisions move through the tree in four ways.

Downward from grants. A grant on a property set flows to every attribute within it, each child node still applying first-writer-wins for itself.

Upward from grants. When every attribute within a property set has been granted the same right, that right propagates up to the set's node. The propagation is a per-bit intersection, so a right reaches the parent only if all siblings share it.

Upward from denials. A denial on an attribute propagates to every ancestor regardless of what its siblings hold, and siblings are themselves unaffected. Ancestors still apply first-writer-wins to the propagated bits, so a denial cannot overturn something an ancestor had already decided.

Downward from denials. A denial on a property set flows to every attribute within it, again subject to first-writer-wins.

3.8.5.3 PRINCIPAL_SELF #

PRINCIPAL_SELF (S-1-5-10) is a placeholder for the object's associated principal. An ACE targeting it matches the caller when the caller's token represents the same principal as the object, which the caller establishes by passing the object's principal SID as the self_sid parameter. With a null self_sid, PRINCIPAL_SELF ACEs match nothing.

It follows the ordinary deny-only rules: if the caller's matching SID is deny-only, PRINCIPAL_SELF matches deny ACEs but not allow ACEs.

3.8.5.4 Scalar and per-node results #

AccessCheck requires every node to pass, so a denial on any one property fails the whole request. AccessCheckResultList returns a separate verdict per node.

The scalar result AccessCheck returns is the root node's granted mask. That is equivalent to the intersection across all nodes, but by construction rather than by computation: upward denial propagation forces every descendant's denial into all of its ancestors' decided sets, which guarantees the root's granted mask is a subset of every node's.

3.8.5.5 Validation #

Object type lists are validated strictly, at parse time.

  • A supplied list is non-empty.
  • Its first node is at level 0.
  • There is exactly one level-0 node.
  • There are no level gaps, meaning no node at level N+2 following one at level N.
  • No GUID appears twice.

These checks are stricter than MS-DTYP, which does not specify them. They exist because a duplicate GUID makes node lookup return the wrong node, and a level gap makes propagation undefined.

3.8.6 Application Confinement

Peios / Peios technical reference / PKM / KACS / AccessCheck

Confinement restricts a token's effective access to what is explicitly granted to its confinement identity. Even where the normal DACL walk grants access through the user SID or a group SID, the confinement pass intersects that with what the confinement SIDs would receive on their own, and revokes anything they cannot independently justify.

A confined token carries confinement_sid, its confinement identity; confinement_capabilities, the capability SIDs the application declares; and confinement_exempt, an escape hatch that skips confinement evaluation entirely.

The confinement SID set is confinement_sid together with every SID in confinement_capabilities. Capabilities are presence-based identities rather than ordinary ACE-matching groups: a capability SID participates whenever it is present, and disabling an entry or marking it deny-only does not remove it from the confinement identity.

The pass also injects two confinement-scoped virtual groups. S-1-5-10 (PRINCIPAL_SELF) is injected only when self_sid equals the confinement SID or one of the capability SIDs, and S-1-3-4 (OWNER RIGHTS) only when the object's owner SID does. Both apply to ACE SID matching during the confinement walk and to the conditional membership operators — Member_of, Member_of_Any, and their device and negated variants — evaluated during that pass.

3.8.6.1 An absolute boundary #

Confinement is not overridable. Privileges do not bypass it: the confinement merge takes no privilege-granted input at all, so backup, restore, SeTakeOwnershipPrivilege and SeSecurityPrivilege are alike unable to grant access the confinement check denies. Owner implicit rights are skipped entirely, because the pass runs with skip_owner_implicit set.

One thing does pass through: an object with a null DACL grants in the confinement pass exactly as it does in the normal pass. A null DACL means "no discretionary restrictions", and the confinement pass follows standard evaluation, granting all valid bits.

3.8.6.2 Strict confinement #

A normal confined token carries both ALL_APPLICATION_PACKAGES and ALL_RESTRICTED_APPLICATION_PACKAGES among its capabilities. Omitting ALL_APPLICATION_PACKAGES gives strict confinement: far fewer system objects grant to ALL_RESTRICTED_APPLICATION_PACKAGES, so the access surface is much narrower.

Strict confinement is not a separate kernel mode bit. It is derived purely from the SID set supplied at token creation — if ALL_APPLICATION_PACKAGES is absent, AccessCheck simply evaluates the remaining confinement SIDs. The kernel never synthesises it, and never rejects an otherwise valid confined token for carrying it. Deciding which capabilities a package token receives belongs to authd and policy tooling.

3.8.6.3 Consequences worth stating #

SACL access is unreachable. ACCESS_SYSTEM_SECURITY is only ever privilege-granted, and privileges do not bypass confinement, so a confined token cannot reach a SACL unless a confinement ACE grants the right outright.

OWNER RIGHTS is confinement-scoped. S-1-3-4 matches in the confinement pass only when the owner SID is part of the confinement SID set.

PRINCIPAL_SELF is isolated from user identity. S-1-5-10 is injected only when self_sid matches a confinement SID — the package SID or one of its capabilities — not when it matches the user.

Conditional expressions still see the full token. The confinement pass isolates ordinary SID matching to the confinement identity, but conditional expressions inside ACEs continue to evaluate against the user's real groups and claims. The two confinement-scoped virtual groups are the only exception, and they follow the confinement rules during conditional membership evaluation.

3.8.6.4 Ordering #

Confinement runs after the restricted token merge and after its privilege restoration. The order is load-bearing: privileges bypass the restricted pass but must not bypass confinement, and if confinement ran first the privilege restoration would resurrect bits that confinement had already blocked.

3.8.7 PIP in AccessCheck

Peios / Peios technical reference / PKM / KACS / AccessCheck

An object opts in to PIP protection by carrying a SYSTEM_PROCESS_TRUST_LABEL_ACE in its SACL. The ACE's SID encodes the required type and trust, and its access mask names exactly the rights a non-dominant caller may still have.

A caller that dominates — pip_type and pip_trust both greater than or equal to the ACE's — is unrestricted by PIP. A caller that does not dominate is limited to the ACE mask, and everything else is denied.

Unlike MIC, PIP has no default. An object with no trust label is unrestricted, reachable by any process whatever its PIP identity.

3.8.7.1 The label SID #

A trust label SID has the form S-1-19-{type}-{trust} — the Process Trust authority, exactly two sub-authorities. Both axes are compared numerically. The conventional type values are 0 (None), 512 (Protected) and 1024 (Isolated), but they are labels rather than a closed enum: any other numeric type is valid and compared by the same dominance rule.

A trust label SID of any other shape — wrong authority, wrong sub-authority count — makes the descriptor malformed, and AccessCheck rejects it outright rather than guessing.

Where a SACL carries more than one trust label ACE, only the first non-inherit-only one is used; inherit-only labels do not apply to the object carrying them.

3.8.7.2 Privilege revocation #

This is the critical difference from MIC. PIP does not merely constrain what the DACL may grant — it revokes rights privileges already granted. A non-dominant caller who used SeBackupPrivilege to obtain read has those bits stripped; SeTakeOwnershipPrivilege's WRITE_OWNER is stripped; SeSecurityPrivilege's ACCESS_SYSTEM_SECURITY is stripped.

The last of those is the point. Without privilege revocation, a non-dominant administrator holding SeSecurityPrivilege could read the SACL of a PIP-protected object — and remove the trust label from it. PIP would be self-defeating.

There is no escape hatch. PIP has no SeRelabelPrivilege equivalent, and no privilege compensates for insufficient trust. It is an absolute boundary, which is why the enforcement step explicitly ORs ACCESS_SYSTEM_SECURITY into the set of bits it can take away — that right is outside the generic mapping and would otherwise escape.

3.8.7.3 The algorithm #

EnforcePIP(ace, pip_type, pip_trust, mapping, &decided,
           &granted, &privilege_granted):

    // pip_type and pip_trust are the subject's process-trust context,
    // not a token field. See "Where the values come from" below.

    ace_type  = ace.sid.pip_type
    ace_trust = ace.sid.pip_trust

    caller_dominates = (pip_type  >= ace_type
                    and pip_trust >= ace_trust)

    if caller_dominates:
        return

    // Non-dominant: the ACE mask IS the allowed set.
    allowed = MapGenericBits(ace.mask, mapping)

    // Everything not explicitly allowed is denied, including
    // ACCESS_SYSTEM_SECURITY.
    all_bits = MapGenericBits(GENERIC_ALL, mapping)
             | ACCESS_SYSTEM_SECURITY
    pip_denied = all_bits & ~allowed

    decided |= pip_denied

    // Revoke privilege-granted rights.
    granted           &= ~pip_denied
    privilege_granted &= ~pip_denied

3.8.7.4 Where the values come from #

pip_type and pip_trust are the subject's process trust context and are never derived from a token field — the token structure has no PIP field at all. They are passed into AccessCheck as explicit parameters by whichever layer is enforcing.

During enforcement — FACS file access, process boundaries — the values are the subject process's PSB, set at exec from the binary's signature (§3.6, §3.7).

The kacs_access_check query may instead supply them through its arguments, per axis: zero means "use the calling process's PSB value" and a nonzero value evaluates against the supplied context. This lets a userspace broker evaluate access under a client's trust level, in the same way the query's token argument lets it evaluate a token other than its own. The query is advisory and gates nothing in the kernel; enforcement always uses the PSB. The same effective values are used for the verdict and for the event the check emits, so an audit record never disagrees with the decision it describes.

The enforcement step also computes a record of which bits PIP decided. Nothing currently consumes it — it is threaded through three structures and exported, and no caller reads it.

3.8.8 Central Access and Auditing Policy

Peios / Peios technical reference / PKM / KACS / AccessCheck

CAAP separates policy definition from the objects it governs. A policy is defined once, centrally; objects reference it by SID through a SYSTEM_SCOPED_POLICY_ID_ACE in their SACL. When the policy changes, every future AccessCheck against a referencing object uses the new rules — already-open handles are unaffected, because the model is check-at-open.

It extends the Windows Central Access Policy model by adding an audit component: each rule carries both an access restriction and an audit requirement.

3.8.8.1 Policy structure #

A policy is a named collection of rules identified by a policy SID. Each rule carries:

An optional applies-to condition, a conditional expression determining whether the rule governs this decision. It may reference the @Resource, @User, @Device and @Local claim namespaces, but it cannot inspect SID or device-group membership: Member_of, Member_of_Any, Device_Member_of, Device_Member_of_Any and their negated forms all evaluate to UNKNOWN inside applies_to. A rule with no condition applies to every object referencing the policy.

A mandatory effective DACL — a real DACL evaluated through the full pipeline. An optional effective SACL, whose audit ACEs merge with the object's own during the audit walk. And optional staged DACL and SACL, proposed replacements used for testing.

3.8.8.2 Access evaluation #

The DACL result is ANDed with the normal evaluation. CAAP can only restrict, never expand: if the object's DACL grants read and write but the applicable rule's effective DACL grants only read, the result is read.

A SACL may carry several scoped policy ACEs, which makes policies composable — and the AND semantics are what make composition safe, since each additional policy can only narrow. Inherit-only scoped policy ACEs do not apply to the object carrying them and are ignored during lookup. MS-DTYP allows one scoped policy ACE per SACL; KACS allows several.

For each scoped policy ACE, AccessCheck looks the policy up in the kernel cache, then for every rule whose applies_to is TRUE or absent evaluates the rule's effective DACL through the full pipeline — privilege grants, MIC, PIP, the DACL walk, restricted tokens, confinement — and intersects the result with the running total, which starts at the normal evaluation's granted mask.

A rule whose condition evaluates FALSE or UNKNOWN is skipped. That is deliberately the opposite of the deny-ACE UNKNOWN rule: skipping is the conservative choice here, because a rule's DACL can only narrow what the normal DACL already granted.

If no rules apply — every condition false or unknown, or the policy has no rules — CAAP has no effect and the normal result stands.

The rule's DACL is evaluated with backup and restore intent not passed: intent is a caller concern, not a policy concern. And CAAP never recurses. A rule's synthetic descriptor has scoped policy ACEs stripped before evaluation, so nested CAAP evaluation cannot occur even when the original SACL carried more of them. The synthetic descriptor keeps the original owner and group, and preserves the MIC and PIP labels, so a rule is evaluated against the same mandatory constraints as the object itself.

3.8.8.3 Audit evaluation #

For each applicable rule carrying an effective SACL, the rule's audit ACEs are evaluated alongside the object's own during the audit walk. They are treated identically — if either says to audit an operation, it is audited.

The SACL component is purely additive. A CAAP SACL can add audit coverage and can never suppress auditing the object's own SACL asks for.

3.8.8.4 The policy cache #

The kernel keeps a map from policy SID to policy object, empty at boot. Policies are pushed in through kacs_set_caap, which requires SeTcbPrivilege — and marks it used. A non-null spec for an existing SID replaces the policy; a null spec or zero length removes it. Every call that passes the privilege gate writes a kacs.caap.policy.changed record (§3.C) naming the caller, the policy SID as caap.policy.sid, and operation.name set or remove, with outcome.success and, when the change did not apply, outcome.errno. The policy SID's length is bounded to 8–68 bytes before parsing begins, and until the cache has been initialised both setting and evaluating fail with EACCES.

The wire format is:

[version:u8 = 0x01]
[rule_count:u32le]
per rule:
  [applies_to_len:u32le][applies_to_expr bytes]     (0 = no condition)
  [effective_dacl_len:u32le][effective_dacl bytes]  (MUST NOT be 0)
  [effective_sacl_len:u32le][effective_sacl bytes]  (0 = no audit rules)
  [staged_dacl_len:u32le][staged_dacl bytes]        (0 = no staged DACL)
  [staged_sacl_len:u32le][staged_sacl bytes]        (0 = no staged SACL)

All lengths are little-endian u32. ACLs use the standard binary format, and every ACE type valid in a DACL or SACL is permitted inside a policy ACL. The applies_to expression is conditional ACE bytecode, carrying the same artx prefix as callback ACE application data.

The limits are a spec of at most 256 KB, at most 256 rules, an applies_to of at most 64 KB, and an individual ACL of at most 64 KB. The version byte has to be 0x01. Trailing bytes after the declared rules are rejected.

Validation is strict and total. Malformed or truncated applies_to bytecode fails the whole call with EINVAL at ingestion rather than being admitted and later treated as a runtime UNKNOWN — the structural check happens once, at the boundary. A rule with a zero-length effective DACL fails the same way, as do truncated fields, lengths exceeding the buffer, and invalid ACL headers. SeTcbPrivilege is checked before any parsing begins.

authd populates the cache — from the registry on a standalone machine, from Active Directory on a domain-joined one. The kernel neither knows nor cares about the source; it is a passive cache. Policies pushed after services are running do not retroactively affect handles already opened.

3.8.8.5 The recovery policy #

When a scoped policy ACE names a SID that is not in the cache — authd failed to push it, the policy was deleted, the machine is disconnected — a hardcoded recovery policy is used instead: GENERIC_ALL to BUILTIN\Administrators, to SYSTEM, and to OWNER RIGHTS.

Those masks are stored as the literal GENERIC_ALL bit rather than pre-mapped object-specific bits, and are expanded through the caller's GenericMapping at evaluation time, so the recovery policy works correctly for every object type.

Because CAAP is an intersection, recovery does not widen access beyond the object's own DACL: it limits missing-policy access to callers who also satisfy the recovery DACL. This is a fail-closed recovery mode with administrator, SYSTEM and owner escape hatches — not a no-effect fallback.

3.8.8.6 Errors #

A rule whose DACL evaluation errors denies everything except rights granted by privileges — in the effective and the staged evaluation alike, and in the scalar total exactly as on every node of a result-list check. Preserving those is the escape hatch: an administrator with SeSecurityPrivilege keeps the ability to read and modify the SACL and remove the offending scoped policy ACE.

The escape hatch is conditional, though, and the ordering is why. PIP runs before CAAP and may already have stripped ACCESS_SYSTEM_SECURITY from the privilege-granted set for a non-dominant caller. The hatch therefore only works for callers who are PIP-dominant, or where the object carries no trust label.

Rule evaluation swallows every error kind, including allocation failure, so an out-of-memory condition inside a rule is reported as "this rule denied all except privileges" rather than as ENOMEM.

A rule whose SACL evaluation errors has its audit contribution skipped, and a diagnostic event is emitted.

3.8.8.7 Staging #

A rule may carry staged DACLs and SACLs alongside its effective ones, and AccessCheck evaluates both in parallel. The staged result affects neither access nor audit; where effective and staged differ, the difference is reported through a staging mismatch flag returned to the caller and a diagnostic event.

A rule with no staged DACL contributes its effective result to both running totals, and a rule with no staged SACL contributes its effective SACL to both.

3.8.9 Auditing in AccessCheck

Peios / Peios technical reference / PKM / KACS / AccessCheck

Auditing is purely observational. No audit rule affects the access decision, and audit ACEs are evaluated after the decision is final.

Three mechanisms operate inside the pipeline, emitting two families of KMES record: kacs.audit.access.checked for object-access events from the SACL walk and from token audit-policy forcing, and kacs.audit.privilege.used for privilege-use events. A third family is emitted for the CAAP conditions of §3.8.8: kacs.caap.sacl.skipped for a rule SACL that would not parse or evaluate, and kacs.caap.staging.diverged for a staged-versus-effective mismatch. The event type strings and payload schemas are in §3.C.

Event delivery happens before any result is written back to the caller, and a failure to deliver fails the call. An audit event cannot be suppressed by handing the syscall a bad output pointer.

3.8.9.1 Access auditing #

SYSTEM_AUDIT ACEs in the SACL define which attempts to log. Each carries a SID, an access mask, and success and failure flags — SUCCESSFUL_ACCESS_ACE_FLAG (0x40) and FAILED_ACCESS_ACE_FLAG (0x80).

An event is emitted when the ACE's SID matches the caller, its mask overlaps the requested access, and its flags match the outcome.

3.8.9.1.1 On the AccessCheck syscall #

Through kacs_access_check and kacs_access_check_list the descriptor is the caller's own, SACL included. So that no process can write kernel-origin audit records at will by supplying a SACL that matches itself, the records a SACL generates there are written only when the calling process's effective token holds SeAuditPrivilege, enabled. Without it the check runs and answers exactly as it would otherwise, and its SACL records are withheld; the privilege is marked used when it admits them. The privilege is the caller's, not the subject's: a daemon checking a client through token_fd needs it on its own token. This is Windows' rule for AccessCheckAndAuditAlarm.

The records the checked token's own audit policy forces, and privilege-use and CAAP records, are not gated: the policy belongs to the token, not to the caller.

Two details matter. The SID is matched with deny polarity — the broadest identity view, in which deny-only groups are visible — because auditing should capture the widest possible picture rather than the narrowest. And the overlap is tested against the generic-mapped requested mask, not the final granted mask, so a failed request is still auditable for the rights it actually asked for.

Conditional audit ACEs gate the event on an expression, using the same deny-side membership polarity. An expression evaluating to UNKNOWN emits the event: when in doubt, audit.

3.8.9.2 Continuous auditing #

Access auditing fires once, where AccessCheck runs. Continuous auditing covers per-operation monitoring.

SYSTEM_ALARM ACEs configure it. When AccessCheck evaluates an alarm ACE whose SID matches, the ACE's mask is accumulated into a continuous audit mask returned to the caller, which stores it on the open handle and enforces it per operation. Conditional alarm ACEs use the same deny-side polarity as conditional audit ACEs. The alarm branch deliberately performs no overlap test against the requested mask — an alarm ACE contributes its mask on a SID match alone.

On each later operation the enforcement point emits a kacs.audit.handle.used event when the operation's normalised required-access mask overlaps the stored mask. For FACS handles that is the same mask used by the use-time check (§3.9.4). Where an operation's authorization accepts any one of several rights — append or write data, say — the required mask holds the accepted set and the event records the subset that overlapped, as access.matched, beside the whole stored mask as access.audit-mask.

Events are emitted after the per-operation decision is known, for successful and denied attempts alike. The subject and process recorded are the operation-time effective token and current task, not necessarily the ones that opened the handle. That keeps attribution correct after a handle is passed between processes, while still using the opener-computed mask to decide whether the handle is audited at all.

An enforcement point that cannot construct a required continuous-audit event fails closed. Transport buffering and drop accounting remain KMES's concern (§2.7).

3.8.9.3 Privilege-use auditing #

When a privilege is exercised to grant access the DACL would not have granted independently, a privilege-use event may be emitted. This runs after the complete pipeline — after integrity policy, confinement and central access policy — so it reflects the final result rather than an intermediate one.

Successful privilege use means the privilege's contributed bits survive into the final granted result. The privilege is marked used, and an event is emitted when the token's audit_policy carries PRIVILEGE_USE_SUCCESS (0x04). Failed privilege use means the privilege contributed bits during evaluation that did not survive. The privilege is not marked used, and an event is emitted under PRIVILEGE_USE_FAILURE (0x08). A privilege that contributed nothing to the requested access produces no event either way.

With an object type list, the test is per-node: a privilege counts as successfully used if its bits survive on any node's final mask.

A MAXIMUM_ALLOWED request short-circuits this stage entirely, recording no used bits and emitting no privilege-use events at all.

How counterfactual the accounting really is varies by privilege, as §3.4.1 describes: SeSecurityPrivilege and SeTakeOwnershipPrivilege contribute only where the DACL had not already granted the right, while backup and restore seed their bits unconditionally and so also report use for accesses the DACL alone would have permitted.

3.8.9.4 Per-token audit policy #

A token's audit_policy can force events regardless of SACL content. This runs after the SACL walk and before result computation: if the access succeeded and the policy carries OBJECT_ACCESS_SUCCESS (0x01), a success event is emitted; if it failed and the policy carries OBJECT_ACCESS_FAILURE (0x02), a failure event is.

Success here means every requested bit was granted, or that nothing was requested at all.

These events are additive — they fire even when no SACL ACE matched — and they carry the object identity from the audit context the caller supplied, exactly as a SACL-triggered record does. The policy is per-token, fixed at creation, and follows impersonation, since it is read from the effective token.

3.8.9.5 Event contents #

An event carries the subject, the calling token's identity — subject.token.sid, .groups with their parallel .group-attributes, .integrity, the token's own LUID as .id, its logon session as .auth-id, .type, .impersonation and the projected .uid — and the PIP state as subject.pip.type and subject.pip.trust; the object, as object.kind and the fields beneath it; the access, as access.requested and access.granted, with outcome.success; the trigger, trigger.kind (sacl or policy) and, for sacl, the matched ACE as trigger.ace, or for a privilege record the privilege.* fields; and the process, as emitter.process.pid, .name and .executable.

An access record also says what the mandatory checks withheld, since that is often the whole reason a check the DACL would allow was denied: access.denied-integrity is the requested bits the object's mandatory label denied, and access.denied-trust the requested bits its process-trust label denied — every bit each denied, for a MAXIMUM_ALLOWED request — and each is absent when it denied none.

The pipeline itself produces only the object-and-access half — the matched ACE bytes, the requested and granted masks, the outcome, whether the event was policy-forced, the privilege, and the audit context. The subject and process halves are attached at emission time from the resolved call context, which is also where the effective PIP values used for the verdict are reused for attribution.

3.8.9.5.1 The object #

Where the kernel makes the check itself it knows the kind of object and writes object.kind: file, process, token, socket (which covers port-reservation binds) or ipc. A token check also writes the target's object.token.id and object.token.guid. Some identity is not yet reachable where the record is built, and is absent rather than invented: object.file.path on file checks, object.process.pid and object.process.guid on process checks. A mount-namespace check writes object.kind mount-namespace and nothing beneath it: a namespace has no name or identifier to carry.

A check made through kacs_access_check names its object through the audit context (audit_context_ptr, audit_context_len), the PGSS §6.7 map:

{kind: "service", service: {name: "jellyfin"}}

The kernel validates it before running the check and fails the call with EINVAL unless it is well-formed msgpack no deeper than KMES's default nesting depth less one; its root is a map of one or two entries with string keys; it has exactly one kind, a string matching the PGSS §6.3 segment grammar [a-z][a-z0-9]*(-[a-z0-9]+)*; and its only other entry is keyed by that kind and holds a non-empty map whose keys match the same grammar. The kernel copies it into the record as object.kind and object.<kind>.* — object.service.name above. A call without an audit context writes no object: the kernel does not know what was checked.

These values are the caller's claim, which the kernel copied but did not observe. So is everything else on the syscall path: the descriptor, and with it trigger.ace, is the caller's, as is any PIP type or trust it passes. Every record of kacs_access_check and kacs_access_check_list therefore carries fields.attestation.userspace set to true, with or without an audit context, and a record of a check the kernel makes itself never does. A context that is anything other than this map, such as the free-form strings earlier components passed, fails with EINVAL.

3.8.10 The Algorithm

Peios / Peios technical reference / PKM / KACS / AccessCheck

The preceding sections describe what each layer of AccessCheck does. This one describes how they compose, and is the definitive statement of the evaluation order.

3.8.10.1 Pipeline overview #

Before the pipeline proper, two things are checked and can fail the call outright.

  • The token's own invariants are validated — a write_restricted token without user_deny_only is rejected as invalid.
  • In the orchestrator, a null descriptor is rejected and AccessCheckResultList is required to have been given an object type list.

A null descriptor presented by an Identification-level token therefore fails as an invalid parameter rather than as access denied, because the null check runs first.

The pipeline then runs in this order:

  1. Impersonation level gate. An impersonation token at Identification level is denied immediately. Anonymous tokens proceed through the full pipeline.
  2. Input validation. Reject a descriptor with no owner. A null group SID is valid and has no direct effect on the decision.
  3. Generic mapping. Map generic bits in the desired mask to object-specific bits; strip MAXIMUM_ALLOWED.
  4. Effective privileges. Clear the backup and restore bits when the corresponding intent flag is absent.
  5. Privilege grants. Resolve ACCESS_SYSTEM_SECURITY, backup and restore. Seed decided, granted and privilege_granted.
  6. Pre-SACL walk. Extract the mandatory integrity label, the PIP trust label, resource attributes and scoped policy SIDs from the SACL, then enforce MIC and PIP.
  7. Virtual group resolution. S-1-3-4 and S-1-5-10 become matchable where the caller is the owner or the object's principal.
  8. Tree initialisation. Seed each node from the scalar state.
  9. Normal DACL evaluation. Owner implicit rights, then the walk.
  10. Post-DACL WRITE_OWNER override. SeTakeOwnershipPrivilege grants WRITE_OWNER if the DACL did not and no mandatory mechanism blocked it.
  11. Restricted token pass, with intersection and privilege restoration.
  12. Confinement pass, with absolute intersection.
  13. CAAP. Evaluate each applicable rule's DACL through the full per-descriptor pipeline and intersect; collect SACLs.
  14. Privilege-use auditing.
  15. Audit emission, over the object's SACL and any CAAP SACLs.
  16. Result computation.

Object type lists are validated at parse time rather than at step 1 — non-empty, one level-0 node first, no level gaps, no duplicate GUIDs — so a malformed list never reaches the pipeline. Step 1 re-checks only emptiness.

Reserved access-mask bits (0x0CE0_0000) are rejected wherever a mask is mapped. That applies to the caller's desired mask and to every ACE mask, so a single ACE carrying a reserved bit aborts the entire check rather than being skipped.

3.8.10.2 EvaluateSecurityDescriptor #

Steps 0–11, called once for the normal evaluation and once per CAAP rule with a synthetic descriptor.

EvaluateSecurityDescriptor(
    sd, token, pip_type, pip_trust, desired, mapping,
    object_tree, self_sid, local_claims, privilege_intent
) -> (decided, granted, privilege_granted,
      max_allowed_mode, mapped_desired, resource_attributes,
      policy_sids) | error

    // Step 0: Impersonation level gate.
    if token.token_type == Impersonation
       and token.impersonation_level == Identification:
        return ERROR_ACCESS_DENIED

    // Step 1: Input validation.
    if sd.owner is null:
        return ERROR_INVALID_SECURITY_DESCR

    // Step 2: Generic mapping.
    desired = MapGenericBits(desired, mapping)
    max_allowed_mode = (desired & MAXIMUM_ALLOWED) != 0
    desired = desired & ~MAXIMUM_ALLOWED

    // Step 3: Effective privileges.
    effective_privileges = token.privileges_enabled
    if not (privilege_intent & BACKUP_INTENT):
        effective_privileges &= ~SeBackupPrivilege
    if not (privilege_intent & RESTORE_INTENT):
        effective_privileges &= ~SeRestorePrivilege

    // Step 4: Privilege-based grants.
    decided = 0; granted = 0; privilege_granted = 0

    // ACCESS_SYSTEM_SECURITY is always decided by privilege.
    decided |= ACCESS_SYSTEM_SECURITY
    if (effective_privileges & SeSecurityPrivilege):
        granted           |= ACCESS_SYSTEM_SECURITY
        privilege_granted |= ACCESS_SYSTEM_SECURITY

    if (effective_privileges & SeBackupPrivilege):
        backup_bits = MapGenericBits(GENERIC_READ, mapping)
        decided |= backup_bits; granted |= backup_bits
        privilege_granted |= backup_bits

    if (effective_privileges & SeRestorePrivilege):
        restore_bits = MapGenericBits(GENERIC_WRITE, mapping)
                     | WRITE_DAC | WRITE_OWNER | DELETE
                     | ACCESS_SYSTEM_SECURITY
        decided |= restore_bits; granted |= restore_bits
        privilege_granted |= restore_bits

    // Restore already includes WRITE_OWNER, so when it is active
    // step 9 has nothing left to do. Step 9 is the fallback for
    // when restore is inactive and the DACL did not grant it.

    // Step 5: Pre-SACL walk. mandatory_decided records bits decided
    // by MIC and PIP, so step 9 cannot override them.
    resource_attributes = {}; policy_sids = []; mandatory_decided = 0
    PreSACLWalk(sd, token, pip_type, pip_trust, mapping,
                &decided, &granted, &privilege_granted,
                &mandatory_decided, &resource_attributes,
                &policy_sids)

    // Steps 6-8: owner implicit rights are granted first, inside
    // EvaluateDACL, and the tree is seeded from the already
    // augmented scalar state. Virtual groups are resolved per
    // lookup rather than by building an enriched token.
    EvaluateDACL(sd, token, mapping, object_tree,
                 SidMatchesToken, desired, max_allowed_mode,
                 resource_attributes, local_claims,
                 skip_owner_implicit=false,
                 &decided, &granted)

    // Step 9: Post-DACL WRITE_OWNER override.
    if (desired & WRITE_OWNER) != 0 or max_allowed_mode:
        if (effective_privileges & SeTakeOwnershipPrivilege):
            if not (mandatory_decided & WRITE_OWNER)
               and not (granted & WRITE_OWNER):
                decided |= WRITE_OWNER
                granted |= WRITE_OWNER
                privilege_granted |= WRITE_OWNER
                for each node with WRITE_OWNER ungranted:
                    node.decided |= WRITE_OWNER
                    node.granted |= WRITE_OWNER

    // Step 10: Restricted token pass.
    if token.restricted_sids or token.restricted_device_groups:
        // Restricted identity view: only restricting SIDs, plus
        // S-1-3-4 if the owner is among them and S-1-5-10 if
        // self_sid is. Restricted device groups swap in.
        // Fresh tree, zeroed state.
        EvaluateDACL(sd, restricted_view, mapping, r_tree,
                     SidInRestrictingSids, desired,
                     max_allowed_mode, resource_attributes,
                     local_claims, skip_owner_implicit=false,
                     &r_decided, &r_granted)
        if token.write_restricted:
            write_bits = MapGenericBits(GENERIC_WRITE, mapping)
            granted = (granted & ~write_bits)
                    | (granted & r_granted & write_bits)
        else:
            granted = granted & r_granted
        granted |= privilege_granted        // privileges bypass
        // Same intersection and restoration per node.

    // Step 11: Confinement pass.
    if token.confinement_sid and not token.confinement_exempt:
        // Confinement SID set: confinement_sid plus every
        // capability, presence-based. S-1-3-4 and S-1-5-10 are
        // injected only if the owner or self_sid is in that set.
        EvaluateDACL(sd, token, mapping, c_tree,
                     SidInConfinementSids, desired,
                     max_allowed_mode, resource_attributes,
                     local_claims, skip_owner_implicit=true,
                     &c_decided, &c_granted)
        granted = granted & c_granted       // no privilege bypass
        // Same absolute intersection per node.

    return (decided, granted & root-consistent state,
            privilege_granted narrowed to what survived,
            max_allowed_mode, desired, resource_attributes,
            policy_sids)

The returned privilege_granted is narrowed by what actually survived the pass — it is intersected with the root's granted mask on return, and the orchestrator narrows it again against the CAAP result. A privilege-granted bit that the write-restricted merge or the confinement intersection removed is therefore no longer part of it, which matters in two places: the CAAP error escape hatch (§3.8.8) has fewer bits to preserve, and the audit provenance masks reflect the narrowed set.

3.8.10.3 AccessCheckCore #

The orchestrator runs steps 12 through 15.

Step 12, CAAP. For each scoped policy SID, look the policy up — falling back to the recovery policy when it is absent — and for each rule whose applies_to is TRUE or absent, evaluate the rule's synthetic descriptor through EvaluateSecurityDescriptor and intersect. A rule that errors denies everything except the (already narrowed) privilege-granted bits. Staged DACLs are evaluated in parallel into a separate running total; a rule with no staged DACL contributes its effective result to both.

After all policies, the staged and effective totals are compared and any difference sets the staging mismatch flag. In result-list mode a per-node delta sets it too — and so does a scalar delta, since the comparison is not mode-branched.

Step 13, privilege-use auditing. For each of the five provenance masks — security, backup, restore, take-ownership and relabel:

success_bits = provenance & mapped_desired & granted
failure_bits = provenance & mapped_desired & ~granted

Nonzero success_bits means the privilege was load-bearing: mark it used on the token, and emit a success event under PRIVILEGE_USE_SUCCESS. Otherwise nonzero failure_bits means it was exercised but did not survive: do not mark it used, and emit a failure event under PRIVILEGE_USE_FAILURE. Both zero means no event. In result-list mode the comparison folds across nodes — success if the bits survive on any node, failure only if they survive on none.

The event is kacs.audit.privilege.used. Its privilege.contributed is provenance & mapped_desired, its privilege.surviving is success_bits (empty on a failure event), and its access.requested and access.granted are the whole check's mapped_desired and final granted, not this privilege's share of them.

The whole step is skipped in MAXIMUM_ALLOWED mode, so such a request marks nothing used and emits nothing.

Note that relabel_granted is tracked as provenance but is deliberately excluded from privilege_granted itself, so a relabel-loosened WRITE_OWNER is neither restored after the restricted merge nor preserved by the CAAP error hatch.

Step 14, audit emission. Walk the object's SACL, then each CAAP effective SACL, accumulating audit events and ORing alarm masks into the continuous audit mask. This is read-only with respect to granted.

The staged comparison then walks the object's SACL again followed by the staged SACLs. That second walk is driven by the staged granted total rather than the effective one, so the success and failure classification of staged audit events reflects the staged access result — which is what makes the flag sensitive to descriptors whose staged and effective grants differ.

Step 14b, forced auditing. With success = (granted & mapped_desired) == mapped_desired or mapped_desired == 0, the token's audit_policy forces a success or failure event additively, regardless of what the SACL matched.

Step 15 returns the accumulated state.

3.8.10.4 The wrappers #

AccessCheck takes the root node's granted mask when a tree is present, then computes allowed as mapped_desired == 0 or every requested bit granted. The root's mask equals the intersection across all nodes by construction rather than by computation: upward denial propagation (§3.8.5) forces every descendant's denial into all of its ancestors, so the root can never grant what a descendant denies.

AccessCheckResultList requires a tree and returns a per-node granted mask and status, each node judged against mapped_desired independently.

Neither wrapper filters the returned granted to the requested mask. Privilege seeding at step 4 ORs bits in regardless of what was asked for, so a caller that requested only READ_CONTROL while holding backup can see read bits it never requested. The file enforcement path does filter its result; the generic query path does not.

3.8.10.5 Helpers #

SidMatchesToken(sid, token, for_allow) -> bool
    if sid == token.user_sid:
        if for_allow and token.user_deny_only:
            return false
        return true
    for group in token.groups:
        if not group.enabled and not group.deny_only:
            continue
        if for_allow and group.deny_only:
            continue
        if sid == group.sid:
            return true
    return false
MapGenericBits(mask, mapping) -> ACCESS_MASK
    if mask & RESERVED_BITS: reject
    mapped = mask & ~(GENERIC_READ | GENERIC_WRITE
                    | GENERIC_EXECUTE | GENERIC_ALL)
    if mask & GENERIC_READ:    mapped |= mapping.read
    if mask & GENERIC_WRITE:   mapped |= mapping.write
    if mask & GENERIC_EXECUTE: mapped |= mapping.execute
    if mask & GENERIC_ALL:     mapped |= mapping.all
    return mapped

All four generic bits are cleared before any is expanded, so a mask naming several generics maps every one of them.

Virtual group resolution. There is no enrichment step producing a modified token. S-1-3-4 and S-1-5-10 are resolved at each lookup instead, in the DACL walk, the SACL walk and conditional membership alike. S-1-5-10 resolves through the ordinary polarity rules against self_sid, and S-1-3-4 through the ordinary polarity rules against the object's owner SID. Both are computed once per walk — the owner is fixed while the polarity is per ACE — so each carries its allow and deny answers together.

EvaluateSACL walks in a fixed order per ACE: SID match with deny polarity, then object-type scoping against the tree, then the condition, then the mask overlap against mapped_desired. Audit ACEs need all four; alarm ACEs deliberately skip the overlap test and contribute their mask on a SID match alone. Inherit-only ACEs are skipped throughout.

synthetic_sd builds a CAAP rule's descriptor from the original's owner and optional group with the rule's DACL substituted, and the SACL copied with every scoped policy ACE stripped — which is what prevents recursion. The MIC and PIP labels are preserved, so a rule is evaluated under the same mandatory constraints as the object. The control bits and the stripped SACL's revision are recomputed rather than copied.

3.8.10.6 Provenance masks #

VariableSet atMeaning
security_grantedStep 4SeSecurityPrivilege granted ACCESS_SYSTEM_SECURITY.
backup_grantedStep 4SeBackupPrivilege granted read bits.
restore_grantedStep 4SeRestorePrivilege granted write and metadata bits.
take_ownership_grantedStep 9SeTakeOwnershipPrivilege granted WRITE_OWNER.
relabel_grantedStep 5SeRelabelPrivilege added WRITE_OWNER to the MIC allowed set.

Each records which bits that privilege contributed, and step 13 compares each against the requested mask and the final result.

3.9.1 The Handle Model

Peios / Peios technical reference / PKM / KACS / FACS

FACS, the File Access Control Shim, is the file-specific enforcement surface of KACS. It replaces Linux DAC — UID, GID and mode-bit checks — with security-descriptor evaluation on files.

Enforcement follows the handle pattern. AccessCheck runs once at open time, the granted mask is cached on the file description, and later operations test the cached mask:

(fd.granted & required) == required ? allow : deny

The mask is set once and never modified, and it is immutable for the descriptor's whole lifetime regardless of any later descriptor change. A file's DACL can be rewritten while a process holds it open, and that process keeps the rights it was granted. A few operations use a live AccessCheck instead; §3.9.4 lists them.

A second immutable mask is stamped alongside it: the continuous audit mask (§3.8.9), which every use-time operation consults to decide whether to emit a per-operation audit event. It travels with the granted mask through every path described below.

3.9.1.1 Mount policy #

Every mounted filesystem exposes exactly one FACS mount-policy class, scoped to the kernel superblock object rather than to a pathname or a bind mount — several paths or bind mounts over one superblock all observe the same class.

unmanaged puts the mount outside the handle model entirely: no granted mask is stamped on its file descriptions. facs_deny_missing is FACS-managed and denies access where a descriptor is missing. facs_synthesize_ephemeral synthesises missing descriptors and caches them in memory only. facs_synthesize_persistent synthesises them and writes them back immediately. The three facs_* classes are the only managed ones.

The default classifier is conservative. Hardcoded pseudo-filesystems — /proc, /sys, nullfs — are unmanaged. Filesystems that cannot reliably store the canonical descriptor xattr are facs_synthesize_ephemeral: ramfs, FAT, exFAT, NFS client mounts, ISO9660, and cgroup2. StrataFS is fixed at facs_deny_missing. Everything else — tmpfs, squashfs, ext4, btrfs — defaults to facs_deny_missing unless a trusted policy agent adopts it.

Userspace cannot set a superblock to unmanaged through the public ABI; the class is reserved for the kernel classifier and the hardcoded rules. Attempting to set a policy on a magic-derived unmanaged superblock fails with EOPNOTSUPP, and so does attempting to change StrataFS's.

3.9.1.2 Scope #

The sole-authority claim covers local FACS-managed filesystems. It does not cover:

  • O_PATH descriptors, which are not managed;
  • NFS client mounts, which are ephemeral-synthesising and retain dual authority with the server;
  • /proc, which is unmanaged and PIP-protected; or
  • /sys, which is unmanaged and carries a hardcoded rule instead — writes there require Administrators or SYSTEM, enforced against a built-in descriptor.

Descriptors for processes obtained through pidfd bypass FACS at open as well.

3.9.1.3 Handle acquisition #

A descriptor carries its granted mask through every acquisition path, and transfer is an intentional capability-delegation mechanism.

  • dup/dup3 and fork produce the same open file description and therefore the same rights;
  • descriptors without FD_CLOEXEC survive exec unchanged;
  • SCM_RIGHTS transfers the descriptor as a capability token, with possession as authorization and no re-check of the receiver's identity; and
  • pidfd_getfd() is gated by an AccessCheck for PROCESS_DUP_HANDLE against the target process, after which the caller receives the descriptor with its full mask.

The security boundary is at open time. Every subsequent transfer carries the mask unchanged.

This means mandatory subject policy — MIC and PIP — is evaluated once, at open, against the opener. A high-integrity process that opens a file and passes the descriptor to a low-integrity one has effectively delegated its access. That is the handle model: authority is on the handle, not on the holder.

3.9.1.4 Stacked backing files #

When a stacking filesystem creates a kernel-private backing file for a managed user-visible file, the outer file's granted and continuous audit masks are captured into the backing-file blob, and the backing file's ordinary blob receives that exact snapshot when the provider is opened. No new AccessCheck runs against the task executing the stacking filesystem.

The inheritance is deliberately narrow. It is admitted only through the kernel's typed backing-file allocation path, only from a managed non-O_PATH outer file, and it retains no reference to that outer file — only the values. It is not a general snapshot-cloning mechanism.

An active StrataFS copy-up context takes precedence: a backing open that does not match its exact object and phase does not fall back to inherited authority. When an exactly-bound copy-up backing file is adopted by the outer descriptor, the outer snapshot is installed into both blobs as a single transition, after which the backing file behaves like any other stacked open.

The user-visible operation is checked and continuously audited once, against the outer handle. Immediate provider re-entry through security_file_permission or security_mmap_file neither repeats the caller authorization nor emits a second caller audit event. The backing file keeps the snapshot for later descriptor-local enforcement, including mprotect() after a stacked mmap, and the mmap_backing_file handoff verifies that the outer, backing and captured snapshots still agree before the provider mapping is installed.

This suppresses duplicate KACS authorization only. It bypasses nothing else — not another LSM, not filesystem errors, not read-only mounts, immutable state, quota, space, I/O, or format validation.

3.9.2 KACS-Native Open

Peios / Peios technical reference / PKM / KACS / FACS

Important

The open-interface comparison records unresolved differences and the limited pinned-source findings applied here. Creator-SD rejection, the accepted create-option bits, and native-open versus SD-query no-follow behavior were checked in source; the other differences remain unresolved. No runtime test or historical release boundary is established.

kacs_open takes an explicit desired access mask. The caller names every right it will need — FILE_READ_DATA, FILE_WRITE_DATA, WRITE_DAC, READ_CONTROL, any combination — and AccessCheck evaluates the whole requested mask at open. If every requested right is granted the descriptor's mask is set to the requested mask; if any is denied, the open fails. This is the strict mode, in contrast to the legacy path's subset behaviour (§3.9.3).

MAXIMUM_ALLOWED may be combined with at least one concrete data right or FILE_EXECUTE. The concrete bits define the Linux f_mode of the returned descriptor and have to be granted; MAXIMUM_ALLOWED then makes the cached mask the full computed maximum rather than the requested set. Alone it is invalid, because it defines no file mode, and fails with EINVAL.

3.9.2.1 The required data right #

Every native open names at least one data right — FILE_READ_DATA, FILE_WRITE_DATA, FILE_APPEND_DATA — or FILE_EXECUTE, so that every descriptor has a valid mode. FILE_READ_DATA maps to FMODE_READ; either write right maps to FMODE_WRITE; and FILE_EXECUTE alone maps to FMODE_EXEC, which enables execveat(fd, "", ..., AT_EMPTY_PATH) — an execute-only handle that can neither read nor write the file's contents.

3.9.2.2 Directories #

Directory rights share bit positions with file data rights: FILE_LIST_DIRECTORY is FILE_READ_DATA (0x0001), FILE_ADD_FILE is FILE_WRITE_DATA (0x0002), and FILE_ADD_SUBDIRECTORY is FILE_APPEND_DATA (0x0004). A native directory open with FILE_LIST_DIRECTORY satisfies the data-right requirement and maps to FMODE_READ.

The two write aliases are not cacheable directory handle rights, though. kacs_open rejects a desired mask naming FILE_ADD_FILE, FILE_ADD_SUBDIRECTORY or FILE_DELETE_CHILD on a directory with EOPNOTSUPP. Those are parent-directory authorization rights for namespace operations, evaluated live at the operation, not rights a handle carries.

3.9.2.3 Special nodes and symlinks #

Existing FIFOs, pathname socket nodes, and character and block device nodes on managed mounts are ordinary filesystem file objects for authorization: the file object type, the file right mapping, and the file GenericMapping for metadata, standard and generic rights.

FILE_EXECUTE is not a valid data substitute for them — a kacs_open naming it on such a node fails closed. The Linux object implementation may still impose its own device- or filesystem-specific denial after KACS has authorized the file object.

Symlink objects are file objects for kacs_get_sd, kacs_set_sd and readlink, but they are not opened as terminal objects by kacs_open. Following is the default resolution behaviour, and AT_SYMLINK_NOFOLLOW fails with ELOOP. This differs deliberately from kacs_get_sd and kacs_set_sd, where the same flag resolves the symlink object itself.

Operations needing neither data nor execute access — changing a DACL without reading contents — use path-based interfaces or O_PATH descriptors as object anchors, which the get and set security calls accept through AT_EMPTY_PATH.

3.9.2.4 Create dispositions #

ValueNameIf it existsIf it does not
0FILE_SUPERSEDEDelete and recreateCreate
1FILE_OPENOpenFail
2FILE_CREATEFailCreate
3FILE_OPEN_IFOpenCreate
4FILE_OVERWRITETruncate to zeroFail
5FILE_OVERWRITE_IFTruncate to zeroCreate

FILE_SUPERSEDE deletes the existing name and creates a new file under it, requiring DELETE on the existing file — or FILE_DELETE_CHILD on the parent — together with FILE_ADD_FILE on the parent. The new file gets a new inode and a new descriptor, inherited or caller-supplied. The superseded pathname is broken away from the old hardlink set and names the new inode; other pre-existing hardlinks continue to name the old inode, and already-open descriptors still reference it.

FILE_OVERWRITE truncates in place — same inode, same descriptor, hardlinks preserved — and requires FILE_WRITE_DATA.

On an unmanaged mount, FILE_OPEN and FILE_OPEN_IF succeed and return an unmanaged descriptor with no stamped mask rather than failing; the creating dispositions fail with EOPNOTSUPP.

3.9.2.4.1 The DELETE fallback #

Both FILE_SUPERSEDE and delete-on-close reference "DELETE on the file, or FILE_DELETE_CHILD on the parent". That is a two-descriptor check inside the open path: AccessCheck runs first against the target file for DELETE, and if that is not granted it runs again against the parent directory for FILE_DELETE_CHILD. If neither grants, the open fails. The same duality governs link operations.

3.9.2.5 Create options #

ValueNameDescription
0x0001KACS_CREATE_OPT_DIRECTORYThe target has to be a directory. When creating, create a directory rather than a regular file; when opening an existing non-directory, fail with ENOTDIR.
0x0002KACS_CREATE_OPT_DELETE_ON_CLOSEDelete the file when the last handle in its lineage closes. Requires DELETE on the file or FILE_DELETE_CHILD on the parent.

All other bits are reserved and have to be zero; a nonzero reserved bit fails with EINVAL.

Delete-on-close is deliberately no-share. kacs_open_how carries no share-mode field, so the Windows FILE_SHARE_DELETE compatibility matrix is not representable in the frozen ABI, and the kernel contract is bounded instead. The obligation attaches to one ordinary file description lineage rather than to Linux inode last-reference semantics, so dup(), fork() and SCM_RIGHTS all preserve it because they preserve the description. Once a lineage exists for a file object, later opens of that object fail closed rather than emulating share-mode compatibility. The unlink happens at final close of that lineage — not at open, and not at generic inode last-reference drop — and if the pathname is already gone by then the close path treats it as a no-op rather than a new error. Regular files only: directory delete-on-close fails closed.

3.9.2.6 Caller-supplied descriptors #

The descriptor format and size bound are defined in SD Structure.

When a disposition results in a new file the caller may supply a descriptor. A null pointer with zero length means the new file's descriptor is inherited from the parent directory through the inheritance algorithm.

Supplying one on a branch that opens an existing object is invalid input rather than something silently ignored. FILE_OPEN_IF resolving to an existing object fails with EINVAL, and so do FILE_OVERWRITE and the existing-object branch of FILE_OVERWRITE_IF, since those retain the existing inode and its descriptor. FILE_OPEN with a descriptor supplied fails with EOPNOTSUPP.

For a genuine creation the kernel computes the new object's descriptor first, then runs the strict native-open AccessCheck against that descriptor for the requested access. Parent create rights authorize namespace creation; they do not by themselves authorize the returned handle. A failed strict check rolls the creation back — the newly created file or directory is removed — and the syscall fails.

Creator-supplied descriptors are validated at create time. An owner SID has to be the caller's own or a token group marked SE_GROUP_OWNER, unless SeRestorePrivilege is enabled, in which case any owner is allowed. A supplied SACL is treated as a full SACL input rather than a label-only fragment, and therefore requires SeSecurityPrivilege. If that SACL carries an explicit mandatory label ACE, the label also has to satisfy the ordinary label-write constraint: at or below the caller's integrity level, unless SeRelabelPrivilege is held.

3.9.2.7 Modes and status #

kacs_open_how carries no POSIX mode field, so the raw Linux inode mode for native creation is fixed: 0600 for regular files, 0700 for directories. These are compatibility metadata only — and if Linux DAC ever denies an operation KACS would have authorized, the operation fails closed.

Native creation does not create FIFOs, socket nodes, device nodes or symlinks. Those stay on the Linux namespace APIs — mknod(), mkfifo(), Unix bind() to a path — governed by the namespace hooks. NTFS is excluded from native creation entirely.

The syscall reports what happened: created, opened, overwritten, or superseded. KACS_STATUS_SUPERSEDED is used only when an existing object was actually replaced — a FILE_SUPERSEDE that finds no target and simply creates one reports KACS_STATUS_CREATED.

3.9.3 Legacy Open Compatibility

Peios / Peios technical reference / PKM / KACS / FACS

Important

The open-interface references contain unresolved documentation differences. Read that note alongside this page; no runtime behavior or release boundary has been established for those differences.

Linux's open() and openat() cannot express rights like WRITE_DAC or READ_CONTROL. FACS maps the open flags to a core set of required rights plus a compat set of POSIX-expected ones, and evaluates both in a single AccessCheck.

3.9.3.1 Core rights #

The core set is the minimum for a usable descriptor, and the open fails if any of it is denied.

For regular files, device nodes, FIFOs and pathname sockets:

FlagsCore rights
O_RDONLYFILE_READ_DATA | FILE_READ_ATTRIBUTES
O_WRONLYFILE_WRITE_DATA | FILE_READ_ATTRIBUTES
O_RDWRFILE_READ_DATA | FILE_WRITE_DATA | FILE_READ_ATTRIBUTES

For directories, O_RDONLY gives FILE_READ_ATTRIBUTES | FILE_TRAVERSE. Directory core deliberately excludes FILE_LIST_DIRECTORY, so a directory opened O_RDONLY can be used for fchdir() and fstat() without listing permission; listing is a compat right.

Two modifiers apply in order. O_APPEND replaces FILE_WRITE_DATA with FILE_APPEND_DATA, and O_TRUNC adds FILE_WRITE_DATA. Given both, the replacement happens first and the re-addition second, so core ends up with FILE_APPEND_DATA and FILE_WRITE_DATA together.

FILE_READ_ATTRIBUTES is always core: a descriptor granting data access but denying attribute reads is not openable through the legacy APIs at all.

3.9.3.2 Compat rights #

Requested alongside core and silently omitted where denied: FILE_READ_EA for fgetxattr(); READ_CONTROL for reading the descriptor; FILE_WRITE_ATTRIBUTES for futimens(); FILE_WRITE_EA for fsetxattr(); FILE_WRITE_DATA on O_APPEND opens so ftruncate() works where the descriptor allows it; WRITE_DAC, because POSIX permits fchmod() on any descriptor; WRITE_OWNER, for fchown() on the same grounds; SYNCHRONIZE; FILE_LIST_DIRECTORY, enabling readdir() on directory descriptors; and FILE_EXECUTE, enabling fexecve() on regular files.

3.9.3.3 The flow #

The full requested mask is core plus compat. AccessCheck returns the subset the descriptor allows. If every core right is present the actual granted mask — which may include all, some or none of compat — is stamped on the descriptor; otherwise the open fails with EACCES.

Both open paths run the same pipeline with different success criteria. Native open is strict: everything requested has to be granted. Legacy open is subset: only core has to be fully present.

3.9.3.4 O_PATH #

O_PATH descriptors are not FACS-managed. The open hook does fire for them, but returns immediately without evaluating anything, so they carry no granted mask and are left unmanaged. They serve as namespace anchors for the *at() syscalls.

fstat() and fstatfs() on them are allowed unconditionally. fchdir() runs a live FILE_TRAVERSE check at use time. fchmod(), fchown(), fgetxattr(), fsetxattr(), ioctl() and mmap() are denied with EBADF — though futimens() currently has no such guard. execveat(fd, "", ..., AT_EMPTY_PATH) has exec permission enforced by a live AccessCheck in the bprm hook, and kacs_get_sd and kacs_set_sd with AT_EMPTY_PATH likewise run live, which gives race-free object identity without snapshot authorization.

That fstat() is unconditional means FILE_READ_ATTRIBUTES is not authoritative for attribute confidentiality. In practice size, timestamps and inode number are rarely confidential. The descriptor itself is protected: kacs_get_sd on an O_PATH handle performs a live check.

3.9.4 Use-Time Semantics

Peios / Peios technical reference / PKM / KACS / FACS

Every operation on an open descriptor is a mask check against the granted mask, with one exception: execveat(AT_EMPTY_PATH) uses a live AccessCheck.

3.9.4.1 Data operations #

OperationRequired right
ReadFILE_READ_DATA
Sequential write, no append intentFILE_WRITE_DATA
Append-intent write (O_APPEND descriptor or RWF_APPEND)FILE_APPEND_DATA or FILE_WRITE_DATA
Positioned write, or a no-append overrideFILE_WRITE_DATA — denied on append-only descriptors
Directory listingFILE_LIST_DIRECTORY
ftruncateFILE_WRITE_DATA
fallocate allocation (ALLOCATE_RANGE, with or without KEEP_SIZE)FILE_APPEND_DATA or FILE_WRITE_DATA
fallocate mutation (PUNCH_HOLE, ZERO_RANGE, COLLAPSE_RANGE, INSERT_RANGE, UNSHARE_RANGE, WRITE_ZEROES)FILE_WRITE_DATA
mmap PROT_READFILE_READ_DATA
mmap PROT_WRITE | MAP_SHAREDFILE_WRITE_DATA — FILE_APPEND_DATA alone is insufficient
mmap PROT_WRITE | MAP_PRIVATEFILE_READ_DATA — copy-on-write, no write to the file
mmap PROT_EXECFILE_EXECUTE
mprotectAs mmap, for the new protection flags
flock LOCK_SH / F_RDLCKFILE_READ_DATA
flock LOCK_EX / F_WRLCKFILE_WRITE_DATA or FILE_APPEND_DATA
fsync / fdatasyncSYNCHRONIZE

A fallocate mode outside the supported set fails closed, and PUNCH_HOLE additionally requires KEEP_SIZE.

3.9.4.2 Metadata operations #

OperationRequired right
stat / lstat / path statxFILE_READ_ATTRIBUTES
fstat / descriptor statxFILE_READ_ATTRIBUTES
fstatfsFILE_READ_ATTRIBUTES
path and descriptor file_getattrFILE_READ_ATTRIBUTES
path and descriptor file_setattrFILE_WRITE_ATTRIBUTES
truncate by pathnameFILE_WRITE_DATA
chmod / fchmodat / fchmodWRITE_DAC
chown / lchown / fchownat / fchownWRITE_OWNER
utimensat / utimes / futimensFILE_WRITE_ATTRIBUTES
getxattr / lgetxattr / fgetxattrFILE_READ_EA
setxattr / lsetxattr / removexattr / fsetxattr / fremovexattrFILE_WRITE_EA
listxattr / llistxattr / flistxattrnone
access / faccessat F_OKFILE_READ_ATTRIBUTES
access / faccessat R_OKFILE_READ_DATA
access / faccessat W_OKFILE_WRITE_DATA
access / faccessat X_OKFILE_EXECUTE

Descriptor-based file_getattr and file_setattr with AT_EMPTY_PATH retain the open file through the operation and metadata cleanup. A concurrent close cannot invalidate an operation that has already acquired its file reference. This also covers unsupported-operation and other error paths.

Reads and writes of the canonical descriptor xattr are denied unconditionally through the xattr hooks — security.peios.sd, or system.ntfs_security on NTFS. All descriptor access goes through kacs_get_sd and kacs_set_sd. POSIX ACL xattr writes are denied unconditionally too, with EOPNOTSUPP rather than EACCES so that probe-then-tolerate callers behave sensibly.

3.9.4.3 Directory traversal #

Path resolution checks FILE_TRAVERSE on managed directory components. A token holding SeChangeNotifyPrivilege bypasses the intermediate checks, including on directories whose descriptor is missing.

Explicit changes of the current or root directory are not intermediate resolution: chdir() and chroot() take a live FILE_TRAVERSE check on the final directory, and the privilege bypass does not apply (§3.4.2). An ordinary fchdir() checks the descriptor's cached mask; an O_PATH fchdir() runs live.

3.9.4.4 Watch placement #

Placing a watch — inotify_add_watch(), fanotify_mark() on an inode, F_NOTIFY — is a read of the object by other means: a watch on a directory reveals the names of its children as they come and go, a watch on a file reports its activity. Linux gates it on read permission, but under KACS a bare MAY_READ never reaches a descriptor (read rights are decided at open), so the path_notify hook checks the read-class right live against the object's own descriptor: FILE_LIST_DIRECTORY for a directory, FILE_READ_DATA for anything else. A denial is EACCES, and SeChangeNotifyPrivilege does not bypass it — that privilege covers traversal, not reading.

Mount, filesystem and mount-namespace marks are not object watches and have no single descriptor to check. Linux requires CAP_SYS_ADMIN for them, and for the fanotify permission classes (FAN_CLASS_CONTENT, FAN_CLASS_PRE_CONTENT — the ones whose holder can veto or delay opens and reads system-wide); the capability switchboard (§3.10.2) resolves that to SeTcbPrivilege, so these are TCB-only. PIP dominance is not part of the decision: a permission-event holder intercepts opens of the object, never the opening process, and a holder that is by construction TCB dominates every process anyway.

3.9.4.5 Append-only enforcement #

A handle carrying FILE_APPEND_DATA but not FILE_WRITE_DATA allows only true append-intent writes. Append intent means the effective write position is forced to end-of-file by O_APPEND or per-I/O RWF_APPEND, and is not negated by RWF_NOAPPEND on the same operation. Requesting both RWF_APPEND and RWF_NOAPPEND together fails with EACCES.

Denied on such a handle: positioned writes without effective append intent — pwrite64, pwritev, pwritev2 with an explicit offset, io_uring writes with an explicit offset, and AIO writes with an offset; any write using RWF_NOAPPEND, since it can negate append semantics inherited from O_APPEND; shared writable mmap and mprotect upgrades to PROT_WRITE; and the fallocate mutation modes.

3.9.4.6 fcntl #

For F_SETFL, KACS evaluates the mutable status flags Linux accepts — O_APPEND, O_NONBLOCK/O_NDELAY, O_DIRECT, O_NOATIME. Clearing O_APPEND is denied on a handle with FILE_APPEND_DATA but not FILE_WRITE_DATA; setting it is always allowed, being a privilege reduction. Adding O_NOATIME requires FILE_WRITE_ATTRIBUTES and clearing it is always allowed. Changing only O_NONBLOCK, O_NDELAY or O_DIRECT needs no KACS right, though ordinary Linux validation still applies.

These commands are descriptor-local and require no KACS right, and none of them widens the cached mask: F_CREATED_QUERY; F_DUPFD, F_DUPFD_CLOEXEC and F_DUPFD_QUERY, which preserve the same file description and mask; F_GETFD and F_SETFD; F_GETFL; and the async-notification set F_GETOWN, F_GETOWN_EX, F_GETOWNER_UIDS, F_GETSIG, F_SETOWN, F_SETOWN_EX and F_SETSIG, where Linux pid and signal validation still applies.

These are object-state queries or mutations, checked against the cached mask before Linux-specific validation:

CommandRequired right
F_GETLK / F_GETLK64 / F_OFD_GETLKAny data right
F_GETLEASE / F_GETDELEGFILE_READ_ATTRIBUTES
F_GETPIPE_SZFILE_READ_ATTRIBUTES
F_SETPIPE_SZFILE_WRITE_ATTRIBUTES
F_GET_SEALSFILE_READ_ATTRIBUTES
F_ADD_SEALSFILE_WRITE_ATTRIBUTES
F_GET_RW_HINT / F_GET_FILE_RW_HINTFILE_READ_ATTRIBUTES
F_SET_RW_HINT / F_SET_FILE_RW_HINTFILE_WRITE_ATTRIBUTES

Lock, lease and delegation commands — F_SETLK, F_SETLKW, F_SETLK64, F_SETLKW64, F_OFD_SETLK, F_OFD_SETLKW, F_SETLEASE, F_SETDELEG — pass through the fcntl hook so that the later file-lock hook can enforce them against normalised F_RDLCK, F_WRLCK or F_UNLCK values. An unknown lock type fails closed there.

For F_NOTIFY, removing a watch — a zero event mask, ignoring DN_MULTISHOT — requires nothing. Installing one with any known DN_* event requires FILE_LIST_DIRECTORY. Unknown DN_* bits on a managed descriptor fail closed.

Unmanaged descriptors sit outside the handle check entirely, and an unknown fcntl command on a managed one fails closed.

3.9.4.7 ioctl #

Known ioctls are classified by required right; an unclassified one is allowed if the descriptor carries at least one data right. The 32-bit compat aliases take the same right as their native command, including FS_IOC32_GETFLAGS, FS_IOC32_SETFLAGS, FS_IOC32_GETVERSION, FS_IOC32_SETVERSION and the compat preallocation commands.

Descriptor-local, requiring nothing: FIOCLEX and FIONCLEX, which change close-on-exec state; FIONBIO, which changes nonblocking state; and FIOASYNC, which changes async notification state with Linux and fops validation still applying.

Common VFS:

ioctlRequired right
FIBMAPFILE_READ_DATA
FIGETBSZFILE_READ_ATTRIBUTES
FIFREEZE / FITHAWFILE_WRITE_ATTRIBUTES
FITRIMFILE_WRITE_ATTRIBUTES
FS_IOC_GETFSUUIDFILE_READ_ATTRIBUTES
FS_IOC_GETFSSYSFSPATHFILE_READ_ATTRIBUTES
FS_IOC_GETLBMD_CAPFILE_READ_ATTRIBUTES

FIFREEZE, FITHAW and FITRIM mutate filesystem operational state, and Linux's own CAP_SYS_ADMIN checks still apply on top.

File and object:

ioctlRequired right
FS_IOC_FIEMAPFILE_READ_DATA
FIONREADFILE_READ_DATA on a regular file; any data right otherwise
FS_IOC_GETFLAGSFILE_READ_ATTRIBUTES
FS_IOC_SETFLAGSFILE_WRITE_ATTRIBUTES
FS_IOC_GETVERSIONFILE_READ_ATTRIBUTES
FS_IOC_SETVERSIONFILE_WRITE_ATTRIBUTES
FS_IOC_RESVSP / FS_IOC_RESVSP64FILE_APPEND_DATA or FILE_WRITE_DATA
FS_IOC_UNRESVSP / FS_IOC_UNRESVSP64FILE_WRITE_DATA
FS_IOC_ZERO_RANGEFILE_WRITE_DATA
FICLONE / FICLONERANGEFILE_WRITE_DATA
FIDEDUPERANGEFILE_WRITE_DATA
FIOQSIZEFILE_READ_ATTRIBUTES
FS_IOC_FSGETXATTRFILE_READ_ATTRIBUTES
FS_IOC_FSSETXATTRFILE_WRITE_ATTRIBUTES
FS_IOC_GETFSLABELFILE_READ_ATTRIBUTES
FS_IOC_SETFSLABELFILE_WRITE_ATTRIBUTES
FS_IOC_GET_ENCRYPTION_PWSALTFILE_READ_ATTRIBUTES
FS_IOC_GET_ENCRYPTION_POLICYFILE_READ_ATTRIBUTES
FS_IOC_GET_ENCRYPTION_POLICY_EXFILE_READ_ATTRIBUTES
FS_IOC_SET_ENCRYPTION_POLICYFILE_WRITE_ATTRIBUTES
FS_IOC_ADD_ENCRYPTION_KEYFILE_WRITE_ATTRIBUTES
FS_IOC_REMOVE_ENCRYPTION_KEYFILE_WRITE_ATTRIBUTES
FS_IOC_REMOVE_ENCRYPTION_KEY_ALL_USERSFILE_WRITE_ATTRIBUTES
FS_IOC_GET_ENCRYPTION_KEY_STATUSFILE_READ_ATTRIBUTES
BLKGETSIZE64FILE_READ_ATTRIBUTES
BLKFLSBUFFILE_WRITE_DATA

On directories, FS_IOC_GETFLAGS and FS_IOC_SETFLAGS take the same rights as on files.

Anything unclassified is allowed on any data right. For device nodes, pipes and sockets, device-specific ioctl semantics are outside FACS scope: the node's descriptor is the authorization boundary, and Linux's device-specific validation may still deny.

A pinned inode (§3.6) narrows all of this. Every content-, range- or allocation-mutating ioctl is rejected on one, and so is every ioctl the classifier does not recognise — unknown ioctls fail closed there rather than falling back to the data-right rule.

3.9.4.8 Execution #

Execution is nominally a two-layer check. The mode execute bit is the prerequisite meaning "this file is a program", set by package managers and chmod +x, applying to execve and execveat but not to mmap(PROT_EXEC). The descriptor's FILE_EXECUTE is the access control, gating both.

KACS enforces only the second. Nothing in the kernel module tests the execute mode bit; the prerequisite survives because Linux's own generic_permission() refuses MAY_EXEC on a file with no execute bit set, by way of capable_wrt_inode_uidgid(CAP_DAC_OVERRIDE). The +x requirement is therefore a Linux DAC property that KACS inherits rather than a FACS rule, and it is one of the few decisions where mode bits still matter (§3.10.2).

For descriptor-based exec — execveat with AT_EMPTY_PATH, including on O_PATH handles — a live AccessCheck for FILE_EXECUTE runs against the re-opened file rather than the cached mask being consulted.

3.9.5 File Descriptor Storage

Peios / Peios technical reference / PKM / KACS / FACS

3.9.5.1 Xattr protection #

FACS intercepts every raw xattr operation on the canonical descriptor xattr — security.peios.sd, or system.ntfs_security on NTFS — and denies all three directions. An NTFS inode with no $Secure entry, whose descriptor is an inline attribute the driver never reads, counts as carrying no descriptor: the mount's missing-descriptor policy decides for it, as it does for a file with no security.peios.sd.

Writes are denied: all modification goes through the set-security interface. Removal is denied: a descriptor is never detached from a file. And reads are denied, which is the least obvious of the three and the most important. The raw xattr holds the entire descriptor including the SACL, so allowing a read under READ_CONTROL alone would leak SACL content that properly requires ACCESS_SYSTEM_SECURITY. All reads go through kacs_get_sd, which distinguishes the two.

3.9.5.2 Caching #

A validated, parsed descriptor object is cached in the inode's LSM blob, holding immutable self-relative bytes together with a prevalidated component layout — enough for AccessCheck readers never to reparse untrusted storage bytes.

Readers on the AccessCheck path use the RCU-published pointer. Once a current entry exists, a reader does not take the inode mutex merely to run AccessCheck. It either completes the evaluation inside an RCU read-side critical section, or pins the object with a refcount while still under RCU and drops the RCU lock before doing anything that can allocate, sleep or emit an audit event. A pin is acquired with a non-zero refcount check and dropped afterwards.

Writers allocate a new object, swap the pointer atomically, and free the old one after a grace period and after reader pins have drained. No partial read is possible.

Population is lazy, on first access. The xattr is read through an internal kernel path that bypasses the read-denial hook, and the parsed result is installed by compare-and-swap; a thread that loses the race frees its own copy.

Both native and legacy open finish descriptor-cache resolution before acquiring the central access policy (CAAP) cache mutex. Resolution can take an inode security mutex, while set-security takes that inode mutex before CAAP; acquiring them in the opposite order could deadlock an open against a descriptor update. Once CAAP is locked, open pins the current immutable descriptor through RCU and evaluates it without taking the inode mutex. This keeps the descriptor alive and CAAP stable for the check without holding CAAP across lazy descriptor population.

Eviction frees the cached descriptor when the inode is evicted, through an RCU-safe callback with the same pin draining, so in-flight permission checks complete before the object goes away.

Invalidation happens on write, but not atomically with it. The set-security path deliberately releases the inode security lock across the xattr write and re-acquires it afterwards to publish the new parsed object, because holding it across the write would invert the i_rwsem ordering the access path requires. Two concurrent set-security calls on one inode are therefore last-writer-wins rather than serialised end to end, and there is a window in which the xattr and the cache disagree. Readers are never blocked, and no reader sees a partially written object — the exposure is which of two racing writes lands, not a torn state.

Invalidation is also driven from the other end, by the write itself: any canonical descriptor xattr landing on an inode drops that inode's cached entry, whoever wrote it and by whatever path. The set-security path above then publishes the new parsed object as usual; for anything else, the next reader re-reads the authoritative bytes.

This is keyed on the xattr rather than on the writer, because the writer is not always the one holding the cache. A descriptor set through a stacking filesystem reaches two inodes — the one the caller named, and the real one beneath, which the write is re-entered on. Only the first is refreshed by the set-security path. The second is not merely cold: a stacking filesystem resolves and caches descriptors during its own metadata work on an inode it has just created, so it can hold an entry from the moment the object existed and keep serving it after the xattr underneath has changed. Keying on the xattr closes that without either side needing to know the other is there.

3.9.5.3 Mount policy classes #

The superblock policy object carries the class (§3.9.1) and, for synthesise-class mounts, an optional mount-level default template.

The default classifier maps from the superblock's filesystem magic. PROC_SUPER_MAGIC and SYSFS_MAGIC are unmanaged: these expose kernel state through inode-shaped handles with no on-disk identity and no descriptor to consult. NULL_FS_MAGIC is unmanaged too — nullfs is the immutable, permanently empty filesystem the kernel mounts as the mount-namespace root, with the mutable rootfs mounted on top of it. It declares no xattr support at all, so it can never carry a descriptor, and its single root inode is immutable and childless: nothing to stamp and nothing to protect.

STRATAFS_SUPER_MAGIC is fixed at facs_deny_missing for the superblock's lifetime, because StrataFS delegates every check to current provider objects and must never synthesise a descriptor for its merged namespace. An attempt to change it fails with EOPNOTSUPP.

RAMFS_MAGIC, NFS_SUPER_MAGIC, MSDOS_SUPER_MAGIC, EXFAT_SUPER_MAGIC, ISOFS_SUPER_MAGIC and CGROUP2_SUPER_MAGIC are facs_synthesize_ephemeral — either no persistent backing at all, or storage with no native descriptor slot.

Everything else, including TMPFS_MAGIC, SQUASHFS_MAGIC, EXT4_SUPER_MAGIC and BTRFS_SUPER_MAGIC, defaults to facs_deny_missing. These can all carry the descriptor xattr natively and are expected to on every inode that participates in access checks.

TMPFS_MAGIC covers both userspace tmpfs mounts and the kernel-mounted instances established before any userspace runs. The latter are not exempt from the default; they are handled by seeding.

3.9.5.4 Kernel-internal mounts #

Two filesystems are mounted by the kernel before any userspace process exists and before anything can call kacs_set_mount_policy or kacs_set_sd: the mutable root filesystem mounted by init_mount_tree, a tmpfs mounted on top of the immutable nullfs namespace root and made / by set_fs_root; and the devtmpfs instance mounted by devtmpfs_init and populated by the kdevtmpfs thread.

Both are TMPFS_MAGIC and therefore facs_deny_missing, and their root inodes are kernel-created, never passing through a userspace-supplied artifact, so they carry no descriptor at the moment they become reachable. To make the class viable the kernel seeds one.

The mutable root filesystem is a tmpfs whatever the command line says. Upstream Linux makes it a ramfs when the command line carries root=, or a rootfstype= that does not name tmpfs, and ramfs stores no extended attributes: the seed below would have nowhere to go, nothing the boot archive unpacks would inherit a descriptor, and StrataFS, which reads a provider's stored descriptor and never a synthesised one, would refuse every view an initramfs mounts over it. An installed system always boots with root=, so the upstream rule is not followed.

The rootfs root is seeded inside init_mount_tree, immediately after vfs_kern_mount returns and before the mount is published into init_mnt_ns, with the inode's i_rwsem held. The devtmpfs root is seeded inside devtmpfs_init, after vfs_kern_mount and before kdevtmpfs starts, likewise under i_rwsem. The nullfs root is not seeded — it is unmanaged, empty, and incapable of xattr storage.

A rootfs seed that fails panics the kernel. A boot that went on without it would fail later, in whatever first reached the initramfs through a StrataFS view, with nothing to connect the refusal to its cause.

The seeded descriptor is byte-for-byte identical in both places: owner and group SYSTEM (S-1-5-18), a DACL of one ACCESS_ALLOWED ACE granting GENERIC_ALL to SYSTEM flagged OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE so that inheritance derives a child descriptor for every inode created on the mount afterwards, and no SACL.

The writes go through the kernel-internal xattr path, bypassing both the FACS denial hooks and the LSM setxattr permission hook. They consult no token — at the point either runs there may be no meaningful subject — and the seeded descriptor is the sole authority for the mount until trusted userspace replaces it. They depend on nothing beyond the LSM scaffold that allocates inode and superblock blobs.

These are not exempt from later management: trusted userspace can overwrite the per-inode descriptor or change the superblock's class once it holds the privileges.

3.9.5.5 Boot artifacts #

A filesystem shipped as a boot artifact — a squashfs concatenated into the initrd, a vendor squashfs delivered as a package, a flashed partition image — defaults to facs_deny_missing and the kernel does not seed it. These are already-populated trees the kernel cannot extend at mount time, and in the read-only case cannot extend at all.

The obligation falls on the build pipeline, which emits security.peios.sd on every inode ordinary access checks will reach. mksquashfs, the ext utilities and the standard userland xattr surfaces all preserve security.* natively.

An artifact without descriptors is a packaging defect. FACS treats every missing descriptor on a facs_deny_missing mount as a corruption indicator and denies. The operator path is to rebuild the artifact, or to adopt the superblock under a synthesise class.

3.9.5.6 Administration #

The privilege gate below is verified in kernel source 8e0e22de3a59cad506bbbf8873de456e16ad272d, including its use by both set and get. This is source-level verification, not a deployed-kernel test or a historical release boundary.

Trusted userspace adopts a mounted filesystem by calling kacs_set_mount_policy on a descriptor naming any object on the target superblock; O_PATH descriptors are valid targets. The change applies to the superblock, not the pathname used to reach it.

Setting a policy is volume management: the call requires SeManageVolumePrivilege or SeTcbPrivilege, enabled, and must successfully mark the enabled privilege as used; kacs_get_mount_policy is gated the same way. Every change writes a kacs.mount.policy.changed record (§3.C), whose subject is the caller and which names the filesystem's type, the policy set (object.mount.policy), the one it replaced (object.mount.policy-previous) and the new generation (object.mount.policy-generation). Every refusal is decided before the change, so a record is always of a change made; the template is not carried. The public ABI accepts only the three managed classes; unmanaged, unknown values, nonzero reserved flags and malformed arguments all fail closed. The one policy the kernel sets on its own initiative is for a tmpfs that a token without the privilege brought into being inside its own mount namespace, which is stamped synth-ephemeral with a creator-owned template at sb_kern_mount (§3.13).

The optional template is accepted only with a synthesise class. It is a complete self-relative descriptor rather than a subset, passes structural validation, and is at most 65535 bytes. A null pointer with zero length clears it. Setting facs_deny_missing clears it and rejects non-empty template input. Pointer and length mismatches and invalid bytes fail before any state changes.

Policy changes are lazy. They do not walk the filesystem and do not stamp anything. The superblock carries a monotonic generation counter, incremented on every successful policy or template replacement. Missing-descriptor, ephemeral-synthetic and not-yet-written-back persistent-synthetic cache entries record the generation they came from and are discarded and repopulated when it changes. Xattr-backed and corrupt-descriptor caches are not made valid by a policy change, and open file descriptions keep their immutable masks.

3.9.5.7 Missing descriptors #

Under facs_deny_missing, no descriptor means deny. Two exceptions keep the repair path open. SeChangeNotifyPrivilege bypasses intermediate traverse checks including on directories with no descriptor, though not explicit chdir(), chroot() or fchdir() use-time checks. And O_PATH opens bypass the open hook entirely, so a file with a missing descriptor can still be acquired as an O_PATH reference — which is exactly the repair route: open(path, O_PATH) then kacs_set_sd with AT_EMPTY_PATH under SeRestorePrivilege.

Under the synthesise classes, a missing descriptor is generated from two sources in order. First, inheritance from the parent: if the parent has one, the inheritance algorithm runs as though a new file were being created. Otherwise the mount-level template, applied where there is no parent descriptor — typically only at the mount root. With no template configured, the fallback grants GENERIC_ALL to SYSTEM and BUILTIN\Administrators and GENERIC_READ | GENERIC_EXECUTE to Everyone, owned by SYSTEM with SYSTEM as group.

Because these files already exist, the accessor is not their creator. Where inheritance needs creator inputs — owner, primary group, default DACL — a synthetic system-policy creator supplies them: the template's owner, group and DACL if one exists, the fallback's otherwise. The accessor's token never affects the synthesised descriptor, and the synthesis path takes no subject token at all.

Inheritance is recursive — a parent whose own descriptor is missing is synthesised first, walking toward the mount root where the template terminates it. The walk is bounded at 32 ancestor levels; a target nested deeper than that below the nearest resolvable ancestor fails closed with EACCES rather than synthesising.

Resolution does not nest FACS inode locks. It releases the current inode's lock before resolving an ancestor or calling the filesystem's descriptor xattr handler, which may itself resolve a lower inode in a stacked filesystem. Referenced dentries and immutable descriptor-cache entries keep the inputs alive across that work.

Before publishing the result, resolution reacquires the inode lock and checks both the mount-policy generation and the inode's cache mutation sequence. An explicit replacement or invalidation makes it discard the obsolete result and retry, including when an empty cache was populated and cleared or invalidated while still empty. Ordinary cached files also retry a competing reader publication, then use the current entry. A failed reread never makes an entry from an old policy generation current again.

StrataFS re-reads the current provider descriptor on every check, including when an earlier result was missing or corrupt. Concurrent reader publication does not restart these reads: without an explicit invalidation or policy change, N checks perform N descriptor reads (each may query size and then bytes). A reader may reuse a result published by another reader whose read began later than its own; an older read cannot overwrite that later result, including a missing or corrupt result. Its own read error is still returned. Explicit writes and policy changes continue to trigger revalidation and may require another read. This does not alter the fixed deny-missing policy or a handle's frozen grant.

An ephemeral synthesis is cached in the inode blob only and never written back, leaving the original filesystem unmodified. A persistent one is additionally written to the xattr so the medium acquires durable descriptors — but never inline.

3.9.5.8 Deferred write-back #

Writing the xattr takes the inode's i_rwsem. Synthesis may be reached from a metadata operation whose VFS caller already holds that lock, so inline write-back would self-deadlock even though resolution releases its FACS inode lock around parent resolution and xattr reads. Write-back therefore runs with no FACS or VFS lock held.

Synthesis caches the descriptor immediately and marks the entry pending. The access decision is correct from that cached value the instant synthesis completes — correctness never depends on the xattr reaching disk. The write-back runs later from a task-work callback firing as the triggering syscall returns to userspace, so a persistent descriptor is normally on disk by the time the operation that first observed it missing returns.

A pending entry is generation-tagged exactly like an ephemeral one, so a policy or template change before the write-back discards it and re-synthesises; a stale pre-change descriptor is never pinned to disk.

Write-back is best-effort, and can be because the synthesised descriptor is a deterministic function of the parent or the template — the on-disk xattr is a cache of a recomputable value, not unique state. If it does not happen, because the entry was evicted or the task exited first, the identical descriptor is re-synthesised on next access and retried. A failed or skipped write-back never fails the operation that triggered synthesis. Kernel threads, and a failure to queue the callback, fall back to re-synthesis the same way.

Once written, the cached entry becomes an ordinary xattr-backed descriptor: durable, no longer generation-tagged, never synthesised again, and owed no further write-back.

An ancestor synthesised only to supply inheritance inputs for a descendant is itself pending, and persists under the same rules when it is next accessed in its own right.

3.9.5.9 Corrupt descriptors #

A descriptor xattr that exists but fails structural validation is corrupt, and the policy is fail-closed: deny all access, do not call AccessCheck, and never treat a truncated DACL as an empty one.

Every encounter emits an audit event, fired exactly once per inode per cache population rather than per access, so a hot corrupt inode does not flood the log.

Recovery is a process holding SeRestorePrivilege calling set-security to overwrite it. Offline repair tools can also rewrite xattrs directly on an unmounted filesystem.

3.9.5.10 NFS client mounts #

NFS is the one managed class where the sole-authority guarantee does not hold. The server enforces its own access control independently: FACS evaluates locally against a synthesised descriptor, and the server may deny I/O that FACS allowed. A locally authorized open() can therefore produce a descriptor whose read() calls fail. This is inherent to network filesystems with server-side enforcement, and nothing suppresses the server's denial.

3.9.6 The Set-Security Interface

Peios / Peios technical reference / PKM / KACS / FACS

All descriptor modification flows through one syscall. The caller provides a file descriptor or path, a bitmask naming which components to modify, and a self-relative descriptor blob carrying the new values.

FlagComponentRequired right
OWNER_SECURITY_INFORMATIONOwner SIDWRITE_OWNER
GROUP_SECURITY_INFORMATIONGroup SIDWRITE_OWNER
DACL_SECURITY_INFORMATIONDiscretionary ACLWRITE_DAC
SACL_SECURITY_INFORMATIONSystem ACLACCESS_SYSTEM_SECURITY
LABEL_SECURITY_INFORMATIONMandatory integrity labelWRITE_OWNER, plus the integrity constraints below

The blob is validated structurally — parseable, well-formed ACEs, valid SIDs, at most 65535 bytes. With an existing valid file security descriptor, only the indicated components are merged into it. Unindicated components are preserved unchanged.

The input is always one self-relative descriptor subset, never a raw SID or ACL fragment. SACL_SECURITY_INFORMATION and LABEL_SECURITY_INFORMATION cannot be combined in one call, because both target the SACL field with incompatible meanings; the pair fails with EINVAL.

A SACL_SECURITY_INFORMATION write replaces the object's entire SACL. A LABEL_SECURITY_INFORMATION write interprets the input SACL as the label subset only: no SACL component removes the explicit mandatory label and returns the object to the default unlabelled state; a present SACL contains exactly one non-inherit-only SYSTEM_MANDATORY_LABEL_ACE and nothing else; and the object's non-label SACL ACEs are preserved.

After merging, the result still has a non-null owner — the group SID may be null — and a merge that would leave no owner fails.

MIC and PIP apply to these checks. A low-integrity caller cannot modify a high-integrity file's descriptor even where the DACL grants WRITE_OWNER.

A read-only mount withholds the write along that path whatever the descriptor grants: the call fails with EROFS, as every modifying operation through a read-only bind does, and the descriptor stays writable through any writable path to the same object. The mount is consulted after the access check, so a caller the descriptor refuses sees the denial rather than a read-only error that would confirm the object exists.

3.9.6.1 Component merging and replacement #

For a file whose cached descriptor is valid, the C dispatcher selects the merge path. The control-bit merge starts from the destination control value and replaces these bits from the input when their component is selected:

Selected componentReplaced control bits
OwnerOWNER_DEFAULTED
GroupGROUP_DEFAULTED
DACLDACL_DEFAULTED, DACL_TRUSTED, DACL_AUTO_INHERIT_REQ, DACL_AUTO_INHERITED, DACL_PROTECTED
Full SACLSACL_DEFAULTED, SACL_AUTO_INHERIT_REQ, SACL_AUTO_INHERITED, SACL_PROTECTED

This copies more than ACE lists, but is not byte-identical descriptor replacement. If group is not selected, the destination group remains. The resource-manager control byte remains the destination's, as does its RM_CONTROL_VALID state. A label-only merge does not replace the full-SACL control-bit set in the table.

A missing or invalid cached file security descriptor does not take that merge path. The dispatcher requires enabled SeRestorePrivilege and calls the replacement builder, which requires owner, group and DACL selections and validates the supplied owner and label. Do not apply valid-descriptor field-retention guarantees to that repair case.

These details are source-checked against kernel commit 8e0e22de, not a runtime-validated restore procedure.

3.9.6.2 Ownership #

A new owner may be set only to the caller's own SID, or to a group SID on the token carrying SE_GROUP_OWNER. SeTakeOwnershipPrivilege allows setting ownership to the caller's own SID regardless of what the current descriptor says, and SeRestorePrivilege allows any arbitrary SID.

3.9.6.3 Integrity labels #

Without enabled SeRelabelPrivilege a caller may set a label only at or below its own integrity level; with it, the label-level check permits any level. Other access and validation checks still apply.

The constraint applies through both paths — the dedicated label subset, and a label ACE embedded in a full SACL write. A SACL write whose ACL contains a mandatory label ACE raising integrity above the caller's level requires SeRelabelPrivilege exactly as the label path does, even though the SACL component itself is gated only by ACCESS_SYSTEM_SECURITY. SeSecurityPrivilege or SeRestorePrivilege alone is not sufficient for a label above the caller's integrity: the label-assignment check independently requires enabled SeRelabelPrivilege.

3.9.6.4 The SeRestorePrivilege bypass #

SeRestorePrivilege fires inside the AccessCheck pipeline, so it bypasses the check only where kacs_set_sd runs a live one: an O_PATH descriptor with AT_EMPTY_PATH, a pidfd, a token descriptor with AT_EMPTY_PATH, or a path. On those paths it grants every requested right, WRITE_OWNER, WRITE_DAC and ACCESS_SYSTEM_SECURITY included.

Called on an ordinary file descriptor the required rights are checked against the cached mask instead, no AccessCheck runs, and the privilege has no effect at all. A caller needing the bypass has to use the O_PATH route — which is the mechanism behind backup restoration, administrative repair, and the missing-descriptor repair path (§3.9.5).

3.9.6.5 Mandatory resource attributes #

When a caller modifies the SACL, the existing and new SACLs are compared for changes to SYSTEM_RESOURCE_ATTRIBUTE_ACE entries. An existing attribute carrying CLAIM_SECURITY_ATTRIBUTE_MANDATORY (0x0020) cannot be removed, nor its values modified, without SeTcbPrivilege — and an attempt without it fails the entire call rather than silently dropping the change.

3.9.6.6 Write mechanics #

The updated descriptor is serialised to self-relative binary form and written to the xattr through an internal kernel path bypassing the denial hook, and the in-memory cache is updated. An audit event is emitted if the file's SACL carries a matching audit ACE.

The cache update is not atomic with the xattr write; §3.9.5 describes the lock ordering that forces this and the last-writer-wins window it produces.

3.9.7 The StrataFS Copy-Up Context

Peios / Peios technical reference / PKM / KACS / FACS

StrataFS copy-up is the internal realisation of an operation already authorized against a StrataFS handle — not a second caller-requested operation. KACS therefore provides a kernel-internal context so that the mechanics of copy-up introduce no new rights checks against the task that happens to execute them.

The context exempts KACS caller authorization only. It does not replace credentials, borrow an identity, grant a privilege, bypass another LSM, neutralise an underlying filesystem check, make a read-only mount writable, or suppress immutable, append-only, quota, space, I/O or format errors. Every exemption is a return before the authorize call, and every mutation still goes through the ordinary vfs_* path under mnt_want_write().

3.9.7.1 Admission and lifetime #

Only the in-kernel StrataFS implementation can create or enter a context. There is no userspace surface of any kind — no ABI, file descriptor, token, ioctl, syscall or securityfs control — and the copy-up API is declared in a kernel-private header with no exported symbols.

A context is created only after the StrataFS operation requiring copy-up has passed its complete outer authorization. KACS does not verify that: the context creation call performs no check, and the ordering is satisfied by StrataFS calling in the right order. The context is not itself an alternative authorization path.

The exemption covers namespace creation the outer handle authorises without an add-entry right on the create stratum, so the context is reachable only from a create-enabled mount established by a caller holding CAP_SYS_ADMIN in the initial user namespace. That closure rests on an explicit test of the mounter's user namespace at stack establishment — the capability half contributes nothing to it, because the KACS switchboard discards the target namespace and answers from SeTcbPrivilege alone (§3.10.2). The check is made when the immutable stack is established rather than when a copy-up runs, so descriptor delegation cannot reintroduce acting-task authorization. Reconfiguration cannot re-supply the strata list.

A context attaches to at most one task, and a task carries at most one. It is not inherited by fork(), clone() or execve() — exec explicitly clears it. A refcounted context can be transferred to a kernel worker, but the originating task leaves it first. Leaving, task exit, every error path, and completion all remove the attachment and clear any armed phase.

The interface fails closed on nesting, concurrent attachment, a stale phase, or an object mismatch. A mismatched operation is evaluated normally and neither consumes nor broadens the armed exemption.

3.9.7.2 Object and phase binding #

Creation pins the exact provider path, its current inode, a complete validated copy of its effective descriptor, and the provider-visible security.capability value or its absence. Every later positive path is paired with a pinned inode as well — retaining a dentry alone is insufficient, because an unlink followed by recreation can reinstantiate it over a different inode.

One phase is armed at a time:

PhaseObjects admitted
Source readThe pinned provider only.
CreateOne pinned provider, one destination parent, and either one named negative dentry or one anonymous creation in that parent. Used for both parent materialisation and the staged object.
PopulateThe pinned provider and the one staged object bound to the context.
Publish by linkThe bound staged object, one destination parent, one absent destination dentry.
Publish by renameThe bound staged object and its parent, one destination parent, one absent destination dentry.
CleanupOne bound staging or materialisation dentry and its exact parent.
Orphan cleanupOne positive provider dentry carrying an authenticated stale staging marker, and its exact parent.
Orphan-marker cleanupThe exact positive provider dentry whose authenticated stale marker is being removed after publication.

Path, dentry, inode, parent, object type and operation kind are all compared wherever the hook supplies them — and path comparison includes the mount, so the same dentry reached through a different mount of the same superblock does not match. An inode-only hook can match the pinned inode, but that does not authorize a pathname operation on another dentry, and a phase never acts as a wildcard for other objects of the same filesystem or directory.

The staged binding stays valid only while its dentry names the pinned inode under the pinned staging parent, and that parent still names its own pinned inode. A rename or parent substitution invalidates the populate, protected-metadata and publish exemptions even when the staged dentry and inode are themselves unchanged.

Named creation is bound to the exact final component. Anonymous creation is bound to its parent, expected object type, the attached task, and the single armed create phase — and once created, the anonymous object has to be bound as the staged object before populate, publish or cleanup can use it.

Where the destination is a stacking filesystem creating a real inode below the armed destination dentry, the transition is authenticated at the exact outer dentry, and only the one subsequent real-inode security initialisation of the expected type receives the pinned provider descriptor. The real inode is not the staged binding: the outer inode is separately anchored, through the matching post-create event for an anonymous object or an explicit confirmation of the exact positive outer dentry for a named one. A missing, repeated, mismatched or out-of-order transition fails closed, and an inner post-create event from the real filesystem is rejected as the outer anchor by superblock comparison.

KACS retains the exact path, inode, parent path and parent inode of every named object whose creation it admits. Cleanup can be armed only for one of those or for the exact bound staging object; an unrelated positive dentry fails with ESTALE. The cleanup setup call is not itself authority to choose a deletion victim.

After an atomic rename or link publishes the staged inode, the staging identity can be rebound to the published path, but only while mount, inode, parent dentry and pinned parent inode all still match. The rebind API exists and is not called by StrataFS: the link case is rebound internally by the publish path, and the rename case relies on publish preserving the dentry. That holds because publication always renames within a single parent. A cross-directory publish rename would silently invalidate the staging binding, since the rename-publish entry point does not require the destination parent to equal the staging parent — currently unreachable, but the guard is the caller's discipline rather than the interface's.

Named staging entries carry security.peios.stratafs_staging. Caller-originated writes and removals of it are denied. A write or removal is admitted only on the exact bound staging inode during populate, or on the exact orphan-marker object during authenticated recovery — where the predicate admits a write as well as a removal, being shared between the setxattr and removexattr hooks. Probing the marker for recovery is a kernel-only raw read conveying no caller authority, and orphan deletion is bound to the exact dentry, inode, parent dentry and parent inode supplied when the phase was armed, so an arbitrary name sharing the staging prefix never matches. Recovery processes at most 128 entries per batch.

3.9.7.3 Exempt operations #

For a matching object in a matching phase, the ordinary AccessCheck, cached-grant check, privilege check and caller-access audit decision are omitted at these points:

Copy-up actionEnforcement points
Open and read provider data or a symlink targetsecurity_inode_permission, security_file_open, security_file_permission, security_inode_readlink
Read provider attributes and extended attributesthe inode and file getattr, setattr-preflight, getxattr and listxattr paths as applicable
Create a staged file, directory or symlink, and materialise parentssecurity_inode_permission, security_inode_create, security_inode_mkdir, security_inode_symlink, security_inode_init_security; for a stacking destination also security_dentry_create_files_as and, for an anonymous object, security_inode_post_create_tmpfile
Populate staged data, attributes and eligible non-descriptor xattrssecurity_inode_permission, security_file_open, security_file_permission, the inode and file setattr paths, setxattr and removexattr. security.capability uses only the dedicated clone call below.
Publishsecurity_inode_permission on the exact staged object or destination parent, then security_inode_link or security_inode_rename
Remove staging or roll back materialised entriessecurity_inode_permission on the exact parent, then security_inode_unlink or security_inode_rmdir

A security_inode_permission or security_file_permission match also matches the requested mask. Provider access is read-only — write, append and non-directory execute requests never match it. Staging access is limited to the read, write and append masks population needs, plus execute and chdir for directories, and namespace parents to the write and traverse masks the one armed action needs, plus open and chdir. Unknown mask bits fail closed.

The list is exhaustive. The context does not exempt execution, memory mapping, ioctl, locking, arbitrary fcntl, device access, process access, socket access, mount operations, or operations on a descriptor installed into a userspace file table — each verified by the absence of any copy-up branch on those paths. Internal copy-up files are additionally denied statfs, truncate, fsync and fallocate.

A file opened internally under the exemption is marked copy-up-internal in its blob and carries a granted mask of zero. It is usable without a cached caller grant only while the same context is attached and its phase admits that exact file. Use after phase completion, from another task, or after transfer through SCM_RIGHTS fails closed. Each armed phase has a distinct monotonically increasing generation — overflowing it fails closed — and an internal file is sealed to the generation it was opened in, so a later phase of the same kind after a worker transfer cannot reactivate an older file. Final release drops the context reference.

The one exception is the read-only provider-directory cursor used by bounded staging recovery. StrataFS may retain that file while it leaves the context to clean one captured batch, then resume it after re-entering the same context and arming a new source-read phase. Only a directory file already marked internal for that exact context, still naming the pinned provider path and inode, opened for read without write, execute or path-only mode, and inside that attached source-read phase, resumes; it is then resealed to the new generation. Between phases it is unusable and conveys no deletion authority.

3.9.7.4 Backing-file adoption #

Once a regular-file copy is published, one exact copy-up-internal backing file can become the backing file for the outer StrataFS open description. The staging binding is verified, and the backing file's recorded user path is confirmed to be that same outer description; the outer description's immutable granted and continuous-audit snapshot is then copied across and the internal marker removed. This is a transfer of authority already attached to the descriptor, not a new AccessCheck against the task that caused the copy-up, which is what preserves descriptor delegation when that task is not the opener. It is one-shot, and requires the backing file mode.

The ordering is worth noting: StrataFS performs the adoption before publishing the anonymous staged object rather than after, so what is verified is the still-bound staging binding rather than a published one.

3.9.7.5 Deferred deletion #

Delete-on-close is authorized when armed and recorded on the open file description (§3.9.2). At final close there is no re-authorization against the closing task. Instead a synchronous, non-nesting internal deletion scope is armed for that exact outer file, and StrataFS binds it once to the exact provider parent, dentry and inode selected from the descriptor's settled provider. Only the corresponding outer and lower unlink calls match, and the scope is cleared on every return.

The mechanism never turns an ordinary close into deletion authority, never admits a different provider entry, and never permits unlinking an entry that no longer names the descriptor's inode. If the original entry has disappeared or changed identity, the deletion is already complete and no exemption is used.

3.9.7.6 Exact protected-metadata cloning #

KACS owns descriptor cloning rather than StrataFS raw-xattr code. Before a create phase is armed, the provider's complete effective descriptor is resolved and pinned using the ordinary mount-policy and corrupt-descriptor rules (§3.9.5). A missing, corrupt, unresolvable, oversized or unsupported descriptor fails the phase before the destination is created.

For a matching inode security initialisation, an exact byte-for-byte copy of the pinned descriptor is installed instead of inheritance running. The canonical xattr is installed as part of inode creation and the inode's validated parsed cache is seeded from identical bytes before the inode becomes usable. Failure to allocate, validate or install either representation fails creation — there is no window in which a named staging inode carries an inherited or otherwise weaker descriptor.

On a stacking destination the same applies to the real inode created below the outer dentry, with the authenticated outer transition as the only authority to redirect installation there. The pinned bytes are installed and cached during the real inode's creation, before the outer object is confirmed or bound; inheriting the parent's descriptor and repairing it afterwards would not be conforming.

The canonical descriptor is not copied by ordinary xattr enumeration — it is reported as cancelled so a stacking filesystem discards it — and raw canonical getxattr and setxattr stay denied even inside the context, with the hook-side denial evaluated before any phase match. The context does not override the unconditional denial of POSIX ACL mutation either.

Raw setxattr, including through an internal copy-up file, remains unable to install security.capability. Where the provider has one, StrataFS calls the dedicated clone entry point during populate, after any operation that might clear file capabilities. That call accepts only a kernel buffer exactly matching the pinned value, under the same user namespace it was pinned in, for the still-bound staged inode. It re-reads the provider immediately before installing and fails with ESTALE if the value or its presence changed.

The call copies the caller's buffer before comparing, and installs with XATTR_CREATE through the ordinary VFS path, so mount-idmap conversion, xattr validation, filesystem permission and format checks and other LSM checks all still apply. The otherwise-dead CAP_SETFCAP gate is satisfied only synchronously inside that validated call, and the corresponding setxattr re-entry is admitted only after the first hook matches the exact staged inode. The capability answer targets only the pinned caller namespace or the staged inode's filesystem namespace, and the condition is cleared on every return. Nothing is installed when the provider had no attribute, a different one, or an unreadable or invalid one. The exception does not revive exec-time file-capability grants.

There is one asymmetry here: the removal path does not reject security.capability the way the set path does, so an internal copy-up descriptor can remove it from the staged object during populate.

Other eligible provider xattrs follow StrataFS's replication rules while KACS's caller checks on their source and staging objects are exempted as above. An ineligible xattr that cannot be replicated triggers StrataFS's ordinary copy-up failure rule rather than being silently omitted.

3.9.7.7 Auditing #

The outer authorized handle operation remains subject to ordinary audit. No second caller AccessCheck or privilege-use audit is emitted for an exempt internal sub-operation, since that would attribute StrataFS mechanics to a caller decision that never happened — and internal files carry a continuous-audit mask of zero, so they generate no per-operation events either. The CAP_SETFCAP satisfaction path returns before the capability check that would record privilege use.

StrataFS decides when its own copy-up lifecycle and failure events — stratafs.file.copied-up and stratafs.mutation.refused (§4.6.5) — occur; KACS supplies the kernel-only emitter, so KMES stamps each event with the effective token of the task whose operation caused it (§2.2). Two of those emissions are best-effort: an allocation failure, or an operation string that is empty or over 64 bytes, drops the event silently.

Denied mismatches, and operations performed with no active matching context, follow the ordinary authorization and audit paths.

3.9.8 Overlayfs Creates

Peios / Peios technical reference / PKM / KACS / FACS

Overlayfs is a different problem from the StrataFS context of §3.9.7. Nothing here needs an authorization exemption: the caller has already been authorized against the merged inode. What has to be corrected is the descriptor the new object ends up wearing.

Both of the inputs the inheritance algorithm needs (§PCDS 5.6) are wrong by the time the real create runs:

  • The parent is wrong. Overlayfs creates on the upper filesystem, in the upper directory or — for a copy-up, or a create over a whiteout — in the overlay's workdir. Neither is the directory the caller named. A backing directory is also a distinct inode with its own descriptor cache, so a descriptor set at runtime through the overlay is not the one a create sees.
  • The subject is wrong. Overlayfs performs upper-layer work under the mounter's credentials. The subject the inheritance algorithm sees is therefore the principal that mounted the filesystem, not the one making the object.

Left alone, the second is the more damaging: on a live image the root is an overlay mounted by the system, so every object created anywhere on it came out owned by SYSTEM, and every inherited CREATOR OWNER ACE resolved to SYSTEM rather than to the principal that created the object.

KACS resolves the descriptor before the create, while both inputs are still right, and hands the answer down.

3.9.8.1 The descriptor rides on a credential #

The kernel offers two hooks for this, and both work the same way: they hand the LSM a credential to fill in, and overlayfs then performs the creation under it.

HookFires forKACS resolves
security_inode_copy_upA lower object being materialised in the upper layerThe source object's own effective descriptor
security_dentry_create_files_asAn ordinary create, mkdir, mknod, symlink or tmpfileInheritance from the overlay parent, for the calling principal

Either way the result is attached to that credential, and the inode creation path prefers it over computing inheritance itself.

The credential is what makes the lifetime sound, and it is the reason this needs no context of its own:

  • Overlayfs installs the credential with override_creds() and reverts it through a scope guard, on every exit path including errors.
  • Each scope wraps exactly one creation and nothing else. A create over a whiteout makes one temporary object and then renames it over the whiteout, rather than creating a second.
  • Releasing the credential releases the descriptor.

So there is nothing to arm and nothing to disarm. Outside that scope the credential is simply not current.

A credential derived from one carrying a pending descriptor does not inherit it. Without that rule the descriptor would escape its create and be stamped on the next unrelated object the task made.

3.9.8.2 A copied-up object keeps its own descriptor #

A copy-up is not a new object, and inheritance is the wrong answer for it even from the right parent: the object already had a descriptor. KACS carries the source's across, whether that descriptor was stored on the lower inode or synthesized for it under a mount policy (§3.9.5).

Without this the defect runs in both directions. An object with a deliberately narrow descriptor is widened by the act of writing to it, and a deliberately permissive one is narrowed — in both cases changing what other principals may do to it as a side effect of somebody else's write. The principal doing the writing already held write access; nothing about that should re-decide the object's policy for everyone else.

3.9.8.3 An ordinary create inherits from the overlay #

A genuinely new object has nothing to preserve, so it inherits — but from the merged directory the caller named and as the caller, which is what it would have got had overlayfs not been in the way. The parent's descriptor is read through the overlay inode, so a descriptor set at runtime governs the children created after it, and CREATOR OWNER resolves to the principal making the object.

A creator descriptor supplied explicitly to a native create (§3.9.2) is picked up here for the same reason. The request records the parent the caller resolved — an overlay inode — so a match attempted at inode creation time, where only the backing inode is visible, could never fire, and the descriptor the caller asked for would be silently replaced by inheritance.

A caller with no token is left alone rather than denied here, so the inode creation path reaches its own denial. Two places must not decide the same thing differently.

3.9.8.4 The descriptor xattr is not copied #

On copy-up the canonical descriptor xattr is discarded during the xattr copy rather than replicated, because the credential above already carries it; copying it as well would overwrite the descriptor with the same answer. It could not be copied through that path in any case — the canonical xattr is not readable or writable as an ordinary xattr, since descriptor mutation is the dedicated interface's job (§3.9.6).

3.9.8.5 Failure #

Failing to resolve a descriptor fails the create. That holds for both hooks: a source whose descriptor cannot be read fails the copy-up, and a parent whose descriptor cannot be resolved — or which does not grant the caller the right to add the object — fails the create.

The alternative in each case is to fall back to inheritance from a backing directory as the mounter, which is the outcome this exists to prevent, and which leaves nothing behind to find later.

An object on an unmanaged mount is the one case that passes through untouched: there is nothing there to preserve or to inherit.

3.9.9 Mount-policy syscall notes

Peios / Peios technical reference / PKM / KACS / FACS

These syscall notes preserve the argument and error descriptions from the operator guide. For the kernel contract, use File Descriptor Storage.

Warning

The privilege gate has been source-verified below, but other descriptions retained from the operator guide still differ. The kernel contract allows templates only with synthesising classes, caps them at 65,535 bytes, and rejects non-empty templates for deny-missing. The SDK wrapper documents different small-buffer handling from the raw syscall error table below. These differences need implementation verification; do not use these notes to infer a broader grant of access or a safe retry.

3.9.9.1 kacs_set_mount_policy #

The write syscall:

result = kacs_set_mount_policy(fd, args)

Where fd is a file descriptor referring to any object on the target superblock (the kernel uses the fd to identify which superblock — the file itself does not need to be the root of the mount). args is a kacs_mount_policy_args struct:

FieldMeaning
policyThe new policy class (one of KACS_MOUNT_POLICY_DENY_MISSING, KACS_MOUNT_POLICY_SYNTHESIZE_EPHEMERAL, KACS_MOUNT_POLICY_SYNTHESIZE_PERSISTENT).
flagsReserved; must be zero.
generation(Output) The new generation counter value after the change.
template_sd_ptr, template_sd_lenOptional mount-level SD template. Max 64 KiB.

The kernel:

  1. Validates the fd and identifies the superblock.
  2. Validates the policy value. Attempts to set KACS_MOUNT_POLICY_UNMANAGED (which is not settable via this ABI) are rejected with -EINVAL.
  3. Validates the template SD if provided — must parse, must be within size limits.
  4. Checks the caller's privileges. Enabled SeManageVolumePrivilege or SeTcbPrivilege is required, and marking the privilege as used must succeed.
  5. Atomically updates the superblock's policy class, the template SD, and increments the generation counter.
  6. Writes the new generation to args.generation.
  7. Returns 0.

The change is atomic at the superblock level — every future access against any inode on this superblock sees the new policy. There is no transitional state where some inodes are under the old policy and others under the new.

3.9.9.1.1 Privilege requirement #

Important

In kernel source 8e0e22de3a59cad506bbbf8873de456e16ad272d, both policy reads and writes use the volume-management gate: enabled SeManageVolumePrivilege or SeTcbPrivilege, with successful privilege-use marking. Administrator membership alone is insufficient. The write gate and read gate establish this for the pinned source, not a deployed build or a historical release boundary.

Changing a mount's policy affects the entire filesystem's access semantics. SeManageVolumePrivilege can author synthesised policy and shadow existing paths; see its authority warning. The SDK wrappers at libpeios de0018bdcaed14abb796aeccec9c8387fd30e452 add no TCB-only gate despite their comments. The command diagnostic also retains a TCB-only hint. Do not automatically grant TCB or globally remap CAP_SYS_ADMIN. A ready-to-use operator interface for changing an existing policy is not established here.

The generated KACS ABI trace table retains the KACS_MP_TCB_DENIED name and TCB-only descriptions. Those labels do not override the source-verified gate; this clarification leaves the generated table unchanged.

3.9.9.1.2 The template SD #

The optional template SD is the default SD used during synthesis when the parent's inheritance does not yield one. The kernel stores the template on the superblock; subsequent synthesis-on-missing calls use it.

The template is a complete self-relative SD — owner, primary group, DACL, optional SACL. The kernel validates the template at policy-set time:

  • Must be in self-relative format.
  • Must include an owner (an SD without one is malformed and rejected).
  • Must fit within 65,535 bytes total; the template specifically is capped at 64 KiB.
  • ACLs and ACEs must parse.

A bad template at policy-set time is rejected with -EINVAL; the current policy and template are unchanged.

The template can be omitted entirely (zero pointers) for mounts that don't need one. The synthesis chain then falls through to the hardcoded fallback for any file whose parent doesn't yield an SD.

3.9.9.2 kacs_get_mount_policy #

The read syscall:

result = kacs_get_mount_policy(fd, args)

Same fd and args semantics, returning the current policy class, current template (if buffer provided), and current generation counter.

The kernel:

  1. Validates the fd, identifies the superblock.
  2. Checks the caller's privileges. Enabled SeManageVolumePrivilege or SeTcbPrivilege is required, with successful privilege-use marking.
  3. Writes the policy class, generation, and template to args (template only if a buffer was provided).
  4. Returns 0.

Like the write syscall, this uses the pinned-source volume-management gate. A successful policy query does not grant authority to bypass any other mount or file-access checks.

This is the kernel's read-back-the-current-policy interface. A management tool reads the policy, perhaps applies a transformation, and writes the new value back. The read-modify-write pattern is what most policy management code uses.

3.9.9.2.1 Reading the generation #

Compare the value from kacs_get_mount_policy with your earlier read to detect a change. Initial values are implementation-defined, and values from different superblocks are unrelated. The counter identifies changes, not a wall-clock change time. See the kernel administration contract for generation increments and cache invalidation.

3.9.9.3 Errors #

Possible failures from kacs_set_mount_policy:

ErrorCause
-EBADFThe fd is invalid.
-EPERMNeither accepted privilege is enabled, or privilege-use marking failed.
-EINVALSetting an unmanaged policy via the public ABI; setting an unknown policy value; non-zero reserved flags; malformed template SD; template size exceeded.

Possible failures from kacs_get_mount_policy:

ErrorCause
-EBADFThe fd is invalid.
-EPERMNeither accepted privilege is enabled, or privilege-use marking failed.
-ERANGEA template buffer was provided but is smaller than the template. The required size is written to the output.

In normal operation both calls succeed. Failures are typically privilege issues or malformed inputs.

3.10.1 Credential Projection

Peios / Peios technical reference / PKM / KACS / The Linux Credential Model

KACS tokens are the sole identity-based authorization mechanism, and Linux applications do not know tokens exist. They call getuid(), getgid() and getgroups(), read /proc/self/status, and assume those numbers determine their access. KACS projects token identity onto standard Linux credentials so unmodified applications work.

When a token is installed on a process, the process's Linux credentials are set to match. The numbers themselves are already on the token: the user SID's projected uid, the primary group SID's projected gid, and a projected supplementary gid per group SID are all computed by authd when the token is minted, and KACS copies them.

KACS never resolves a SID to a number itself. It holds no directory handle and consults nothing at install time — which is what makes projection cheap enough to do on every credential change, and what keeps a name-service outage from being able to change what a running process may do.

How a SID becomes a number is therefore not KACS's rule to state, and this chapter deliberately does not restate it. The authority is the principal source interface's numeric scope (PSPU §2): identifiers are computed — an authority grants a source a band (base, count) and derives base + r from a relative identifier — rather than looked up per principal, and a source's assertion outside its band is refused rather than clamped.

65534 does appear in KACS, but not as a "no attribute was set" fallback: it is ANONYMOUS_PROJECTED_ID, what the projected-id accessors return for the anonymous identity and for an invalid token pointer. It is a sentinel for no identity, not a default for an identity whose number could not be found.

The consequences are mostly convenient ones. No process runs as UID 0 unless it holds the SYSTEM token — enforced, not merely expected: a token creation naming a projected UID of 0 with any user SID other than S-1-5-18 is rejected, and the projection path refuses it again at install time. Home directories work naturally, because getpwuid(getuid()) returns the right answer when the UID is real and consistent with NSS. And different services get different UIDs, which is incidental defence in depth alongside KACS's own enforcement.

3.10.1.1 Projection is one-way #

Token state flows into credential fields and never the reverse. The projected credentials are observational compatibility data; the token is the authority. The setuid family restores the old credential rather than deriving a token from it (§3.10.3).

Projection reflects all groups regardless of enabled state, so adjusting groups never triggers recalculation.

Projected credentials reflect the effective token — the impersonated one during impersonation, the primary one otherwise. When a service thread impersonates a client and creates a file, the file is owned by the client's projected UID, quota is charged to the client, and audit attributes to the client.

The two accessors deliberately disagree during impersonation. current_fsuid() reads the projected UID from the effective credential, so it yields the client's UID; getuid() reads the primary credential's UID, so it yields the service's. During impersonation getuid() returns the service and current_fsuid() returns the client, and that is the intended behaviour rather than an inconsistency. Only getuid() and getgid() are redirected to the primary credential; geteuid() and getegid() keep their Linux meaning and follow the effective credential, so during impersonation they report the client.

One caveat applies to a credential carrying no token at all — a blank credential, or one created before KACS initialised. current_fsuid() falls back to cred->fsuid in that case, and the capability switchboard denies before consulting the ALLOW list (§3.10.2), so Linux DAC becomes authoritative for such a task.

3.10.1.2 Precomputed values #

Every token carries precomputed projected UID and GID values, calculated by authd at creation and stored on the token. KACS never resolves a SID-to-UID mapping at runtime; the accessors are pure field reads.

Because SIDs are one namespace while Linux UIDs and GIDs are two, authd allocates from a single unified counter across all principal types rather than a separate one per namespace, so every SID projects to a unique number whichever Linux namespace it lands in. A user and a group can never collide on a number, which is what lets one SID answer both getuid() and getgid() questions without ambiguity.

3.10.2 DAC Neutralisation

Peios / Peios technical reference / PKM / KACS / The Linux Credential Model

Linux evaluates DAC — UID, GID and mode-bit checks — before consulting LSM hooks. If DAC denies an operation the hook never fires, and KACS cannot override a DAC denial: LSM hooks are restrictive, able to further deny what DAC would allow but never to grant what DAC has refused.

KACS therefore neutralises DAC so the hooks always fire. Every process receives a set of Linux capabilities that bypass the DAC gates, and those capabilities are mandatory substrate rather than grants.

3.10.2.1 The capability switchboard #

Linux's capabilities fall into three categories, and security_capable() is authoritative for all of them — the raw capability sets on struct cred are compatibility-visible state that answers nothing.

ALLOW capabilities exist to override UID-based permission checks on operations KACS enforces through its own hooks, so DAC never blocks something KACS will evaluate independently:

CAPNameRationale
0CAP_CHOWNKACS file hooks enforce.
1CAP_DAC_OVERRIDEKACS file hooks enforce.
2CAP_DAC_READ_SEARCHKACS file hooks enforce.
3CAP_FOWNERKACS file hooks enforce.
4CAP_FSETIDKACS file hooks enforce.
5CAP_KILLThe task_kill hook enforces.
6CAP_SETGIDCosmetic under KACS.
7CAP_SETUIDCosmetic under KACS.
11CAP_NET_BROADCASTUnused in modern kernels.
15CAP_IPC_OWNERKACS IPC hooks enforce.
28CAP_LEASEKACS file hooks enforce.
10CAP_NET_BIND_SERVICEThe port's reservation SD decides at socket_bind; the Linux privileged-port floor never refuses.

PRIVILEGE capabilities gate operations no other KACS hook covers, and map to a KACS privilege:

CAPNamePrivilege
9CAP_LINUX_IMMUTABLESeTcbPrivilege
12CAP_NET_ADMINSeTcbPrivilege
13CAP_NET_RAWSeTcbPrivilege
14CAP_IPC_LOCKSeLockMemoryPrivilege
16CAP_SYS_MODULESeLoadDriverPrivilege
17CAP_SYS_RAWIOSeTcbPrivilege
18CAP_SYS_CHROOTSeTcbPrivilege
19CAP_SYS_PTRACESeDebugPrivilege
20CAP_SYS_PACCTSeTcbPrivilege
21CAP_SYS_ADMINSeTcbPrivilege (but see the mount note below)
22CAP_SYS_BOOTSeShutdownPrivilege
23CAP_SYS_NICESeIncreaseBasePriorityPrivilege
24CAP_SYS_RESOURCESeIncreaseQuotaPrivilege
25CAP_SYS_TIMESeSystemtimePrivilege
26CAP_SYS_TTY_CONFIGSeTcbPrivilege
27CAP_MKNODSeTcbPrivilege
29CAP_AUDIT_WRITESeAuditPrivilege
30CAP_AUDIT_CONTROLSeSecurityPrivilege
33CAP_MAC_ADMINSeSecurityPrivilege
34CAP_SYSLOGSeTcbPrivilege
35CAP_WAKE_ALARMSeTcbPrivilege
36CAP_BLOCK_SUSPENDSeTcbPrivilege
37CAP_AUDIT_READSeSecurityPrivilege
38CAP_PERFMONSeSystemProfilePrivilege or SeProfileSingleProcessPrivilege or SeLoadDriverPrivilege
39CAP_BPFSeTcbPrivilege
40CAP_CHECKPOINT_RESTORESeTcbPrivilege

CAP_PERFMON is the one OR-mapped entry: the Linux capability genuinely spans several Peios privilege tiers, and no single privilege covers everything it gates. The check succeeds if the caller holds any of the three, and every one it holds is marked used. Per-operation enforcement then happens at the relevant syscall hook, which checks the specific privilege the specific operation needs (§3.7). OR-mapping stops the capability ceiling manufacturing false denials; it grants nothing the holder does not already have.

CAP_SYS_ADMIN maps to SeTcbPrivilege and is not OR-mapped, which would be the obvious way to let administrators mount and is the wrong one: CAP_SYS_ADMIN gates dozens of unrelated operations, so widening it would hand out far more than mounting.

Mounting is handled outside the capability table instead. may_mount() (fs/namespace.c, patched) calls pkm_kacs_may_mount_op(), whose first rung accepts SeManageVolumePrivilege or SeTcbPrivilege, before falling back to the ordinary CAP_SYS_ADMIN check. In a mount namespace the caller created for itself, a second rung admits a bind mount, an unmount, a pivot_root, or a new tmpfs, proc or read-only stratafs on the namespace's own security descriptor instead (§3.13); in the initial namespace the privilege is the only route. Every other CAP_SYS_ADMIN caller still needs the TCB.

It has to be asked there rather than through the sb_mount LSM hook: may_mount()'s capability check runs before security_sb_mount(), so an LSM is never consulted about a mount the capability check already refused. A hook can narrow that decision; it cannot widen it.

The CAP_SYS_ADMIN gate on creating a namespace (kernel/nsproxy.c, patched) has one carve-out of the same kind: a request for a mount namespace alone passes without it, because the table it yields is an object its creator owns. Any other namespace type, alone or in combination, still needs the TCB.

CAP_SYS_BOOT carries an extra condition the table cannot express: a token whose logon session is of a remote origin — Network, NetworkCleartext, NewCredentials or RemoteInteractive — additionally requires SeRemoteShutdownPrivilege.

DENY capabilities are refused unconditionally, whatever privilege the caller holds: CAP_SETPCAP (8) and CAP_SETFCAP (31), because capabilities are dead under KACS, and CAP_MAC_OVERRIDE (32), because KACS is the active LSM and must not be bypassable.

An unmapped or unknown capability is denied by default. The switchboard fails closed.

3.10.2.2 Compatibility state #

Programs may inspect the credential capability sets with capget() or /proc/<pid>/status, and may attempt to mutate the non-ALLOW subset with capset() or prctl(). None of that is authoritative.

capget() and /proc/<pid>/status report the ALLOW substrate as present in the effective, permitted and inheritable sets — reported as CapEff, CapPrm and CapInh, and additionally CapBnd, by the proc interface. Non-ALLOW bits present in the credential state may also be reported, but grant no authority. CapAmb reports raw Linux ambient state; the ALLOW substrate does not depend on ambient capabilities.

The strict invariant is that the ALLOW set is mandatory substrate and has to survive wherever Linux capability mechanics would otherwise drop it out from under KACS. capset() rejects any request clearing an ALLOW capability from the effective, permitted or inheritable sets, and bounding-set drops and ambient manipulation reject anything that would clear or exclude one. The implementation is slightly stricter than that: an ambient raise of an ALLOW capability is refused too, not only a clear.

After that validation, capset() follows ordinary Linux ambient behaviour — ambient bits no longer present in both the requested permitted and inheritable sets may be cleared. Because requests clearing ALLOW bits from permitted or inheritable are denied outright, that intersection can never indirectly clear an existing ALLOW ambient bit.

Direct mutation of non-ALLOW state is therefore compatibility-only. It may change what capget() reports and cannot change the authority answer for any capability-gated operation.

3.10.2.3 Neutralised native paths #

Native commoncap helpers that make raw capability-subset decisions before a KACS hook are neutralised where KACS has an authoritative hook of its own. That covers the subset gates in ptrace access, PTRACE_TRACEME, and task_setnice, task_setscheduler and task_setioprio — each replaced by an unconditional allow, leaving the KACS process-descriptor and PIP hooks to decide. Capability checks that reach security_capable() directly stay under the switchboard.

For raw xattr operations the FACS metadata hooks are authoritative, so the native security-xattr capability prechecks that would run first are skipped. This does not revive Linux file capabilities: installing or replacing non-empty security.capability data stays denied by the dead CAP_SETFCAP policy, with the single exception of the KACS-owned StrataFS clone (§3.9.7), and exec-time file-capability grants remain suppressed. Removing stale metadata goes through the ordinary FILE_WRITE_EA path.

One structural wrinkle: security_capable() reaches the KACS switchboard twice — once through the patched commoncap entry point and once through the KACS capable hook — so a privilege consulted this way is marked used twice. The authorization answer is unaffected; the privilege-use accounting double-counts.

3.10.2.4 The LSM stack #

MAC LSMs — SELinux, AppArmor, SMACK, TOMOYO — and the BPF LSM have to be disabled. They would independently deny operations from their own label and policy systems, undermining KACS's claim to be the sole identity-based authorization mechanism and FACS's to be the sole file access authority. Non-MAC LSMs are permitted where they make no identity-based access decision: landlock, lockdown and integrity stack safely. Yama is not among them and is not built: its relational ptrace_scope answers every attach to a non-descendant with "does the caller hold CAP_SYS_PTRACE", which the switchboard maps to SeDebugPrivilege, so it would decide ahead of the process descriptor and PIP dominance that §3.7 makes the ptrace authority. The same-uid attach it exists to close cannot arise under KACS, which never grants on uid.

The check is made at initialisation and KACS refuses to activate if it fails — but it is a build-configuration test rather than an inspection of the live LSM stack. It tests whether each conflicting LSM is enabled in the kernel config, and never parses CONFIG_LSM or enumerates what is actually registered.

3.10.3 setuid Behaviour

Peios / Peios technical reference / PKM / KACS / The Linux Credential Model

Under KACS a UID has no security properties whatever. It is not consulted in any access decision, appears in no descriptor, and is referenced by no ACE. It is a compatibility value stored in struct cred for the sole purpose of answering getuid().

3.10.3.1 The setuid syscalls #

The setuid family — setuid, setgid, setresuid, setresgid, setgroups — changes Linux credentials.

Without SeAssignPrimaryTokenPrivilege, which is the common case, the call is a silent no-op. It returns success, and every credential field is restored from the old credential: UIDs, GIDs, supplementary groups, capabilities. Neither the credential nor the token changes, and the process's authority before and after is identical.

The silent success preserves consistency between the visible UID and the KACS identity. Changing the UID without changing the token would produce subtle failures — the wrong home directory from getpwuid(), for instance — for no security benefit.

With SeAssignPrimaryTokenPrivilege the design calls for the credential change to trigger a full identity swap, redirected to authd to obtain a token for the target UID's principal, so that both token and credential change together.

That is not what happens. A caller holding the privilege receives EOPNOTSUPP and the call fails. There is no authd redirect anywhere in the LSM. The practical effect is that the privileged path is unavailable rather than dangerous: a TCB component cannot change identity this way, and has to install a token directly instead (§3.2.3).

3.10.3.2 The setuid bit on exec #

The filesystem setuid bit tells the kernel to change the effective UID to the file owner's on exec.

Without the privilege, the Linux-visible UID and GID slots change to the file owner's identity while the token is untouched — a cosmetic escalation in which the process sees geteuid() == 0 while KACS continues to enforce the original token. Concretely uid and suid are set from euid, the GID counterparts mirror that, fsuid and fsgid are carried over unchanged from the old credential, and the token is cloned as-is.

With the privilege, the design calls for the slots and the token to change — genuine escalation. As with the syscall, this is not implemented: an exec that would change UID or GID under a token holding SeAssignPrimaryTokenPrivilege returns EOPNOTSUPP and the exec fails.

The asymmetry between the two mechanisms is intentional in the design. setuid() is de-escalation, so leaving everything unchanged is the safe failure mode. The setuid bit is escalation, and the target binary expects the euid and will check it — sudo verifies geteuid() == 0 — so the euid has to change for the binary to function at all.

MechanismWith privilegeWithout privilege
setuid() syscallDesigned: all UIDs and token change. Actual: fails with EOPNOTSUPP.Silent no-op
Setuid bit on execDesigned: euid/suid and token change. Actual: exec fails with EOPNOTSUPP.euid/suid change, token unchanged

3.10.3.3 The current_fsuid patch #

The kernel calls current_fsuid() whenever it needs a UID for a filesystem operation — file creation ownership, quota tracking, keyring lookup, NFS credentials. KACS redefines it, along with current_fsgid() and current_fsuid_fsgid(), to return the projected value from the effective token rather than cred->fsuid.

So files are created owned by the projected UID, quotas track against it, per-user keyrings are keyed by it, and an NFS server sees the real identity rather than UID 0.

3.10.3.4 Compatibility gaps #

The privilege-drop pattern. A daemon that calls setuid(target) and then checks getuid() != target to confirm the drop sees success returned with the UID unchanged. This is intentional — UIDs carry no security meaning here — and software ported to Peios should use KACS-native token operations for privilege management instead.

Direct capability manipulation. Software that manipulates capabilities with capset() and capget(), writes seccomp filters, or inspects its own capability set may behave unexpectedly (§3.10.2). Seccomp filters themselves install as on Linux — no_new_privs, or CAP_SYS_ADMIN resolved to SeTcbPrivilege — and no_new_privs keeps its Linux meaning for the one Peios gain an exec can confer: under it, an exec cannot raise the process's PIP label (§3.7).

setfsuid() is a no-op for filesystem purposes, since current_fsuid() ignores cred->fsuid; and cosmetic setuid-bit exec transitions do not flow into the projected values either.

access() and faccessat() use the effective token rather than the real credential — the entire native credential-override machinery those calls normally use is compiled out. The concept of a "real identity" separate from the acting one does not exist in KACS.

SO_PEERCRED returns projected UIDs rather than token information, and because the switchboard allows CAP_SETUID, cosmetic UID forgery in SCM_CREDENTIALS is possible. Both are Linux compatibility metadata, not peer-token authorities: a service needing authoritative identity uses stream or seqpacket peer-token capture, or explicit token descriptor passing (§3.5.3).

Legacy auditd records projected UIDs, so KACS audit through KMES replaces Linux audit for security-relevant logging.

A uid0 utility — running a program with cred->uid forced to 0 for legacy programs that refuse to start otherwise — is described in the design and does not exist in the tree. The kernel-side guarantee it would rely on does hold: current_fsuid() ignores cred->uid entirely, so even with such a utility active, filesystem operations would use the projected UID and files would be owned by the real user.

3.10.4 Netlink

Peios / Peios technical reference / PKM / KACS / The Linux Credential Model

Netlink is the kernel's message interface: a process sends a request to a kernel subsystem (routing, firewalling, audit, generic netlink families) and the subsystem answers, or pushes notifications to multicast groups. The kernel authorises those requests with capability checks, and on Peios every one of them is a KACS privilege check.

3.10.4.1 Where the checks run #

A request to the kernel is handled synchronously: netlink_unicast delivers the message to the family's receive function inside the sending task's own sendmsg, under the family's mutex. There is no later context in which a message is processed, so the sender at every check is the current thread, and audit attribution needs no capture. Only messages between two user sockets are queued.

The netlink permission helpers — netlink_capable, netlink_ns_capable and netlink_net_capable, and through them the generic-netlink GENL_ADMIN_PERM and GENL_UNS_ADMIN_PERM operation flags — test two subjects and require both to pass: the credential that opened the netlink socket, held on the socket's file, and the sender, the current thread. netlink_allowed, which gates binding to a family's multicast groups and sending to another port, tests the current thread. This is Linux's own two-point rule, introduced after CVE-2014-0181 so that a privileged program could not be turned into a deputy by inheriting an unprivileged process's netlink socket as its standard output.

3.10.4.2 What decides #

Every one of those tests reaches security_capable() with a specific credential, and the capability switchboard (§3.10.2) evaluates that credential's token: pkm_kacs_capable_in_cred_ns looks up the token on the credential and asks whether it holds a privilege the capability maps to. The opener's file credential carries the opener's token; the current thread's credential carries the sender's effective token, impersonation included. So a netlink request is authorised against two KACS tokens, with the same privilege catalogue and the same fail-closed behaviour as any other capability check — not against uid bits, and not against a projection of the token.

The catalogue maps CAP_NET_ADMIN, CAP_NET_RAW and CAP_SYS_ADMIN to SeTcbPrivilege. Configuring the network — adding a route, setting an interface up, editing a firewall — is therefore a TCB operation on Peios; an administrator's session cannot do it, and a RTM_NEWLINK from an administrator is answered with EPERM. Audit control (CAP_AUDIT_CONTROL, CAP_AUDIT_READ) maps to SeSecurityPrivilege and audit writes (CAP_AUDIT_WRITE) to SeAuditPrivilege. Requests that need no capability — dumps of the routing table or the link list — are answered for any caller.

3.10.4.3 What netlink carries about the sender #

A netlink message's metadata (NETLINK_CB) records the sender's projected UID and GID, in the same form SO_PEERCRED uses, and delivers them to a receiving user socket that asked for SCM_CREDENTIALS with SO_PASSCRED. As everywhere on Peios, those values are a projection for compatibility and display, not an authorisation input (§3.10.1). Netlink between two user sockets is not a supported identity-carrying transport: it carries the projection and nothing more, and a program that needs the peer's token uses an AF_UNIX socket (§3.5).

3.11.1 Objects and Their Descriptors

Peios / Peios technical reference / PKM / KACS / System V IPC

System V IPC — message queues (msgget), shared memory segments (shmget) and semaphore arrays (semget) — predates file descriptors and paths: an object is named by an integer key, identified by an integer id, and persists in the kernel until removed or reboot. Linux protects each with a nine-bit mode in struct ipc_perm, checked by ipcperms() against the caller's UID and GID.

On Peios that mode is inert. CAP_IPC_OWNER is in KACS's always-allow set (§3.10.2), so ipcperms() never denies on the mode and always reaches security_ipc_permission(); there KACS decides, against a security descriptor the object carries.

3.11.1.1 The descriptor #

Each object gets a descriptor when it is created, built from the creator's effective token: owner and group from the token; the creator's user SID, BUILTIN\Administrators and SYSTEM at GENERIC_ALL. It is held on the object's LSM blob for the object's life — persisting with the object across the creator's exit, as SysV objects do — and released when the object is removed.

The rights are those of <pkm/ipc.h>:

RightGrants
KACS_IPC_READmsgrcv; shmat read-only; semop without alter; GETVAL, GETPID, GETNCNT, GETZCNT, GETALL
KACS_IPC_WRITEmsgsnd; shmat read-write; semop with alter; SETVAL, SETALL
KACS_IPC_QUERY_INFORMATIONIPC_STAT and the SHM_STAT, MSG_STAT, SEM_STAT families — together with KACS_IPC_READ, since Linux's own ipcperms() read check runs before the hook
KACS_IPC_SET_INFORMATIONSHM_LOCK, SHM_UNLOCK — SHM_LOCK additionally needs SeLockMemoryPrivilege, Linux's CAP_IPC_LOCK gate
DELETEIPC_RMID
WRITE_DAC and WRITE_OWNERIPC_SET — one command carries mode, uid and gid, so it needs both
READ_CONTROLreading the descriptor with kacs_get_sd

The generic mapping: GENERIC_READ is read plus query and READ_CONTROL; GENERIC_WRITE is write plus set-information and READ_CONTROL; GENERIC_EXECUTE is query and READ_CONTROL; GENERIC_ALL is everything above.

3.11.1.2 Where the checks run #

ipcperms() runs for every data operation with the mode bits the operation would have needed — read, write or both — and security_ipc_permission() maps them to KACS_IPC_READ and KACS_IPC_WRITE and runs AccessCheck for the caller's effective token, under the caller's PIP context, against the object's descriptor. The *get path with an existing key goes through the same check with the requested mode. The control operations go through the per-object shmctl, msgctl and semctl hooks, which map the command to the right in the table above; commands that address no object (IPC_INFO, SHM_INFO, MSG_INFO, SEM_INFO) need nothing.

Linux's own ownership check on IPC_SET and IPC_RMID — that the caller's effective UID matches the object's creator or owner, or holds CAP_SYS_ADMIN — still runs first, on the projected UID, and cannot be relaxed by the descriptor: the hooks only ever further restrict. So an administrator who is not the object's creator needs both the descriptor's DELETE (which the default grants Administrators) and SeTcbPrivilege (what CAP_SYS_ADMIN maps to) to remove it. The uid, gid and mode that IPC_SET writes remain as Linux stores them and are what ipcs and IPC_STAT report; they are informational, as file mode bits are under FACS.

3.11.1.3 Reading and changing a descriptor #

A SysV object has no fd and no path, so kacs_get_sd and kacs_set_sd address it by kind and id: one of KACS_SD_AT_SYSV_SHM, KACS_SD_AT_SYSV_MSG or KACS_SD_AT_SYSV_SEM in flags, the object id in dirfd, and a NULL path. The object is looked up in the caller's IPC namespace; an unknown id is EINVAL, and so is a removed one, because IPC_RMID drops the id from the namespace in the same step that marks the object deleted — EIDRM is reserved for a lookup that races that removal and finds the object already dead. Reading needs the rights the requested SECURITY_INFORMATION implies (READ_CONTROL for owner, group and DACL; ACCESS_SYSTEM_SECURITY for the SACL), changing needs WRITE_DAC or WRITE_OWNER as for any object, and the new descriptor is merged into the existing one component by component, as for a process descriptor (§3.3.3).

3.11.1.4 Names #

The key namespace is claim-on-create: whoever calls *get with IPC_CREAT on a free key owns the object, and nothing prevents a process from claiming a key another program expected to create — the same shape as the abstract socket namespace and /dev/shm. The descriptor protects the object once it exists; it cannot protect the name. A program that needs a specific key uses IPC_EXCL and treats a failure as a signal, and a program that needs identity-carrying messaging uses an AF_UNIX socket (§3.5): a message queue conveys no sender identity, and msgrcv returns bytes and a type, nothing more.

3.12.1 Port Reservations

Peios / Peios technical reference / PKM / KACS / Network Objects

Ports are a shared, unowned namespace. Linux protects it with one rule: bind(2) below port 1024 needs CAP_NET_BIND_SERVICE, and everything above is anyone's. That is too coarse in one direction — any privileged process gets every low port — and absent in the other: nothing protects 8080, 3306 or 5432 from whichever process asks first.

On Peios the only thing worth authorising about a port is the claim — binding a non-zero port — and it is authorised the way everything else is: by a security descriptor. CAP_NET_BIND_SERVICE is in the always-allow set (§3.10.2), so the Linux floor never refuses first, and every claim reaches security_socket_bind(), where KACS decides.

The precedent is not Linux but HTTP.sys's URL reservations on Windows: a sparse table of namespace prefixes, each carrying a descriptor, consulted when a name is claimed, default-permissive where nothing is reserved. This chapter moves that model down to the port layer.

3.12.1.1 The object #

A reservation is a selector mapped to a descriptor. The selector names a set of protocols and an inclusive port range:

<proto>[,<proto>]:<lo>[-<hi>]      proto ∈ tcp | udp | *

tcp:80, udp:53, tcp,udp:1-1023, *:8080. Ports are decimal, 1–65535, no leading zeros, lo <= hi; a protocol may not repeat, so every selector has one spelling. The address family is deliberately not part of the selector: a reservation covers IPv4 and IPv6 alike, or dual-stack squatting — claiming the v6 side of a port reserved on v4 — would bypass it.

The rights are those of <pkm/net.h>:

RightGrants
KACS_PORT_BIND (0x00000001)bind(2) to any port the selector contains
READ_CONTROLreading the descriptor

That is the whole mask. Editing a reservation is a registry write, governed by the registry key's own descriptor — one edit policy for the table — so WRITE_DAC and WRITE_OWNER inside a port descriptor mean nothing and are never honoured. The generic mapping is GENERIC_EXECUTE → PORT_BIND, GENERIC_READ → READ_CONTROL, GENERIC_WRITE → nothing, GENERIC_ALL → both.

3.12.1.2 The table #

Reservations live as values under one registry key:

Machine\System\Network\TcpIp\PortReservations\
    @                 REG_BINARY   SD     the default reservation
    tcp,udp:1-1023    REG_BINARY   SD
    tcp:80            REG_BINARY   SD

Each value's name is its selector and its data a self-relative descriptor. The key's unnamed default value, @, is the default reservation: the descriptor for every port no selector contains. The shipped seed grants PORT_BIND to Everyone on @ — an unreserved port is anyone's, as on Windows — and to SYSTEM and the bind-low-ports capability SID on tcp,udp:1-1023, which is the Linux convention restated as policy. Services then carry their own entries: tcp:80,443 → NT SERVICE\httpd: PORT_BIND, which composes with per-service SIDs so that a compromised sshd cannot take 443 even though both are "privileged".

The kernel reads the key whole and rejects it whole. Every name must parse, every descriptor must parse, exactly one @ must exist, and no two selectors of equal width may overlap — the most specific match would be ambiguous, and the kernel refuses to guess. On any failure the load is audited and the previous table stays in force. The rejection is recorded as kacs.config.value.rejected (§3.C): config.key.path names the key, config.name the value at fault where one is, and outcome.reason why — empty, too-large, bad-selector, bad-descriptor, duplicate-default, missing-default or overlap — with policy.previous-retained true and policy.fallback saying whether what stayed in force is the compiled-in table. Nested overlap, and overlap between selectors of different widths, is how specificity works and is accepted.

3.12.1.3 The decision #

At bind(2) on an AF_INET or AF_INET6 socket whose protocol is TCP, UDP or UDP-Lite:

  1. If the requested port is 0 — ephemeral allocation — nothing is checked. Protocols no reservation covers (SCTP, raw) pass untouched.
  2. The most specific reservation containing (protocol, port) is selected: the one covering the fewest ports, regardless of how many protocols it names. None → the default reservation.
  3. The caller's effective token is evaluated against that descriptor for PORT_BIND, through the ordinary AccessCheck pipeline (§3.8) with PIP context, privilege-use marking and audit events — a port descriptor may carry a SACL, and its decisions surface through the same KMES stream as any other object's, with object.kind socket.
  4. Deny → -EACCES. Grant → the bind proceeds, and the caller's token is recorded on the socket as its binder.

An explicit bind(2) before an outbound connect(2) meets the same rule; it is not special-cased.

Rebinding. Binding onto a port that is already bound — with SO_REUSEADDR or SO_REUSEPORT — is still a bind(2) and still meets the reservation. The rule beyond that is that the new binder's user SID must equal the existing binder's, or the caller must hold SeTcbPrivilege: rebind is a property of the current binding, not of policy, and a right on the reservation that let one principal rebind onto another's port is a thing nothing legitimately needs. The binder recorded on each socket is what makes that comparison local; the comparison itself is applied where the stack resolves bind conflicts, and Linux already applies the same-owner half of it for SO_REUSEPORT by comparing the sockets' projected UIDs.

3.12.1.4 Before the registry #

The table comes from the registry through the same self-configuration path LCS uses for its own limits (§5.10): read at the first source's registration, then watched, so a change to the key is live without a restart. Until the first successful load — early boot, or a registry that never becomes available — a compiled-in fallback answers: owner and group SYSTEM, one entry granting SYSTEM PORT_BIND, nobody else. It is stricter than the shipped seed on purpose: nothing but system components should be claiming ports before the registry exists.

The fallback is replaced, never merged. The moment the key loads it is the sole authority; if a later load is rejected the last good table stays, not the fallback. Effective policy therefore never depends on boot timing.

3.12.1.5 What this retired #

SeBindPrivilegedPortPrivilege (bit 63) was the KACS privilege CAP_NET_BIND_SERVICE mapped to before this object existed. A reservation's grant is an ACE, and an ACE names a SID, not a privilege; keeping the privilege would have been a second authorisation path around the descriptor. It is retired and its bit is not reused. Low ports are granted through the capability SID on the 1-1023 reservation, which reaches a token by confinement — peinit for a service, or positive confinement from the authority at logon for a user.

3.12.1.6 Tracing #

Every decision emits kacs:kacs_socket_bind with reason port-bind: desired carries PORT_BIND, max_imp carries the port, and ret the verdict. Table loads emit reason port-table. See Appendix 3.C for the audit events an evaluated descriptor's SACL can produce.

3.12.2 Socket Identity

Peios / Peios technical reference / PKM / KACS / Network Objects

Network policy speaks of programs, and a packet carries no program. What carries one is the socket: KACS records on every AF_INET and AF_INET6 socket the identity that governs its traffic, and the network policy engine (Chapter 6, §6.9) reads it at the first judgment of each flow. AF_UNIX sockets carry the peer-identity machinery of §3.5 instead and are not stamped.

3.12.2.1 The stamp #

The stamp is the caller's effective token — the impersonation token during impersonation, the primary otherwise — held by a counted reference, together with the process facts of that moment: the process GUID (§3.3.2), the thread-group id, and the task's comm. Choosing the effective token means a service thread acting for a client attributes the socket to the client, exactly as file creation and audit do under impersonation; the service's own identity is then not on the socket for that flow.

It is taken at every act that commits the socket to a role, and the last one governs:

ActWhere
creationsocket_post_create; a kernel socket (sock_create_kern) is stamped as the kernel's, with no token
bind(2)after the port reservation permits it (§3.12.1)
listen(2)socket_listen
connect(2)socket_connect
accept(2)sk_clone_security: the child inherits the listener's stamp, its own reference
KACS_SO_RESTAMPany state: the caller's effective identity replaces the stamp

Restamping is what makes a hand-off well defined. A listener opened by one program and passed to another — socket activation, a descriptor store, a broker — is governed by whichever program last committed it, and a program that receives a socket it did not create can say so explicitly with KACS_SO_RESTAMP, self-gated like every attestation of one's own identity. Sockets that never pass through these hooks (a task with no token in early boot) read as unstamped, which the engine treats as the kernel's and confesses.

3.12.2.2 Reading it #

The engine reads the stamp with pkm_kacs_socket_owner() (<linux/peios_ntfe.h>): a counted token reference the caller releases with pkm_kacs_socket_owner_put(), and a copy of the kind, GUID, pid and comm. The copy is what makes the facts outlive the process; the reference is what makes them outlive the socket — a flow keeps its identity for its life (§6.9) even after the socket that started it is gone. The fields live under their own lock, taken with bottom halves disabled, because both the accept path and the engine's read run in softirq context.

3.12.2.3 Final release #

Once the socket's last user is gone, KACS releases the token references and security descriptor owned by the socket. This also releases any peer, listener, binder and send-cache references; each field owns its own reference even when several refer to the same token. Final cleanup can run from an RCU callback and must not sleep or acquire the locks used to coordinate live socket users. It has exclusive ownership at this point. References already taken by a flow or another reader remain valid until that reader releases them.

3.12.2.4 Tracing #

Every stamp emits kacs:kacs_socket_token with reason owner; the max_imp field carries the kind (1 program, 2 kernel). See Appendix 3.C.

3.13.1 A Private Mount Table

Peios / Peios technical reference / PKM / KACS / Mount Namespaces

A mount namespace is a private copy of the kernel's mount table: the map from directory paths to the filesystems mounted there. Linux answers "who may change this table" with CAP_SYS_ADMIN in the user namespace that owns it, and lets an unprivileged process manufacture that capability by creating a user namespace first — which is how bubblewrap, and every unprivileged container tool built the same way, gets a private root.

On Peios that route leads nowhere. The capability switchboard discards the target user namespace and answers from the token's privileges alone (§3.10.2), so being root in a user namespace confers nothing, and CAP_SYS_ADMIN maps to SeTcbPrivilege. KACS answers the question the way it answers every other: the mount namespace is an object with a security descriptor, and changing it is an access check.

3.13.1.1 The object #

Every mount namespace other than the initial one carries a security descriptor, minted in copy_mnt_ns() from the creating task's effective token and released when the namespace is. The initial namespace, and the anonymous namespaces open_tree(OPEN_TREE_CLONE) and fsmount create for detached trees, carry none.

The minted descriptor names its creator and nobody else: owner and group come from the token, and the DACL is a single allow ACE granting the creator's user SID GENERIC_ALL. SYSTEM and BUILTIN\Administrators, which the SysV IPC and socket defaults name, are absent on purpose — the privilege rung of the mount gate admits them before the descriptor is read, so an ACE would only pretend the descriptor was what let them in.

The rights are those of <pkm/mntns.h>:

RightGrants
KACS_MNTNS_MOUNTchanging the table: a bind mount, an unmount, pivot_root
KACS_MNTNS_ENTERjoining the namespace with setns — defined so the descriptor's meaning is fixed; no code consults it yet, and setns into a mount namespace still needs SeTcbPrivilege
READ_CONTROL, WRITE_DAC, WRITE_OWNERthe standard rights, which KACS_MNTNS_ALL_ACCESS includes; nothing exposes a namespace's descriptor to kacs_get_sd or kacs_set_sd yet

The generic mapping: GENERIC_WRITE is KACS_MNTNS_MOUNT and WRITE_DAC; GENERIC_EXECUTE is KACS_MNTNS_ENTER and READ_CONTROL; GENERIC_READ is READ_CONTROL alone, there being nothing to read from a namespace beyond its descriptor; GENERIC_ALL is KACS_MNTNS_ALL_ACCESS.

3.13.1.2 Creating one #

unshare(CLONE_NEWNS), and clone with CLONE_NEWNS, need no privilege when the mount namespace is the only namespace requested. Every other namespace type — and any combination that includes one — keeps the CAP_SYS_ADMIN gate in kernel/nsproxy.c, which the switchboard answers with SeTcbPrivilege. A user namespace may still be created alongside, as upstream allows; it changes nothing about what follows.

A table created by a task that does not hold the mount privilege receives its mounts as slaves of the parent table's, exactly as upstream does for a copy owned by a different user namespace: propagation runs parent to child only, so whatever the creator mounts in its own table never appears in the one it copied. A table created with the privilege is copied as upstream copies it, peers and all.

3.13.1.3 The mount gate #

may_mount() in fs/namespace.c is the gate for mount, umount2, pivot_root, open_tree(OPEN_TREE_CLONE), fsmount, move_mount and mount_setattr, and for fsopen and fspick in fs/fsopen.c. mount_capable() in fs/super.c is the second gate a new filesystem passes, when its superblock is brought into being. Under KACS both take the operation as an argument, with the filesystem type where one is being created, and ask pkm_kacs_may_mount_op() in three rungs, against the caller's effective token and the descriptor of the caller's current namespace:

  1. Privilege. A token holding SeManageVolumePrivilege or SeTcbPrivilege, enabled, is admitted to every operation in every namespace, and the privilege is marked used. This rung is decided first and alone: a token that holds the privilege never reaches the descriptor, and a token that does not is never asked the privilege question, so an unprivileged caller's routine bind mount records no privilege refusal.
  2. No descriptor. In a namespace with no descriptor — the initial one — nothing else admits anything. This is the whole of the rule that the root table keeps: without the privilege, no process changes it.
  3. Descriptor. Otherwise, and only for an operation the descriptor can admit, AccessCheck for KACS_MNTNS_MOUNT runs against the namespace's descriptor under the caller's PIP context, exactly as it does for a socket or a SysV object.

The operations the descriptor can admit are the ones that put no kernel filesystem parser of an untrusted image in reach of the caller:

mount(2) shapeGate operationAdmissible by descriptor
MS_BIND, with or without MS_RECbindyes
umount2 without MNT_FORCEunmountyes
pivot_rootpivot_rootyes
a new tmpfs, proc or stratafs (do_new_mount)new filesystemyes
any other filesystem typenew filesystemno
MS_REMOUNT, including MS_REMOUNT|MS_BINDotherno
MS_MOVE; MS_SHARED, MS_PRIVATE, MS_SLAVE, MS_UNBINDABLEotherno
open_tree(OPEN_TREE_CLONE), fsopen, fspick, fsmount, move_mount, mount_setattrotherno

The filesystem types a descriptor can admit are an allowlist KACS keeps, and it holds exactly tmpfs, proc and stratafs: all three read nothing but the caller's own mount options. tmpfs has no backing image, proc is a view of the kernel's own state, and stratafs is a view of directories the caller can already traverse, every access through it decided on the providing object (§4.6.1). A type that parses an image — ext4, squashfs, iso9660, ntfs3 — is refused before the type is even looked up. The list is an attack-surface list and not a policy knob: who may change a table is decided by the table's descriptor alone, and the same descriptor that admits tmpfs refuses ext4.

A stratafs stack admitted this way is read-only or absent-tolerant. stratafs's own admission (§4.2.3) refuses a create stratum to any caller outside the initial user namespace or without CAP_SYS_ADMIN, which is SeTcbPrivilege, and that test runs after the gate, so an unprivileged caller that asks for one gets EPERM from stratafs whatever its table's descriptor grants. Copy-up carries no add-entry right of its own, which is why a writable stack is authority in itself and stays with the TCB; a private table's writable paths are tmpfs, or bind mounts of directories the caller owns. Admitting the type does put stratafs's option parser and stack validation in reach of unprivileged input for the first time; both fail closed with EINVAL on a relative stratum, a duplicate, create combined with ro, an empty stack or more than sixteen strata, and with ENOENT on a missing stratum not marked am.

Everything in the no rows needs the privilege whatever the descriptor grants, and MNT_FORCE additionally keeps its own CAP_SYS_ADMIN test on the superblock's user namespace. A mount that the gate admits still goes through every check upstream applies afterwards: the bind source is looked up through the caller's own table, an unmount needs a mount point that belongs to it, and pivot_root needs the propagation conditions it always needed, which the slave copy of an unprivileged table satisfies.

3.13.1.3.1 An unprivileged tmpfs is stamped for its creator #

A fresh tmpfs is deny-missing under FACS (§3.9.5): nothing on it carries a descriptor, so nothing on it is reachable until someone sets the superblock's mount policy, which kacs_set_mount_policy reserves to the mount privilege. That is right for the system's own overlays, which are seeded deliberately, and it would leave a tmpfs admitted by a namespace descriptor permanently empty. So at sb_kern_mount, a tmpfs brought into being by a token that holds no mount privilege is stamped synthesize-ephemeral with a template descriptor the kernel mints itself: owner and group from the mounter's token, one allow ACE granting the mounter's user SID GENERIC_ALL — the same shape as the namespace descriptor. The policy generation moves to 1, which retires the deny-missing default the root inode's creation cached during fill_super. The mounter cannot choose the template, and a superblock whose policy kacs_set_mount_policy has already set is left alone. A privileged mounter's tmpfs is untouched: it keeps the magic default and the mounter seeds it, as before. proc is unmanaged and needs no stamp; a second proc mount in the same pid namespace shows the same processes under the same per-process checks.

So a process whose whole world is a directory of its choosing is:

unshare(CLONE_NEWNS);
mount(dir, dir, NULL, MS_BIND, NULL);
chdir(dir);
pivot_root(".", ".");
umount2(".", MNT_DETACH);
chdir("/");

with no user namespace and no privilege. The bind makes the directory a mount point, which pivot_root requires; pivoting onto the same path stacks the old root on the new one, and detaching . drops it.

3.13.1.4 What the object does not change #

The token never changes at exec (§3.10.3), so a program run inside a private table has exactly the authority of the process that built it, and a fake /etc fools nobody but its author. Every access to a file reached through a bind mount is decided on the real object's descriptor, so a bind mount shows a name and denies at open. The kernel's own helpers — modprobe and its kin — are executed in the initial namespace and refused unless TCB-signed (§3.7). A signed binary's PIP tier is derived from the binary and not from the table it was found in, and library verification, where a process enables it, compares signatures at mmap rather than paths.

3.13.1.5 Tracing #

The kacs:kacs_mntns event records a descriptor minted at namespace creation, each rung of the gate, and a tmpfs stamped for its creator, with the operation code, the right asked of the descriptor where a rung reached it, a reason from <pkm/trace.h> — sd-alloc, sd-alloc-fail, gate-privilege, gate-no-sd, gate-op-not-admitted, gate-sd-decision, gate-pip-context, gate-fs-not-admitted, sb-stamp, sb-stamp-fail — and the verdict.

Appendix 3.A KACS ABI Reference

Peios / Peios technical reference / PKM / KACS

Every name, value, offset and size in this appendix is generated from pkm/uapi/pkm/ by pkm/tools/gen-kacs-abi.py, with struct layouts measured by compiling a probe against the real headers. Regenerate it whenever the ABI changes; do not edit it by hand.

The names here are the ones a program actually compiles against. Everything about the ABI a compiler cannot measure -- token query payload shapes, the specification spellings that differ from these names, what is documented elsewhere, and the kernel configuration -- is in the notes appendix, §3.D, which this generator does not touch.

3.A.1 Syscall numbers #

Signatures are read from the SYSCALL_DEFINE sites in pkm/kacs/. The PKM UAPI exports the uppercase constants in <pkm/syscall.h>. Peios glibc 2.44-5 and later exports the standard lowercase SYS_kacs_* aliases in <sys/syscall.h>; each alias has the same number as its PKM constant.

NumberPKM constantglibc aliasSignature
1000SYS_KACS_OPEN_SELF_TOKENSYS_kacs_open_self_tokenkacs_open_self_token(unsigned int flags, u32 access_mask)
1001SYS_KACS_OPEN_PROCESS_TOKENSYS_kacs_open_process_tokenkacs_open_process_token(int pidfd, u32 access_mask)
1002SYS_KACS_OPEN_THREAD_TOKENSYS_kacs_open_thread_tokenkacs_open_thread_token(int pidfd, int tid, u32 access_mask)
1003SYS_KACS_CREATE_TOKENSYS_kacs_create_tokenkacs_create_token(const void __user *spec, size_t spec_len)
1004SYS_KACS_CREATE_LOGON_SESSIONSYS_kacs_create_logon_sessionkacs_create_logon_session(const void __user *spec, size_t spec_len)
1005SYS_KACS_SET_PSBSYS_kacs_set_psbkacs_set_psb(int pidfd, u32 mitigations)
1006SYS_KACS_DESTROY_EMPTY_LOGON_SESSIONSYS_kacs_destroy_empty_logon_sessionkacs_destroy_empty_logon_session(u64 auth_id)
1012SYS_KACS_REVERTSYS_kacs_revertkacs_revert(void)
1020SYS_KACS_OPENSYS_kacs_openkacs_open(int dirfd, const char __user *path, struct kacs_open_how __user *uhow, size_t howsize, u32 __user *status_out)
1021SYS_KACS_GET_SDSYS_kacs_get_sdkacs_get_sd(int dirfd, const char __user *path, u32 security_info, void __user *buf, u32 buf_len, u32 flags)
1022SYS_KACS_SET_SDSYS_kacs_set_sdkacs_set_sd(int dirfd, const char __user *path, u32 security_info, const void __user *sd_buf, u32 sd_len, u32 flags)
1023SYS_KACS_ACCESS_CHECKSYS_kacs_access_checkkacs_access_check(const void __user *uargs)
1024SYS_KACS_ACCESS_CHECK_LISTSYS_kacs_access_check_listkacs_access_check_list(const void __user *uargs, struct kacs_node_result __user *results, u32 results_count)
1025SYS_KACS_SET_CAAPSYS_kacs_set_caapkacs_set_caap(const void __user *policy_sid, u32 policy_sid_len, const void __user *spec, u32 spec_len)
1026SYS_KACS_GET_MOUNT_POLICYSYS_kacs_get_mount_policykacs_get_mount_policy(int fd, struct kacs_mount_policy_args __user *uargs, size_t argsize)
1027SYS_KACS_SET_MOUNT_POLICYSYS_kacs_set_mount_policykacs_set_mount_policy(int fd, struct kacs_mount_policy_args __user *uargs, size_t argsize)

uapi/pkm/syscall.h also registers the KMES and LCS numbers, 1090–1102, documented in their own chapters.

3.A.2 Structure layouts #

3.A.2.1 struct kacs_query_args #

Total size 16 bytes.

OffsetSizeTypeField
04__u32token_class
44__u32buf_len
88__u64buf_ptr

3.A.2.2 struct kacs_adjust_privs_args #

Total size 24 bytes.

OffsetSizeTypeField
04__u32count
44__u32_pad
88__u64data_ptr
168__u64previous_enabled

3.A.2.3 struct kacs_priv_entry #

Total size 8 bytes.

OffsetSizeTypeField
04__u32luid
44__u32attributes

3.A.2.4 struct kacs_adjust_groups_args #

Total size 144 bytes.

OffsetSizeTypeField
04__u32count
44__u32_pad
88__u64data_ptr
16128__u64``[16]previous_state

3.A.2.5 struct kacs_duplicate_args #

Total size 16 bytes.

OffsetSizeTypeField
04__u32access_mask
44__u32token_type
84__u32impersonation_level
124__s32result_fd

3.A.2.6 struct kacs_group_entry #

Total size 8 bytes.

OffsetSizeTypeField
04__u32index
44__u32enable

3.A.2.7 struct kacs_adjust_default_args #

Total size 16 bytes.

OffsetSizeTypeField
08__u64dacl_ptr
84__u32dacl_len
122__u16owner_index
142__u16group_index

3.A.2.8 struct kacs_restrict_args #

Total size 40 bytes.

OffsetSizeTypeField
08__u64privs_to_delete
84__u32num_deny_indices
124__u32num_restrict_sids
164__u32data_len
204__u32flags
248__u64data_ptr
324__s32result_fd
364__u32_pad

3.A.2.9 struct kacs_link_tokens_args #

Total size 16 bytes.

OffsetSizeTypeField
04__s32elevated_fd
44__s32filtered_fd
88__u64logon_session_id

3.A.2.10 struct kacs_get_linked_token_args #

Total size 4 bytes.

OffsetSizeTypeField
04__s32result_fd

3.A.2.11 struct kacs_access_check_args #

Total size 136 bytes.

OffsetSizeTypeField
04__u32caller_size
44__s32token_fd
88__u64sd_ptr
164__u32sd_len
204__u32desired_access
244__u32mapping_read
284__u32mapping_write
324__u32mapping_execute
364__u32mapping_all
408__u64self_sid_ptr
484__u32self_sid_len
524__u32privilege_intent
568__u64object_tree_ptr
644__u32object_tree_count
684__u32_pad0
728__u64local_claims_ptr
804__u32local_claims_len
844__u32_pad1
888__u64granted_out_ptr
964__u32pip_type
1004__u32pip_trust
1048__u64audit_context_ptr
1124__u32audit_context_len
1164__u32_pad2
1208__u64continuous_audit_out_ptr
1288__u64staging_mismatch_out_ptr

3.A.2.12 struct kacs_object_type_entry #

Total size 20 bytes.

OffsetSizeTypeField
02__u16level
22__u16_reserved
416__u8``[16]guid

3.A.2.13 struct kacs_node_result #

Total size 8 bytes.

OffsetSizeTypeField
04__u32granted
44__s32status

3.A.2.14 struct kacs_open_how #

Total size 32 bytes.

OffsetSizeTypeField
04__u32desired_access
44__u32create_disposition
84__u32create_options
124__u32flags
168__u64sd_ptr
244__u32sd_len
284__u32__pad

3.A.2.15 struct kacs_mount_policy_args #

Total size 32 bytes.

OffsetSizeTypeField
04__u32policy
44__u32flags
84__u32generation
124__u32__pad0
168__u64template_sd_ptr
244__u32template_sd_len
284__u32__pad1

3.A.2.16 struct kacs_generic_mapping #

Total size 16 bytes.

OffsetSizeTypeField
04__u32read
44__u32write
84__u32execute
124__u32all

3.A.3 Token constants #

From uapi/pkm/token.h.

kacs_open_self_token (SYS_KACS_OPEN_SELF_TOKEN) flags.

ConstantValue
KACS_TOKEN_OPEN_REAL0x01 (1)

Per-handle token rights (the low 16 bits of a token access mask).

ConstantValue
KACS_TOKEN_ASSIGN_PRIMARY0x0001 (1)
KACS_TOKEN_DUPLICATE0x0002 (2)
KACS_TOKEN_IMPERSONATE0x0004 (4)
KACS_TOKEN_QUERY0x0008 (8)
KACS_TOKEN_QUERY_SOURCE0x0010 (16)
KACS_TOKEN_ADJUST_PRIVS0x0020 (32)
KACS_TOKEN_ADJUST_GROUPS0x0040 (64)
KACS_TOKEN_ADJUST_DEFAULT0x0080 (128)
KACS_TOKEN_ADJUST_INTERACTIVITY_SCOPE0x0100 (256)
KACS_TOKEN_ALL_ACCESS0x000F01FF

Token ioctl interface identifier.

ConstantValue
KACS_IOC_MAGIC0x4B (75)

kacs_priv_entry.attributes bits.

ConstantValue
KACS_PRIVILEGE_ATTR_ENABLED0x00000002 (2)
KACS_PRIVILEGE_ATTR_REMOVED0x00000004 (4)

kacs_adjust_privs bulk-reset flag (not a per-entry attribute).

ConstantValue
KACS_PRIVILEGE_RESET_ALL_DEFAULTS0x80000000

kacs_restrict_args.flags bits.

ConstantValue
KACS_TOKEN_RESTRICT_WRITE_RESTRICTED0x00000001 (1)

Token type (KACS_TOKEN_CLASS_TYPE).

ConstantValue
KACS_TOKEN_TYPE_PRIMARY0x01 (1)
KACS_TOKEN_TYPE_IMPERSONATION0x02 (2)

Impersonation level (KACS_TOKEN_CLASS_IMPERSONATION_LEVEL).

ConstantValue
KACS_IMLEVEL_ANONYMOUS0x00 (0)
KACS_IMLEVEL_IDENTIFICATION0x01 (1)
KACS_IMLEVEL_IMPERSONATION0x02 (2)
KACS_IMLEVEL_DELEGATION0x03 (3)

Elevation type (KACS_TOKEN_CLASS_ELEVATION_TYPE).

ConstantValue
KACS_ELEVATION_DEFAULT0x01 (1)
KACS_ELEVATION_FULL0x02 (2)
KACS_ELEVATION_LIMITED0x03 (3)

Mandatory-policy bits (KACS_TOKEN_CLASS_MANDATORY_POLICY).

ConstantValue
KACS_TOKEN_MANDATORY_POLICY_NO_WRITE_UP0x00000001 (1)
KACS_TOKEN_MANDATORY_POLICY_NEW_PROCESS_MIN0x00000002 (2)

Per-token audit-policy bits — the create-token spec audit_policy field (KACS_TOKEN_SPEC_OFF_AUDIT_POLICY). They select which access-check outcomes the token's object accesses generate audit events for.

ConstantValue
KACS_AUDIT_POLICY_OBJECT_ACCESS_SUCCESS0x00000001 (1)
KACS_AUDIT_POLICY_OBJECT_ACCESS_FAILURE0x00000002 (2)
KACS_AUDIT_POLICY_PRIVILEGE_USE_SUCCESS0x00000004 (4)
KACS_AUDIT_POLICY_PRIVILEGE_USE_FAILURE0x00000008 (8)

Logon type (KACS_TOKEN_CLASS_LOGON_TYPE).

ConstantValue
KACS_LOGON_TYPE_INTERACTIVE2
KACS_LOGON_TYPE_NETWORK3
KACS_LOGON_TYPE_BATCH4
KACS_LOGON_TYPE_SERVICE5
KACS_LOGON_TYPE_NETWORK_CLEARTEXT8
KACS_LOGON_TYPE_NEW_CREDENTIALS9
KACS_LOGON_TYPE_REMOTE_INTERACTIVE10

Maximum number of groups a token may carry.

ConstantValue
KACS_TOKEN_MAX_GROUPS1024

Number of 64-bit words in a group enabled-state bitmask (KACS_TOKEN_MAX_GROUPS / 64).

ConstantValue
KACS_TOKEN_GROUP_MASK_WORDS16

kacs_create_token (SYS_KACS_CREATE_TOKEN) spec wire format.

The (spec, len) buffer the syscall consumes is a fixed KACS_TOKEN_SPEC_HEADER_BYTES-byte header followed by variable-length sections at header-specified byte offsets. An offset/length (or offset/count) pair that is both zero means the section is absent. Sections may appear in any order; every offset+length is validated to fall within the buffer. The header is not a C struct (it carries packed mixed-width fields and crosses the syscall boundary as raw bytes); read each field at its KACS_TOKEN_SPEC_OFF_* offset. Its fields, in order: __u32 version must be KACS_TOKEN_SPEC_VERSION _u8 token_type KACS_TOKEN_TYPE _u8 impersonation_level KACS_IMLEVEL __u8 _reserved0[2] must be 0 __u32 integrity_rid integrity-level RID _u32 mandatory_policy KACS_TOKEN_MANDATORY_POLICY* bits _u64 privs_present privilege bitmask (KACS_SE*_PRIVILEGE) __u64 privs_enabled initially enabled privileges (subset) __u32 _reserved1 must be 0 (elevation set only by LINK_TOKENS) __u32 projected_uid Linux UID for credential projection __u32 projected_gid Linux GID for credential projection __u32 audit_policy per-token audit flags __u64 expiration expiry timestamp (0 = none) __u64 logon_session_id logon session ID (auth_id) __u32 owner_sid_index 0 = user SID, 1..N = caller group __u32 primary_group_index 0 = user SID, 1..N = caller group __u8 source_name[8] token source name __u64 source_id token source LUID __u32 user_sid_offset byte offset to the user SID __u32 groups_offset byte offset to the groups array __u32 groups_count number of group entries __u32 default_dacl_offset byte offset to the default DACL (0 = none) __u32 default_dacl_len default DACL byte length (0 = none) __u32 user_claims_offset byte offset to user claims (0 = none) __u32 user_claims_len user claims byte length (0 = none) __u32 device_claims_offset byte offset to device claims (0 = none) __u32 device_claims_len device claims byte length (0 = none) __u32 device_groups_offset byte offset to device groups (0 = none) __u32 device_groups_count number of device-group entries (0 = none) __u32 restricted_sids_offset byte offset to restricted SIDs (0 = none) __u32 restricted_sids_count number of restricted-SID entries (0 = none) __u32 confinement_sid_offset byte offset to confinement SID (0 = none) __u32 confinement_sid_len confinement SID byte length (0 = none) __u32 confinement_caps_offset byte offset to confinement caps (0 = none) __u32 confinement_caps_count number of confinement-cap entries (0 = none) __u8 confinement_exempt 1 = exempt from confinement __u8 write_restricted 1 = write-restricted mode __u8 user_deny_only 1 = user SID matches deny ACEs only __u8 isolation_boundary 1 = enable namespace filtering __u32 supp_gids_offset byte offset to supplementary GIDs (0 = none) __u32 supp_gids_count number of supplementary-GID entries (0 = none) __u32 restricted_device_groups_offset byte offset (0 = none) __u32 restricted_device_groups_count entry count (0 = none) __u64 origin originating logon-session LUID (0 = none) __u32 interactivity_scope interactive session number __u32 lcs_credentials_offset byte offset to the LCS extension (0 = none) A group/device-group/restricted- SID/confinement-cap/restricted-device-group entry is [__u32 sid_len][__u8 sid[sid_len]][__u32 attributes]. A supplementary-GIDs section is supp_gids_count little-endian __u32 GIDs. All multi-byte header and section scalars are little-endian.

ConstantValue
KACS_TOKEN_SPEC_VERSION2
KACS_TOKEN_SPEC_HEADER_BYTES192
KACS_TOKEN_SPEC_MIN_BYTES192
KACS_TOKEN_SPEC_MAX_BYTES65536

Byte offsets of the fixed token-spec header fields.

ConstantValue
KACS_TOKEN_SPEC_OFF_VERSION0
KACS_TOKEN_SPEC_OFF_TOKEN_TYPE4
KACS_TOKEN_SPEC_OFF_IMPERSONATION_LEVEL5
KACS_TOKEN_SPEC_OFF_RESERVED06
KACS_TOKEN_SPEC_OFF_INTEGRITY_RID8
KACS_TOKEN_SPEC_OFF_MANDATORY_POLICY12
KACS_TOKEN_SPEC_OFF_PRIVS_PRESENT16
KACS_TOKEN_SPEC_OFF_PRIVS_ENABLED24
KACS_TOKEN_SPEC_OFF_RESERVED132
KACS_TOKEN_SPEC_OFF_PROJECTED_UID36
KACS_TOKEN_SPEC_OFF_PROJECTED_GID40
KACS_TOKEN_SPEC_OFF_AUDIT_POLICY44
KACS_TOKEN_SPEC_OFF_EXPIRATION48
KACS_TOKEN_SPEC_OFF_LOGON_SESSION_ID56
KACS_TOKEN_SPEC_OFF_OWNER_SID_INDEX64
KACS_TOKEN_SPEC_OFF_PRIMARY_GROUP_INDEX68
KACS_TOKEN_SPEC_OFF_SOURCE_NAME72
KACS_TOKEN_SPEC_OFF_SOURCE_ID80
KACS_TOKEN_SPEC_OFF_USER_SID_OFFSET88
KACS_TOKEN_SPEC_OFF_GROUPS_OFFSET92
KACS_TOKEN_SPEC_OFF_GROUPS_COUNT96
KACS_TOKEN_SPEC_OFF_DEFAULT_DACL_OFFSET100
KACS_TOKEN_SPEC_OFF_DEFAULT_DACL_LEN104
KACS_TOKEN_SPEC_OFF_USER_CLAIMS_OFFSET108
KACS_TOKEN_SPEC_OFF_USER_CLAIMS_LEN112
KACS_TOKEN_SPEC_OFF_DEVICE_CLAIMS_OFFSET116
KACS_TOKEN_SPEC_OFF_DEVICE_CLAIMS_LEN120
KACS_TOKEN_SPEC_OFF_DEVICE_GROUPS_OFFSET124
KACS_TOKEN_SPEC_OFF_DEVICE_GROUPS_COUNT128
KACS_TOKEN_SPEC_OFF_RESTRICTED_SIDS_OFFSET132
KACS_TOKEN_SPEC_OFF_RESTRICTED_SIDS_COUNT136
KACS_TOKEN_SPEC_OFF_CONFINEMENT_SID_OFFSET140
KACS_TOKEN_SPEC_OFF_CONFINEMENT_SID_LEN144
KACS_TOKEN_SPEC_OFF_CONFINEMENT_CAPS_OFFSET148
KACS_TOKEN_SPEC_OFF_CONFINEMENT_CAPS_COUNT152
KACS_TOKEN_SPEC_OFF_CONFINEMENT_EXEMPT156
KACS_TOKEN_SPEC_OFF_WRITE_RESTRICTED157
KACS_TOKEN_SPEC_OFF_USER_DENY_ONLY158
KACS_TOKEN_SPEC_OFF_ISOLATION_BOUNDARY159
KACS_TOKEN_SPEC_OFF_SUPP_GIDS_OFFSET160
KACS_TOKEN_SPEC_OFF_SUPP_GIDS_COUNT164
KACS_TOKEN_SPEC_OFF_RESTRICTED_DEVICE_GROUPS_OFFSET168
KACS_TOKEN_SPEC_OFF_RESTRICTED_DEVICE_GROUPS_COUNT172
KACS_TOKEN_SPEC_OFF_ORIGIN176
KACS_TOKEN_SPEC_OFF_INTERACTIVITY_SCOPE184
KACS_TOKEN_SPEC_OFF_LCS_CREDENTIALS_OFFSET188

Byte length of the fixed token-source-name field.

ConstantValue
KACS_TOKEN_SPEC_SOURCE_NAME_BYTES8

Optional LCS registry-credentials extension, located at the token-spec header's lcs_credentials_offset. The section is a fixed KACS_TOKEN_LCS_EXT_HEADER_BYTES-byte header bounded by the next active variable-section offset or the end of the spec; it is consumed exactly (trailing bytes are malformed). Header fields, in order: __u32 version must be KACS_TOKEN_LCS_EXT_VERSION __u32 _reserved must be 0 __u32 scope_count private hive scope GUIDs (<= max) __u32 private_layer_count private layer names (<= max) Payload: scope_count raw 16-byte GUIDs, then private_layer_count little-endian __u32 name byte lengths, then the concatenated UTF-8 layer names. Scope GUIDs must be non-nil and unique; layer names must be 1.. KACS_TOKEN_LCS_MAX_LAYER_NAME_BYTES bytes, must not contain '\', '/', or NUL, and must be unique under case-insensitive matching.

ConstantValue
KACS_TOKEN_LCS_EXT_VERSION1
KACS_TOKEN_LCS_EXT_HEADER_BYTES16
KACS_TOKEN_LCS_SCOPE_GUID_BYTES16
KACS_TOKEN_LCS_MAX_SCOPE_GUIDS256
KACS_TOKEN_LCS_MAX_PRIVATE_LAYERS256
KACS_TOKEN_LCS_MAX_LAYER_NAME_BYTES255

Byte offsets of the fixed LCS-extension header fields.

ConstantValue
KACS_TOKEN_LCS_EXT_OFF_VERSION0
KACS_TOKEN_LCS_EXT_OFF_RESERVED4
KACS_TOKEN_LCS_EXT_OFF_SCOPE_COUNT8
KACS_TOKEN_LCS_EXT_OFF_PRIVATE_LAYER_COUNT12

kacs_create_logon_session (SYS_KACS_CREATE_LOGON_SESSION) spec wire format.

The (spec, len) buffer the syscall consumes is, in order: _u8 logon_type one of KACS_LOGON_TYPE* above __le16 auth_pkg_len byte length of the auth-package name __u8 auth_pkg[auth_pkg_len] auth-package name (valid UTF-8) __le32 user_sid_len byte length of the user SID __u8 user_sid[user_sid_len] binary SID of the authenticated user The buffer is consumed exactly: 7 + auth_pkg_len + user_sid_len must equal len. The kernel assigns the session ID and derives the logon SID from it.

ConstantValue
KACS_LOGON_SESSION_SPEC_MIN_BYTES15
KACS_LOGON_SESSION_SPEC_MAX_BYTES4096

Byte offsets of the fixed-position session-spec fields.

ConstantValue
KACS_LOGON_SESSION_SPEC_OFF_LOGON_TYPE0
KACS_LOGON_SESSION_SPEC_OFF_AUTH_PKG_LEN1
KACS_LOGON_SESSION_SPEC_OFF_AUTH_PKG3

Token-handle ioctls.

ConstantValue
KACS_IOC_QUERY0xC0104B00
KACS_IOC_ADJUST_PRIVS0x40184B01
KACS_IOC_DUPLICATE0xC0104B02
KACS_IOC_INSTALL0x00004B03
KACS_IOC_RESTRICT0xC0284B04
KACS_IOC_LINK_TOKENS0x40104B05
KACS_IOC_GET_LINKED_TOKEN0xC0044B06
KACS_IOC_ADJUST_GROUPS0x40904B07
KACS_IOC_IMPERSONATE0x00004B08
KACS_IOC_ADJUST_DEFAULT0x40104B09
KACS_IOC_ADJUST_INTERACTIVITY_SCOPE0x40044B0A

Token information classes (kacs_query_args.token_class).

GUID returns 16 immutable raw bytes in the same order as KMES headers.

ConstantValue
KACS_TOKEN_CLASS_USER0x01 (1)
KACS_TOKEN_CLASS_GROUPS0x02 (2)
KACS_TOKEN_CLASS_PRIVILEGES0x03 (3)
KACS_TOKEN_CLASS_TYPE0x04 (4)
KACS_TOKEN_CLASS_INTEGRITY_LEVEL0x05 (5)
KACS_TOKEN_CLASS_OWNER0x06 (6)
KACS_TOKEN_CLASS_PRIMARY_GROUP0x07 (7)
KACS_TOKEN_CLASS_INTERACTIVITY_SCOPE0x08 (8)
KACS_TOKEN_CLASS_RESTRICTED_SIDS0x09 (9)
KACS_TOKEN_CLASS_SOURCE0x0A (10)
KACS_TOKEN_CLASS_STATISTICS0x0B (11)
KACS_TOKEN_CLASS_ORIGIN0x0C (12)
KACS_TOKEN_CLASS_ELEVATION_TYPE0x0D (13)
KACS_TOKEN_CLASS_DEVICE_GROUPS0x0E (14)
KACS_TOKEN_CLASS_APPCONTAINER_SID0x0F (15)
KACS_TOKEN_CLASS_CAPABILITIES0x10 (16)
KACS_TOKEN_CLASS_MANDATORY_POLICY0x11 (17)
KACS_TOKEN_CLASS_LOGON_TYPE0x12 (18)
KACS_TOKEN_CLASS_LOGON_SID0x13 (19)
KACS_TOKEN_CLASS_DEFAULT_DACL0x14 (20)
KACS_TOKEN_CLASS_IMPERSONATION_LEVEL0x15 (21)
KACS_TOKEN_CLASS_USER_CLAIMS0x16 (22)
KACS_TOKEN_CLASS_DEVICE_CLAIMS0x17 (23)
KACS_TOKEN_CLASS_PROJECTED_SUPPLEMENTARY_GIDS0x18 (24)
KACS_TOKEN_CLASS_GUID0x19 (25)

Privileges, as single-bit masks within a token's 64-bit privilege word (present / enabled / enabled-by-default / used are each one such word). Named for the Windows privilege identifiers (SeTcbPrivilege, …).

ConstantValue
KACS_SE_CREATE_TOKEN_PRIVILEGE4
KACS_SE_ASSIGN_PRIMARY_TOKEN_PRIVILEGE8
KACS_SE_LOCK_MEMORY_PRIVILEGE16
KACS_SE_INCREASE_QUOTA_PRIVILEGE32
KACS_SE_TCB_PRIVILEGE128
KACS_SE_SECURITY_PRIVILEGE256
KACS_SE_TAKE_OWNERSHIP_PRIVILEGE512
KACS_SE_LOAD_DRIVER_PRIVILEGE1024
KACS_SE_SYSTEM_PROFILE_PRIVILEGE2048
KACS_SE_SYSTEMTIME_PRIVILEGE4096
KACS_SE_PROFILE_SINGLE_PROCESS_PRIVILEGE8192
KACS_SE_INCREASE_BASE_PRIORITY_PRIVILEGE16384
KACS_SE_BACKUP_PRIVILEGE131072
KACS_SE_RESTORE_PRIVILEGE262144
KACS_SE_SHUTDOWN_PRIVILEGE524288
KACS_SE_DEBUG_PRIVILEGE0x100000 (1048576)
KACS_SE_AUDIT_PRIVILEGE0x200000 (2097152)
KACS_SE_CHANGE_NOTIFY_PRIVILEGE0x800000 (8388608)
KACS_SE_REMOTE_SHUTDOWN_PRIVILEGE0x1000000 (16777216)
KACS_SE_MANAGE_VOLUME_PRIVILEGE0x10000000 (268435456)
KACS_SE_IMPERSONATE_PRIVILEGE0x20000000 (536870912)
KACS_SE_RELABEL_PRIVILEGE0x100000000 (4294967296)
KACS_SE_CREATE_SYMBOLIC_LINK_PRIVILEGE0x800000000 (34359738368)

3.A.4 socket.h #

From uapi/pkm/socket.h.

KACS socket options — peer identity on local sockets.

KACS captures a connecting client's identity onto the accepted end of an AF_UNIX SOCK_STREAM / SOCK_SEQPACKET connection at connect(). This header is the setsockopt(2)/getsockopt(2) surface through which that identity is bounded and read back. Every option lives under one option level, SOL_KACS, which the kernel dispatches ahead of the protocol's own setsockopt/getsockopt (net/socket.c), so the options behave identically on every socket family that supports them. SOL_KACS is 4096: far above the upstream SOL_* range, which grows by one per new protocol, so it cannot collide with a future Linux level. Errors: -ENOPROTOOPT for an option this level does not define (or a get-only option passed to setsockopt); -EOPNOTSUPP on a socket family KACS does not carry identity on (and, for the register, on a socket type that has none); -EINVAL for a short optlen or an out-of-range value; -EFAULT for an unreadable/unwritable optval.

ConstantValue
SOL_KACS4096

getsockopt only.

optval: int — a new token fd, carrying fixed TOKEN_QUERY | TOKEN_IMPERSONATE | TOKEN_DUPLICATE access and opened O_CLOEXEC, for this end's conveyed-identity register: the peer identity associated with the data this end has consumed so far. The register is initialised at connect() — on the accepted end with the client's identity, on the connecting end with the listener's identity as captured at listen() (at KACS_IMLEVEL_IDENTIFICATION unless the listener set its own level) — and advanced by each KACS_SCM_TOKEN the reader's position passes (a token still queued but unread is not yet visible). Every call returns a fresh fd to an immutable snapshot; two calls may name different tokens, but no token ever changes. -ENOTCONN if the socket is not connected; -ENODATA if nothing has been conveyed yet.

ConstantValue
KACS_SO_PEER_TOKEN1

getsockopt / setsockopt.

optval: _u32 KACS_IMLEVEL* — the maximum impersonation level at which identity leaving this end may be captured: at connect(), and at each send that conveys identity. The default is KACS_IMLEVEL_IMPERSONATION. May be changed at any time; a change bounds captures from then on and never rewrites one already made.

ConstantValue
KACS_SO_IMPERSONATION_LEVEL2

getsockopt / setsockopt.

optval: int (0/1) — sender-side automation: while set, every send from this end carries the sender's effective identity as a KACS_SCM_TOKEN, derived at this end's impersonation level. Costs nothing at the trust level: the sender can always attest to what it is. Default 0.

ConstantValue
KACS_SO_PASS_TOKEN3

setsockopt only.

optval: int, ignored. Self-gated: a process can always attest to what it is. On a listening AF_UNIX socket: replaces the identity the listener conveys to connecting clients — captured when listen() was called — with the caller's current effective identity, at the listener's level. For a process that receives a listener it did not create (from a descriptor store after a restart, or from a broker), so that clients see the process actually accepting. -EINVAL if the socket is not listening. On an AF_INET / AF_INET6 socket, in any state: replaces the identity that governs the socket's traffic for network policy — stamped at creation, bind, listen, connect and accept — with the caller's current effective identity, so a socket handed to another program (socket activation, descriptor passing) is governed as that program's. The identity is read by the network policy engine at the first judgment of each flow and fixed for that flow's life.

ConstantValue
KACS_SO_RESTAMP4

Ancillary message type, at cmsg_level SOL_KACS.

Data: one int. Received: a token fd (TOKEN_QUERY | TOKEN_IMPERSONATE | TOKEN_DUPLICATE, O_CLOEXEC) for the identity the kernel attests sent the accompanying data. Delivered when the receive buffer has room for it and the conveyed identity differs from the reader's register; a receiver that reads no ancillary data still has the register (KACS_SO_PEER_TOKEN). Sent: a token fd the sender wishes to attach to the data. The kernel gates the send as if the sender were impersonating that token: the two-gate model runs with the sender as installer, and an attach that would lower the token's level fails with -EPERM rather than downgrading. A primary token is derived to an impersonation token at this end's level. The fd must carry TOKEN_IMPERSONATE (-EACCES otherwise); at most one per message (-EINVAL). Attaching is exactly equivalent to impersonating the token for the duration of the send.

ConstantValue
KACS_SCM_TOKEN1

3.A.5 ipc.h #

From uapi/pkm/ipc.h.

KACS access rights for System V IPC objects — message queues, shared memory segments and semaphore arrays. Every SysV object carries a security descriptor, created from the creator's token at *get time and enforced through the LSM IPC hooks; the nine-bit ipc_perm mode is never consulted (CAP_IPC_OWNER is in KACS's always-allow set). The key namespace is claim-on-create: the descriptor protects the object, nothing protects the name. Object-specific rights (low 16 bits); the standard rights (DELETE, READ_CONTROL, WRITE_DAC, WRITE_OWNER — <pkm/access.h>) apply as on every KACS object.

ConstantValueNotes
KACS_IPC_READ0x00000001 (1)msgrcv, shmat read-only, semop without alter, GETVAL/GETALL
KACS_IPC_WRITE0x00000002 (2)msgsnd, shmat read-write, semop with alter, SETVAL/SETALL
KACS_IPC_QUERY_INFORMATION0x00000004 (4)IPC_STAT and the *_STAT variants
KACS_IPC_SET_INFORMATION0x00000008 (8)SHM_LOCK / SHM_UNLOCK
KACS_IPC_ALL_ACCESS983055

ipcctl gates: IPC_RMID needs DELETE; IPC_SET needs WRITE_DAC and WRITE_OWNER (one command carries mode, uid and gid); the mode, uid and gid it writes are informational (ipcs, IPC_STAT) and never decide access. Addressing a SysV object's descriptor with kacs_get_sd / kacs_set_sd: pass one of these in flags, the object id in dirfd, and a NULL path. The object is looked up in the caller's IPC namespace.

ConstantValue
KACS_SD_AT_SYSV_SHM0x01000000
KACS_SD_AT_SYSV_MSG0x02000000
KACS_SD_AT_SYSV_SEM0x04000000
KACS_SD_AT_SYSV_MASK117440512

3.A.6 mntns.h #

From uapi/pkm/mntns.h.

KACS access rights for mount namespaces.

ConstantValueNotes
KACS_MNTNS_MOUNT0x00000001 (1)change the mount table
KACS_MNTNS_ENTER0x00000002 (2)join the namespace with setns(2)
KACS_MNTNS_ALL_ACCESS917507

3.A.7 AccessCheck constants #

From uapi/pkm/access.h.

Full size of kacs_access_check_args the current kernel copies.

ConstantValue
KACS_ACCESS_CHECK_ARGS_SIZE136

Minimum caller_size the kernel accepts (the v1 / v0.20 layout).

ConstantValue
KACS_ACCESS_CHECK_ARGS_V1_SIZE40

Byte size of one kacs_object_type_entry in the object-type tree array.

ConstantValue
KACS_OBJECT_TYPE_ENTRY_SIZE20

Largest object-audit-context buffer the kernel accepts.

ConstantValue
KACS_ACCESS_CHECK_MAX_AUDIT_CONTEXT_LEN4096

Largest @Local claims blob the kernel accepts (local_claims_ptr).

ConstantValue
KACS_ACCESS_CHECK_MAX_LOCAL_CLAIMS_LEN65536

Largest object-type tree entry count the kernel accepts.

ConstantValue
KACS_ACCESS_CHECK_MAX_OBJECT_TYPE_COUNT1024

Claim value types — the discriminant of one attribute in the @Local claims array (local_claims_ptr).

ConstantValue
KACS_CLAIM_TYPE_INT640x0001 (1)
KACS_CLAIM_TYPE_UINT640x0002 (2)
KACS_CLAIM_TYPE_STRING0x0003 (3)
KACS_CLAIM_TYPE_SID0x0005 (5)
KACS_CLAIM_TYPE_BOOLEAN0x0006 (6)
KACS_CLAIM_TYPE_OCTET0x0010 (16)

Claim attribute flags.

ConstantValue
KACS_CLAIM_ATTR_CASE_SENSITIVE0x0002 (2)
KACS_CLAIM_ATTR_USE_FOR_DENY_ONLY0x0004 (4)
KACS_CLAIM_ATTR_DISABLED0x0010 (16)

Central Access Policy (CAAP) spec wire format — the (spec, spec_len) buffer kacs_set_caap (SYS_KACS_SET_CAAP) consumes. A non-empty spec replaces the policy identified by the call's policy SID; a NULL/zero spec removes it. The buffer is a fixed prefix followed by rule_count per-rule sections, and is consumed exactly (trailing bytes are rejected). It is not a C struct (the rule sections are variable-length); read it as raw bytes: __u8 version must be KACS_CAAP_SPEC_VERSION __le32 rule_count number of rules that follow (<= max) rule_count * { __le32 applies_to_len [__u8 applies_to[applies_to_len]] conditional-expression bytecode; 0 = always __le32 effective_dacl_len [__u8 effective_dacl[...]] binary ACL; length MUST be nonzero __le32 effective_sacl_len [__u8 effective_sacl[...]] (0 = none) __le32 staged_dacl_len [__u8 staged_dacl[...]] (0 = none) __le32 staged_sacl_len [__u8 staged_sacl[...]] (0 = none) } Every length- prefixed field uses a little-endian __u32 length and is bounded by KACS_CAAP_MAX_FIELD_BYTES; ACL payloads additionally parse under the security-descriptor size limit (KACS_CAAP_MAX_ACL_BYTES). ACLs use the binary ACL format from <pkm/sd.h>; applies_to is conditional-ACE bytecode.

ConstantValue
KACS_CAAP_SPEC_VERSION0x01 (1)
KACS_CAAP_MAX_SPEC_BYTES262144
KACS_CAAP_MAX_RULE_COUNT256
KACS_CAAP_MAX_FIELD_BYTES65536
KACS_CAAP_MAX_ACL_BYTES65535

Byte offsets of the fixed CAAP-spec prefix fields.

ConstantValue
KACS_CAAP_SPEC_OFF_VERSION0
KACS_CAAP_SPEC_OFF_RULE_COUNT1

Byte length of the fixed CAAP-spec prefix (version + rule_count).

ConstantValue
KACS_CAAP_SPEC_PREFIX_BYTES5

3.A.8 File and open constants #

From uapi/pkm/file.h.

Minimum caller-supplied size accepted for each argument block.

ConstantValue
KACS_OPEN_HOW_MIN_SIZE16
KACS_MOUNT_POLICY_ARGS_MIN_SIZE16

Create dispositions (kacs_open_how.create_disposition).

ConstantValue
KACS_DISPOSITION_SUPERSEDE0
KACS_DISPOSITION_OPEN1
KACS_DISPOSITION_CREATE2
KACS_DISPOSITION_OPEN_IF3
KACS_DISPOSITION_OVERWRITE4
KACS_DISPOSITION_OVERWRITE_IF5

Create options (kacs_open_how.create_options).

ConstantValue
KACS_CREATE_OPT_DIRECTORY0x0001 (1)
KACS_CREATE_OPT_DELETE_ON_CLOSE0x0002 (2)

kacs_open_how.flags bits.

ConstantValue
KACS_BACKUP_INTENT0x00000001 (1)
KACS_RESTORE_INTENT0x00000002 (2)

File and directory object-specific access rights (the low 16 bits of a file access mask).

The directory aliases name the same bit as the file right it acts as for a directory object.

ConstantValue
KACS_FILE_READ_DATA0x00000001 (1)
KACS_FILE_WRITE_DATA0x00000002 (2)
KACS_FILE_APPEND_DATA0x00000004 (4)
KACS_FILE_READ_EA0x00000008 (8)
KACS_FILE_WRITE_EA0x00000010 (16)
KACS_FILE_EXECUTE0x00000020 (32)
KACS_FILE_DELETE_CHILD0x00000040 (64)
KACS_FILE_READ_ATTRIBUTES0x00000080 (128)
KACS_FILE_WRITE_ATTRIBUTES0x00000100 (256)
KACS_FILE_LIST_DIRECTORY1
KACS_FILE_TRAVERSE32
KACS_FILE_ADD_FILE2
KACS_FILE_ADD_SUBDIRECTORY4

Mount-policy values (kacs_mount_policy_args.policy).

ConstantValue
KACS_MOUNT_POLICY_UNMANAGED1
KACS_MOUNT_POLICY_DENY_MISSING2
KACS_MOUNT_POLICY_SYNTHESIZE_EPHEMERAL3
KACS_MOUNT_POLICY_SYNTHESIZE_PERSISTENT4

Status word kacs_open writes back, describing what happened to the file.

ConstantValue
KACS_STATUS_OPENED1
KACS_STATUS_CREATED2
KACS_STATUS_OVERWRITTEN3
KACS_STATUS_SUPERSEDED4

3.A.9 Process access rights #

From uapi/pkm/process.h.

KACS process object-specific access rights (the low 16 bits of a process access mask).

Named for the Windows process rights; an access check folds the generic bits (<pkm/sd.h>) into these via the process generic mapping.

ConstantValue
KACS_PROCESS_TERMINATE0x00000001 (1)
KACS_PROCESS_SIGNAL0x00000002 (2)
KACS_PROCESS_VM_READ0x00000010 (16)
KACS_PROCESS_VM_WRITE0x00000020 (32)
KACS_PROCESS_DUP_HANDLE0x00000040 (64)
KACS_PROCESS_SET_INFORMATION0x00000200 (512)
KACS_PROCESS_QUERY_INFORMATION0x00000400 (1024)
KACS_PROCESS_SUSPEND_RESUME0x00000800 (2048)
KACS_PROCESS_QUERY_LIMITED0x00001000 (4096)

3.A.10 Process mitigation bits #

From uapi/pkm/psb.h.

Process Security Block (PSB) process-mitigation bits.

The mitigations argument of kacs_set_psb (SYS_KACS_SET_PSB) is a bitmask of these flags. Setting a bit is activation-backed: KACS either places the target process in the protected state (or verifies it already satisfies the invariant) before committing, and rejects later operations that would disable the protection. Each mitigation is enforced at its own enforcement point and persists across exec. See Kernel TRM section 3.3 (Process Security Block). Only the bits in KACS_MIT_ALL are valid; any other bit set in the request is rejected. KACS_MIT_CFI is a legacy alias: requesting it sets both KACS_MIT_CFIF and KACS_MIT_CFIB, and the alias bit itself is not retained.

ConstantValueNotes
KACS_MIT_WXP0x001 (1)Write-XOR-Execute protection
KACS_MIT_TLP0x002 (2)Trusted Library Paths
KACS_MIT_LSV0x004 (4)Library Signature Verification
KACS_MIT_CFI0x008 (8)legacy alias: CFIF | CFIB
KACS_MIT_UI_ACCESS0x010 (16)UI interaction (reserved)
KACS_MIT_NO_CHILD0x020 (32)cannot fork (one-way)
KACS_MIT_CFIF0x040 (64)forward-edge CFI (Intel IBT)
KACS_MIT_CFIB0x080 (128)backward-edge CFI (shadow stack)
KACS_MIT_PIE0x100 (256)reject non-PIE binaries at exec
KACS_MIT_SML0x200 (512)speculation mitigation lock

All valid mitigation bits OR'd together — the accepted-request mask.

ConstantValue
KACS_MIT_ALL0x3FF (1023)

3.A.11 Security descriptor constants #

From uapi/pkm/sd.h.

Byte length of the self-relative security-descriptor header.

ConstantValue
KACS_SD_HEADER_BYTES20

SECURITY_INFORMATION selector bits — which components of a security descriptor a kacs_get_sd / kacs_set_sd call reads or writes.

ConstantValue
KACS_SECINFO_OWNER0x00000001 (1)
KACS_SECINFO_GROUP0x00000002 (2)
KACS_SECINFO_DACL0x00000004 (4)
KACS_SECINFO_SACL0x00000008 (8)
KACS_SECINFO_LABEL0x00000010 (16)

SECURITY_DESCRIPTOR_CONTROL bits — the SD header control field.

ConstantValue
KACS_SD_OWNER_DEFAULTED0x0001 (1)
KACS_SD_GROUP_DEFAULTED0x0002 (2)
KACS_SD_DACL_PRESENT0x0004 (4)
KACS_SD_DACL_DEFAULTED0x0008 (8)
KACS_SD_SACL_PRESENT0x0010 (16)
KACS_SD_SACL_DEFAULTED0x0020 (32)
KACS_SD_DACL_TRUSTED0x0040 (64)
KACS_SD_SERVER_SECURITY0x0080 (128)
KACS_SD_DACL_AUTO_INHERIT_REQ0x0100 (256)
KACS_SD_SACL_AUTO_INHERIT_REQ0x0200 (512)
KACS_SD_DACL_AUTO_INHERITED0x0400 (1024)
KACS_SD_SACL_AUTO_INHERITED0x0800 (2048)
KACS_SD_DACL_PROTECTED0x1000 (4096)
KACS_SD_SACL_PROTECTED0x2000 (8192)
KACS_SD_RM_CONTROL_VALID0x4000 (16384)
KACS_SD_SELF_RELATIVE0x8000 (32768)

Access-mask bits — standard rights (bits 16-24) and generic rights (bits 28-31).

The low 16 bits of a mask are object-class specific; see <pkm/file.h> and <pkm/token.h> for those.

ConstantValue
KACS_ACCESS_DELETE0x00010000 (65536)
KACS_ACCESS_READ_CONTROL0x00020000
KACS_ACCESS_WRITE_DAC0x00040000
KACS_ACCESS_WRITE_OWNER0x00080000
KACS_ACCESS_SYNCHRONIZE0x00100000
KACS_ACCESS_ACCESS_SYSTEM_SECURITY0x01000000
KACS_ACCESS_MAXIMUM_ALLOWED0x02000000
KACS_ACCESS_GENERIC_ALL0x10000000
KACS_ACCESS_GENERIC_EXECUTE0x20000000
KACS_ACCESS_GENERIC_WRITE0x40000000
KACS_ACCESS_GENERIC_READ0x80000000

ACE types — the ace_type byte of an ACE header (MS-DTYP 2.4.4.1).

ConstantValue
KACS_ACE_TYPE_ACCESS_ALLOWED0x00 (0)
KACS_ACE_TYPE_ACCESS_DENIED0x01 (1)
KACS_ACE_TYPE_SYSTEM_AUDIT0x02 (2)
KACS_ACE_TYPE_SYSTEM_ALARM0x03 (3)
KACS_ACE_TYPE_ACCESS_ALLOWED_COMPOUND0x04 (4)
KACS_ACE_TYPE_ACCESS_ALLOWED_OBJECT0x05 (5)
KACS_ACE_TYPE_ACCESS_DENIED_OBJECT0x06 (6)
KACS_ACE_TYPE_SYSTEM_AUDIT_OBJECT0x07 (7)
KACS_ACE_TYPE_SYSTEM_ALARM_OBJECT0x08 (8)
KACS_ACE_TYPE_ACCESS_ALLOWED_CALLBACK0x09 (9)
KACS_ACE_TYPE_ACCESS_DENIED_CALLBACK0x0A (10)
KACS_ACE_TYPE_ACCESS_ALLOWED_CALLBACK_OBJECT0x0B (11)
KACS_ACE_TYPE_ACCESS_DENIED_CALLBACK_OBJECT0x0C (12)
KACS_ACE_TYPE_SYSTEM_AUDIT_CALLBACK0x0D (13)
KACS_ACE_TYPE_SYSTEM_ALARM_CALLBACK0x0E (14)
KACS_ACE_TYPE_SYSTEM_AUDIT_CALLBACK_OBJECT0x0F (15)
KACS_ACE_TYPE_SYSTEM_ALARM_CALLBACK_OBJECT0x10 (16)
KACS_ACE_TYPE_SYSTEM_MANDATORY_LABEL0x11 (17)
KACS_ACE_TYPE_SYSTEM_RESOURCE_ATTRIBUTE0x12 (18)
KACS_ACE_TYPE_SYSTEM_SCOPED_POLICY_ID0x13 (19)
KACS_ACE_TYPE_SYSTEM_PROCESS_TRUST_LABEL0x14 (20)
KACS_ACE_TYPE_SYSTEM_ACCESS_FILTER0x15 (21)

ACE header ace_flags byte — inheritance and audit control.

ConstantValue
KACS_ACE_FLAG_OBJECT_INHERIT0x01 (1)
KACS_ACE_FLAG_CONTAINER_INHERIT0x02 (2)
KACS_ACE_FLAG_NO_PROPAGATE_INHERIT0x04 (4)
KACS_ACE_FLAG_INHERIT_ONLY0x08 (8)
KACS_ACE_FLAG_INHERITED0x10 (16)
KACS_ACE_FLAG_SUCCESSFUL_ACCESS0x40 (64)
KACS_ACE_FLAG_FAILED_ACCESS0x80 (128)

Mandatory-label policy bits — the __le32 access mask of a KACS_ACE_TYPE_SYSTEM_MANDATORY_LABEL ACE. They control which DACL- granted rights a caller whose integrity level does not dominate the object's label (the "up" direction) is denied. Each bit suppresses the rights mapped from the corresponding generic class; unknown bits MUST be ignored.

ConstantValue
KACS_SYSTEM_MANDATORY_LABEL_NO_READ_UP0x00000001 (1)
KACS_SYSTEM_MANDATORY_LABEL_NO_WRITE_UP0x00000002 (2)
KACS_SYSTEM_MANDATORY_LABEL_NO_EXECUTE_UP0x00000004 (4)

Object-ACE body Flags field — the __le32 at object-ACE body offset 8, distinct from the 1-byte ace_flags header field above. Indicates which optional GUIDs the object-ACE body carries.

ConstantValue
KACS_ACE_OBJECT_TYPE_PRESENT0x00000001 (1)
KACS_ACE_INHERITED_OBJECT_TYPE_PRESENT0x00000002 (2)

3.A.12 SID constants #

From uapi/pkm/sid.h.

Largest sub_authority_count a valid SID may declare.

ConstantValue
KACS_SID_MAX_SUB_AUTHORITIES15

Encoded byte length of a SID with the given sub-authority count.

ConstantValue
KACS_SID_BYTE_LEN(count)(8 + 4 * (count))

SID_AND_ATTRIBUTES "Attributes" bits (MS-DTYP 2.4.4).

These describe how a group or restricted SID participates in an access check. MS-DTYP names them for groups; they apply to any SID_AND_ATTRIBUTES entry.

ConstantValue
KACS_SID_GROUP_MANDATORY0x00000001 (1)
KACS_SID_GROUP_ENABLED_BY_DEFAULT0x00000002 (2)
KACS_SID_GROUP_ENABLED0x00000004 (4)
KACS_SID_GROUP_OWNER0x00000008 (8)
KACS_SID_GROUP_USE_FOR_DENY_ONLY0x00000010 (16)
KACS_SID_GROUP_INTEGRITY0x00000020 (32)
KACS_SID_GROUP_INTEGRITY_ENABLED0x00000040 (64)
KACS_SID_GROUP_RESOURCE0x20000000
KACS_SID_GROUP_LOGON_ID0xC0000000

3.A.13 Tracepoint diagnostic codes #

From uapi/pkm/trace.h.

kacs_access_decision reason — why a KACS access hook took the return path it did.

Emitted by the kacs:kacs_file_access / _file_open / _native_open _inode_file_access / _inode_permission events. Verdict (allow vs deny) is a separate signal, read from the ret field (0 == allow).

ConstantValueNotes
KACS_TR_DECISION0resolved allow/deny
KACS_TR_BAD_ARGS1NULL/zero argument guard
KACS_TR_NO_ISEC2inode has no i_security blob
KACS_TR_UNMANAGED3superblock mount policy UNMANAGED
KACS_TR_PIP_CONTEXT4current PIP context unavailable
KACS_TR_NO_TOKEN5no effective subject token
KACS_TR_NO_DENTRY_ALIAS6inode has no dentry alias yet
KACS_TR_DELETE_ON_CLOSE_PENDING7open of a delete-on-close file
KACS_TR_NATIVE_STAMP8native-open granted-access stamp
KACS_TR_NATIVE_ARM9native-open delete-on-close arm
KACS_TR_STAMP10legacy-open granted-access stamp
KACS_TR_LAZY_DENTRY_RELOOKUP11native create lazy re-lookup
KACS_TR_NEGATIVE_AFTER_CREATE12negative dentry after create
KACS_TR_CHANGE_NOTIFY_PRIV13traverse via CHANGE_NOTIFY priv
KACS_TR_CHANGE_NOTIFY_PRIV_EXHAUSTED14CHANGE_NOTIFY priv use exhausted

kacs_sd_cache reason — the inode security-descriptor cache outcome.

The lookup miss codes disambiguate the three cache-absent paths that were previously an indistinguishable NULL return (no cache / stale generation missing-but-synthesis-required); the corrupt codes name why a stored SD was rejected. Emitted by kacs:kacs_sd_cache_lookup / _corrupt.

ConstantValueNotes
KACS_SDC_HIT0current, valid cache present
KACS_SDC_MISS_NONE1no cache attached
KACS_SDC_MISS_STALE_GEN2cache present but stale generation
KACS_SDC_MISS_NEEDS_SYNTH3missing SD requires synthesis
KACS_SDC_CORRUPT_EMPTY_OR_OVERSIZE4stored SD zero-length or oversize
KACS_SDC_CORRUPT_VALIDATE_FAIL5stored SD failed validation

kacs_process_access reason — the outcome of a cross-process access decision (signal, ptrace, scheduler/attribute, prlimit). The reason distinguishes the paths that all surface as -EACCES: an SD denial, a PIP-based denial, a denial rescued (or not) by SeDebugPrivilege, and a PIP-dominance failure. Emitted by kacs:kacs_process_access.

ConstantValueNotes
KACS_PA_ALLOW0access granted
KACS_PA_BAD_ARGS1NULL subject/target guard
KACS_PA_NO_TARGET2target has no process state/SD
KACS_PA_NO_SD3target process SD unavailable
KACS_PA_SD_ERROR4SD check failed (non-EACCES)
KACS_PA_PIP_DENIED5denied by process-integrity policy
KACS_PA_DEBUG_RESCUE6SD denial rescued by SeDebugPrivilege
KACS_PA_DEBUG_DENIED7denied; no usable SeDebugPrivilege
KACS_PA_PIP_DOMINANCE8caller PIP does not dominate target

kacs_exec reason — an exec/bprm credential or PIP transition.

Distinguishes the uid/gid-change gate outcomes, the exec primary-token derivation paths and their failures, the exec file integrity-label lookup failures, and the two commit-time transitions. Verdict is the ret field. Emitted by kacs:kacs_exec.

ConstantValueNotes
KACS_EXEC_CREDS_ALLOW0exec cred transition allowed
KACS_EXEC_BAD_ARGS1NULL cred/token guard
KACS_EXEC_ID_CHANGE_NO_TOKEN2uid/gid change, no subject token
KACS_EXEC_ID_CHANGE_PRIV_UNSUPPORTED3id change + ASSIGN_PRIMARY_TOKEN priv
KACS_EXEC_TOKEN_NPM_DERIVED4exec token derived via new-process-min
KACS_EXEC_TOKEN_CLONE5exec token via primary clone fallback
KACS_EXEC_NPM_NO_FILE6new-process-min needs file, none supplied
KACS_EXEC_NPM_DERIVE_FAIL7new_process_min_exec derivation failed
KACS_EXEC_TOKEN_INSTALL_FAIL8install token ref on new cred failed
KACS_EXEC_TOKEN_CLONE_FAIL9primary token clone returned NULL
KACS_EXEC_INTEGRITY_NO_ISEC10exec file inode has no i_security
KACS_EXEC_INTEGRITY_NO_CACHE11exec file SD cache absent
KACS_EXEC_INTEGRITY_INVALID_SD12exec file cached SD invalid/empty
KACS_EXEC_IMPERSONATION_REVERT_FAIL13bprm impersonation revert failed
KACS_EXEC_PIP_COMMITTED14pending exec PIP committed at commit
KACS_EXEC_UMH_NOT_TCB15usermodehelper exec below PeiosTcb trust
KACS_EXEC_SIGNATURE_UNVERIFIABLE16exec refused: signature could not be verified
KACS_EXEC_PIP_CAPPED_UNSAFE17exec PIP label capped at the current one: traced or no_new_privs

kacs_signing reason — code-signature verification outcomes and the distinct reject reasons of the signing-material probe. Only source enum, verified flag, PIP tier codes, file length, reason, and ret are recorded — never key, signature, xattr, or file bytes. Emitted by kacs:kacs_signing_verify _crypto / _probe.

ConstantValueNotes
KACS_SIG_UNSIGNED0material source NONE (unsigned)
KACS_SIG_BAD_KEY_TABLE1key table malformed / bad args
KACS_SIG_NO_KEY_MATCH2no key verified the signature
KACS_SIG_VERIFIED3a key verified; trust assigned
KACS_SIG_CRYPTO_UNAVAILABLE4mldsa65 tfm allocation failed
KACS_SIG_CRYPTO_MISMATCH5set-pubkey/verify returned nonzero
KACS_SIG_PROBE_FOUND6valid signature material committed
KACS_SIG_ELF_MAGIC_READ7failed reading ELF magic
KACS_SIG_ELF_SHORT_EHDR8file too short for Elf64_Ehdr
KACS_SIG_ELF_EHDR_READ9failed reading ELF header
KACS_SIG_ELF_BAD_IDENT10unsupported e_ident class/data/version
KACS_SIG_ELF_BAD_SHTABLE11bad shentsize/shstrndx
KACS_SIG_ELF_SHDRS_RANGE12section-header table offset/len out of range
KACS_SIG_ELF_SHSTR_READ13failed reading shstrtab section header
KACS_SIG_ELF_STRTAB_RANGE14shstrtab offset/len out of range
KACS_SIG_ELF_SHDR_READ15failed reading a section header
KACS_SIG_ELF_NAME_READ16failed reading a section name
KACS_SIG_ELF_BAD_SIG_SECTION17sig section wrong type/size/range
KACS_SIG_ELF_BAD_BLOB18sig blob read failed or invalid
KACS_SIG_ELF_HASH_FAIL19hashing failed for ELF sig
KACS_SIG_XATTR_BAD_BLOB20xattr sig blob invalid
KACS_SIG_XATTR_HASH_FAIL21hashing failed for xattr sig
KACS_SIG_SIZE_CHANGED22file size changed during probe (TOCTOU)

kacs_firmware reason — why a kernel-initiated firmware read was allowed or refused (kacs/firmware.c, PEI-493). Firmware is code that runs on a device with DMA into host memory, so it is held to the PeiosTcb tier: the same bar as a usermodehelper exec. ret is the verdict handed to the loader; under log mode it is 0 whatever the reason. Emitted by kacs:kacs_firmware_load. No path, key, or signature bytes are recorded.

ConstantValueNotes
KACS_FW_ALLOWED0verified at PeiosTcb
KACS_FW_UNSIGNED1no signature material on the file
KACS_FW_NO_KEY_MATCH2signed, but no built-in key verified it
KACS_FW_BELOW_TCB3verified, but the key's tier is below PeiosTcb
KACS_FW_UNVERIFIABLE4verification could not run (crypto unavailable)
KACS_FW_PROBE_FAILED5reading the signature material failed

kacs_socket reason — the outcome of an AF_UNIX socket SD / impersonation hook.

Distinguishes the guard, not-applicable, and verdict paths that otherwise collapse into an indistinguishable -EACCES. Verdict is the ret field. No address, pathname, or SD bytes are recorded.

ConstantValueNotes
KACS_SOCK_BAD_ARGS0NULL/guard argument rejected
KACS_SOCK_NOT_UNIX1not AF_UNIX / unsupported type
KACS_SOCK_NO_SECURITY2sock has no sk_security blob
KACS_SOCK_NO_TOKEN3no effective subject/client token
KACS_SOCK_BAD_LEVEL4invalid impersonation level
KACS_SOCK_WRONG_STATE5socket state forbids the op
KACS_SOCK_NO_PEER_TOKEN6no captured peer token present
KACS_SOCK_PIP_CONTEXT7caller PIP context unavailable
KACS_SOCK_SD_DECISION8socket-SD check produced verdict
KACS_SOCK_NO_SD9no socket SD; allowed without check
KACS_SOCK_HAVE_SD10socket SD present; check performed
KACS_SOCK_ALREADY_BOUND11socket SD already installed
KACS_SOCK_BIND12abstract-socket SD bind result
KACS_SOCK_CONNECT13unix_stream_connect result
KACS_SOCK_LEVEL_SET14impersonation level updated
KACS_SOCK_OPEN_TOKEN15open peer-token fd result

16 was KACS_SOCK_IMPERSONATE (kacs_impersonate_peer); retired, not reused.

ConstantValueNotes
KACS_SOCK_ATTACH17identity attached to a send
KACS_SOCK_GATE18explicit KACS_SCM_TOKEN send-gate verdict
KACS_SOCK_REGISTER19conveyed-identity register advanced
KACS_SOCK_DELIVER20KACS_SCM_TOKEN delivered to a receiver
KACS_SOCK_LISTEN21listener identity captured at listen()
KACS_SOCK_RESTAMP22listener identity replaced by KACS_SO_RESTAMP
KACS_SOCK_PORT_BIND23inet bind: port reservation SD verdict
KACS_SOCK_PORT_TABLE24port reservation table load result
KACS_SOCK_OWNER25governing identity stamped on an inet socket

kacs_ipc reason — a System V IPC object SD decision (ipc.c).

ConstantValueNotes
KACS_IPC_ALLOC0default SD stamped at *get creation
KACS_IPC_PERMISSION1ipc_permission: read/write access to the object
KACS_IPC_CTL2*ctl command gate (RMID/SET/STAT/...)
KACS_IPC_SD_QUERY3kacs_get_sd on the object
KACS_IPC_SD_SET4kacs_set_sd on the object
KACS_IPC_NO_SD5object carries no SD; fail closed

kacs_namespace stage — which sub-decision of a namespace-mutation hook a record describes.

Single-decision ops report PRIMARY; multi-stage ops (link, rename, delete fallback) tag each distinct verdict. Verdict is the ret field. Never records a pathname. Emitted by the kacs:kacs_inode_* events.

ConstantValueNotes
KACS_NS_PRIMARY0the op's principal decision
KACS_NS_PARENT_FALLBACK1delete: parent DELETE_CHILD fallback
KACS_NS_SOURCE2link/rename source-side decision
KACS_NS_DEST3link/rename destination-parent add
KACS_NS_DELETE_EXISTING4rename: delete pre-existing dest

kacs_psb reason — which process-security-baseline path an event marks: mitigation activation (apply) or a W^X / LSV / PIE / prctl-lock enforcement denial. The ok-vs-deny verdict is read from ret. Emitted by kacs:kacs_psb_*.

ConstantValueNotes
KACS_PSB_APPLY_OK0mitigations applied; result_bits set
KACS_PSB_APPLY_NORMALIZE1requested mask bad or unsupported (EINVAL/ENODEV)
KACS_PSB_APPLY_MM_ACQUIRE2could not acquire target mm (EACCES)
KACS_PSB_APPLY_CFIF3forward-CFI (IBT) activation failed
KACS_PSB_APPLY_SML4speculative-mitigation-lock activation failed
KACS_PSB_APPLY_CFIB5backward-CFI (shadow stack) activation failed
KACS_PSB_WXP_MMAP6W^X blocked a W+X mmap
KACS_PSB_WXP_MPROTECT7W^X blocked an mprotect transition
KACS_PSB_WXP_EXISTING_VMA8W^X activation blocked by an existing W+X vma
KACS_PSB_LSV_PROBE9LSV signing probe of the image failed
KACS_PSB_LSV_VERIFY10LSV signature not verified/trusted
KACS_PSB_LSV_PIP_DOMINANCE11LSV image PIP does not dominate process PIP
KACS_PSB_PIE_ET_EXEC12PIE blocked a non-PIE ET_EXEC image
KACS_PSB_PRCTL_SML13prctl blocked by SML lock
KACS_PSB_PRCTL_CFIB14prctl blocked by shadow-stack (CFIB) lock
KACS_PSB_PRCTL_PIP15prctl set-dumpable blocked by process PIP

kacs_token_ioctl cmd — which token-fd ioctl verb a record describes.

The verdict (allow vs deny) is read from the ret field (0 == allow); the access-mask-gate rejections surface as ret == -EACCES. token is an opaque numeric id (never token bytes). Emitted by kacs:kacs_token_ioctl.

ConstantValueNotes
KACS_TOK_QUERY0KACS_IOC_QUERY
KACS_TOK_ADJUST_PRIVS1KACS_IOC_ADJUST_PRIVS
KACS_TOK_ADJUST_GROUPS2KACS_IOC_ADJUST_GROUPS
KACS_TOK_DUPLICATE3KACS_IOC_DUPLICATE
KACS_TOK_INSTALL4KACS_IOC_INSTALL
KACS_TOK_RESTRICT5KACS_IOC_RESTRICT
KACS_TOK_LINK6KACS_IOC_LINK_TOKENS
KACS_TOK_GET_LINKED7KACS_IOC_GET_LINKED_TOKEN
KACS_TOK_IMPERSONATE8KACS_IOC_IMPERSONATE
KACS_TOK_ADJUST_DEFAULT9KACS_IOC_ADJUST_DEFAULT
KACS_TOK_ADJUST_INTERACTIVITY_SCOPE10KACS_IOC_ADJUST_INTERACTIVITY_SCOPE
KACS_TOK_UNKNOWN11unrecognised ioctl verb (-ENOTTY)

kacs_token_ref reason — a token-fd reference lifecycle transition.

TO_FD is a token installed into a fresh anon-inode handle; RELEASE is the handle teardown that drops the token ref; BIND clones a token onto an existing file; OPEN is the checked/fixed-access open path that clones the target token. token is an opaque numeric id, never token bytes. Emitted by kacs:kacs_token_ref.

ConstantValueNotes
KACS_TREF_TO_FD0token installed into a new fd (ret == fd)
KACS_TREF_RELEASE1token-fd released; ref dropped
KACS_TREF_BIND2token cloned + bound onto an existing file
KACS_TREF_OPEN3token cloned for a token-open path

kacs_logon_session reason — a session/token creation-surface outcome.

The *_DENIED codes name the privilege-gate rejections (the value); the plain op codes mark the successful op. Verdict is also in ret. No token/spec bytes are recorded. Emitted by kacs:kacs_logon_session.

ConstantValueNotes
KACS_SES_CREATE0create_logon_session published a session
KACS_SES_CREATE_PRIV_DENIED1create_logon_session: TCB privilege gate denied
KACS_SES_DESTROY2destroy_empty_logon_session outcome
KACS_SES_DESTROY_PRIV_DENIED3destroy: TCB privilege gate denied
KACS_SES_CREATE_TOKEN4create_token issued a token fd
KACS_SES_CREATE_TOKEN_PRIV_DENIED5create_token: CREATE_TOKEN privilege denied

kacs_cred reason — a credential-security lifecycle transition: LSM cred prepare/transfer/alloc/free, explicit token-ref install, the clone-time primary-token lifecycle (CLONE_THREAD share vs fork deep-copy), and the project-linux-cred rejection paths. old_token/new_token are opaque token pointer ids (0 when absent); clone_flags is set only on the clone paths. Verdict/outcome is the ret field. Emitted by kacs:kacs_cred.

ConstantValueNotes
KACS_CRED_PREPARE0cred_prepare token clone
KACS_CRED_TRANSFER1cred_transfer token clone
KACS_CRED_ALLOC_BLANK2cred_alloc_blank cleared sec
KACS_CRED_FREE3cred_free released token/state
KACS_CRED_INSTALL_TOKEN_REF4install token ref on a cred
KACS_CRED_CLONE_THREAD_SHARE5CLONE_THREAD shares parent primary cred
KACS_CRED_CLONE_FORK_COPY6fork deep-copies parent primary token
KACS_CRED_PROJECT_UID0_BLOCKED7uid0 projection not allowed by token
KACS_CRED_PROJECT_GROUPS_ALLOC_FAIL8groups_alloc failed (ENOMEM)
KACS_CRED_PROJECT_E2BIG9supplementary gid count > NGROUPS_MAX

kacs_setid reason — the KACS gate on a Linux setid projection (task_fix_setuid / _setgid / setgroups). Each op has two distinct denials that otherwise collapse: NO_TOKEN (-EACCES, no effective subject token) and PRIV_GATE (-EOPNOTSUPP, holder of ASSIGN_PRIMARY_TOKEN privilege). flags is the LSM_SETID* mask (0 for setgroups). Emitted by kacs:kacs_setid.

ConstantValueNotes
KACS_SETID_SETUID_NO_TOKEN0setuid gate: no subject token
KACS_SETID_SETUID_PRIV_GATE1setuid gate: ASSIGN_PRIMARY priv
KACS_SETID_SETGID_NO_TOKEN2setgid gate: no subject token
KACS_SETID_SETGID_PRIV_GATE3setgid gate: ASSIGN_PRIMARY priv
KACS_SETID_SETGROUPS_NO_TOKEN4setgroups gate: no subject token
KACS_SETID_SETGROUPS_PRIV_GATE5setgroups gate: ASSIGN_PRIMARY priv

kacs_task reason — a task-security lifecycle transition.

task_alloc reports a NO_CHILD-mitigation clone block, a process-state inherit ENOMEM, or success; task_free marks teardown. process_state is an opaque process-state pointer id (0 when absent); clone_flags is set on the alloc paths. Outcome is ret. Emitted by kacs:kacs_task.

ConstantValueNotes
KACS_TASK_ALLOC_NO_CHILD_BLOCKED0clone blocked by NO_CHILD mitigation
KACS_TASK_ALLOC_INHERIT_ENOMEM1process-state inherit failed (ENOMEM)
KACS_TASK_ALLOC2task_alloc completed
KACS_TASK_FREE3task_free teardown

kacs_primary_install reason — a primary-token / impersonation credential transition.

Distinguishes the install commit, the user-SID-change process-SD reallocation and its ENOMEM, the commit_creds apply, the impersonation override/revert, and the sibling-thread taskwork requeue/failure. old_primary and new_primary are opaque token identity ids (never token bytes). Verdict is the ret field. Emitted by kacs:kacs_primary_install.

ConstantValueNotes
KACS_PRIM_INSTALL_OK0primary token install committed
KACS_PRIM_SD_REALLOC1user-SID changed; process SD reallocated
KACS_PRIM_SD_ALLOC_FAIL2process SD realloc failed (ENOMEM)
KACS_PRIM_APPLY_COMMIT3new real creds committed (commit_creds)
KACS_PRIM_IMPERSONATE_INSTALL4impersonation token installed (override_creds)
KACS_PRIM_IMPERSONATE_REVERT5impersonation reverted (revert_creds)
KACS_PRIM_SIBLING_REQUEUE6queued sibling install re-queued on ENOMEM
KACS_PRIM_SIBLING_FAILED7queued sibling install failed after apply

kacs_process_token_open reason — the outcome of opening a process/thread primary or effective token (kacs_open_process_token / _thread_token and the proc inspection files). Distinguishes the bad access-mask reject, the no-target-token path, the process access-check denial, the self vs cross inspection verdicts, and the successful open. subject_token/target_token are opaque token identity ids (0 when unknown at the emit site); access_mask is the requested mask. Verdict is ret (>=0 fd == allow). Emitted by kacs:kacs_process_token_open.

ConstantValueNotes
KACS_PTO_OPEN_OK0token fd opened
KACS_PTO_BAD_ARGS1NULL subject/state/task guard
KACS_PTO_NO_TARGET2target has no token
KACS_PTO_BAD_ACCESS3invalid access mask rejected
KACS_PTO_ACCESS_DENIED4process access check denied
KACS_PTO_SELF5self-target inspection allowed
KACS_PTO_CROSS6cross-process inspection authorized

kacs_process_state reason — a process-state / process-SD lifecycle or PIP transition.

Covers process-state alloc/free, the CLONE_THREAD share vs fork inheritance split, the no-child clone block, the pending-exec-PIP stage/commit and dumpable hardening, and the process-SD alloc/wrap/replace primitives. process_state is an opaque state-object id (0 when none in scope, e.g. the process-SD primitives); pip_type/pip_trust carry the PIP tier. ret is the outcome (0 == ok). No token or SD bytes. Emitted by kacs:kacs_process_state.

ConstantValueNotes
KACS_PST_ALLOC0process state allocated
KACS_PST_ALLOC_FAIL1process state alloc failed (ENOMEM)
KACS_PST_FREE2process state freed (refcount hit 0)
KACS_PST_INHERIT_SHARE3CLONE_THREAD: parent state shared
KACS_PST_INHERIT_FORK4fork: new state allocated from parent
KACS_PST_EXEC_PIP_STAGE5pending exec PIP staged
KACS_PST_EXEC_PIP_COMMIT6pending exec PIP committed to state
KACS_PST_DUMPABLE7exec dumpable hardened by PIP
KACS_PST_CLONE_BLOCKED_NOCHILD8clone blocked by NO_CHILD mitigation
KACS_PST_SD_ALLOC9default process SD allocated
KACS_PST_SD_ALLOC_FAIL10process/socket SD alloc failed
KACS_PST_SD_WRAP_FAIL11process SD wrapper alloc failed (ENOMEM)
KACS_PST_SD_REPLACE12process SD replaced on state
KACS_PST_SOCKET_SD_ALLOC13default socket SD allocated

kacs_mount_policy reason — the outcome of a mount-policy set (TCB-gated) or get.

SET_OK marks a committed policy change (generation bumped); the guard codes name the pre-commit rejects that otherwise collapse into a bare -EINVAL/-EOPNOTSUPP/-EPERM. GET_OK/GET_NO_SECURITY are the snapshot paths. Verdict is also in ret. Emitted by kacs:kacs_mount_policy_set / _get.

ConstantValueNotes
KACS_MP_SET_OK0policy committed; generation bumped
KACS_MP_BAD_ARGS1NULL subject/sb/args guard (EINVAL)
KACS_MP_NO_SECURITY2superblock has no s_security (EOPNOTSUPP)
KACS_MP_UNMANAGED3magic-derived UNMANAGED; not settable (EOPNOTSUPP)
KACS_MP_VALIDATE4mount-policy args validation failed (EINVAL)
KACS_MP_TEMPLATE_INVALID5template SD bytes failed validation (EINVAL)
KACS_MP_TCB_DENIED6SeTcbPrivilege gate denied (EPERM)
KACS_MP_GET_OK7policy snapshot returned
KACS_MP_GET_NO_SECURITY8get: no s_security; magic-derived policy returned
KACS_MP_FIXED_POLICY9filesystem fixes its policy class (EOPNOTSUPP)

kacs_sd_syscall target_kind — which SD-bearing object a query/set record describes, resolved by the get_sd/set_sd syscall target-kind fallthrough. ACCESS_CHECK tags the AccessCheck ingress events (kacs_access_check*), whose other scalar fields are 0 at the ingress boundary.

ConstantValueNotes
KACS_SDS_KIND_TOKEN0token-fd target
KACS_SDS_KIND_FILE1file/inode target
KACS_SDS_KIND_PROCESS2pidfd process target
KACS_SDS_KIND_PATH3path-resolved file target
KACS_SDS_KIND_ACCESS_CHECK4AccessCheck ingress (not an SD get/set)

kacs_sd_syscall reason — the outcome of an SD query/set core.

QUERY_OK/SET_OK are the success paths; the remaining codes name the guard / denial paths that otherwise surface as an indistinguishable -EINVAL/-EACCES/-EOPNOTSUPP. Verdict is also in ret. Emitted by kacs:kacs_sd_query / _set.

ConstantValueNotes
KACS_SDS_QUERY_OK0SD subset extracted and returned
KACS_SDS_SET_OK1SD merged/replaced
KACS_SDS_BAD_ARGS2NULL/zero argument guard (EINVAL)
KACS_SDS_UNMANAGED3superblock UNMANAGED (EOPNOTSUPP)
KACS_SDS_ACCESS_DENIED4SD access check denied (EACCES)
KACS_SDS_NO_SD5target has no usable SD (EACCES)
KACS_SDS_RESTORE_BYPASS6set via SeRestorePrivilege bypass
KACS_SDS_QUERY_FAIL7subset extraction failed after auth

kacs_access_check reason — the AccessCheck kernel-ingress outcome, above the closed Slice 15 ABI bridge. OK is a completed ingress; the remaining codes name the ingress-time rejects (token-eval-context gate, token resolution, and caap-cache lock acquisition). Verdict is also in ret. Emitted by kacs:kacs_access_check / _list.

ConstantValueNotes
KACS_ACK_OK0ingress dispatched to the ABI bridge
KACS_ACK_EVAL_CONTEXT1token-eval-context gate denied (EACCES)
KACS_ACK_TOKEN_RESOLVE2token/args resolution failed
KACS_ACK_CAAP_LOCK_FAIL3caap-cache lock acquisition failed

kacs_file_snapshot op — which snapshot-grant file operation an event marks.

The allow-vs-deny verdict is read from ret; reason names why a deny path was taken. Emitted by kacs:kacs_file_snapshot (file_access.c).

ConstantValueNotes
KACS_FSOP_ACCESS0generic snapshot-grant access check
KACS_FSOP_PERMISSION1file_permission hook
KACS_FSOP_IOCTL2file ioctl snapshot
KACS_FSOP_LOCK3file lock snapshot
KACS_FSOP_FCNTL4file fcntl snapshot
KACS_FSOP_TRUNCATE5file truncate snapshot
KACS_FSOP_FALLOCATE6file fallocate snapshot
KACS_FSOP_MMAP7file mmap snapshot
KACS_FSOP_MPROTECT8file mprotect snapshot
KACS_FSOP_WRITE_INTENT9write-intent snapshot
KACS_FSOP_SYSFS_WRITE_GATE10unmanaged sysfs write gate

kacs_file_snapshot reason — why a snapshot-grant op took its return path.

DECISION is the resolved allow/deny (verdict in ret); the remaining codes name the distinct deny causes. Emitted by kacs:kacs_file_snapshot.

ConstantValueNotes
KACS_FSR_DECISION0resolved allow/deny (grant compare)
KACS_FSR_SIGNED_EXEC1signed-exec content mutation denied
KACS_FSR_GRANT_DENY2granted access lacked required right
KACS_FSR_APPEND_DENY3append/write intent lacked write grant
KACS_FSR_UNMANAGED_SYSFS4unmanaged fd: sysfs write gate applied
KACS_FSR_AUDIT_EMIT_FAIL5kacs.audit.handle.used emit failed

kacs_metadata reason — the file-metadata (getattr/setattr/xattr/getsecurity) decision path.

DECISION/CONSUME_HIT/BEGIN_BUSY mark the begin/consume decision lifecycle; the remaining codes name the distinct deny reasons of the xattr setattr hooks. op_class carries the internal PKM_KACS_METADATA_OP_* value; matched is the consume match flag. Emitted by kacs:kacs_metadata (file_metadata.c).

ConstantValueNotes
KACS_META_DECISION0generic metadata decision
KACS_META_CONSUME_HIT1consumed a pre-staged decision
KACS_META_BEGIN_BUSY2begin failed: a decision already active
KACS_META_CANONICAL_SD3canonical SD xattr access denied
KACS_META_CAPS_XATTR4capability xattr mutation denied (EPERM)
KACS_META_ACL5POSIX ACL xattr denied
KACS_META_SIGNED_EXEC6signed-exec xattr/size mutation denied
KACS_META_BAD_ARGS7NULL name / dentry guard
KACS_META_INTERNAL_SD8internal SD read/write re-entry allowed
KACS_META_GETSECURITY9inode_getsecurity outcome

kacs_native_open_ext reason — a widening decision inside the native (kacs_open) create/open machinery. The PREPARE_* codes name the arg- validation reject buckets of pkm_kacs_prepare_native_open; RESOLVE / BUILD_CREATED_SD DELETE_ON_CLOSE_ARM name the later stage outcomes (verdict in ret). Emitted by kacs:kacs_native_open_ext (native_open.c).

ConstantValueNotes
KACS_NOX_PREPARE_OK0prepare accepted the request
KACS_NOX_PREPARE_BAD_FLAGS1flags/create_options/__pad rejected
KACS_NOX_PREPARE_BAD_SD_ARGS2sd_ptr/sd_len/disposition-sd combo bad
KACS_NOX_PREPARE_BAD_DISPOSITION3create_disposition out of range
KACS_NOX_PREPARE_BAD_ACCESS4desired-access mask invalid/empty
KACS_NOX_PREPARE_UNSUPPORTED5valid but unsupported combination
KACS_NOX_RESOLVE6resolve-existing-path outcome
KACS_NOX_BUILD_CREATED_SD7build-created-file-SD outcome
KACS_NOX_DELETE_ON_CLOSE_ARM8delete-on-close arm outcome

kacs_object reason — which object-lifecycle verdict a record marks.

Only the high-value transitions are traced (pure inode/file/sb alloc/free are not). ret is the outcome (0 == ok). Emitted by kacs:kacs_object.

ConstantValueNotes
KACS_OBJ_DELETE_ON_CLOSE_UNLINK0file_release delete-on-close unlink attempt
KACS_OBJ_SIGNED_EXEC_PIN1inode pinned as signed-exec (immutable)
KACS_OBJ_SIGNED_EXEC_MUTATION_BLOCKED2content mutation of a signed-exec-pinned inode denied

kacs_securityfs reason — which securityfs endpoint path a record marks.

The sessions_read codes disambiguate the deny rungs that otherwise collapse into an errno; open_self / init report the endpoint outcome. Verdict is ret. Emitted by kacs:kacs_securityfs.

ConstantValueNotes
KACS_SFS_LOGON_SESSIONS_NO_TOKEN0sessions read: no effective subject token
KACS_SFS_LOGON_SESSIONS_PIP_CONTEXT1sessions read: caller PIP context unavailable
KACS_SFS_LOGON_SESSIONS_ACCESS_CHECK2sessions read: rust access check denied
KACS_SFS_OPEN_SELF3open of kacs/self self-token file outcome
KACS_SFS_INIT4securityfs kacs/ endpoint init outcome

kacs_caap reason — which CAAP policy-cache path a record marks.

SET carries the post-set cache_len (an insert grows it; an evict/replace may shrink it); INIT/DESTROY are cache lifecycle; TCB_GATE is the SeTcbPrivilege gate deny. Emitted by kacs:kacs_caap. No SID or spec bytes — lengths only.

ConstantValueNotes
KACS_CAAP_TCB_GATE0SeTcbPrivilege gate denied the caller
KACS_CAAP_SET1cache set (insert/evict); cache_len is post-set count
KACS_CAAP_INIT2CAAP cache created
KACS_CAAP_DESTROY3CAAP cache destroyed

kacs_capability reason — the capability->privilege gate verdicts and the capability LSM-hook outcomes. ALLOW_GRANT is an auto-granted allow-cap; HARD_DENY is the SETPCAP/SETFCAP/MAC_OVERRIDE hard block; PRIV_NOT_ENABLED USE_MARK_FAIL are the mapped-privilege gate failures; CAPSET / PRCTL_GUARD CAPABLE / CAPGET report the corresponding hook outcome. Emitted by kacs:kacs_capability. Verdict is ret.

ConstantValueNotes
KACS_CAP_ALLOW_GRANT0allow-cap auto-granted (no privilege needed)
KACS_CAP_HARD_DENY1SETPCAP/SETFCAP/MAC_OVERRIDE hard-denied
KACS_CAP_PRIV_NOT_ENABLED2mapped privilege not enabled on token
KACS_CAP_USE_MARK_FAIL3privilege use-mark failed
KACS_CAP_CAPSET4capset core outcome
KACS_CAP_PRCTL_GUARD5prctl capability-guard outcome
KACS_CAP_CAPABLE6capable() hook guard-deny outcome
KACS_CAP_CAPGET7capget for-task outcome

kacs_privilege reason — the require_enabled_privilege gate rungs plus two standalone privilege-path markers. NULL_OR_ZERO is a null- token/zero-mask guard; NOT_ENABLED / USE_MARK_FAIL are the gate failures; CHANGE_NOTIFY marks the open_by_handle_at SeChangeNotifyPrivilege check outcome; RCU_ENOMEM_ FALLBACK marks the deferred-free ENOMEM synchronize_rcu fallback. Emitted by kacs:kacs_privilege. Verdict is ret.

ConstantValueNotes
KACS_PRIV_NULL_OR_ZERO0null token or zero privilege mask
KACS_PRIV_NOT_ENABLED1privilege not enabled on token
KACS_PRIV_USE_MARK_FAIL2privilege use-mark failed
KACS_PRIV_CHANGE_NOTIFY3open_by_handle_at CHANGE_NOTIFY gate outcome
KACS_PRIV_RCU_ENOMEM_FALLBACK4deferred-free kmalloc failed; sync-rcu fallback

kacs_tlp reason — the trusted-launch-path decisions.

CHECK_PATH marks a no-prefix-match executable-transition deny (path_len

  • prefix_count only, NEVER path or prefix bytes); REPLACE marks a prefix-table replacement. Emitted by kacs:kacs_tlp. Verdict is ret.
ConstantValueNotes
KACS_TLP_CHECK_PATH0executable transition denied: no prefix match
KACS_TLP_REPLACE1TLP prefix table replaced

kacs_mntns reason — mount-namespace object lifecycle and the mount gate.

SD_ALLOC / SD_ALLOC_FAIL mark a namespace minted with (or without) its creator descriptor; the gate reasons say which rung admitted or refused a mount-table change: PRIVILEGE is SeManageVolume / SeTcb, NO_SD is a namespace with no descriptor (the initial one), OP_NOT_ADMITTED is an operation the descriptor can never grant, SD_DECISION is the access check against the namespace descriptor, PIP_CONTEXT a failure to read the caller's PIP state. Emitted by kacs:kacs_mntns. Verdict is ret.

ConstantValueNotes
KACS_MNTNS_SD_ALLOC0namespace minted with a creator descriptor
KACS_MNTNS_SD_ALLOC_FAIL1descriptor could not be built
KACS_MNTNS_GATE_PRIVILEGE2admitted by SeManageVolume / SeTcb
KACS_MNTNS_GATE_NO_SD3namespace has no descriptor; refused
KACS_MNTNS_GATE_OP_NOT_ADMITTED4op needs the privilege whatever the SD says
KACS_MNTNS_GATE_SD_DECISION5access check against the namespace SD
KACS_MNTNS_GATE_PIP_CONTEXT6caller PIP context unavailable
KACS_MNTNS_GATE_FS_NOT_ADMITTED7filesystem type not on the unprivileged allowlist
KACS_MNTNS_SB_STAMP8unprivileged tmpfs stamped synth-ephemeral, creator template
KACS_MNTNS_SB_STAMP_FAIL9creator template could not be built

Appendix 3.B Departures from MS-DTYP

Peios / Peios technical reference / PKM / KACS

KACS uses the binary formats MS-DTYP specifies, so a descriptor authored by a Windows domain controller and replicated through Samba is evaluated without translation. PCDS specifies those formats normatively.

Evaluator behaviour is a separate question. Given the same token, descriptor and desired mask, KACS generally reaches the same decision MS-DTYP describes — which is what makes policy authored in an AD environment behave predictably here — but it departs deliberately in the following places.

AreaDepartureWhy
Conditional ACE @Local.Resolved from an AccessCheck parameter rather than a token fieldThe context is per-call and varies between checks.
Virtual groups in expressionsMember_of({S-1-3-4}) returns true for the ownerKeeps the SID matcher and the expression evaluator semantically consistent.
INT64/UINT64 promotionRelational operators promote between the twoWithout promotion, UINT64 claims cannot be used in conditions at all.
Member_of filteringFiltered by ACE polarity, so deny-only groups do not satisfy allow-ACE conditionsConsistent with deny-only group semantics everywhere else.
Exists scopeExtended to all four attribute namespacesNo reason to restrict existence tests to Local and Resource.
ACE mask mappingACE masks are mapped through GenericMapping at evaluation timeRequired for GENERIC_ALL in central access policy recovery ACEs (§3.8.8).
MAXIMUM_ALLOWEDFirst-writer-wins for targeted and maximum-allowed requestsEliminates disagreement between "what can I do?" and "can I do this?" on a non-canonically ordered DACL.
Zero desired maskSucceeds rather than returning access denied"Asked for nothing, got nothing" is a valid answer.
Alarm ACEsRepurposed for continuous per-operation auditing, recorded as kacs.audit.handle.used (§3.8.9)Reserved but never implemented in the reference model.
Multiple scoped policy ACEsSeveral permitted per SACLAND semantics make composition safe.
Mandatory policy mutabilitymandatory_policy is immutable on the token (§3.2.2)A mutable policy reduces MIC to advisory.
Impersonation integrity ceilingEnforced unconditionally; SeImpersonatePrivilege does not bypass it (§3.5.2)MIC is a real boundary precisely because the mandatory policy is immutable.
Impersonation origin checkDroppedEliminates hidden impersonation paths.
Impersonation level on primariesMeaningful and queryable: a ratchet every token carries, bounding everything derived from it (§3.5.1). Windows rejects TokenImpersonationLevel on a primary.Delegation here is a flag authd enforces rather than a property of the credential cache, so the flag itself has to be unforgeable across duplication.
PIP determinationKernel-only, from the binary signature, with no parent input (§3.3.2)One input, one answer, no ambiguity.
Object type list validationDuplicate GUIDs and level gaps rejected (§3.8.5)Prevents node lookup returning the wrong node and propagation becoming undefined.
Object type list depthNo limit on levels; the tree is bounded only by the node count the access-check interface accepts. MS-DTYP limits a list to four levels.A depth limit would reject lists the validation rules already make well-defined, and nothing in propagation depends on depth.

3.B.1 Features handled elsewhere #

Several capabilities relevant to a complete security posture are not KACS's, and are named here so their absence is not mistaken for a gap. Kerberos and NTLM authentication, S4U, and credential storage and protection belong to authd, as do Resource-Based Constrained Delegation and Authentication Policies and Silos through the KDC. Active Directory replication is Samba's. Group Policy distribution goes through the registry and roles. Network share permissions belong to the Samba SMB layer. An Encrypting File System is a future service.

Appendix 3.C Audit Event Schemas

Peios / Peios technical reference / PKM / KACS

KACS emits its audit records through KMES with origin class 2 (§2.2). Each event's payload is a msgpack map whose field paths follow PGSS §6.4: access.requested is the key requested inside the map under access, never a key containing a dot, and a value the emitter does not have is absent rather than nil. The shared subject group — the effective token's subject.token.*, the PIP state in subject.pip.*, and the calling process as emitter.process.{pid,name,executable} — is attached at emission time from the resolved call context rather than by the evaluation pipeline (§3.8.9). A process with no executable, such as a kernel thread, has executable left out; the record is still written.

This appendix covers which events KACS emits and from where. The field-by-field payload schemas are in the Peios Events Index, which is canonical for them, and evman <event-type> prints each one.

3.C.1 Event families #

Event typeEmitted by
kacs.audit.access.checkedThe SACL walk, and token audit-policy forcing.
kacs.audit.handle.usedEnforcement points, per operation, against a handle's continuous audit mask.
kacs.audit.privilege.usedPrivilege-use auditing, for the five AccessCheck-influencing privileges in an access check, and at a Linux capability or volume gate (§3.4.1).
kacs.audit.descriptor.changedA change to the descriptor of a file, token, process or System V IPC object (below).
kacs.impersonation.started, kacs.impersonation.revertedAn impersonation attempt, and the end of an impersonation (§3.5.3).
kacs.caap.sacl.skipped, kacs.caap.staging.divergedA CAAP rule SACL that could not be parsed or evaluated; a staged result that differs from the effective one.
kacs.session.destroyedLogonSession teardown (§3.2.7).
kacs.descriptor.rejectedA descriptor xattr that exists but fails structural validation (§3.9.5).
kacs.caap.policy.changedkacs_set_caap installing, replacing or removing a central access policy (§3.8.8).
kacs.mount.policy.changedkacs_set_mount_policy (§3.9.5).
kacs.config.value.rejectedA port reservation table refused whole (§3.12.1).
stratafs.file.copied-upStrataFS copy-up lifecycle and failure (§3.9.7).
stratafs.mutation.refusedA StrataFS arrangement refusal.

kacs.descriptor.rejected carries object.kind file and outcome.reason corrupt. It does not name the file by path: the descriptor is read through internal path anchors whose mount cannot always be resolved to an absolute path, so object.file.path is absent. It names the file by object.file.inode and object.file.device (the filesystem's st_dev) instead, carries the stored attribute's length as object.sd.length, and carries the subject whose access made KACS read the descriptor.

KACS also writes kacs.signature.crypto.failed once at boot when the signature transform is unavailable (§3.6), carrying signature.crypto-stage boot-probe and the negative outcome.errno.

The privilege.name field of a kacs.audit.privilege.used event carries a canonical name. A record of an access check (operation.name access-check) names one of five — SeSecurityPrivilege, SeTakeOwnershipPrivilege, SeBackupPrivilege, SeRestorePrivilege and SeRelabelPrivilege — the only privileges an access check consults. A record of a capability or volume gate (linux-cap, volume-mount) names whichever privilege the gate spent (§3.4.1). The encoder names every defined privilege, and a bit with no name fails it closed rather than emitting an unnamed privilege; a token holding only privileges an access check does not consult produces no access-check record at all.

kacs.audit.handle.used carries an operation.name naming the enforcement point: file.access, file.mmap, file.mprotect, file.permission, file.write, file.ioctl, file.lock, file.fcntl, file.truncate and file.fallocate. It never carries a caller-supplied audit context: its object.kind is always file, with the handle's absolute path in object.file.path (absent only if the path cannot be resolved), and it never sets fields.attestation.userspace.

3.C.2 Descriptor changes #

kacs.audit.descriptor.changed records a change to the descriptor of a file, a token, a process or a System V IPC object by a caller authorised to make it, whether or not the change then applied. A change that includes the SACL is always recorded, whatever either descriptor says — so removing a file's SACL, which leaves nothing to audit the removal itself, is still recorded. Any other change is recorded when the rights it needed overlap the alarm mask on the handle it was made through. Only a file handle opened through FACS carries such a mask, so a change made through a path, or to a token, a process or an IPC object, is recorded only when it includes the SACL.

The record names the object by object.kind and its identity — object.file.path where it can be resolved, object.token.id and .guid, object.process.guid, or object.ipc.type and .id — and describes the change in object.sd: the parts set as components, and the length, SHA-256 digest and owner of the descriptor written (length, digest, owner) and of the one replaced (length-previous, digest-previous, owner-previous; absent where there was no stored descriptor). access.requested is the rights the change needed, with the handle's access.granted, access.audit-mask and the overlap as access.matched — zero when only the SACL made the record exist — for a change made through one. Registry keys have the same record as lcs.audit.key.descriptor.changed.

3.C.3 Delivery #

Audit and privilege-use events are delivered before any result is written back to the caller, and a delivery failure fails the syscall with EIO or EOPNOTSUPP. An audit event cannot be suppressed by handing the call a bad output pointer.

Three emissions are best-effort by contrast, and drop silently rather than failing the operation that caused them:

  • kacs.session.destroyed where the authentication package name is not valid UTF-8.
  • The two StrataFS events on an over-long operation string or path.
  • Any self-emitted payload that would exceed its encoding buffer.

A StrataFS record is not lost for want of memory. When the allocation for the full record fails, a reduced record is written from a fixed buffer instead: the same type with the stratum indices, the operation and the outcome, and without the paths — object.file.path-relative and each stratum.path — whose absence marks it as reduced.

The transport itself — ring buffer delivery, buffering and drop accounting — is KMES's (§2.5, §2.7).

Appendix 3.D KACS ABI Notes

Peios / Peios technical reference / PKM / KACS

§3.A is generated from pkm/uapi/pkm/ and holds only what a compiler can measure. This appendix holds the rest: the two ACE types that have a constant and no behaviour, the payload shapes behind the token query classes, the specification spellings a reader may arrive holding, what is deliberately documented elsewhere, and the kernel configuration KACS is built by.

The split is structural rather than editorial. gen-kacs-abi.py overwrites §3.A wholesale on every run, so anything written there is lost the next time the ABI changes — which is exactly what happened to two sections of this one before they were moved here.

3.D.1 ACE types with no evaluator behaviour #

Two of the ACE type constants in §3.A have a constant and nothing behind it. The ACE parser in kacs-core dispatches on 0x00–0x03, 0x05–0x14 and classifies every other value as opaque, so an ACE of type 0x04 or 0x15 is skipped during evaluation and written back byte-for-byte on serialisation. The constants exist so that a decoder can put a name to the byte. libpeios' SDDL codec does, printing 0x15 as SYSTEM_ACCESS_FILTER; the sd utility does not, and renders both as OTHER(0x04) and OTHER(0x15). PCDS §5.4 records the same state normatively.

3.D.2 Querying a token handle #

The ioctl is straightforward in shape:

ioctl(token_fd, KACS_IOC_QUERY, &args)

Where args is a kacs_query_args struct:

FieldMeaning
token_classThe numeric class identifying what to return (1–24 in v0.20).
buf_lenInput: the size of the output buffer in bytes. Output: the actual number of bytes the query needed.
buf_ptrUserspace pointer to the output buffer.

The kernel:

  1. Validates the class against the catalog. Unknown classes return -EINVAL.
  2. Checks that the fd grants TOKEN_QUERY. If not, returns -EACCES.
  3. Computes the size the response needs.
  4. If buf_ptr is zero or buf_len is zero — this is a size query — writes the required size to buf_len and returns 0.
  5. If buf_ptr is non-zero but buf_len is smaller than required, returns -ERANGE with the required size still written to buf_len.
  6. Otherwise writes the response to the buffer and returns 0.

The "two-call pattern" — size query then fetch — is the standard way to handle variable-length output:

  1. Call once with buf_ptr = NULL (or buf_len = 0). The kernel writes the required size into buf_len and returns 0.
  2. Allocate a buffer of the indicated size.
  3. Call again with buf_ptr set to the buffer and buf_len set to its size. The kernel writes the response.

For classes with a fixed-size response, a single call with a buffer of the known size works in one go. The two-call pattern is needed only for classes whose response size depends on the token's contents (the groups class, the restricted-SIDs class, the default-DACL class, the claims classes).

The ioctl is idempotent — multiple queries for the same class produce the same result as long as the token has not been modified. Tokens carry a modified_id counter that increments on adjustment; if a query is part of a pipeline that depends on consistency across multiple queries, the modified_id can be queried first to detect mid-pipeline changes.

3.D.3 Token query payloads #

The class numbers come from the header and are tabulated in §3.A; these are the payloads each one returns. Sizes are in bytes; a variable-length payload uses the shapes below. An invalid class returns EINVAL.

Two repeating shapes appear throughout. A SID array is [count:u32le] followed by count entries of [sid_len:u32le][sid_bytes][attributes:u32le], and reports a count of zero when the array is empty rather than an empty payload. A claims array is [count:u32le] followed by count entries of [entry_len:u32le][entry_bytes]. A bare SID is the SID bytes alone, and an absent optional SID or ACL is zero bytes.

ClassPayload
USERBare SID.
GROUPSSID array.
PRIVILEGES32 bytes: present, enabled, enabled-by-default and used, four u64 in that order.
TYPEu32, 4 bytes.
INTEGRITY_LEVELThe mandatory-label SID S-1-16-<level>, 12 bytes.
OWNERBare SID, resolved through the owner index: 0 is the user SID, N is groups[N-1].
PRIMARY_GROUPBare SID, resolved the same way.
INTERACTIVITY_SCOPEu32, 4 bytes.
RESTRICTED_SIDSSID array; count 0 on an unrestricted token.
SOURCE16 bytes: an 8-byte name followed by a u64 LUID.
STATISTICS40 bytes: token id, LogonSession id, modified id, token type, a reserved zero, and expiration.
ORIGINu64, 8 bytes.
ELEVATION_TYPEu32, 4 bytes.
DEVICE_GROUPSSID array.
APPCONTAINER_SIDBare SID; empty when the token is unconfined.
CAPABILITIESSID array.
MANDATORY_POLICYu32, 4 bytes.
LOGON_TYPEu32, 4 bytes, read from the LogonSession.
LOGON_SIDBare SID, derived from the LogonSession id.
DEFAULT_DACLBinary ACL; empty when none is set.
IMPERSONATION_LEVELu32, 4 bytes.
USER_CLAIMSClaims array.
DEVICE_CLAIMSClaims array.
PROJECTED_SUPPLEMENTARY_GIDS[count:u32le] followed by count u32 GIDs.
GUID16 raw bytes: the immutable token instance GUID, in the same byte order as KMES headers.

KACS_TOKEN_CLASS_GUID (0x19) uses the standard query contract: a size probe reports 16, a non-probe buffer smaller than 16 returns ERANGE without a partial payload, and every query requires TOKEN_QUERY. TOKEN_QUERY_SOURCE alone does not grant it.

Adjusting a token preserves its GUID; duplicating or restricting it creates a distinct token with a new GUID. Reopening the same token returns the same GUID. The STATISTICS payload remains 40 bytes and its token id is a LUID, not this GUID.

Querying the effective token returns the bytes stamped as KMES effective_token_guid; querying the real primary token returns true_token_guid. Impersonation changes which token the effective open selects, not either token's identity.

Several token fields have no query class at all: created_at, audit_policy, write_restricted, user_deny_only, isolation_boundary, confinement_exempt, the projected UID and GID — only the supplementary GIDs are reportable — restricted_device_groups, and the LCS registry credentials.

3.D.4 Names that differ from the specifications #

This manual uses the names uapi/pkm/ declares, and the generated tables of §3.A are authoritative for them. A reader may instead arrive holding the name PCDS uses, which is MS-DTYP's — a legitimate spelling, not an obsolete one, and the one a third party implementing PCDS will have. This table maps those onto the headers.

PCDS / MS-DTYPuapi name
ACCESS_ALLOWED_ACE_TYPE, SYSTEM_AUDIT_ACE_TYPE, ...KACS_ACE_TYPE_ACCESS_ALLOWED, KACS_ACE_TYPE_SYSTEM_AUDIT, ... (the qualifier moves to the front)
KACS_REAL_TOKENKACS_TOKEN_OPEN_REAL
KACS_LEVEL_*KACS_IMLEVEL_*
KACS_FILE_SUPERSEDE, _OPEN, ...KACS_DISPOSITION_*
OWNER_SECURITY_INFORMATION, ...KACS_SECINFO_*
SE_PRIVILEGE_ENABLED / _REMOVEDKACS_PRIVILEGE_ATTR_ENABLED / _REMOVED
KACS_PRIV_RESET_ALL_DEFAULTSKACS_PRIVILEGE_RESET_ALL_DEFAULTS
KACS_RESTRICT_WRITE_RESTRICTEDKACS_TOKEN_RESTRICT_WRITE_RESTRICTED
SE_GROUP_*KACS_SID_GROUP_*
TOKEN_CLASS_*KACS_TOKEN_CLASS_*

The PIP tiers have no public names at all. The Protected type (512) and the PeiosTcb trust level (8192) exist only as kernel-private constants, and nothing in uapi/pkm/ defines None, Protected or Isolated. A program reasoning about tiers compares the numbers (§3.7).

3.D.5 Socket options and retired syscall numbers #

Peer identity on AF_UNIX sockets is read and bounded through socket options rather than syscalls. uapi/pkm/socket.h defines the option level SOL_KACS (4096), its options KACS_SO_PEER_TOKEN, KACS_SO_IMPERSONATION_LEVEL, KACS_SO_PASS_TOKEN and KACS_SO_RESTAMP, and the ancillary message type KACS_SCM_TOKEN, which travels at cmsg_level SOL_KACS (§3.A). The kernel dispatches the option level in net/socket.c ahead of the protocol's own handlers (patch net/socket-sol-kacs-dispatch.patch), so the options reach KACS on every socket family and KACS decides which it supports. The ancillary message is carried per skb on AF_UNIX: net/scm-kacs-token.patch gives struct scm_cookie a counted token reference and parses SOL_KACS control messages; net/af_unix-kacs-token.patch carries the reference on struct unix_skb_parms, stops a stream read at an identity boundary, and advances the register at the point of consumption. Everything KACS-specific behind those hooks lives in kacs/socket.c. The semantics and error codes are in §3.5.3.

System V IPC objects carry descriptors of their own (§3.11); their rights are uapi/pkm/ipc.h, and the same header defines the KACS_SD_AT_SYSV_* flags that let kacs_get_sd and kacs_set_sd address such an object by kind and id (dirfd carries the id, the path is NULL). The lookup is ipc_lsm_with_object, exported from ipc/util.c by ipc/util-lsm-with-object.patch; the hooks themselves are the LSM's own IPC hooks and need no patch.

Three syscall numbers are retired and left as permanent holes: 1010 (kacs_open_peer_token), 1011 (kacs_impersonate_peer) and 1013 (kacs_set_impersonation_level). A binary built against them gets ENOSYS. The first became getsockopt(SOL_KACS, KACS_SO_PEER_TOKEN), the third setsockopt(SOL_KACS, KACS_SO_IMPERSONATION_LEVEL), and the second was a fusion of the first with KACS_IOC_IMPERSONATE that now lives in libpeios as peios_token_impersonate_peer.

3.D.6 Open-interface documentation discrepancies #

The comparison below records historical documentation differences. The pinned source findings resolve specific creator-SD, create-option and no-follow questions; they do not reconcile every group below. No runtime behavior or historical release boundary was tested or established. The generated ABI supplies layouts and constants; it does not settle these behavioral questions.

Source labels and line numbers refer to the learn snapshot 4b119864d7f51c569ae1ac1932e4430f06731cf2:

  • O: the former Security Fundamentals Opening files source, retained in that revision's history.
  • N: KACS-Native Open, TRM §3.9.2.
  • L: Legacy Open Compatibility, TRM §3.9.3.
  • D: Opening a file, SDK reference.
  • G: Securing files, SDK guide.

The links reach the current references; the line numbers identify the comparison snapshot above.

  1. Maximum allowed. O:116–127 says MAXIMUM_ALLOWED returns the maximum mask without checking the other requested bits, which it calls hints; N:14–19 says the concrete data/execute bits must be granted. Both require a concrete bit and reject MAXIMUM_ALLOWED alone. The general DACL walk describes a separate AccessCheck layer and cannot settle open validity by analogy.

  2. Legacy masks. O:135–145 maps O_RDONLY to FILE_READ_DATA | FILE_READ_ATTRIBUTES | FILE_READ_EA | READ_CONTROL | SYNCHRONIZE, O_WRONLY to FILE_WRITE_DATA | FILE_WRITE_ATTRIBUTES | FILE_WRITE_EA | READ_CONTROL | SYNCHRONIZE, and O_RDWR to their union. O:154–158 makes only FILE_READ_DATA core for read-only opens and allows FILE_READ_ATTRIBUTES to be dropped. L:16–37 instead makes FILE_READ_ATTRIBUTES core with read data, write data, or both according to the flag; L:39–49 lists broader compat rights. O:138 says O_APPEND adds FILE_APPEND_DATA; L:30–33 replaces core FILE_WRITE_DATA with it, then re-adds write data for O_TRUNC. L:44–45 also requests write data as optional compat access on append opens. These are different mappings, not interchangeable summaries.

  3. Creator descriptors. O:96–97 groups open-existing branches under EINVAL and lists SUPERSEDE only for an absent target. N:133–144 distinguishes FILE_OPEN (EOPNOTSUPP) from existing FILE_OPEN_IF, FILE_OVERWRITE and FILE_OVERWRITE_IF (EINVAL); N:82–89 also permits a caller-supplied SD on replacement by FILE_SUPERSEDE. D:36–46 and G:19–36 show OPEN_IF with a non-null creator SD and imply that the existing-file branch succeeds, despite O and N rejecting it. The SDK examples have now been changed to create-only. The pinned source findings below settle these existing-object rejections; this pass does not settle the separate SUPERSEDE comparison.

  4. Replacement and status. O:71 says SUPERSEDE removes the inode and recommends it for atomic-replace patterns. N:82–89 replaces the pathname while preserving old hardlinks and already-open references. O:112 claims nonconditional dispositions predict the status; N:176–179 says an absent-target FILE_SUPERSEDE reports CREATED, with SUPERSEDED only for actual replacement. No atomicity guarantee follows from this comparison.

  5. Delete-on-close. O:80 describes deletion on the last fd referencing the file. N:112–129 instead specifies final close of one file-description lineage, preserved by dup, fork and SCM_RIGHTS; later opens fail closed, and only regular files are supported. Generic inode last-reference semantics and this no-share lineage boundary differ.

  6. Unverified raw details. O:88 claims native AT_EMPTY_PATH opens an empty path against the directory referenced by dirfd; N:66–69 discusses that flag for get/set-security, not native open. O:140–143 maps O_CREAT to OPEN_IF with parent FILE_ADD_FILE, O_CREAT | O_EXCL to CREATE, and O_NOFOLLOW to AT_SYMLINK_NOFOLLOW. Their exact legacy translation and parent-right timing are not established by N/L/D. O:170 attributes legacy creation to umask-based defaults plus parent inheritable ACEs; Inheritance describes parent, creator and token SD sources without establishing that umask contribution. O:183,185 also lists path-component ENOTDIR and invalid-disposition EINVAL. These remain earlier, unverified claims; omission from another reference does not prove rejection or support. O:180 distinguishes a failed open access check from an unreachable path component; directory traversal is a separate authorization check.

  7. SDK destination caveats. D:3 mentions share mode; D:28 describes no-follow, write-through and the rest of the NtCreateFile option set. N:111–120 instead lists two supported create-option bits, reserves all others, and says the ABI has no share-mode field. D:26 calls the native result a “granted subset,” while N:6–19 distinguishes strict requests from maximum-allowed requests. The SDK reference is a programming entry point, not evidence that every difference has been reconciled. The current SDK page no longer advertises share mode or unsupported create options; the pinned source below verifies the accepted option bits and their SDK field mapping. The granted-subset wording versus strict/maximum semantics remains part of the unresolved comparison.

3.D.6.1 Pinned source findings #

A bounded source check used kernel 8e0e22de3a59cad506bbbf8873de456e16ad272d and libpeios 0.5.8 de0018bdcaed14abb796aeccec9c8387fd30e452. These are implementation findings for those commits, not proof about a deployed kernel or the release where a behavior began. Referenced KUnit tests are definitions; they were not executed.

  • Creator SDs (part of group 3). Argument validation rejects OPEN plus creator SD with EOPNOTSUPP and OVERWRITE plus creator SD with EINVAL. Existing-path handling rejects OPEN_IF and OVERWRITE_IF plus creator SD with EINVAL when the object exists. The OPEN_IF test records that expectation. SDK marshalling forwards the disposition and SD, rather than stripping the SD on an existing path. Use create-only with an explicit SD or open-existing with NULL SD; an EEXIST retry is a separate operation, not an atomic transaction.
  • Create options (part of group 7). The accepted-bit mask contains only DIRECTORY and DELETE_ON_CLOSE; unsupported bits return EINVAL. The same SDK marshalling copies options to create_options and flags to flags, so AT_SYMLINK_NOFOLLOW belongs in the latter.
  • No-follow is operation-specific. Native open rejects a terminal symlink with ELOOP. The by-path SD query instead selects the link itself through its lookup flags and path resolver, as recorded by a link-query test. The SDK query path forwards the flags; its buffer-size adaptation does not impose a terminal-link rejection. This query finding does not verify a successful link-SD write or remove the set-security component and storage checks.

Groups 1, 2, 4, 5 and 6 remain unresolved by this check, as do the other behavioral claims in groups 3 and 7. Do not treat the TRM, SDK or generated ABI as a blanket resolution of those differences.

3.D.7 What is not here #

Required rights, error codes and validation rules are properties of the implementation rather than of the headers, so they are documented with the operations themselves: token rights and the per-ioctl requirements in §3.2.8, the file rights in §3.9, the process rights in §3.3.3, and the privileges in §3.4.2.

Two neighbouring ABIs are generated or documented separately. uapi/pkm/trace.h is a versioned, append-only ABI of tracepoint reason, operation and state codes intended for tooling. uapi/pkm/kmes.h and uapi/pkm/lcs.h belong to their own chapters.

3.D.8 Build configuration #

CONFIG_SECURITY_PKM=y and CONFIG_RUST=y are required, as are CONFIG_STRICT_DEVMEM=y and CONFIG_MODULE_SIG_FORCE=y. The last two, and the absence of every other MAC LSM and of CONFIG_BPF_LSM, are enforced at build time by BUILD_BUG_ON in pkm_init (§3.7): a kernel configured without them does not compile. They were once checked at initialisation instead, which is no refusal at all -- the LSM framework answers a failed init with a warning and boots with the hooks uninstalled, the opposite of what the gate intends (PEI-487). CONFIG_SECURITY_SELINUX, _APPARMOR, _SMACK and _TOMOYO are additionally refused by Kconfig dependency. CONFIG_LSM is never parsed.

Two further symbols gate large bodies of code:

  • CONFIG_SECURITY_PKM_KUNIT, which compiles in the test harness and, in the signing path, a different and publicly known verification key (§3.6).
  • CONFIG_STRATAFS_FS, without which the copy-up API of §3.9.7 is inert.

Appendix 3.E LSM integration

Peios / Peios technical reference / PKM / KACS

KACS registers as the pkm Linux Security Module. This appendix is an implementation inventory for kernel developers: it describes the storage and call sites behind the contracts in this chapter. These C structures and callbacks are kernel-private, not userspace layouts or extension APIs. The exported ABI remains in §3.A.

The inventory comes from pkm/kacs/lsm.c (pkm_blob_sizes and pkm_hooks[]), the structures in kacs/lsm_internal.h, and the callback implementations listed below. When changing those declarations, update this inventory with the same change. The hook matrix preserves registration order; each row names an actual LSM_HOOK_INIT entry.

3.E.1 Security-blob inventory #

pkm_blob_sizes requests eight sized blob families. Each lbs_* entry is initialized with sizeof the corresponding structure; Linux's LSM infrastructure assigns its offset in the object's security storage. Byte sizes and offsets depend on the build and are not fixed by this manual.

LSM size entryKACS structureState carried
lbs_credpkm_kacs_cred_securityToken reference, shared process_state, projected UID/GID, and the pending descriptor used while overlayfs creates or copies up an inode.
lbs_taskpkm_kacs_task_securityShared process_state, usermode-helper and impersonation state, the applied primary-installation epoch and thread-enrollment callback, deferred privilege-audit work, native-open/create requests, write/metadata decisions, and narrowly scoped StrataFS creation, supersede, cleanup and delete-on-close context.
lbs_sockpkm_kacs_socket_securitySocket descriptor, conveyed peer/listener identities, token-conveyance cache and impersonation limits, binder identity/reuse decision, and the committed socket-owner identity used by network policy.
lbs_ipcpkm_kacs_ipc_securityRefcounted descriptor and lock for a System V message queue, shared-memory segment or semaphore array.
lbs_filepkm_kacs_file_securityCached granted_access and continuous_audit_mask, copy-up context and phase generation, managed, delete_on_close, and the referenced token that armed deferred deletion.
lbs_backing_filepkm_kacs_backing_file_securityCached granted_access and continuous_audit_mask, plus managed and inherited, for the explicit outer-to-backing-file snapshot transfer.
lbs_inodepkm_kacs_inode_securityLock and RCU descriptor-cache pointer, delete-on-close lineage and signed-exec pin counters, and the deferred descriptor-persistence state. Test-only members exist under CONFIG_SECURITY_PKM_KUNIT.
lbs_superblockpkm_kacs_superblock_securityMount-policy lock, template descriptor bytes/length, policy generation and mount policy.

The additional lbs_xattr_count = 1 reserves one xattr-initialization slot. It is not a ninth object blob or a byte-size declaration.

The file blob uses separate u8 managed and u8 delete_on_close fields. There is no flags member or KACS_FILE_FACS_MANAGED constant. The backing-file blob is distinct from the ordinary file blob: its inherited marker records explicit adoption of the outer description's cached grants and continuous-audit mask (§3.9.7).

The task blob reaches process-wide state through its process_state pointer. The refcounted pkm_kacs_process_state contains process_guid, pip_type, pip_trust, one u32 mitigation_bits, the KMES rate bucket, the process_sd reference and process-scoped privilege-audit bookkeeping. It also holds primary_lock, desired_primary_token and primary_epoch, which order primary-token installation across the process's threads (§3.2.3). PIP identity and the process descriptor are not inline task-blob fields; the process mitigations are not nine independent booleans. This shared state is separate from the effective token used during impersonation.

The credential blob's pending_create_sd has the lifetime of an override credential. Overlayfs uses it to stamp the descriptor computed for the original object or caller/parent onto the actual created inode; credential preparation does not inherit it into a derived credential. The task blob's internal SD read/write depth and operation contexts similarly identify specific kernel work; they are not caller-settable authorization flags.

3.E.2 Registered hook matrix #

The current pkm_hooks[] table contains 99 registrations. Paths in the last column are relative to the pkm source repository. A callback's presence here identifies where KACS is called; the operation's access, privilege and lifetime rules remain specified in the relevant chapter.

Linux LSM hookKACS callbackImplementation
cred_preparepkm_kacs_cred_preparekacs/cred_lifecycle.c
cred_transferpkm_kacs_cred_transferkacs/cred_lifecycle.c
cred_alloc_blankpkm_kacs_cred_alloc_blankkacs/cred_lifecycle.c
cred_freepkm_kacs_cred_freekacs/cred_lifecycle.c
sb_alloc_securitypkm_kacs_sb_alloc_securitykacs/object_lifecycle.c
sb_free_securitypkm_kacs_sb_free_securitykacs/object_lifecycle.c
sb_kern_mountpkm_kacs_sb_kern_mountkacs/mnt_namespace.c
inode_alloc_securitypkm_kacs_inode_alloc_securitykacs/object_lifecycle.c
inode_free_security_rcupkm_kacs_inode_free_security_rcukacs/object_lifecycle.c
inode_getattrpkm_kacs_inode_getattrkacs/file_metadata.c
path_notifypkm_kacs_path_notifykacs/namespace.c
inode_setattrpkm_kacs_inode_setattrkacs/file_metadata.c
inode_file_getattrpkm_kacs_inode_file_getattrkacs/file_metadata.c
inode_file_setattrpkm_kacs_inode_file_setattrkacs/file_metadata.c
inode_xattr_skipcappkm_kacs_inode_xattr_skipcapkacs/file_metadata.c
inode_getxattrpkm_kacs_inode_getxattrkacs/file_metadata.c
inode_setxattrpkm_kacs_inode_setxattrkacs/file_metadata.c
inode_post_setxattrpkm_kacs_inode_post_setxattrkacs/file_metadata.c
inode_removexattrpkm_kacs_inode_removexattrkacs/file_metadata.c
inode_listxattrpkm_kacs_inode_listxattrkacs/file_metadata.c
inode_copy_uppkm_kacs_inode_copy_upkacs/file_metadata.c
inode_copy_up_xattrpkm_kacs_inode_copy_up_xattrkacs/file_metadata.c
inode_follow_linkpkm_kacs_inode_follow_linkkacs/file_metadata.c
inode_set_aclpkm_kacs_inode_set_aclkacs/file_metadata.c
inode_remove_aclpkm_kacs_inode_remove_aclkacs/file_metadata.c
inode_getsecuritypkm_kacs_inode_getsecuritykacs/file_metadata.c
inode_permissionpkm_kacs_inode_permissionkacs/namespace.c
inode_createpkm_kacs_inode_createkacs/namespace.c
inode_linkpkm_kacs_inode_linkkacs/namespace.c
inode_unlinkpkm_kacs_inode_unlinkkacs/namespace.c
inode_symlinkpkm_kacs_inode_symlinkkacs/namespace.c
inode_mkdirpkm_kacs_inode_mkdirkacs/namespace.c
inode_rmdirpkm_kacs_inode_rmdirkacs/namespace.c
inode_mknodpkm_kacs_inode_mknodkacs/namespace.c
inode_renamepkm_kacs_inode_renamekacs/namespace.c
inode_readlinkpkm_kacs_inode_readlinkkacs/namespace.c
inode_init_securitypkm_kacs_inode_init_securitykacs/namespace.c
inode_post_create_tmpfilepkm_kacs_copy_up_post_create_tmpfilekacs/copy_up.c
dentry_create_files_aspkm_kacs_dentry_create_files_askacs/namespace.c
file_alloc_securitypkm_kacs_file_alloc_securitykacs/object_lifecycle.c
backing_file_allocpkm_kacs_backing_file_allockacs/object_lifecycle.c
file_releasepkm_kacs_file_releasekacs/object_lifecycle.c
file_openpkm_kacs_file_openkacs/file_access.c
file_receivepkm_kacs_file_receivekacs/object_lifecycle.c
file_permissionpkm_kacs_file_permissionkacs/file_access.c
file_ioctlpkm_kacs_file_ioctlkacs/file_access.c
file_ioctl_compatpkm_kacs_file_ioctl_compatkacs/file_access.c
file_lockpkm_kacs_file_lockkacs/file_access.c
file_fcntlpkm_kacs_file_fcntlkacs/file_access.c
file_truncatepkm_kacs_file_truncatekacs/file_access.c
kernel_read_filepkm_kacs_kernel_read_filekacs/firmware.c
kernel_post_read_filepkm_kacs_kernel_post_read_filekacs/firmware.c
task_allocpkm_kacs_task_allockacs/task_lifecycle.c
task_freepkm_kacs_task_freekacs/task_lifecycle.c
sk_alloc_securitypkm_kacs_sk_alloc_securitykacs/socket.c
sk_free_securitypkm_kacs_sk_free_securitykacs/socket.c
sk_clone_securitypkm_kacs_sk_clone_securitykacs/socket.c
socket_post_createpkm_kacs_socket_post_createkacs/socket.c
socket_bindpkm_kacs_socket_bindkacs/socket.c
socket_connectpkm_kacs_socket_connectkacs/socket.c
unix_stream_connectpkm_kacs_unix_stream_connectkacs/socket.c
unix_may_sendpkm_kacs_unix_may_sendkacs/socket.c
socket_listenpkm_kacs_socket_listenkacs/socket.c
ipc_permissionpkm_kacs_ipc_permissionkacs/ipc.c
msg_queue_alloc_securitypkm_kacs_ipc_alloc_securitykacs/ipc.c
msg_queue_free_securitypkm_kacs_ipc_free_securitykacs/ipc.c
msg_queue_msgctlpkm_kacs_msg_queue_msgctlkacs/ipc.c
shm_alloc_securitypkm_kacs_ipc_alloc_securitykacs/ipc.c
shm_free_securitypkm_kacs_ipc_free_securitykacs/ipc.c
shm_shmctlpkm_kacs_shm_shmctlkacs/ipc.c
sem_alloc_securitypkm_kacs_ipc_alloc_securitykacs/ipc.c
sem_free_securitypkm_kacs_ipc_free_securitykacs/ipc.c
sem_semctlpkm_kacs_sem_semctlkacs/ipc.c
task_killpkm_kacs_task_killkacs/process_access.c
ptrace_access_checkpkm_kacs_ptrace_access_checkkacs/process_access.c
ptrace_tracemepkm_kacs_ptrace_tracemekacs/process_access.c
task_setnicepkm_kacs_task_setnicekacs/process_access.c
task_setschedulerpkm_kacs_task_setschedulerkacs/process_access.c
task_setiopriopkm_kacs_task_setiopriokacs/process_access.c
task_setpgidpkm_kacs_task_setpgidkacs/process_access.c
task_getpgidpkm_kacs_task_getpgidkacs/process_access.c
task_getsidpkm_kacs_task_getsidkacs/process_access.c
task_getschedulerpkm_kacs_task_getschedulerkacs/process_access.c
task_getiopriopkm_kacs_task_getiopriokacs/process_access.c
task_movememorypkm_kacs_task_movememorykacs/process_access.c
task_fix_setuidpkm_kacs_task_fix_setuidkacs/cred_projection.c
task_fix_setgidpkm_kacs_task_fix_setgidkacs/cred_projection.c
task_fix_setgroupspkm_kacs_task_fix_setgroupskacs/cred_projection.c
task_prlimitpkm_kacs_task_prlimitkacs/process_access.c
capablepkm_kacs_capablekacs/capability.c
capsetpkm_kacs_capsetkacs/capability.c
task_prctlpkm_kacs_task_prctlkacs/psb.c
mmap_filepkm_kacs_mmap_filekacs/psb.c
mmap_backing_filepkm_kacs_mmap_backing_filekacs/object_lifecycle.c
file_mprotectpkm_kacs_file_mprotectkacs/psb.c
bprm_check_securitypkm_kacs_bprm_check_securitykacs/exec.c
bprm_creds_from_filepkm_kacs_bprm_creds_from_filekacs/exec.c
bprm_committing_credspkm_kacs_bprm_committing_credskacs/exec.c
bprm_committed_credspkm_kacs_bprm_committed_credskacs/exec.c

3.E.3 Direct file-metadata entry points #

An LSM hook receives the arguments supplied by the Linux hook API. Some file-descriptor operations also need the particular open file's cached FACS grant. PKM's patch series therefore calls KACS directly at the file-descriptor call site, before the later inode-level work. These functions are declared and called directly under CONFIG_SECURITY_PKM; they are not additional entries in pkm_hooks[] and are not dispatched through another LSM's hook table.

File-descriptor operationDirect KACS entry pointPatch in kernel/patches/fs/
Change modepkm_kacs_file_chmodopen-fchmod-fchown-gate.patch
Change owner/grouppkm_kacs_file_chownopen-fchmod-fchown-gate.patch
Change timestamps through a file descriptorpkm_kacs_file_utimensutimes-gate.patch
Read file attributes through a file descriptorpkm_kacs_file_getattrstat-getattr-gate.patch
Read an xattr through a file descriptorpkm_kacs_file_sd_xattr_getxattr-raw-sd-guard.patch
Set an xattr through a file descriptorpkm_kacs_file_sd_xattr_setxattr-raw-sd-guard.patch
Remove an xattr through a file descriptorpkm_kacs_file_sd_xattr_removexattr-raw-sd-guard.patch

All seven implementations are in kacs/file_metadata.c. The xattr functions enforce the file's metadata grant as well as the separate rules for protected SD attributes; their names do not make raw SD-xattr access a supported security-descriptor API.

The names security_file_setattr, security_file_getattr, security_file_getxattr and security_file_setxattr do not name hooks provided by this kernel. In particular, do not confuse them with the real inode_file_getattr and inode_file_setattr registrations in the matrix: those receive a dentry and struct file_kattr, not the open file whose cached grant the direct entry points consult. The metadata decision record connects the file-level check to the later inode-level operation. This table covers those file-metadata entry points, not every direct KACS call in PKM's patch series.

3.E.4 Reserved access-mask bits #

RESERVED_ACCESS_MASK_BITS in crates/kacs-core/src/access_mask.rs is 0x0ce0_0000 (bits 21, 22, 23, 26 and 27, counting from zero). It remains a private evaluator constant, not a new UAPI definition. §3.8.10 already specifies the public behavior: reserved bits in a desired mask or any ACE mask reject the check, rather than being ignored. This inventory records the implementation value without adding another exported spelling.

4.1 Overview

Peios / Peios technical reference / PKM / stratafs

stratafs presents an ordered set of existing directories — its strata — as one merged directory tree. Each stratum is an ordinary directory on an ordinary filesystem, owned and written by whatever agent owns it, with no coordination with stratafs and no notification to it. The merged view reflects changes to any stratum without a remount.

It is a stacking filesystem in the strict sense: it stores no file data, no directory entries, and no security descriptors. Every object reachable through a stratafs mount is a real object on a real stratum, and every data and metadata operation is performed against that object. stratafs inodes carry no address-space operations, so there is no second page cache to keep coherent; reads, writes, mappings and splices are forwarded to a backing file opened on the provider.

Unlike overlayfs, stratafs has no whiteouts and no opaque-directory markers. There is no mechanism anywhere in the filesystem for recording that a name should be absent. That single omission accounts for most of what is unusual about it: removing a name can leave the name visible, some names cannot be removed at all, and several operations that succeed on an ordinary filesystem are refused here rather than faked. §4.8 collects the consequences.

4.1.1 Where it sits #

stratafs is not part of PKM. It is staged into the kernel tree as fs/stratafs, built by CONFIG_STRATAFS_FS — a boolean option, so it is linked into vmlinux — and registered by an fs_initcall. The option depends on CONFIG_SECURITY_PKM, because stratafs reaches KACS for every access decision and for the whole of the copy-up context. That reach is through <linux/kacs_stratafs.h>, a kernel-private header staged into include/linux whose symbols are deliberately not exported to modules: there is no userspace surface to the interface between the two, and no way for anything but the in-tree filesystem to enter it.

The filesystem type registers under the name stratafs, with the superblock magic 0x53545241 — ASCII STRA — and the flags FS_USERNS_MOUNT and FS_RENAME_DOES_D_MOVE. It takes no device; superblocks come from get_tree_nodev, so the device identifier reported for every object in a mount is an anonymous one belonging to the mount rather than to any stratum.

A small part of the filesystem is written in Rust. The crate stratafs-core is staged into the kernel alongside PKM's own Rust cores and holds three pure, allocation-free decisions: validating the stack-wide flag rules, selecting the provider from a presence bitmap, and routing one modifying operation. Everything else — the VFS glue, resolution, enumeration, copy-up — is C.

4.1.2 The model #

A mount is defined by its stratum stack: an ordered list of strata, highest-precedence first, fixed for the life of the mount. A stratum is identified by its path, not by the directory that path resolved to at mount time, which is what allows a package transaction to replace a whole stratum by renaming trees around underneath a live mount.

For a given name in a given directory, the provider is the highest-precedence stratum that holds it. A name whose provider is a directory, and which lower strata also hold as a directory, resolves to a merged directory whose entries are the union of theirs. A name whose provider is anything else masks every lower entry of that name completely, subtree and all.

At most one stratum carries the create flag. That create stratum receives newly created objects and is the destination of copy-up — the replication of an object into the create stratum so that a modification can be applied without touching the stratum that provides it. A stack may have no create stratum, in which case nothing can be created and nothing copied up.

Routing a modification is a decision about strata alone. It happens when the modifying operation is performed, never at open, and it never consults the caller: by the time an operation reaches stratafs, KACS has already decided the caller was entitled to perform it. §4.5.1 sets out why no other formulation is implementable.

4.1.3 What it delegates #

stratafs holds no security descriptors, so it makes no access decisions of its own about the objects it presents. The descriptor evaluated for an operation is the one on the object the operation will be performed against, and KACS reads it through the ordinary extended-attribute path, which the stacking layer forwards down to the provider. A stratafs mount cannot grant access its provider stratum would refuse.

A merged directory is the exception, because it stands for several real directories with several descriptors and forwarding yields only the provider's. Those checks stratafs performs itself, against every participating directory, requiring all of them to succeed (§4.6.2).

Copy-up is the other exception, in the opposite direction. It is machinery serving an operation that was already authorised, not an operation a caller requests, so it must introduce no new checks against whichever task happens to execute it. KACS provides a kernel-internal copy-up context for exactly this, described in full at §3.9.7; §4.6.3 covers the stratafs half.

4.1.4 This chapter #

§4.2 covers the stack, the mount options that define it, what a mounter must be entitled to, and absent strata. §4.3 covers resolution: providers, merging, type conflicts, and enumeration. §4.4 covers coherency — how uncoordinated change is observed, and the inode identity presented over it. §4.5 covers mutation: routing, copy-up, creation, removal, rename, links, locking and durability. §4.6 covers the security seam with KACS, and §4.7 the one interface stratafs synthesises for userspace. §4.8 collects the failure modes, including the divergences from ordinary filesystem behaviour that follow from having no whiteouts, and §4.A the constants.

4.2.1 The Stratum Stack

Peios / Peios technical reference / PKM / stratafs / Strata and Mounting

A mount is defined by its stratum stack: an ordered, non-empty list of strata, highest-precedence first. The stack is fixed for the life of the mount. Nothing reorders it, and precedence never varies by path, by caller, or by operation.

Index 0 is the highest-precedence stratum. That convention runs all the way through: strata are stored in a fixed array in mount-option order, presence across the stack is a u64 bitmap indexed by the same position, and selecting the provider is the trailing-zero count of that bitmap — the lowest set index, which is the highest-precedence stratum that holds the name.

The array is STRATAFS_MAX_STRATA entries, which is 16. A stack longer than that is refused at parse time. Absence never renumbers anything: an absent stratum simply has its bit clear, keeping its slot and its precedence.

4.2.1.1 A stratum is a path #

What is stored for each stratum is a string and a flag word — nothing else. No reference is held on a stratum's directory, and no resolution result is retained across operations. Every time a name is resolved, the stratum's path string is joined with the relative path of the name and walked from scratch.

This is what makes a stratum follow a wholesale replacement. A package transaction that renames the old tree aside and the new tree into place leaves the original directory object intact and referenced by anything that held it; a stratum defined as that object would go on serving the replaced tree. Defined as a path, it follows.

The cost is that every lookup does the walk. §4.4.2 covers what that means in practice, because the implementation does not cache resolutions at all.

4.2.1.2 The resolution context #

Stratum paths are joined absolute and walked from a root captured when the mount was created — the creating process's own filesystem root — under credentials captured at the same moment. Both are pinned for the life of the mount and released only when the superblock is freed.

The credential override matters as much as the root. A stratum path is resolved with the mounter's credentials, not with those of whatever process later touches the mount, so a caller in a different mount namespace cannot shift what a stratum denotes, and the paths reported by the origin attribute (§4.7) always name the same things.

Symbolic links and mounts along a stratum's path are followed as they stand at the moment of resolution, since the walk is an ordinary filename_lookup with no restricting flags. A mount established inside a stratum is therefore part of that stratum's tree, and one stratum can span several filesystems — which §4.4.3 has to account for when deriving inode numbers.

4.2.1.3 What a stratum's filesystem must provide #

Nothing beyond ordinary directory and file operations. stratafs probes no capability at mount time: it checks only that each stratum path resolves, names a directory, is not a duplicate of another stratum, and does not push the composed stack past the kernel's maximum stacking depth. There is no test for a usable directory version value, because nothing in the implementation would use one.

4.2.1.4 Flags #

Each stratum carries zero or more flags, declared with it in the mount options and stored as a bit field.

FlagBitMeaning
createSTRATAFS_F_CREATEThis stratum receives newly created objects and is the destination of copy-up.
roSTRATAFS_F_ROstratafs does not modify this stratum.
amSTRATAFS_F_AMThis stratum's directory may be absent.

The stack-wide rules are decided in Rust, in stratafs-core: the stack must be non-empty, must not exceed 16 strata, must contain no unrecognised flag bit, must not carry create twice, and must not carry create and ro on one stratum. The crate distinguishes five error cases, but the C boundary collapses all of them to EINVAL, so the distinction is not observable to a caller.

4.2.1.4.1 create #

At most one stratum carries create, and a stack may carry none, in which case create_index is -1 and every creation and every copy-up is refused with EROFS. Modification of an object provided by a stratum that accepts modification is unaffected, because routing tests the provider before it consults the create stratum at all (§4.5.1).

create does not mean "writable", and it is not the only stratum stratafs writes to. It designates where objects that do not yet exist in any stratum are created, and where an object is copied when its provider will not accept a modification. A stratum carrying neither create nor ro is modified in place, and any number of such strata may sit above the create stratum, below it, or both.

4.2.1.4.2 ro #

ro is a stratafs-level assertion, independent of whether the stratum's filesystem is itself read-only. It is one of three terms in the predicate that decides whether a stratum accepts modification of an object it provides:

  • the stratum does not carry ro;
  • the provider's mount is not read-only;
  • the provider's inode is not marked immutable.

The predicate takes only the superblock, the stratum index, and the provider path. It reads no credentials, consults no security descriptor, and calls into KACS not at all — which is what stops a caller who has been refused write access from provoking a copy-up (§4.5.1).

Note the third term is specifically the immutable inode flag. A file that is unwritable by its mode bits is not excluded by this predicate; the write is routed in place and the underlying filesystem refuses it.

4.2.1.4.3 am #

Without am, a stratum's directory must exist when the mount is created, and a mount whose stratum directory is absent fails with ENOENT. With am, an absent directory is accepted at mount time.

The flag governs mount time only. At runtime the resolver never consults it: a stratum whose directory has gone is skipped exactly the same way whether or not it carries am. §4.2.4 covers what absence means once the mount is live.

4.2.2 Mount Options

Peios / Peios technical reference / PKM / stratafs / Strata and Mounting

stratafs registers exactly one filesystem-specific mount parameter, strata, and rejects every other name. There is no option to select a security descriptor, an access-check behaviour, an inode-numbering scheme, or a caching mode.

strata=<stratum>[:<stratum>]...
<stratum> := <path>[+<flag>]...
<flag>    := create | ro | am

Strata are separated by : and given highest-precedence first. Each stratum is an absolute path, optionally followed by flags, each introduced by +.

strata=/system/retc:/lcl/etc+create:/usr/etc+ro

: separates strata rather than , because a mount options string is itself comma-separated when passed through the legacy mount interface, which would otherwise split the value.

4.2.2.1 Escaping #

Within a path, a literal :, +, , or \ is escaped by a preceding \. Those four characters are the entire escapable set; the escaped byte is stored literally, and the backslash is consumed.

Because the legacy mount(2) data is one comma-separated string, stratafs replaces the VFS's monolithic option splitter with one that honours backslash escapes, so that an escaped comma inside a stratum path survives the split rather than being read as an option boundary.

4.2.2.2 Parse failures #

Every malformed value is refused with EINVAL. The parser consumes the whole string and errors on any byte it cannot classify; there is no skip-and-continue path, so nothing it does not understand is silently ignored.

Condition
The strata= option is absentThere is no default stack
Its value is empty
An element between two separators is empty, or the value begins or ends with a separator
A path is not absoluteTested on the raw first byte, which is exact since / is not escapable
A path is empty after unescapingDefensive; the absolute-path test already guarantees one byte
An unescaped , appears in a path, or inside a flag tokenThe option string is comma-separated at the outer level
A \ appears at the end of the value, or before a character that is not :, +, , or \A dangling or meaningless escape
A + is followed by no flag, or by an unrecognised oneFlag names are matched by exact length and content
The same flag appears more than once on one stratum
More than 16 strataThe array bound is reached mid-parse
strata= appears twice in one option string

Two paths return ENOMEM rather than EINVAL, both allocation failures during parsing. Nothing bounds a stratum path at parse time: an over-long one is accepted here and fails later with ENAMETOOLONG when the joined path exceeds PATH_MAX during a resolution.

The stack-wide conditions — an empty stack, two create strata, a stratum carrying both create and ro — are checked separately, after parsing and before any path is resolved. They depend on nothing but the option string, so they are reported whatever the caller's access (§4.2.3).

4.2.2.3 Generic mount flags #

Generic flags apply as they do to any filesystem, with one addition. A stratafs mount may be mounted read-only, and the superblock's read-only state is the first term of the routing decision, short-circuiting before the provider or the create stratum is considered at all. It therefore refuses every mutation with EROFS regardless of the stratum stack, and is both independent of and stricter than a stack with no create stratum.

4.2.2.3.1 Locking and synchronising a read-only mount #

Locking and synchronising are unaffected. Neither modifies an object, neither consults the superblock's read-only state, and a reader of a merged tree may need both.

4.2.2.4 Remount #

A remount may alter generic mount flags. It may not alter the stack: a remount supplying a strata= whose value differs from the mounted one's is refused with EINVAL.

The test is on the value, not on the presence of the parameter, so a remount that replays the current stack byte-for-byte is accepted — it adds, removes, reorders and re-flags nothing. That is what makes option-string replay work: a tool reconstructing the whole option string from the mount table and handing it back is doing exactly this, and flipping a stratafs mount read-only would otherwise fail on a strata= that changed nothing.

The comparison is of the stored option string (§4.2.2, below), which is why that string being reported without a second escaping layer is what lets a replayed stack compare equal at all.

4.2.2.5 The mount table #

The stack is reported in the filesystem options the kernel exposes for mounts, so it is discoverable by anything that can read the mount table, which on Linux is unprivileged. What is stored for this purpose is the caller's own option string, kept verbatim at parse time: nothing is abbreviated, no stratum is omitted, no path is canonicalised, and an absent stratum is reported like any other, because the string is fixed at mount and never filtered by what currently exists.

The filesystem type is reported as stratafs.

The value is emitted through the kernel's seq_show_option, which applies its own escaping — octal for ,, \, and whitespace — on top of the escaping the value already carries. For a path containing none of : + , \ or whitespace, which is the ordinary case, the reported value is byte-identical to what was supplied. For a path containing any of them it is not reconstructable: a stored \: is re-escaped to \134:, which reads back as a dangling escape. This is tracked as a defect.

4.2.3 Mount Admission

Peios / Peios technical reference / PKM / stratafs / Strata and Mounting

A stratafs mount is configured entirely at mount time. This section covers what the caller must be entitled to, the conditions a configuration has to satisfy, and the order in which those conditions are evaluated.

4.2.3.1 Entitlement #

Establishing a mount with no create stratum takes no privilege of its own. The filesystem type carries FS_USERNS_MOUNT, and stratafs is on the allowlist of types a mount namespace's descriptor may admit (§3.13), so a caller may establish one in a mount namespace it created without the mount privilege; what the caller needs beyond that is only the access that resolving the stratum paths already requires, which falls out of the resolution itself. A user namespace by itself confers nothing here, as everywhere under KACS.

A stack that carries create is different. Copy-up is authorised by the outer handle and deliberately requires no add-entry right on the create stratum (§4.6.2), so the mount configuration carries authority to materialise names in a real directory outside the mount. The caller establishing such a stack must be operating in the initial user namespace and hold CAP_SYS_ADMIN there; anything else fails with EPERM.

The test is made in get_tree, before the tree is built and therefore before any stratum path is resolved, and it is stricter than requiring the capability alone: the credential's own user namespace must be the initial one, not merely a namespace in which the capability resolves. This is the closure the KACS copy-up context depends on. The check is made once, when the immutable stack is established, rather than at each copy-up, so descriptor delegation cannot reintroduce authorisation against the acting task, and no reconfiguration can re-supply the strata list.

There is no explicit per-stratum entitlement check in the mount path. Both halves come out of the ordinary machinery: traverse rights are enforced by the path walk, which runs under the mounting caller's credentials, and the right to read a stratum directory's attributes is enforced by using the security-checking vfs_getattr rather than its unchecked variant, which reaches KACS's getattr hook and demands FILE_READ_ATTRIBUTES.

One seam is worth recording: the path walk uses the credentials captured on the filesystem context, while vfs_getattr runs outside that override and so uses the acting task's. For a mount established the ordinary way the two are the same task. For one established with fsopen and fsconfig from different tasks they need not be.

Nothing re-checks entitlement if a stratum directory later appears. That is sound because every access through the mount is checked against the providing object in any case (§4.6.1), so a caller who mounts a stratum they cannot read still cannot read it.

4.2.3.2 Validity #

ConditionError
The stratum stack is emptyEINVAL
The stack exceeds 16 strataEINVAL
More than one stratum carries createEINVAL
A stratum carries both create and roEINVAL
The same directory appears as more than one stratumEINVAL
A malformed strata= value (§4.2.2)EINVAL
A create-bearing stack from outside the initial user namespace, or without CAP_SYS_ADMIN thereEPERM
A stratum's path names something other than a directoryENOTDIR
A stratum's directory is absent and the stratum does not carry amENOENT
The composed stack reaches the kernel's maximum stacking depthELOOP
A stratum lies within the mount point, or within another stratafs mount whose strata include this mount pointELOOP
Sixteen consecutive collisions allocating a mount cookieEAGAIN

Two strata are the same directory when they resolve to the same directory object, not merely when their paths are equal as strings: the comparison is on resolved inodes, so two paths reaching one directory through different symbolic links or bind mounts are caught. Absent strata are skipped and never compared.

4.2.3.3 Evaluation order #

The conditions that depend on nothing but the option string are decided first, during parsing and stack-wide validation, and are reported whatever the caller's access. The EPERM admission test comes next, in get_tree, still before any path is touched. Only then are the strata resolved.

The purpose of that ordering is to stop the validity conditions being an oracle: a caller with no right to traverse a directory should not be able to name it as a stratum and learn from the errno whether it exists and whether it is a directory.

The path walk and the attribute read both run under the caller's credentials, so a path the caller cannot resolve returns EACCES from the walk itself, and the EACCES is propagated unchanged.

The check is stack-wide because it runs in two passes. The first resolves and stats every stratum; an EACCES from any of them is the answer, whatever an earlier stratum reported. Only when every stratum has passed entitlement does the second pass apply the type, duplicate and depth conditions. A caller who names a readable non-directory first and an unreadable path second is told EACCES, not ENOTDIR.

The mount-point loop condition is evaluated in a different call entirely, after the tree has been built, and so always follows every entitlement check.

4.2.3.4 Loops #

A stratum inside the mount point is detected directly. The indirect case — a stratum inside another stratafs mount whose own strata include this mount point — is detected by recursing into any stratum whose superblock carries the stratafs magic, bounded by a visited- superblock set and by the kernel's maximum stacking depth. This runs through a Peios-added super_operations hook, validate_mountpoint, wired into the new-mount, bind and move-mount paths by the patch series; it is not an upstream interface.

That check cannot bind after the fact: a mount established within a stratum, or a bind mount of this mount into one of its own strata, can create a cycle later. Resolution is guarded separately, and differently — not by a depth counter but by a per-task, per-superblock re-entrancy list. A task that is already resolving in a superblock and re-enters it gets ELOOP immediately, so a cycle spanning any number of stratafs mounts terminates the moment it returns to one it is already inside.

4.2.3.5 Immutability and mount identity #

The stack is fixed for the life of the mount; changing one is expressed by unmounting and mounting again (§4.2.2).

Two stratafs mounts may name the same directory as a stratum. Each resolves independently and neither is aware of the other; where both have a create stratum in common they mutate the same objects, with the same result as any two writers of one directory. There is no registry of stratum paths to make them aware of each other.

There is a registry of mounts, but it exists for a different purpose. Each mount draws a random non-zero cookie and inserts itself into a global table, retrying up to sixteen times on collision; a second random non-zero cookie is drawn once per boot. The pair identifies which live mount owns a staging entry, so that a mount sharing a create stratum can distinguish another mount's copy-up in flight from an orphan left by a crash (§4.5.2).

4.2.3.6 An all-absent stack #

A mount succeeds even when no stratum root is present at all — a stack whose only strata are absent am strata is legal. The root inode is constructed with no provider, and reports mode S_IFDIR with no permission bits.

Such a root still carries a security descriptor, since an object with none would refuse every access check made on the mount root and the provider-less root could never be observed. stratafs synthesises it: owned by the mounter, with read and traverse for everyone, and nothing more — there is nothing beneath such a root to write. The moment a stratum root appears the root's descriptor is that root's own, and when it goes again the synthesised one returns; KACS never caches a StrataFS root's descriptor, so no access is decided against a root that has since gained or lost its provider.

4.2.4 Absent Strata

Peios / Peios technical reference / PKM / stratafs / Strata and Mounting

A stratum's directory may not exist. It may be absent when the mount is created — which requires am (§4.2.3) — or exist at mount time and be removed while the mount is live, which nothing prevents for any stratum.

4.2.4.1 While absent #

An absent stratum holds no names, participates in no merged directory, and contributes no entries to any enumeration. It keeps its position and its precedence; only its presence bit is clear.

The mechanism is a single test in the resolver. Resolving one stratum either succeeds, or fails with ENOENT or ENOTDIR, in which case the stratum is skipped and its bit is left clear. Any other error — EACCES, EIO, ELOOP, ENAMETOOLONG, ESTALE — is not treated as absence and fails the whole resolution instead. So a stratum that is unreadable for a reason other than not being there masks the name for every stratum, rather than being passed over.

An absent create stratum additionally causes every operation that would create or copy up to fail with EROFS. The create stratum's root is re-resolved at the point of use and its ENOENT is mapped to EROFS by each of the paths that needs it — the copy-up parent walk, the creation authorisation pre-check, and the routing decision, which computes create-stratum presence live and requires the result to be a directory. stratafs does not create the stratum's own directory to satisfy such an operation: establishing that directory, with the security descriptor it should have, belongs to whatever provisions the system, and a mount that minted it would be choosing that descriptor.

4.2.4.2 Appearing and disappearing #

Neither event is detected. Both are simply observed, because there is nothing to invalidate.

A stratum's directory is never held; only its path string is. Every resolution walks that string afresh, so a directory that comes into existence is picked up by the very next lookup, and one that is removed, renamed away, or replaced by another directory is picked up just as immediately — the walk finds nothing, or finds the replacement.

The specification describes this in terms of a version tuple recorded over the nearest existing ancestor of an absent stratum's path, and an identity comparison for a stratum that disappears. Neither exists in the implementation, and neither is needed: they are the machinery for knowing when a cached resolution has gone stale, and nothing is cached. §4.4.2 covers the trade that represents.

The am flag is not consulted at runtime at all. It governs whether the mount is allowed to be established with the directory missing, and nothing more; a stratum without it that vanishes afterwards behaves exactly like one with it.

4.2.4.3 Reappearance #

A stratum that disappears and reappears is the same stratum in the same stack position, and nothing about the old directory is remembered. Resolutions are recomputed against whatever the new directory holds.

Provider identity does survive the gap, though it is not resolution memory. The derived inode number (§4.4.3) identifies the provider's filesystem and inode number. If the same underlying object is reached again — because the directory was renamed away and back rather than replaced — it receives the same number it had before, without a mount-lifetime pin on ordinary provider inodes. Only identities that cannot be derived use the pinned fallback map. That is exactly what the identity rule requires: equal numbers for one provider object, whatever path reached it.

4.3.1 Lookup

Peios / Peios technical reference / PKM / stratafs / Name Resolution

Resolution is defined for one name in one directory. Every path operation is a sequence of such resolutions, each independent of the last.

4.3.1.1 The provider #

To resolve a name in a stratafs directory, the corresponding directory of each stratum is examined in precedence order, and the first stratum holding an entry of that name is its provider. If no stratum holds it, the resolution produces a negative dentry and the VFS reports ENOENT.

Mechanically, each stratum's path string is joined with the relative path of the name and walked in full, once per stratum. A walk that succeeds sets that stratum's bit in a presence bitmap; a walk that fails with ENOENT or ENOTDIR leaves it clear and the stratum is skipped. Selecting the provider is then the trailing-zero count of the bitmap, computed in stratafs-core.

Any other error from a stratum's walk — EACCES, EIO, ELOOP, ENAMETOOLONG, ESTALE — is not treated as absence. It fails the whole resolution, so a stratum that is unreadable for a reason other than not being there masks the name entirely rather than being passed over.

A joined stratum path, or a child relative path, that would exceed PATH_MAX fails with ENAMETOOLONG.

4.3.1.2 Ancestors #

Resolution does not consult a parent's provider to find a child's. A name's provider is chosen afresh across all strata, so if /a is provided by stratum 2, /a/b may still be provided by stratum 1 — provided stratum 1 also holds /a as a directory and the two therefore merge (§4.3.2).

What resolution does consult is whether any ancestor of the path is masked. Before resolving the final component, every proper prefix of the relative path is resolved across all strata and its merged provider computed; a prefix whose provider is not a directory aborts the whole resolution with ENOTDIR. That is what makes masking total (§4.3.3), and it is why a lookup costs one full walk per stratum for the name itself plus one merged resolution per path component above it.

4.3.1.3 Independence from the caller #

Resolution runs under the credentials captured at mount, against the root captured at mount, and takes no operation argument. It does not depend on the calling token, on what the caller is trying to do, or on whether the operation will ultimately be permitted.

A name whose provider the caller may not access therefore resolves normally and is then refused. It does not fall through to a lower stratum — which would let a caller's rights change which file they read, a considerably worse property than a denial.

4.3.1.4 Reaching the object #

Once a name resolves to a non-directory provider, the object is the provider's object and stratafs does not interpose on its contents. The outer inode takes the provider's mode and, from it, the operations tables for a regular file, symlink or special file; reads, writes, mappings, splices, locks and ioctls are forwarded to a backing file opened on the provider.

Symbolic links are forwarded rather than followed. The outer inode's get_link calls the provider inode's own and returns the raw target verbatim; stratafs deliberately does not use vfs_get_link, which would demand a read right the caller need not hold to traverse a link. The VFS then interprets the target in the caller's own namespace, so an absolute target resolves from the process's root and may re-enter this mount, another stratafs mount, or none.

Where a mount is established at a path within a stratum, stratafs follows it: the stratum walk is an ordinary filename_lookup with no flag restricting it to one filesystem, and everything downstream operates on the inner mount it returns.

4.3.1.5 Staging entries #

One class of name is invisible to resolution. While a copy-up is in flight, its staged object may exist under a name in the create stratum; that name is dropped from resolution for the mount that owns it, so an incomplete copy is never reachable through the merged view (§4.5.2). The suppression applies only to the create stratum, and only within the owning mount — a second stratafs mount sharing that directory, and any direct reader of it, sees an ordinary entry.

4.3.1.5.1 Recovery triggered by lookup #

Staged names begin with .stratafs-stage-, and a lookup of any name with that prefix triggers a recovery scan of the create-stratum parent before resolving. A resolution can therefore have the side effect of removing orphaned staging entries from the create stratum.

4.3.1.6 Recursion #

A task that is already resolving inside a superblock and re-enters the same superblock fails immediately with ELOOP. The guard is a global list of task-and-superblock pairs, not a depth counter, so a cycle formed after mount — a stratafs mount established inside one of its own strata, or bind-mounted into one — terminates the moment resolution returns to a mount it is already inside.

4.3.2 Directory Merge

Peios / Peios technical reference / PKM / stratafs / Name Resolution

Where a name's provider is a directory, and lower-precedence strata hold the same name as a directory, the resolved object is a merged directory.

4.3.2.1 Participation #

The strata participating in a merged directory are, in precedence order, those whose corresponding directory both exists and is a directory. A stratum holding the name as a non-directory does not participate and is masked entirely (§4.3.3); a stratum not holding the name simply does not participate.

Every consumer of a merged directory applies the same two-part filter — the presence bit is set, and the resolved dentry is a directory — in a single ascending loop over stratum indices with a continue for non-participants. Nothing compacts or re-sorts, so relative precedence within a merged directory is always the relative precedence in the stack.

Merging is recursive, and it is recomputed rather than cached: there is no merged-directory object anywhere. Each lookup and each directory open rebuilds the full per-stratum path set from the relative path string, so any child that is a directory in more than one stratum merges at its own level by the same rule.

4.3.2.1.1 Participation at the mount root #

The root of a mount is a merged directory whose participants are the mount's strata. The root has one special case: where the ordinary provider rule would pick a stratum root that is present but not a directory, the root instead takes the first stratum that is both present and a directory, so an absent or non-directory stratum root cannot change the synthetic root's type. A mount whose stratum roots are all absent still has a root inode, with no provider and no permission bits.

4.3.2.2 The create stratum of a merged directory #

A merged directory's create stratum is the correspondingly-named subdirectory of the mount's create stratum, at the same path relative to the mount root — whether or not that subdirectory currently exists. It follows that a merged directory has a create stratum even when the mount's create stratum holds no part of that path, and creation still routes there.

The derivation is positional and mount-wide: create_index is a single integer on the superblock, fixed at mount, and every creation and copy-up site reads it directly. Nothing re-derives it from which strata happen to participate.

That matters because the alternative — taking the create stratum to be the highest-precedence participating writable directory — would make the destination of a write depend on which directories happened to exist, so creating a file in a subdirectory could land in a different stratum from creating one beside it.

Where the create stratum's counterpart of a merged directory does not exist, the path is materialised on demand, from the mount root downwards, at the point an operation first needs it (§4.5.2). The authorisation for creating into it is evaluated against the descriptor that directory will carry once materialised, which is the corresponding provider directory's (§4.6.2).

4.3.2.3 Symbolic links and participation #

Two resolution entry points disagree about the final component of a stratum path, and the difference is visible.

Ordinary lookup resolves without following the final component, so a symbolic link at a name resolves to the link itself and the VFS follows it. Every site that builds the participant set resolves the same way — the set itself, the emptiness scan, the foreign-entry scan, the permission check and directory fsync.

A symlink is a non-directory, so a stratum holding a name as one is masked entirely, exactly as §4.3.3 has it: it does not participate, contributes no entries to the merged listing or to emptiness tests, and masks whatever the strata below hold at that name. A dangling symlink is the same, and for the same reason.

The two views agreeing is what closes a confused-deputy shape. Were the participant set to follow the final component while lookup did not, a stratum owner who replaced a directory with a symlink would change which real directory contributed entries to another stratum's merged view, and the permission check would run against a target the mounter never named.

4.3.3 Type Conflicts

Peios / Peios technical reference / PKM / stratafs / Name Resolution

Two strata may hold the same name with different types — a directory in one, a regular file in another. The provider's type is the type of the resolved object, and the outer inode is given the provider's mode and the operations tables that follow from it.

  • Where the provider is a directory, lower-precedence strata that hold the name as a directory participate in a merged directory (§4.3.2). Lower-precedence strata that hold the name as anything else are masked.
  • Where the provider is not a directory, every lower-precedence entry of that name is masked, whatever its type. Only the provider's path is retained on the dentry; the other strata's references are released as soon as the provider is chosen.

4.3.3.1 Masking is total #

A masked entry is unreachable through the mount. Where the masked entry is a directory, its entire subtree is unreachable: no path beneath the masked name resolves, regardless of what the masked directory contains and regardless of whether some other stratum holds part of that subtree.

This is enforced by the ancestor pass described in §4.3.1. Each proper prefix of a relative path is resolved across every stratum and its merged provider computed; a prefix whose merged provider is not a directory returns ENOTDIR before the final component is considered. Because the test uses the merged answer rather than a per-stratum one, another stratum holding the subtree cannot rescue it.

A regular file at /x in a high-precedence stratum therefore hides a whole /x/… tree in a lower one. That is severe, and deliberate: the alternative — resolving /x as a file but /x/y through the masked directory — would make a path's meaning depend on how far along it the caller looked.

4.3.3.1.1 Masking leaves the strata unchanged #

Masking modifies nothing. Resolution and enumeration are read-only throughout, no marker is written to any stratum, and the masked entry remains present and unchanged in its own stratum, reachable by any path that does not traverse the mount.

4.3.3.2 Stability #

Type conflicts resolve identically for every caller and every operation, because provider selection is a pure function of the presence bitmap and consults neither.

An operation that would only be valid against the masked type does not cause the masked entry to be selected. It fails against the provider instead, with whatever error that type produces — typically ENOTDIR from the ancestor pass, or EISDIR raised by the generic VFS against an outer inode carrying the provider's mode.

4.3.4 Enumeration

Peios / Peios technical reference / PKM / stratafs / Name Resolution

Enumerating a merged directory yields the union of the names held by its participating strata, with each distinct name appearing exactly once.

4.3.4.1 Capture at open #

The whole listing is built when the directory is opened, and the enumeration is served from that capture for the life of the descriptor. Each participant is opened and iterated in ascending stratum order, and its entries are appended to one list.

Deduplication is global rather than per-stratum: before an entry is recorded, the whole accumulated list is scanned for the same name, and a match causes the entry to be dropped. Because participants are visited highest-precedence first, the entry that survives is always the provider's. . and .. are dropped from every participant and synthesised once.

Each surviving entry carries the name, its length, the directory entry type reported by the providing participant, and an inode number obtained by looking the child up in that same participant and deriving its identity, with allocation only as a fallback (§4.4.3), so getdents and stat agree. The final component is not followed during that lookup, so a symlink entry reports its own identity rather than its target's.

Two details of that: an entry that vanishes between the participant's own readdir and the follow-up lookup is silently dropped, which is ordinary provider behaviour; and a participant filesystem that reports DT_UNKNOWN has that propagated unchanged, even though the child path is in hand and the real type could be derived.

Shadowed entries are neither reported nor otherwise detectable. No second record is ever allocated, so nothing about them survives into the listing, and the entry count reflects distinct names only.

The order in which names are reported is stratum-ascending and then each stratum's own readdir order — deterministic for one capture, and not otherwise specified.

The implementation uses a temporary, per-open name hash while capturing entries, then an immutable position index for enumeration. Growing the hash does not reorder entries or change which provider wins a duplicate. The hash buckets are released before the open succeeds. Capture remains eager under the opener's authority; the index does not defer any part of that authorisation to a later reader or descriptor recipient.

Deduplication takes expected linear work in the number of names offered by the participants. Resuming enumeration or seeking to an ordinal does not scan earlier entries again. Provider enumeration, child lookup and access checks still contribute their own costs. Each independent open retains its own names and position index, so memory use still grows with the number of distinct captured names and simultaneously open file descriptions. These are implementation properties, not time or memory limits promised to callers.

4.3.4.2 Consistency #

There is exactly one capture per descriptor. Nothing appends to the list after the directory is opened, and nothing re-captures — a rewind replays the original capture, since the directory uses the generic llseek. A change to any participating stratum after the open is therefore invisible to that descriptor for its lifetime.

What that bounds is only what stratafs itself contributes. Each participating directory is enumerated by its own filesystem, with whatever consistency that filesystem offers its own callers, and participants are read sequentially with no cross-stratum lock or barrier. A merged listing is no better than the listings it is assembled from, and nothing claims otherwise.

4.3.4.3 The settled participant set #

For the purpose of enumeration, the participating set is settled when the directory is opened and does not change for the life of the descriptor. What is settled is the set of participating directory objects, not the set of stratum positions: the descriptor holds a struct path reference on each participant, pinned until release, and nothing re-resolves those positions by path. A participant that has since been removed and replaced by another directory at the same path is a different object and contributes nothing.

The settled set has exactly three consumers: the enumeration itself, the access check performed when the directory is opened (§4.6.2), and the origin attribute read through that descriptor (§4.7). It extends to nothing else. Resolving a name relative to the descriptor — opening, removing, renaming, linking — is an ordinary live resolution under §4.3.1, performed against the strata as they are at that moment.

So a descriptor opened before a stratum began to hold the directory will not list that stratum's names, but openat through the same descriptor will resolve them. The two answers differ deliberately: enumeration is a bulk disclosure whose authorisation was decided once, when the descriptor was opened; a resolution is a fresh operation that carries its own check.

Freezing the participant set is what keeps that open-time check meaningful. Were a later re-read free to admit a stratum that joined afterwards, its names would reach the caller without its directory's descriptor ever having been consulted — and a stratum owner could make a directory participate precisely to expose it. A caller that wants the current participant set reopens.

4.3.4.4 Positions #

Offsets 0 and 1 are . and ... Offset 2 + k is the k-th element of the captured list — a plain ordinal. The offset each participant filesystem supplies is discarded: no provider cookie, no stratum index, and no name hash is encoded.

A position is therefore meaningful only within one open file description. On close and reopen — and so across a remount or a reboot — the capture is rebuilt from each stratum's current contents and current readdir order, and ordinal 2 + k may name a different entry or none. telldir and seekdir across descriptors are unreliable on a stratafs directory, as is NFS re-export of one.

4.3.4.5 Access #

Enumeration requires traverse and list rights on every participating directory, checked before the capture is built. The check returns on the first refusal, and a refusal aborts the open entirely, so no partial listing covering only the readable strata can be produced.

4.4.1 The Coherency Model

Peios / Peios technical reference / PKM / stratafs / Coherency

Every stratum of a mount may be modified at any time by an agent that does not know stratafs exists. stratafs observes such changes without being told of them.

4.4.1.1 No coordination #

There is no notification machinery anywhere in the filesystem — no fsnotify registration, no inotify, nothing a stratum's filesystem is expected to report. No writer announces a change, quiesces, or participates in any protocol. Every resolution is performed from scratch by re-walking the stratum path string, so nothing has to be told anything.

4.4.1.2 What can change a resolution #

A resolution depends only on which names each participating stratum directory holds and what type each entry has. It does not depend on the contents of any file.

A change to an object's contents therefore requires no action at all. stratafs inodes carry no address-space operations; there is no second page cache, and every data operation is forwarded to a backing file opened on the provider. A change to that object is observed through the mount immediately and by construction, because there is nothing to invalidate.

A change to the structure of a participating stratum directory — an entry created, removed, renamed, or replaced by one of another type — may change which stratum provides a name. §4.4.2 covers how that is handled, which is by not caching anything.

4.4.1.3 The guarantee #

A structural change in a stratum becomes visible to any resolution begun after the stratum's own filesystem exposes that change to an ordinary lookup. stratafs adds no delay of its own: there is no timeout, no jiffies comparison, no generation counter, and no resolution cache to serve a stale answer from.

It cannot anticipate a change the underlying filesystem is not yet reporting. A network filesystem holding an attribute cache does not show a change to stratafs any sooner than to any other caller, and nothing claims otherwise.

Resolutions already completed are not revisited. Every regular-file open is detached onto a descriptor-private dentry and inode holding their own reference to the provider, and I/O runs against the file opened at that time, so later masking or removal in any stratum cannot reach that descriptor. It is not re-pointed and it does not fail. A process holding a configuration file open across a package upgrade continues to read the file it opened.

The one exception is copy-up, which §4.4.3 covers: a descriptor whose own write caused a copy-up keeps its inode while that inode's backing object becomes the copy.

4.4.1.4 Live strata #

Because resolutions are made against current state, and because a stratum is a path rather than a directory object (§4.2.1), a stratum's directory may be replaced wholesale — by a package transaction, by a reconciler, by an administrator — while the mount is live, with no remount and no interruption to callers. That is the requirement the filesystem exists to satisfy, and §4.4.2 is the whole of its cost.

4.4.2 Revalidation

Peios / Peios technical reference / PKM / stratafs / Coherency

The specification permits an implementation to cache resolutions, subject to a version tuple recorded per stratum and an identity comparison on reuse. This implementation caches nothing, and so implements neither.

4.4.2.1 Always invalidate #

d_revalidate returns 0 for every dentry except the mount root. The VFS therefore discards the dentry and re-enters lookup on every path walk, and the lookup re-resolves the parent and the child from their relative path strings across every stratum.

Nothing is memoised. No version value is recorded, no directory identity is retained for comparison, no nearest-existing-ancestor walk happens, and no i_version is read anywhere in the filesystem. The per-dentry provider path that is retained is not a cache of a resolution: it is dropped when the dentry is released, and the dentry is released on the next walk.

The specification's machinery exists to know when a cached resolution has gone stale. With no cached resolution, the questions it answers do not arise:

What the tuple would detectWhy it is unnecessary here
A stratum gaining or losing a nameThe next walk resolves the name afresh
A stratum's directory appearingThe next walk finds it
A stratum's directory removed, renamed away, or renamed overThe next walk finds nothing, or finds the replacement

The result is strictly stronger than the specification requires, in the safe direction. The internal filesystem-domain, fallback-inode and staging-name counters do not validate cached resolutions.

4.4.2.2 What it costs #

The cost is real and lands on the hottest path in the kernel.

d_revalidate refuses RCU-walk unconditionally: a lookup in RCU mode returns ECHILD and the walk is retried in ref-walk mode, and the directory permission check does the same. RCU-walk is therefore never used on a stratafs mount, and the fallback is taken always rather than only where it genuinely cannot proceed.

The refusal, invisible to the caller by design, is visible at two tracepoints. stratafs:stratafs_rcu_walk_refused marks the directory permission check turning a walk back under MAY_NOT_BLOCK — in practice the first stratafs directory a walk must traverse does this, so it is the point that actually takes every walk out of RCU mode. stratafs:stratafs_d_revalidate records each revalidation verdict — mount cookie, merged inode number (zero for a negative entry), whether the call was in RCU mode, and the return value — and because the permission check fires first, every recorded call arrives already in ref-walk mode; its -ECHILD branch is the backstop. Like every PKM tracepoint, neither records a pathname.

What replaces it, per path component, is one full filename_lookup per stratum — up to sixteen — plus one merged resolution per proper prefix of the path, for the ancestor-masking test of §4.3.3. Where a mount is established over a directory of executables, that lies on the path of every program execution, and there is no dentry cache hit to avoid it.

This is the one part of the filesystem whose cost is worth measuring rather than assuming, and closing the gap — recording enough per resolution to reuse one safely, and making the comparison RCU-safe — is tracked as work in its own right.

4.4.3 Inode Identity and Lifecycle

Peios / Peios technical reference / PKM / stratafs / Coherency

stratafs presents its own inodes, allocated from its own superblock. Each stands for a provider object and forwards operations to it; none holds file data, directory entries, or a security descriptor.

4.4.3.1 Reported identity #

The device identifier reported for any object in a mount is the stratafs superblock's own anonymous device, not the provider's. getattr calls the provider's directly and then overrides dev, ino and, for directories, nlink; all four inode-operations tables install that same getattr.

The inode number is derived from the provider filesystem's reported inode number and a small mount-local identifier for its reported device. The device identifier distinguishes filesystem domains, not stratum positions: a stratum can cross mount points. The reported device also distinguishes Btrfs subvolumes, which can share a kernel superblock and inode numbers. Two paths to the same provider identity, including hard links and bind-mounted aliases, therefore report the same number.

Derivation reserves the high bit for allocated fallback numbers. A self-delimiting tag in the low bits identifies the filesystem domain; the provider inode number occupies the remaining bits. Small provider numbers stay small enough to derive again through nested stratafs views. Neither the bit layout nor the mount-local domain assignment is a persistent identifier or an ABI that callers should decode. Numbers may differ between mounts; within a mount, reloading the same provider identity after cache eviction produces the same number.

Only identities that cannot fit, or providers that do not report a usable inode number, use the fallback map. It allocates a distinct number and holds a reference on the provider inode until unmount to prevent address reuse. Repeated encounters with that provider reuse its allocation. Synthetic provider-less roots use the same allocated number space without a provider reference. Exhaustion returns EOVERFLOW rather than wrapping, including on 32-bit builds. An attribute lookup error is returned to the caller instead of silently assigning a new identity.

4.4.3.1.1 The mount root #

The root inode is created once, when the superblock is filled, and the root dentry is never replaced — d_revalidate returns 1 for it. Its provider is nevertheless re-resolved live on every use, and the number follows: resolving the root's provider derives or looks up its identity and writes the result to the root inode's number. Mode, owner, timestamps and number are therefore all current.

That the number follows is what keeps §4.4.3's identity rule true at the root. Identity belongs to the provider, so the number the root reports is the number every other merged path to that same provider reports; without it, a root whose provider is also reachable elsewhere would report a different number from that other path — two paths naming one object, unequal. That is the false-inequality direction, the one that breaks hard-link detection.

Where no stratum root exists at mount time, the root receives a stable synthetic number, distinct from derived provider numbers. If a provider later appears, the root's number follows that provider.

4.4.3.2 Attributes of merged directories #

A merged directory's owner, group, mode and timestamps are its provider's — the highest-precedence participating directory — taken straight from a getattr on the provider path, so what is reported is a real directory's attributes rather than a composite.

The security descriptor of a merged directory is not a single descriptor; §4.6.2 defines which participating directory's descriptor governs each operation.

4.4.3.2.1 The synthetic link count #

Its link count is forced to 1, both in the cached inode and in the reported stat. The true count of subdirectories spans strata and cannot be maintained, so the value carries no meaning beyond indicating that the object is a directory. Nothing should infer a subdirectory count from it.

4.4.3.3 Provider change #

When the provider for a path changes — because a higher-precedence stratum gained the name, because the previous provider's entry was removed, or because copy-up produced a new object — a resolution of that path yields a new inode. An inode reached by resolution is never re-associated with a different provider.

That is required because per-inode state is populated from the provider the inode was resolved against and is not in general re-derivable. In particular KACS caches a security descriptor against the outer stratafs inode; applying it to a different provider's object would govern access to one object by another object's descriptor.

The implementation enforces this bluntly. The one function that re-associates an inode with a new provider is reachable from exactly one call site, on the descriptor-private dentry created at open, and never on a hashed dentry reached by resolution. A copy-up performed for a path rather than a descriptor drops the dentry instead of rebinding it. Provider change by masking or removal needs no special handling at all, because the unconditional invalidation of §4.4.2 forces a fresh lookup and a fresh inode.

An object's reported inode number therefore changes when its provider changes. That is expected: it is the same change a caller would observe if the file had been replaced, which is what has happened.

4.4.3.3.1 Descriptors already open #

A descriptor open at the moment its own operation copies its object up is not re-pointed. It keeps the inode it was opened against, and that inode's backing object becomes the copy: the provider path and provider inode are swapped in place under a per-inode lock, the attributes are refreshed, and the backing file is replaced, so subsequent operations through the descriptor reach the copy.

This is the one case in which an inode's backing object changes, and it is safe for the reason the general rule exists: copy-up preserves the source's security descriptor exactly (§4.6.3), so the descriptor cached on that inode remains correct for the copy.

At open, the descriptor-private inode is deliberately given the path inode's number, so before any copy-up fstat and stat agree. After one they do not: the descriptor keeps the number assigned to the pre-copy-up provider, while a fresh resolution of the path derives or allocates a number for the copy. Both name the copy; the numbers disagree for as long as that descriptor lives. §4.8 records it.

4.4.3.4 Lifetime #

A stratafs inode holds a reference on its provider inode for as long as it lives, released on eviction. The dentry additionally holds a full path reference. Ordinary derived identities add no provider reference and no per-inode entry to the identity map. Walking a persistent-backed view and dropping caches can therefore reclaim the provider inodes while the stratafs mount remains live. The per-mount device-domain table stays until unmount; its size tracks encountered filesystem domains rather than the number of files walked.

The fallback alone adds a provider reference held until unmount. That cost applies only to identities that could not be derived.

Renaming a provider out of the view and back, or evicting and reloading its cached inode, preserves its derived identity. Ordinary inode-number reuse by the provider after deletion remains possible, just as it is when accessing that filesystem directly.

Releasing the last reference to a stratafs inode modifies nothing: eviction truncates its own empty mapping, clears the inode, drops the provider reference, and frees its private state.

4.5.1 Write Routing

Peios / Peios technical reference / PKM / stratafs / Mutation

An operation that modifies an existing object is performed against exactly one stratum. This section covers which, and when the decision is made.

4.5.1.1 Accepting modification #

A stratum accepts modification of an object it provides when all three of the following hold:

  • the stratum does not carry ro;
  • the provider's mount is not read-only;
  • the provider's inode is not marked immutable.

4.5.1.1.1 Independence from the caller’s rights #

The predicate is a property of the stratum and the object alone. It takes the superblock, the stratum index and the provider path, and nothing else — no credentials, no security descriptor, no access check.

That restriction is load-bearing. Were the predicate to take the caller's rights into account, a caller refused write access by the provider's descriptor could still provoke a copy-up: the write would fail, but the copy would have been published, and from then on the merged path would resolve to a snapshot the provider's legitimate writer could no longer update. A caller with no write access at all could freeze any file in the mount.

Note the third term is the immutable inode flag specifically, not unwritability in general. A file that is unwritable by its mode bits is routed in place and refused by the underlying filesystem.

4.5.1.2 The rule #

The decision itself is route_existing in stratafs-core, which takes the provider index, whether it accepts modification, the create index and whether the create stratum is present, whether the object is of a copyable type, and whether the stratafs mount itself is read-only. It selects in-place modification, copy-up, or read-only through the following ordered checks.

  1. If the mount is read-only, the route is read-only. This term short-circuits everything else.
  2. If the provider accepts modification, the operation is performed against the provider's object.
  3. Otherwise, if the object is copyable, a create stratum exists, is present, and has strictly higher precedence than the provider, the object is copied up and the operation performed against the copy.
  4. Otherwise the route is read-only and the operation fails with EROFS.

The strict create_index < provider comparison is what stops a modification being placed where something already present would shadow it. Where a high-precedence stratum provides a name it will not accept a write for, there is no lower stratum that can take the write without the result vanishing behind the provider, so EROFS is the honest answer — reported at the moment of the write rather than discovered later.

Where the name is held by no stratum, the operation is a creation and §4.5.3 applies instead.

4.5.1.3 When the decision is made #

Routing happens when a modifying operation is performed, not when a descriptor is opened. Every mutating entry point calls route_existing afresh against the provider it currently holds; nothing caches a route decision, and a descriptor opened before the predicate changed is not revisited.

OperationRoutes
Writing or appendingYes
Truncating, or any other setattrYes
Changing mode, owner, timestamps, or the security descriptorYes
Setting or removing an extended attributeYes
fallocateYes
splice into the fileYes
copy_file_range and remap_file_rangeYes
Establishing a shared writable mappingYes
Reading contents, attributes, or extended attributesNo
Opening, for any accessNo
Taking or releasing a lock, or a leaseNo

The security descriptor and the system access control list are both reached as extended attributes, so both route through the setxattr path like any other attribute; there is no descriptor-specific code in stratafs at all.

An open is not itself a routing trigger. open computes a route, but uses it only to decide the flags of the backing open — a non-in-place route downgrades the provider open to read-only and strips O_TRUNC, deferring rather than deciding. The one case in which an open acts is O_TRUNC on a regular file: that is a modification, so it routes, and copies up or fails with EROFS there and then. The truncation itself is applied afterwards by the VFS through setattr, which routes again and lands on the copy.

Routing at operation time rather than at open is what makes the rule implementable. A filesystem cannot see what an open asked for in access-mask terms — the caller's descriptor is stamped by KACS before the filesystem's own open method runs, and its mask lives in a private blob a filesystem cannot reach. Nor does it need to: the access check has already happened by the time an operation reaches stratafs, so a modifying operation arriving here is one its caller was entitled to perform, and routing it is a decision about strata alone.

4.5.1.4 Shared writable mappings #

Establishing a shared writable mapping routes, even though no bytes have been written, because stores through such a mapping reach the object without any further filesystem operation — establishment is the last point at which routing can occur.

The test is on VM_SHARED together with VM_MAYWRITE, the "could become writable" bit rather than the "is writable" bit. A PROT_READ shared mapping taken from a writable descriptor therefore routes, because it can acquire write access later through mprotect with no filesystem operation in between. A private mapping, and a shared mapping that cannot acquire write access, do not route. Where routing yields read-only, the mapping is refused with EROFS.

4.5.1.5 What a copy-up does to an open descriptor #

A copy-up performed for one descriptor changes which object provides the name. That descriptor refers to the copy from then on: it keeps the inode it was opened against while the inode's backing object becomes the copy (§4.4.3), and its backing file is replaced.

Every other descriptor already open against the original continues to refer to the original, and any fresh resolution of the path yields a new inode standing for the copy. The pre-copy-up file is not closed — it is retained so that locks and leases taken before the copy-up keep working (§4.5.7).

4.5.1.6 Special files #

A FIFO, socket, or device node is opened and written without the filesystem object being modified: what is written passes to a pipe, a socket, or a driver, not to the object's contents. Writing to such an object therefore does not route, and such an object is never copied up — every routing site gates on the object being a regular file, and the copyable flag excludes anything that is not a regular file, directory or symlink.

Copying up a FIFO would sever it: a reader holding the original and a writer that arrived after the copy would hold two unrelated pipes. A device node survives copying only by the accident that the copy names the same device.

Opens, reads and writes are forwarded to the provider whether or not it accepts modification, since the write-mode downgrade and the O_TRUNC refusal both apply to regular files only. Operations that modify the object itself — its mode, its descriptor, its extended attributes — do route, and where the provider does not accept modification they fail with EROFS, because the copy-up branch cannot apply to an object that is never copied up.

4.5.1.7 ioctl #

ioctl on a regular file is refused unconditionally with ENOTTY, and its compat form with ENOIOCTLCMD. Stored-file ioctls can mutate data and would need command-by-command routing, which is not implemented; refusing is the conservative stand-in. ioctl on a non-regular file is forwarded to the provider.

4.5.2 Copy-Up

Peios / Peios technical reference / PKM / stratafs / Mutation

Copy-up replicates an object from its provider stratum into the create stratum, at the same path relative to the mount root, so that a modification can be applied without modifying the provider.

Every step runs inside a KACS copy-up context, which exempts the mechanics from caller authorisation without granting anything. §3.9.7 describes that context in full; §4.6.3 covers the stratafs side of the bargain.

4.5.2.1 Parents #

Where the create stratum does not hold the directories containing the object's path, they are created first, walking the relative path component by component from the create-stratum root downwards. Each is created as an empty directory with the mode of the corresponding merged provider directory, and with that directory's security descriptor, installed by KACS during the create phase rather than inherited.

Contents are not copied. The directories exist to hold the copied object; the entries they hold in lower strata continue to be reached by merging.

Where the create stratum holds one of those components as something other than a directory, the copy-up fails with ENOTDIR and the operation requiring it fails. The blocking entry is not removed or replaced — there is no unlink, rmdir or rename anywhere on the parent-materialisation path.

Materialised parents receive a mode and a descriptor, and nothing else: no extended attributes and no timestamp preservation.

4.5.2.2 What is replicated #

Provider typeResult in the create stratum
Regular fileA regular file with the same contents and mode
Symbolic linkA symbolic link with the same target
DirectoryAn empty directory with the same mode; contents are not copied

A device node, FIFO, or socket is never copied up. Anything that is not a regular file, directory or symlink is refused with EROFS before a copy-up begins.

The security descriptor is carried by KACS rather than replicated as an attribute (§4.6.3). Every other extended attribute is copied, with three exclusions: the canonical descriptor attribute, anything in the system.stratafs. namespace, and the staging marker attribute. Each is copied with XATTR_CREATE, and security.capability goes through a dedicated KACS entry point rather than a raw write.

No attribute is silently discarded. Any per-attribute failure aborts the copy-up, as does a listing that fails or exceeds XATTR_LIST_MAX; in every case the error reported is EIO, whatever the underlying one was.

Modification timestamps are preserved for every type, symbolic links included. The mode is carried across too, except for a symbolic link, whose mode is fixed by the VFS and not the caller's to set. Access and change times are not preserved for any type.

Ownership is preserved. The staged object is created with the calling task's credentials, so it begins owned by the caller; where that differs from the source's uid or gid, a second notify_change sets both to the source's. The KACS security descriptor, including its owner SID, is preserved as well (§4.6.3), so both notions of owner name the object's owner rather than whoever provoked the copy.

The chown runs under the mount's own credential — the same resolution context stratafs uses to reach providers the caller cannot (§4.2.1) — because setting another uid needs authority the caller does not have. It is a separate change from the mode and timestamp above, which are the caller's to set and stay that way.

Because disk quota keys on the POSIX owner, a copy is accounted to the owner of the object it was copied from and not to the caller. That is what makes §4.6.2's exemption sound: a caller who may write a file in a stratum that will not accept modification can cause an entry to appear in the create stratum without holding rights over it, gaining neither access nor space.

Hard links are not preserved: an object with several links in its stratum is copied up as a single independent object, and the other links continue to refer to the original.

Contents are copied through a 64 KiB buffer, one read and one write per iteration, with the inner write loop retrying short writes. Where the copy-up was provoked by a path rather than a descriptor, the source is reopened for each chunk; the staged file is reopened for each chunk either way.

4.5.2.3 The source must still be the provider #

Before beginning a copy-up on behalf of a descriptor, the object the descriptor refers to is verified still to be the provider of that name, comparing both the path and the provider inode. Where it is not, the copy-up is not performed and the operation fails with ESTALE.

The verification is repeated immediately before publication, under the create directory's lock, and publication itself uses an operation that fails if the target name already exists: linking an anonymous object into place, or a RENAME_NOREPLACE. Both additionally test the target dentry explicitly, and an EEXIST from either is normalised to ESTALE.

That is decisive for the case that matters. A competing copy-up publishes into the same create-stratum directory, where the filesystem's own atomicity applies, so exactly one of two racing copy-ups succeeds and the other fails ESTALE.

It cannot extend further, and nothing tries to. Strata may be on different filesystems, and stratafs can neither lock them together nor inspect them at one instant. A change in some other stratum after the second verification — a direct writer creating the name in a higher-precedence stratum, say — is an ordinary concurrent structural change, visible to the next resolution, and is neither an error nor detected.

This is not a rare case. Every descriptor other than the one that caused a copy-up still refers to the original object, so a second descriptor opened before the copy-up meets this rule the first time it writes. A caller therefore has to be prepared for a write to fail ESTALE on a descriptor that was valid when it was opened and has done nothing wrong. §4.8 records it.

4.5.2.4 Staging and atomicity #

A copy-up is never observable in a partial state. All content, attribute and metadata work happens on an object that is not reachable through the mount, and publication is a single step.

Two arrangements are used, chosen by type:

  • A regular file is staged as an anonymous object on the create stratum's filesystem — a kernel tmpfile — and linked into place when complete. No reader can reach it.
  • A directory or symlink is staged under a name in the create stratum, since neither has an anonymous form, and published by a no-replace rename.

A staged name is .stratafs-stage- followed by the mount cookie and a per-stage identifier, in a 64-byte buffer, with up to eight retries on collision. It is excluded from resolution and from enumeration for the mount that owns it, and removed if the copy-up fails. The exclusion is local: a second stratafs mount sharing the create stratum, and every direct reader of that directory, sees an ordinary entry containing a partial copy.

4.5.2.4.1 Identifying staging entries #

A staged name alone is not proof of ownership, so each staged object also carries a marker in the extended attribute security.peios.stratafs_staging. The marker is a 24-byte little-endian structure: a magic of 0x53544731 — ASCII STG1 — a version, its own size, a per-boot cookie, and the cookie of the mount that created it.

Recovery of an orphan is therefore precise. A staged entry whose marker is valid and whose owning mount is not live is removed; one belonging to a live mount is left alone, and so is one whose marker is missing, short, or carries the wrong magic, version or size. That matters because two mounts may share a create stratum, and an unqualified cleanup would have each new mount destroy the other's copy-up in flight.

The scan runs in batches of 128 names, with resume, and is triggered from five places: mounting, opening a directory, looking up a name beginning with the staging prefix, copying up into a directory, and the emptiness test of §4.5.4 before it scans the create stratum's participating directory, after the caller's traverse/list access has been checked.

The fifth exists because the other four only reach directories somebody visits. An orphan in a directory that is never opened, looked up, or copied into would otherwise block rmdir: a dead mount's stages are not in the current mount's in-flight list and would be counted as ordinary entries. Recovery before that scan reaches the affected directory without recursively visiting every directory at mount time. It also means the create directory is scanned for recovery when its merged emptiness is tested, even if it ultimately proves nonempty.

The marker is removed after publication. A failure to remove it is only warned about, leaving the marker on the published copy until a later lookup cleans it.

4.5.2.5 Failed copy-up and rollback #

Where a copy-up fails for any reason, the create stratum is left with no new entry at the target path and the operation fails. Nothing falls back to a weaker replication: an object whose descriptor or extended attributes could not be preserved is never published. A published copy whose descriptor handoff then fails is rolled back.

4.5.2.6 Concurrent modification #

The provider may be modified while it is being copied, by a writer that does not know stratafs exists.

Copy-up reads the provider as any reader would, with no locking of the source and no snapshot, and offers no stronger consistency than an ordinary read of that object. Where the provider is modified during the copy, the copy may contain bytes from more than one state of the source, exactly as a concurrent read of the same object may.

Nothing detects that and restarts — there is no retry loop, no generation check. Equally, nothing blocks waiting for a quiescent source, and nothing fails a copy-up merely because the source is being written.

What is guaranteed is that the published object never appears in a partial state, because staging and publication are properties of the create stratum's filesystem, which stratafs does control.

4.5.2.6.1 Independence after publication #

Once published, the copy is independent of its source. Subsequent modifications to the object it was copied from are not reflected in it, and are not visible through the mount for as long as the copy provides the name.

4.5.3 Creation

Peios / Peios technical reference / PKM / stratafs / Mutation

Creating a name held by no participating stratum places it in the create stratum.

  1. If there is no create stratum, or it is absent, the operation fails with EROFS.
  2. Any directories of the create stratum containing the name's path that do not exist are materialised as §4.5.2 requires for copy-up parents. Where the create stratum holds one of those components as something other than a directory, the operation fails with ENOTDIR; the blocking entry is not removed or replaced.
  3. The name is created there.

All six kinds of creation — regular file, directory, symbolic link, device node, FIFO, socket — go through one helper and one path.

The ENOTDIR case arises because creation routes positionally, into the create stratum's subdirectory at the same path whether or not it exists (§4.3.2). Where the create stratum holds that path as a file, and a higher-precedence stratum provides it as a directory, the merged directory exists and is reachable while its create-stratum counterpart is blocked.

4.5.3.1 The security descriptor #

A created object has no provider to inherit from, so its descriptor is established by the ordinary creation semantics of the create stratum's filesystem — by inheritance from the directory it is created in, exactly as if it had been created there directly. The create is an ordinary vfs_create, vfs_mkdir, vfs_symlink or vfs_mknod against the real create-stratum parent, with the KACS creation decision bound to that same parent.

This is cleanly separated from copy-up inside KACS: the copy-up branch of the inode security initialisation is taken first and short-circuits the inheritance builder entirely. Ordinary creation in a create stratum inherits; copy-up preserves.

Where the creating interface lets the caller supply a descriptor — the native open path does — it is honoured rather than replaced by an inherited one. That is entirely KACS's doing; stratafs has no descriptor parameter and cannot express it. All stratafs contributes is re-anchoring the pending native create request onto the create-stratum parent.

4.5.3.2 Dispositions that delete #

A creating interface may offer a disposition that replaces an existing object rather than opening it. In Peios that is the supersede disposition of the native open path: KACS creates a temporary file through the mount, opens it, and renames it onto the target, at which point stratafs diverts the rename into a dedicated supersede path.

Such a disposition requires both removal and creation authority. This does not prescribe their publication order. Before anything is created, the removal is validated: the target's provider must accept modification, or the operation fails with EROFS. Before anything is removed, the replacement is checked for reachability: if any stratum strictly above the create stratum, other than the provider being removed, holds the name, the operation fails with EROFS.

Without that guard the disposition could report success while leaving the caller's new object invisible. The removal takes the name out of the stratum that provided it; the creation puts the replacement in the create stratum; and if some stratum between the two also holds the name, it now outranks the replacement.

The creation half is treated as a creation throughout. It is never reclassified as a modification of a newly-surfaced provider and never copies one up: the caller asked for a new object, and its descriptor is derived as above.

4.5.3.2.1 Implementation restrictions #

Three restrictions are implementation choices rather than consequences of the model:

  1. Supersede applies to regular files only, and anything else is refused with EOPNOTSUPP.

  2. The source must be in the create stratum, and source and destination must share a parent.

  3. Across distinct strata the two halves are not atomic. The staged file is renamed into place in the create stratum before the old provider is unlinked. Between those operations both backing names exist: ordinary precedence selects the replacement if the create stratum is higher, or retains the original until its removal if it is higher. A failure to publish leaves the original untouched, rather than exposing a still-lower provider.

    Write access to the original provider is probed before publication, then released while the replacement is published. It is reacquired for removal; a provider remounted read-only in between therefore fails with EROFS after publication. The two backing mounts never have simultaneous freeze-protection holds, even when they share a superblock.

    A later removal failure is returned to the caller and recorded as supersede-removal. Native-open cleanup then attempts to remove the newly published object, following its final name rather than its old temporary name. If the original still has its name, successful cleanup restores its visibility; concurrent provider changes are preserved. Cleanup is itself a filesystem operation and can fail; in that case both entries can remain, with ordinary precedence deciding which is visible. A crash between publication and removal can likewise leave both. This ordering narrows the destructive failure window; it does not promise a transaction or crash recovery.

    When both objects are in the create stratum, replacement uses one backing-filesystem rename and needs no separate provider unlink.

4.5.3.3 Deferred deletion #

A request to delete an object when the last descriptor to it is closed applies to the object the descriptor resolved to, not to whatever provides that name at close time. Because removal (§4.5.4) is defined over the current provider of a name, the deferred case has its own path.

4.5.3.3.1 An entry already gone #

When the deletion is attempted, stratafs locates the entry at the path the descriptor was opened against, in the stratum that provided it at that time — the descriptor's private dentry carries both — and resolves the parent in that same stratum. Four conditions end the attempt quietly, reporting success because the deletion is already complete: the parent is gone, the name is gone, the name now identifies a different inode, or the unlink raced.

The merged parent can disappear or become a non-directory before the VFS reaches stratafs’s unlink handler. An ENOENT or ENOTDIR from that earlier check is also a successful no-op, but only after a lookup in the descriptor’s original providing stratum confirms that its recorded name is absent or identifies a different inode. A higher stratum masking a still-live backing target does not satisfy that condition: the deletion still fails and is audited. Other errors keep their ordinary failure and audit behavior.

4.5.3.3.2 The original stratum must accept removal #

That stratum must accept modification, or the deletion fails with EROFS. Then the entry is removed, and no other. In particular, where another caller's copy-up has published a new object at that name in a higher stratum, that object is not the one being deleted and is left alone.

4.5.3.3.3 Failures are audited #

Because the attempt has no caller to report to, any failure is audited — and for a deferred deletion, every non-zero result is audited, not only the arrangement errors §4.6.5 covers for ordinary refusals. The object is left in place.

4.5.3.3.4 Authority at deletion time #

The right to delete the entry is checked against the directory of the stratum where the entry lives, not against the merged parent, and it is checked at deletion time rather than only at arm.

Arm time could not do it. The authorizer there holds a struct file, and which stratum provides the entry is stratafs-internal — the provider index and the parent's relative path are not visible to KACS. stratafs computes that directory before removing anything, so the check happens there, where the answer exists.

4.5.3.3.5 The requesting token outlives the arm #

The token is the one that requested the deferred deletion, not the credentials of whoever closes the descriptor. That requires it to outlive the arm: the file's blob takes a reference when armed and releases it in ->file_release, on both the armed and unarmed exits. The file is the thing that was armed, so its lifetime is the right one — arm and close may be far apart and in different tasks.

4.5.3.3.6 Supported object types #

Deferred deletion is restricted to non-directories, and at arm time to regular files on a managed mount.

4.5.3.4 Exclusive creation #

Where creation is requested exclusively, the name must not exist in any participating stratum, and a name provided by a lower stratum causes EEXIST even though the create stratum does not hold it.

Nothing in stratafs implements this, and nothing needs to. The merged lookup instantiates a positive dentry whenever any stratum holds the name, and the VFS refuses O_EXCL on a dentry it did not create. The excl argument stratafs receives is ignored. There is a race backstop in the create path — a positive dentry appearing in the create stratum yields EEXIST — but it sees only that one stratum.

Exclusive creation asks whether the name is free, and through this mount it is not: a caller that created it anyway would find their object shadowing a file they did not know was there, or masking a whole subtree.

4.5.3.5 Non-exclusive creation over a shadowed name #

Where creation is not exclusive and a lower stratum provides the name, the merged dentry is positive, so the VFS never calls the create path at all. The operation is an open, and §4.5.1 routes it.

Where the provider is a regular file that does not accept modification and the create stratum has higher precedence, the result is a copy-up followed by the requested modification, including truncation where O_TRUNC was requested. O_TRUNC is stripped from the backing open on a non-in-place route precisely so that the copy-up source is not destroyed before it is read.

Where the provider is a FIFO, socket, or device node, no copy-up occurs and the open is forwarded to the provider. Such an open succeeds whether or not the provider accepts modification: opening a special file does not modify it.

4.5.3.6 Unnamed files #

A file may be created without a name, for later linking into place. In a merged directory this is supported, and the file is created on the create stratum's filesystem, recorded with the create stratum's index and marked unnamed.

Where the mount has no create stratum, or it is absent, the operation fails with EROFS. Parent materialisation applies as for a named creation: the create stratum's counterpart of the directory the operation named is materialised, and the new file's descriptor is derived by inheritance from it, because that directory is what the descriptor must come from. Linking such a file into the mount is governed by §4.5.6.

4.5.4 Removal

Peios / Peios technical reference / PKM / stratafs / Mutation

stratafs has no whiteouts and cannot record that a name should be absent. Removal can therefore only remove an entry that is actually there, in a stratum it may write to.

4.5.4.1 Unlinking #

To remove a non-directory name from a merged directory: if the provider accepts modification (§4.5.1), the entry is removed from the provider; otherwise the operation fails with EROFS. The parent is resolved in the provider's stratum, and the unlink is performed there.

The provider need not be the create stratum. Any stratum that accepts modification may have an entry removed from it, by the same rule that allows an object it provides to be modified in place.

Where a lower-precedence stratum also holds the name, that entry becomes the provider once the higher one is removed, and the name remains visible, now resolving to the lower stratum's object. That is not an error and is not reported as one: the removal succeeded, and the entry it removed is gone. The dentry is dropped on success, so the next lookup finds the lower entry.

Removing an object that a lower stratum also provides is how a modification is undone. Where a file was copied up in order to be edited, removing it through the mount discards the edit and restores the original, which remained untouched in its own stratum throughout. Callers expecting POSIX removal will find the name still present afterwards; §4.8 records this as an intended divergence.

Refusal because the provider does not accept modification is EROFS, with one exception: where the provider's inode is immutable it is EPERM. The outer inode carries the provider's inode flags, so the VFS refuses the removal before stratafs's own EROFS test is reached — and EPERM is what every filesystem returns for an immutable file, so the distinction is the useful one rather than an accident worth papering over. The ro flag and a read-only provider mount both still produce EROFS.

4.5.4.2 Removing directories #

The same rule applies, with one additional condition: the merged directory must be empty. A directory is empty for this purpose only if no participating stratum holds any entry within it, so a directory that is empty in the provider but not in another participant fails with ENOTEMPTY.

4.5.4.2.1 Access checks precede the emptiness scan #

Because determining this reads the contents of every participating stratum, the caller must hold traverse and list rights on each of them. That check completes over all participants before any of them is enumerated, so a refusal yields EACCES without disclosing whether the directory was empty. Without that ordering the emptiness test would be a disclosure channel: a caller who may not enumerate a protected participant could learn whether it contains anything by attempting rmdir and distinguishing ENOTEMPTY from success.

The emptiness scan filters this mount's in-flight staging entries, as enumeration does. That does not authorise removing a live stage: the provider can still refuse the physical removal with ENOTEMPTY.

4.5.4.2.2 Orphan recovery before inspection #

After the traverse/list checks complete, the emptiness test runs orphan recovery on the create stratum's participating directory before enumerating its contents (§4.5.2). A dead mount's entries are not in the current mount's in-flight list, so waiting until that list has filtered them would reject the directory before recovery could run. A directory containing only recoverable orphan files or empty orphan directories can therefore be removed without first opening or listing it.

Recovery requires the staging prefix, a valid marker, and an owner that is no longer live. Missing or malformed markers, ordinary published names, and stages belonging to a live mount remain untouched; they can still make removal fail with ENOTEMPTY. The scan is local to the participating create directory, not a recursive walk of the stratum.

Where the directory is removed and lower strata hold the same name as a directory, the name remains visible as a merged directory of the remaining strata — which, by the emptiness condition, is empty.

4.5.4.3 What removal never does #

Removal touches exactly one stratum, the provider's. The other strata are opened read-only for the emptiness scan and nothing else. No entry, marker, or object is created in any stratum to suppress a lower entry; no whiteout machinery exists anywhere in the filesystem.

Success is never reported for a name whose provider entry was not removed. The one place zero is returned without a removal is the deferred path of §4.5.3, where the entry the descriptor named is already gone and the deletion is genuinely complete.

4.5.5 Rename

Peios / Peios technical reference / PKM / stratafs / Mutation

Rename both removes a name and creates one. Because stratafs cannot make a name disappear (§4.5.4), the constraint on the source is the same as for removal, and the constraint on the destination follows from the read-after-write direction of §4.5.1.

For a rename of a source to a destination within one mount, letting P be the source's provider:

  1. P must accept modification. Otherwise EROFS — the source cannot be removed, so the rename would leave it still visible and amount to a copy.
  2. The destination must not be provided by a stratum of higher precedence than P. Otherwise EROFS — the renamed object would be shadowed at its destination and unreadable through the path it was renamed to.
  3. P must hold the directory containing the destination. Otherwise EXDEV. That directory is not created to satisfy the condition, whether or not P is the create stratum: parent materialisation exists to receive a copy-up, and a rename is not one.
  4. Where the source is a directory, the merged directory must contain no entries provided by a stratum other than P. Otherwise EXDEV, since those entries cannot move with it. Determining this reads every participating stratum, so traverse and list rights are required on each, checked before any enumeration begins; where that is refused the rename fails with EACCES without disclosing whether other strata contributed.
  5. Where the destination is provided, its type must match the source's. A non-directory onto a directory provider fails with EISDIR; a directory onto anything else fails with ENOTDIR. Only the provider's type is compared, since a lower stratum's entry of a different type is masked and contributes no inode.
  6. Where the destination's provider is a directory, that merged directory must contain no entries at all — the same merged-emptiness test rmdir uses, with the same access requirement. Otherwise ENOTEMPTY.
  7. The rename is performed within P: both parents are resolved at the provider's index, and a single vfs_rename is issued there.

Where the destination is provided by a stratum of lower precedence than P and both are non-directories, the rename succeeds and the renamed object shadows it. Where the destination is held by P itself, it is replaced, as on any filesystem.

Where the renamed object and the destination are both directories, the result is not a shadowing: by §4.3.2 the renamed directory merges with the lower strata's directories of that name. Condition 6 is what keeps that tolerable — those directories are empty, so the merged result is the renamed directory's own contents.

P need not be the create stratum. A rename is performed in whichever stratum provides the source, provided that stratum accepts modification. A rename whose source and destination are in different mounts fails with EXDEV, refused by the VFS before stratafs is consulted, and stratafs additionally refuses one spanning two mounts inside the provider stratum.

The immutable-provider caveat of §4.5.4 applies to condition 1 as well: an immutable source yields EPERM from the VFS rather than EROFS.

4.5.5.1 Replacing atomically #

A rename onto an existing destination is the operation by which most software replaces a file safely, and it works through a stratafs mount for a destination provided by a lower stratum. That case is permitted by condition 2: the destination is shadowed by the renamed object rather than removed, so no whiteout is required, and the replacement is a single vfs_rename within one directory pair of one stratum — atomic on the filesystem holding P.

Where the source of such a rename is a temporary file the caller created in the same directory, §4.5.3 placed it in the create stratum, which never carries ro. Conditions 1 and 3 are then satisfied.

The contrast with the supersede disposition (§4.5.3) is worth noticing: that spans two strata and is not atomic.

4.5.5.2 Flags #

Conditions 1 and 4 concern whether the source can be moved at all, and apply under every flag. Conditions 2, 3, 5 and 6 concern the destination.

FlagBehaviour
RENAME_NOREPLACEThe destination must not be held by any participating stratum, not merely by P. Conditions 2, 5 and 6 are not evaluated, since each presupposes a destination that exists; where any stratum holds the destination the rename fails with EEXIST.
RENAME_EXCHANGEBoth names must be provided by the same stratum, and that stratum must accept modification; otherwise EROFS. Conditions 1 and 4 apply to both names. Conditions 2, 5 and 6 are not evaluated: an exchange swaps two names that both already exist, so neither type matching nor emptiness is required of either, exactly as on any filesystem. Condition 3 is subsumed. Where either name is a directory, no stratum other than the providing one may hold entries under either name; otherwise EXDEV.
RENAME_WHITEOUTFails with EINVAL, checked before everything else. stratafs has no whiteouts and cannot represent one.

Exempting RENAME_EXCHANGE from conditions 5 and 6 is what leaves the flag usable. Its ordinary purpose is to swap two populated directories atomically, which condition 6 would refuse outright and condition 5 would refuse whenever the two differ in type. The stranded-entries condition is the only destination constraint an exchange genuinely needs, because it is the only one that arises from the names spanning strata rather than from what the names hold.

4.5.5.2.1 The RENAME_NOREPLACE ordering #

A RENAME_NOREPLACE whose destination is held by any participating stratum fails with EEXIST, and that EEXIST takes precedence over conditions 1, 3 and 4. It is not stratafs that decides this. For RENAME_NOREPLACE the VFS looks the destination up with LOOKUP_EXCL and returns EEXIST for a positive dentry before vfs_rename runs at all, and stratafs's merged lookup makes the dentry positive whenever any stratum holds the name. namei.c is unpatched, so there is no point inside ->rename from which the order could be changed.

stratafs nonetheless evaluates conditions 1, 3 and 4 first, and the code says so. That ordering is reachable only as a race backstop — where the destination appeared between the VFS lookup and the rename — so a caller should not expect EROFS, EXDEV or EACCES from a RENAME_NOREPLACE whose destination already existed.

4.5.5.3 Other behaviour #

Unknown flags bits are neither rejected nor masked; they pass through to the VFS. Where the two provider-level names resolve to one inode the rename is a no-op returning success. A dentry whose private state is missing, or whose provider identity no longer matches, yields ESTALE. That refusal is per attempt, and the VFS heals it: ESTALE from a rename makes the syscall retry once with a fresh walk, whose rebuilt dentry carries the current provider identity, so the caller usually observes the retry's outcome — a rename of the object now at the name — rather than the errno. The refusal itself is recorded at the stratafs:stratafs_rename_stale tracepoint, with the walk-time and found inode numbers. Because the filesystem sets FS_RENAME_DOES_D_MOVE, stratafs performs the d_move or d_exchange itself, together with swapping the dentries' recorded relative paths.

4.5.6 Links

Peios / Peios technical reference / PKM / stratafs / Mutation

4.5.6.1 Hard links #

To create a hard link from an existing name to a new name within one mount, letting P be the source's provider:

  1. P must accept modification. Otherwise EXDEV.
  2. The destination must not be held by any participating stratum. Otherwise EEXIST.
  3. P must hold the directory containing the destination. Otherwise EXDEV. That directory is not created to satisfy the condition, whether or not P is the create stratum.
  4. The link is created in P.

P need not be the create stratum: a link is made in whichever stratum provides the source, since that is the only stratum in which the two names can share an object. Condition 2 ensures the new link is not shadowed at its destination, since no stratum — above P or below it — holds that name.

Condition 2 is enforced by the VFS rather than by stratafs for a named source: the link path looks the destination up with LOOKUP_EXCL, and stratafs's merged lookup makes the dentry positive whenever any stratum holds the name. stratafs's own test covers only the provider stratum and is a race backstop. For an unnamed source it does check every stratum explicitly.

A link request is never satisfied by copying the source up. The result would be a link to the copy rather than to the object named by the source, so the two names would not share an object — which is the whole of what a hard link is for. Refusing is the only correct answer.

EXDEV is used rather than EROFS because it is the error callers already handle when a link cannot be made between two locations, and because it is accurate: the link would have to span two strata, which through this mount are two filesystems.

Where installing the outer inode fails after the lower link succeeded, the link is rolled back; a failed rollback is audited.

4.5.6.2 Linking an unnamed file #

An unnamed file created under §4.5.3 is linked into the mount by the rules for creation, not by those above: it has no provider, so conditions 1 and 3 have no subject.

The link is created in the create stratum — a source recorded with any other index is refused with EXDEV — and the operation follows §4.5.3: the name must not be held by any participating stratum, parent directories are materialised in the create stratum, and a create stratum that is absent or does not exist fails with EROFS.

4.5.6.2.1 Unnamed files cannot cross mounts #

Linking an unnamed file into a different mount fails with EXDEV, checked both by superblock and by mount, and by the VFS as well.

4.5.6.3 Symbolic links #

Creating a symbolic link is an ordinary creation and follows §4.5.3: the link is created in the create stratum, with a security descriptor established by inheritance there.

A symbolic link's target is stored and returned verbatim. The target string is passed through untouched on creation, and on read the outer inode's get_link calls the provider inode's own and returns its result unmodified. No rewriting exists anywhere: a target naming a path inside a stratum is not rewritten to name the corresponding path inside the mount, nor the reverse.

Resolution of a symbolic link found in a stratum follows §4.3.1. The raw string goes back to the VFS, which interprets it as any filesystem's target is interpreted, so an absolute target resolves from the process's root and may re-enter this mount, a different stratafs mount, or none. A link created directly in a stratum by that stratum's owner is followed as written; the rule constrains only what stratafs itself does, which is nothing.

4.5.7 Locking

Peios / Peios technical reference / PKM / stratafs / Mutation

Advisory file locks — whole-file and record locks alike — exist so that several writers of one file can coordinate. A stratafs mount is established over directories that have their own writers, so a lock taken through the mount and a lock taken directly on the same object must be the same lock.

4.5.7.1 The rule #

A lock taken through a stratafs mount is held on the provider's object, in the same lock space as a lock taken on that object by any other path. Both the POSIX and the flock paths retarget the request onto the descriptor's provider file, so the lock lands on the provider inode's own lock context.

stratafs maintains no lock space of its own. There is no lock list, no fallback onto the outer inode, and no per-inode lock state. Two callers that lock the same provider object — one through the mount, one through the stratum directly, or two through different stratafs mounts sharing that stratum — contend with each other. Open-file-description locks are re-owned onto the provider file so that their per-description semantics are preserved.

4.5.7.1.1 Locking does not copy up #

Taking a lock does not modify an object, so it does not route (§4.5.1) and cannot itself cause a copy-up. FACS still checks the descriptor's granted rights (§3.9.4): a shared lock requires FILE_READ_DATA, and an exclusive lock requires FILE_WRITE_DATA or FILE_APPEND_DATA. A read-only descriptor can therefore carry a shared lock, but not an exclusive lock without a write grant.

4.5.7.2 Locks and a changing provider #

A lock is held on the object a descriptor resolved to. That object may cease to be the provider afterwards — because a higher-precedence stratum gains the name, because the object is removed from its stratum, or because a copy-up produced a new object.

In every such case the lock remains held on the object it was taken on. It is not transferred, and it does not begin to guard the new provider.

Two callers may therefore hold exclusive locks on one merged path without contending, whenever they opened it either side of a change of provider. Neither is wrong about the object it locked; they locked different objects, and each lock is honoured by everything else holding that object. The merged path is what stopped naming one thing. §4.8 records it.

A caller that must be sure it holds a lock on the current provider has to reopen and re-take it, which is the same discipline required of anything that locks a path another process may replace by rename. The exposure here is that a copy-up is a replacement the caller did not perform and cannot see.

4.5.7.3 The retired provider #

Copy-up does not close the file it copied from. The pre-copy-up provider file is moved aside into the descriptor's private state specifically to keep locks and leases taken before the copy-up alive, and the fan-out that follows is invisible to the specification but visible in behaviour:

  • A POSIX or flock unlock is applied to both the retired file and the copy; a non-unlock request goes only to the copy.
  • A lease release is applied to both. A lease acquisition is redirected to the retired file when that file already holds a lease of the same flavour.
  • Querying a lease returns the stronger of the two files' lease types, ordering write above read above none.
  • Closing the descriptor runs the underlying flush and removes POSIX locks on both files, and releasing it breaks leases on both, using the outer file as the owner identity.

All of it is serialised by the descriptor's mutation lock.

4.5.7.4 Leases and mandatory locking #

Leases are established on the provider's object, in that object's own lock space, and every result is the provider's own, returned unchanged. stratafs neither adds to nor removes from whatever semantics the provider's filesystem gives them, and never reports a lock as established where the provider's filesystem refused it.

Mandatory locking has no subject here: the platform does not offer it, and stratafs contains nothing that would obstruct it. The outer inode copies the provider's inode flags wholesale.

4.5.7.5 Internal locks #

The specification names no internal lock and fixes no acquisition order. The implementation's hierarchy, outermost first:

  1. The per-open-file mutation lock, taken by every operation that may copy up.
  2. The per-outer-inode rebind lock, taken inside it whenever an inode's provider is swapped.
  3. The dentry lock or the inode lock, taken inside the rebind lock and never overlapping each other.

The superblock's identity lock and staging lock are each taken alone. copy_file_range and remap_file_range deliberately do not nest the two files' mutation locks: the input file's is taken, a reference grabbed, and released before the output file's is taken.

For mutations the ordering is: the KACS decision, then parent materialisation, then write access on the target mount, then the parent's directory lock through the VFS's create, remove or rename helpers. Refusal auditing takes the dentry lock with an atomic allocation first and falls back to the rebind lock only if that fails, so the two are never nested.

Backing-filesystem freeze protection is held for one mutation stage at a time. Regular-file copy-up releases its creation-stage mount write hold while copying contents: reads and writes keep their ordinary permission and pre-content notification checks, and each write acquires its own freeze protection. It reacquires the mount write hold before copying metadata or publishing the anonymous stage; failure to reacquire aborts the copy without publishing it. This permits a pending filesystem freeze to complete between stages without a writer waiting on itself.

Supersede releases the create mount's write hold before acquiring the original provider's hold for removal. Rename's merged-destination emptiness scan, which can recover orphan staging objects, runs without a backing write hold retained by rename; recovery acquires its own. An early write-access probe preserves the read-only refusal, and the mutation reacquires write access before taking its backing directory locks.

4.5.8 Durability and Accounting

Peios / Peios technical reference / PKM / stratafs / Mutation

stratafs holds no storage, so durability is the providers' and the accounting is theirs too. Two operations nonetheless need a rule, because a merged directory has no single provider to forward to.

4.5.8.1 Synchronising an object #

Synchronising a non-directory is forwarded to the object the descriptor resolved to — the descriptor's own provider file — and reports what that object's filesystem reports. No path re-resolution happens, so it is never forwarded to whichever object currently provides the path. Where the two differ, the data the caller wrote is on the object it opened, and synchronising anything else would report success while leaving that data unsynchronised.

Where a copy-up has occurred through this descriptor, the descriptor's object is the copy, and it is the copy that is synchronised. The retired pre-copy-up file (§4.5.7) is not.

4.5.8.2 Synchronising a merged directory #

Synchronising a merged directory synchronises the corresponding directory in every stratum of the mount that holds it at the time of the call, and fails if any of them fails. The loop continues past a failure, so every stratum is still attempted, and the first error is what is returned.

The set is evaluated when the operation runs, not when the descriptor was opened: the directory is re-resolved across all strata rather than read from the participant set settled at open (§4.3.4). Both halves of that matter:

  • Evaluating at call time catches a directory the create stratum did not hold when the descriptor was opened and does now — which is exactly what happens when a file is created through that descriptor and §4.5.3 materialises its parent.
  • Covering every stratum rather than the provider alone is required by the atomic-replace pattern of §4.5.5, whose rename is performed in the stratum that provided the source, which need not be the stratum providing the merged directory.

Durability is a question about what is on disk now, which is why this is the one place a merged directory is treated as its current set of real directories rather than as the thing a descriptor was opened against.

4.5.8.3 Freezing #

A stratafs mount has no storage to quiesce. Freezing returns EOPNOTSUPP and propagates nothing to any stratum's filesystem; no unfreeze, freeze-super or thaw-super operation is registered at all. Freezing the filesystem a stratum lives on is done through that filesystem, and affects the merged view as it affects any other reader of that stratum.

4.5.8.4 Storage accounting #

Storage consumed by an object created through the mount, or copied up into the create stratum, is consumed on the create stratum's filesystem and accounted there.

4.5.8.4.1 The principal charged by quota #

Disk quota keys on the POSIX owner, and copy-up preserves it (§4.5.2): the staged object is created with the calling task's credentials, and the ownership is then set to the source's under the mount's own credential. A copy is therefore accounted to the owner of the object it was copied from, not to the caller who caused it.

The KACS security descriptor, including its owner SID (§4.6.3), is preserved alongside it. The two notions of owner agree, and both name the object rather than whoever provoked the copy — which is what §4.6.2 relies on when it permits a caller to cause a copy into a directory they hold no rights over.

Ownership is what accounting follows; the caller who caused the copy is recorded in the audit record of §4.6.5 instead.

4.6.1 Access Check Delegation

Peios / Peios technical reference / PKM / stratafs / Security

stratafs stores no security descriptors. It allocates its outer inodes bare and never runs the inode security initialisation over them, and neither its inode state nor its superblock state has anywhere to put a descriptor. Every object reachable through a mount has its descriptor on its own stratum, and that descriptor is what governs access to it.

4.6.1.1 The rule #

An access check for an operation on an object reachable through a stratafs mount evaluates the security descriptor of the object the operation will be performed against. For an operation on a non-directory, that is the provider's object; for a merged directory, §4.6.2 defines which participants' descriptors apply.

stratafs synthesises no descriptor, supplies none of its own, and applies no mount-level template. It could not: constructing one would require a synthesising mount policy class, and stratafs is pinned to the class that denies where a descriptor is missing (§4.6.4).

Because the descriptor evaluated is the provider's own, a stratafs mount cannot grant access that the provider's stratum would refuse. That property is structural rather than a matter of care in implementation: there is no descriptor for stratafs to get wrong, because it holds none.

The guarantee is one-directional, which is the direction that matters. Where the provider carries no descriptor there is nothing to evaluate and the mount's own policy decides, which is to refuse — so such an object is unreachable through the mount even where its own filesystem's policy would have admitted it.

4.6.1.2 Who performs which check #

For an object with a single provider, the descriptor comes back through ordinary forwarding. KACS reads the canonical descriptor attribute the same way it does for any file, the stacking layer forwards the getxattr down to the provider, and the descriptor that comes back is the provider's. For metadata and extended-attribute operations, stratafs re-targets the pending one-shot KACS decision onto the provider inode, so the check is made against the object the operation will reach.

A merged directory is not that case. It stands for several directories with several descriptors, and forwarding yields only the provider's. Those checks are stratafs's own: it walks every present participating directory and evaluates each one's descriptor, failing on the first refusal.

KACS cooperates by standing down on stratafs inodes entirely. Its inode_permission hook, and its create, mkdir, mknod, symlink, link, unlink, rmdir and rename hooks, all return success immediately for a superblock carrying the stratafs magic. The checks that matter are made by stratafs against the real objects, or by KACS against the real objects once stratafs has resolved them.

4.6.1.3 Timing #

The descriptor evaluated is the provider's at the time the check runs, and the open-time grant is frozen into the file's KACS state — the check-at-open principle applies unchanged, and copy-up transfers that immutable snapshot to the new backing file rather than deciding again.

For merged-directory checks, stratafs reads each participant's descriptor attribute on every check. KACS also refreshes an outer stratafs inode's parsed descriptor whenever a check or descriptor query needs it. A writer using the stratum directly therefore needs no invalidation protocol with the merged view. Missing and corrupt descriptors are re-read too, so a subsequent repair can take effect.

This refresh applies to retained handles as well as new pathname operations. An O_PATH handle performs its live access check against the refreshed descriptor. An ordinary file handle retains its granted access, but a permitted descriptor query returns the provider's current descriptor, not the one present when the file opened.

When copy-up rebinds a file's inode to the copy (§4.4.3), stratafs explicitly invalidates the parsed descriptor. Subsequent descriptor queries follow the new provider. The file's frozen grant remains unchanged; invalidating the inode cache does not authorise or revoke access on an already-open file.

4.6.2 Checks on Merged Directories

Peios / Peios technical reference / PKM / stratafs / Security

A merged directory stands for several real directories, each with its own security descriptor. An operation on one is checked against every participating directory whose contents it depends on, and against the directory it modifies. Where more than one applies, all must succeed: the check walks participants in order and returns on the first refusal, and nothing degrades an operation to the subset of strata the caller may reach.

Two composite rights recur:

  • Search — traverse on every participating directory, since every one is searched to resolve a name. The permission hook maps the kernel's execute intent onto it.
  • Enumerate — Search plus list on every participating directory. The permission hook maps the read intent onto the list right, and directory open demands both together.
OperationChecked against
Resolving a nameSearch
EnumeratingEnumerate, before the listing is captured
Reading an object's attributesSearch, plus whatever the object's own descriptor requires
Reading the origin attribute on a merged directorySearch, plus read-EA on every participant (§4.7)
Creating a nameSearch, plus add-file or add-subdirectory on the create stratum's directory
Copy-upNothing beyond what the operation it serves already required
Removing a nameSearch, plus delete-child on the provider's directory
Removing a directoryEnumerate — emptiness is judged across every participant — plus delete-child on the provider's directory
Rename, sourceSearch, plus delete-child on the source provider's directory
Rename, destinationSearch, plus add-entry on the source provider's directory, which §4.5.5 requires to hold the destination, plus delete-child where that directory already holds the name
Rename of a directoryBoth of the above, plus Enumerate on the object being renamed, and Enumerate on the destination where its provider is a directory
RENAME_EXCHANGESearch on both, plus add-entry and delete-child on the directory of the stratum providing both names
LinkSearch on both, plus add-file on the source provider's directory
Creating or linking an unnamed fileSearch, plus add-file on the create stratum's directory

The mutating rights land on the directory that actually changes, which follows from §4.5: an entry appears in the create stratum's directory and disappears from the provider's, so in each case it is that directory's descriptor that decides. The two coincide whenever the create stratum is also the provider.

One right the table does not name is required anyway: the underlying vfs_link makes KACS demand write-attributes on the source object, which is an object-descriptor right outside the directory scope this section covers. Linking an unnamed file is exempt, since stratafs marks the source for that purpose.

The effective rights on a merged directory are therefore the intersection of the rights on its participants, with mutating rights additionally required on the directory that is actually modified. Requiring the intersection is fail-closed and admits no partial results: the alternative would let the names held by a restrictively protected directory be enumerated by a caller who could not enumerate it directly, because a permissive directory of higher precedence happened to provide the merged path.

One consequence is worth stating plainly. Because creation is governed by the create stratum's directory descriptor, and a created name shadows the same name in every lower stratum, the right to create in the create stratum's directory is the right to determine what every lower stratum's entry of that name resolves to.

4.6.2.1 Where the create stratum's directory does not exist #

A merged directory has a create stratum whether or not that subdirectory exists (§4.3.2), so a check naming "the create stratum's directory" has to be evaluated against a directory that is not there yet.

Every such check is performed before any part of the operation is carried out, and in particular before any directory is materialised: the authorisation call strictly precedes parent materialisation in creation, in tmpfile creation, and in the unnamed-link path. Where the checks fail, nothing has been created, so the create stratum is left exactly as it was.

Where the create stratum does not hold the path, the check falls back to the corresponding provider directory — the merged provider of that same relative path — which is the descriptor the directory will carry once materialised (§4.6.3). It is never skipped, and never substituted with an ancestor's descriptor. Where no provider exists either, the result is EROFS.

Materialising the intervening directories is part of the operation, not a separate one. No per-ancestor authorisation is taken; the mkdirs are exempted by the copy-up context. Checking against the descriptor the directory will have keeps the answer independent of how much of the create stratum happens to have been materialised already: the first caller to write into a deep path and the hundredth face the same check, against the same descriptor.

A create that fails after materialisation for a reason other than a check — EEXIST, ENOSPC — does leave the intervening directories in place.

4.6.2.2 Copy-up carries no separate authority #

A copy-up requires no right beyond those the operation that provoked it already required. In particular it does not require the caller to hold the right to read the provider's object, nor the right to add an entry to the create stratum's directory. Neither copy-up path takes any authorisation at all.

Copy-up is the mechanism by which an authorised modification is realised, not an operation a caller requests. The read of the provider is stratafs's own, and the copy carries the source's descriptor unchanged (§4.6.3), so the caller obtains nothing they did not already have: the same content, under the same descriptor, at the same path.

The alternative cannot be expressed. Routing happens when a modification is performed (§4.5.1), and by then the authority that governed the open is a mask cached on the descriptor, immutable and carrying no token — so there is nothing to evaluate a fresh right against. Checking the acting token instead would break descriptor delegation, since a descriptor passed to another process would stop working there.

What remains is a resource consideration rather than an access one: a caller entitled to write a file in a stratum that will not accept modification can cause an entry to appear in the create stratum without holding rights over that directory. They gain neither access nor space by it: §4.5.8 records that the copy is accounted to the preserved owner rather than to them.

That exemption is only enforceable because KACS provides a copy-up context; without it the access-control layer would check the caller against the create stratum's directory at exactly the point where the authority to check no longer exists. §3.9.7 describes the context, its phase binding and its exhaustive list of exempt operations. What matters here is what stratafs must hold up its end of: the context exempts caller authorisation only, so every mutation still goes through the ordinary VFS path under write access on the target mount, a read-only filesystem still refuses, and filesystem errors still propagate. Nothing is performed under a borrowed or elevated identity — every copy-up mutation runs with the calling task's own credentials.

4.6.2.2.1 Recovery retains the mounter’s file credentials #

The stale-staging recovery scan passes the mounter's credentials to the internal directory open, which retains them as the file’s credentials. That does not switch the calling task’s current credentials or select a different KACS token. The scan runs in a source-read copy-up context bound to that exact directory, so the context admits its read before an ordinary token access check.

Consequently, neither the caller nor the mounter needs a directory-list grant for that scoped recovery read. An ordinary open requesting list access remains subject to the directory’s descriptor. Path resolution separately uses the captured mounter credentials; the file-credential argument is not itself an authority switch.

4.6.3 Descriptors on Copy-Up

Peios / Peios technical reference / PKM / stratafs / Security

Copy-up produces a second instance of an existing object. Its security descriptor is the source's — owner, group, discretionary list, system list and integrity label alike — and it is KACS that carries it, not stratafs.

4.6.3.1 How it is carried #

When a copy-up context is created, the provider's complete effective descriptor is resolved and pinned as a byte string on the context. Each create phase copies those bytes into the phase, and the inode security initialisation for the created object installs them verbatim instead of running inheritance. Nothing is reconstructed field by field: it is a copy of the source bytes, so all five components are preserved together.

The canonical descriptor attribute is deliberately excluded from stratafs's own extended-attribute replication, precisely so that the two mechanisms cannot disagree.

The separation inside KACS is clean and explicit. The inode security initialisation takes the copy-up branch first, and taking it short-circuits the inheritance builder entirely. Ordinary creation in a create stratum inherits from its parent (§4.5.3); copy-up preserves. There is no path on which a copied-up object receives an inherited descriptor.

4.6.3.1.1 Descriptors of materialised parents #

Directories materialised in the create stratum to hold a copied-up object are handled the same way, each carrying the descriptor of the corresponding provider directory — the merged provider of that same relative path, which is what the pre-check of §4.6.2 evaluated against.

4.6.3.2 Failure #

Where the source descriptor cannot be replicated, the copy-up fails and the operation that required it fails. A missing, corrupt, unresolvable, oversized or unsupported descriptor fails the phase before the destination is created, and an absent pinned descriptor at install time fails the create.

4.6.3.2.1 Protection precedes publication #

Nothing publishes a copied-up object carrying any other descriptor: the descriptor is installed at inode creation, before any content, and publication is a link or rename of an object already stamped, so there is no window in which a differently-protected object is reachable.

The failure errno is not the specified one. §4.8's table pairs descriptor failure with EIO alongside extended-attribute failure, and the extended-attribute half does report EIO. The descriptor half is carried by KACS, whose failures surface as EACCES, EOPNOTSUPP, EINVAL, ESTALE or ENOMEM; there is no EIO anywhere on that path. This is tracked as a defect.

4.6.3.3 Why preservation rather than inheritance #

Copy-up is reachable by any caller with the right to modify the source object. That right does not include the right to modify the source's descriptor, which is a separate right.

Were a copied-up object to receive an inherited descriptor, a caller holding only write access to a restrictively protected object could cause a copy of it to exist carrying the create stratum directory's inheritable entries — and that copy, having higher precedence, would become what the merged path resolves to. The object's confinement would have been replaced by the directory's, through an operation the caller was entitled to perform, without their ever holding the right to alter a descriptor.

Preserving the source descriptor closes that: a copy is exactly as reachable as its original, and copy-up changes which stratum holds an object without changing who may reach it.

4.6.3.4 Provenance #

Because the descriptor is preserved, the copy's descriptor-level owner is the owner of the object it was copied from, not the caller who caused the copy. Ownership therefore does not record who created the copy and cannot be relied on to; the audit record of §4.6.5 is where that is available.

Setting the copying caller as the descriptor owner would have preserved provenance, and was rejected: an owner holds implicit rights over an object's descriptor, so making the caller the owner would reintroduce the escalation this section exists to prevent by a slightly longer route.

The POSIX owner is preserved separately and for a different reason — §4.5.2 covers the chown and §4.5.8 what it means for accounting.

4.6.3.5 What "the source's descriptor" means #

The descriptor pinned at the start of a copy-up is the provider's effective descriptor rather than its raw stored attribute. On a provider mount whose own policy class synthesises, that would be the synthesised value. In practice that case is unreachable: reaching the object through the stratafs mount at all requires a real descriptor, under stratafs's own deny-missing class (§4.6.4). A provider on an unmanaged mount is refused outright.

4.6.4 Mount Policy

Peios / Peios technical reference / PKM / stratafs / Security

A stratafs mount carries the FACS mount policy class that denies access to an object with no readable security descriptor. The class is derived from the filesystem magic, not from an administrative choice: the policy resolver maps the stratafs magic to the deny-missing class, and falls back to that mapping whenever a cached policy value is not one of the valid ones.

4.6.4.1 Policy changes are refused before validation #

It cannot be set to anything else. The set path rejects a superblock carrying the stratafs magic with EOPNOTSUPP before it validates its arguments or checks privilege, and there is no mount option to choose one — stratafs's parameter table holds exactly one entry, and anything else is refused. Reading a mount's policy is itself privileged, requiring TCB privilege, though for stratafs the answer is fixed.

Two classes are excluded for distinct reasons.

The unmanaged class declares that FACS does not apply to a mount and that the kernel governs it by rules particular to that filesystem. stratafs has no such rules: it delegates every decision to the provider (§4.6.1). An unmanaged stratafs mount would therefore have no access control at all — not delegated control, but none — for every object reachable through it. That is not theoretical: enforcement points consult the mount policy of the superblock an object belongs to before performing their check, and treat an unmanaged mount as requiring none. For a mount established over a directory of executables, that would place every program on the system beyond the execute check.

The synthesising classes would have stratafs supply a descriptor of its own for an object whose provider has none. Either consequence is disqualifying: the mount would grant access to an object that is unreachable through its own stratum, falsifying the guarantee the whole of §4.6 rests on; and the class that persists a synthesised descriptor would write it back onto the provider's object, which stratafs may not do to any stratum and certainly not to one carrying ro.

4.6.4.2 Missing descriptors #

Where a provider object has no descriptor, access through the stratafs mount is denied under the mount's own policy, and the provider filesystem's policy is not consulted. Both paths implement this: stratafs's own merged-directory check turns ENODATA or EOPNOTSUPP straight into EACCES rather than asking the provider's superblock to synthesise, and an object open resolves the missing-descriptor policy against the stratafs superblock, yielding a missing-descriptor cache entry and then EACCES.

4.6.4.2.1 Corrupt is not missing #

A descriptor that is present but cannot be interpreted is a distinct case and is not routed to mount policy. It takes the ordinary corrupt-descriptor outcome: a corrupt cache entry and an emitted event on the open path, and a validation failure on the merged-directory path. The two produce the same errno by different routes, and mount policy is consulted in neither.

4.6.4.3 Consequence #

A stratafs mount is uniform in the sense a mount policy requires: every object reached through it is subject to the same policy, which is the mount's own. What varies between objects is the descriptor evaluated, which is a property of the object rather than of the policy.

Because the policy denies where a descriptor is absent, the divergence from direct access is always in the refusing direction. An object with no descriptor on a stratum whose own filesystem would synthesise one is refused through the stratafs mount while remaining reachable through its stratum path. An object reachable through its stratum path is never made more reachable by being merged. The same holds for a stratum on an unmanaged filesystem, whose objects carry no descriptors at all: merging one is permitted but yields nothing readable, and is not a useful arrangement.

4.6.5 Audit

Peios / Peios technical reference / PKM / stratafs / Security

Two classes of stratafs event carry information that cannot be recovered from the filesystem afterwards, and are recorded when they happen. Both are emitted through KACS's kernel-only emitter, so KMES stamps each with the effective token of the task whose operation caused it.

4.6.5.1 Copy-up #

Every copy-up emits a stratafs.file.copied-up record, successful or not. Its payload carries these fields, each a path of nested maps:

Field
object.file.path-relativeThe relative path within the mount, /-prefixed
source.stratum.indexThe stratum the object was copied from
source.stratum.pathThat stratum's path
destination.stratum.indexThe stratum it was copied into
destination.stratum.pathThat stratum's path
outcome.successWhether the copy-up succeeded
outcome.errnoThe failure, as a negative errno; absent on success

The caller's identity is not in this payload. It does not need to be: KMES stamps the effective, true and process token GUIDs onto every event header at ring-write time, and because copy-up runs in the caller's own context those are the caller's. The identity is carried in the envelope, once, for every event — duplicating it into the payload would give a reader a second copy that could disagree with the first.

Recording it matters because §4.6.3 preserves the source's descriptor, so nothing about the resulting object records who caused it to exist. A reader of these records must take the identity from the event header, not look for it among the fields.

The ENOTDIR of parent materialisation is reported through this event in its outcome.errno rather than through the refusal event below; the required fields are all present, under a different event type.

4.6.5.2 Refused mutation #

A mutation refused because of how the mount is arranged emits a stratafs.mutation.refused record carrying the relative path (object.file.path-relative), the operation (operation.name), the providing stratum (source.stratum.index and source.stratum.path), the error (outcome.errno, negative) and whether the refusal was deferred (outcome.deferred). The source.stratum fields are present only where a provider is known; see below.

What counts as an arrangement refusal is one explicit list — EROFS, EXDEV, ENOTDIR, EISDIR, ENOTEMPTY, EEXIST, EINVAL. Call sites cover every mutating path: writes, mappings, truncation, fallocate, splice, copy_file_range, remap_file_range, setattr, setxattr and removexattr, creation, tmpfile, unlink and rmdir, link, supersede, and rename.

EACCES is deliberately absent from that list. A refusal produced by an access check is audited by the mechanism that performed it, and stratafs does not duplicate those records.

These refusals report a mismatch between what a caller attempted and how the mount is arranged — software writing where it cannot, or an arrangement that does not admit an operation someone expected. That is diagnostic information about the system's configuration, and it is otherwise visible only as an error returned to a caller that may discard it.

Rollbacks are audited under the same event with the deferred flag set: a create or link whose outer bookkeeping failed and whose lower object could not be removed again, and a failed publication rollback after a copy-up.

A refusal raised before a provider is known — creation, tmpfile, the heads of link and rename — has no stratum to name. Its record has no source map at all: source.stratum.index and source.stratum.path are both absent, never -1, nil or an empty string, so a reader can tell "no provider was involved" from "the provider's path is empty".

4.6.5.2.1 One exception #

A refused deferred deletion is audited on any non-zero result, not only the arrangement errors, so one refused by an access check does get a stratafs record. That is the right resolution of the two rules, since the requirement to audit a deferred deletion is unconditional — nobody is left to receive the error — but it is a deliberate exception to the EACCES exclusion above.

The record is stratafs's whether or not stratafs was entered. The delete-child check on the merged parent runs before ->unlink, so a directory whose descriptor tightened between the arm and the close refuses the deletion before the filesystem sees it; KACS then raises the stratafs record itself, with no source.stratum fields, since no stratum was consulted.

4.6.5.3 What is not audited #

Resolution, revalidation and enumeration emit no records of their own. They occur on every path operation, they reveal nothing the resulting access check does not, and recording them would produce volume out of all proportion to their significance. There is no audit call anywhere in the lookup path.

Access checks performed against provider objects are audited by KACS, under its own rules.

4.7 The Origin Attribute

Peios / Peios technical reference / PKM / stratafs

A merged path does not reveal which stratum provided it. stratafs exposes that through one synthetic extended attribute, system.stratafs.origin, and deliberately through nothing else — a tool that instead re-implemented §4.3's resolution rules against the strata would be a second implementation of them, and would eventually disagree with the first.

Together with the mount table (§4.2.2), which gives the stratum stack, it is enough to explain any path in a mount without privilege beyond what reading the path itself requires.

4.7.1 The value #

Reading the attribute returns the absolute path of the object that provides it:

  • For a non-directory, the provider's path in its stratum.
  • For a merged directory, the paths of every participating directory in precedence order, separated by newlines.

Each element is the stratum's own path, then a / where the relative part is non-empty and the base does not already end in one, then the relative path. Within a path, a newline or a backslash is escaped by a preceding backslash; those two are the whole escape set, and the / stratafs itself inserts is not escaped. Stratum paths are absolute because the mount parser required them to be (§4.2.2).

There is no trailing newline and no trailing NUL. A null buffer returns the length required; an undersized one returns ERANGE.

The value is synthesised at each read from the current resolution. It is not stored, and it is not the value of any attribute on any stratum. Because non-root dentries are always invalidated (§4.4.2), a read by path always reflects a fresh resolution.

4.7.2 Constraints #

The attribute is not settable. Any set or removal in the reserved namespace fails with EPERM, before the provider is reached at all.

It is not reported by an attribute listing. The listing handler filters reserved names out of both its sizing pass and its copy pass, and the synthetic name is never added to any listing. Hiding it keeps archivers, copy tools and backup software from discovering it, attempting to preserve it, and failing.

The whole system.stratafs. namespace is reserved. No read, write or removal of any name in it is forwarded to the provider: a read of any name other than origin returns ENODATA, and a write or removal returns EPERM. Where a provider object carries a real attribute of one of these names, it is masked — the synthesised value is returned instead, and the provider's attribute is absent from listings through the mount.

Two details of the implementation are worth stating exactly. The namespace test is a fixed-length prefix comparison against system.stratafs. including the trailing dot, so the bare name system.stratafs is not reserved and would be forwarded to the provider. And the reserved set is one name wider than the namespace suggests: the staging marker attribute, security.peios.stratafs_staging, receives the same treatment — EPERM to write, ENODATA to read, hidden from listings, masked from providers — despite lying outside the system.stratafs. namespace.

4.7.3 Access #

Reading the attribute requires the access that reading an extended attribute of the object requires, and is refused where that would be refused. The right to read the object's stat attributes is not sufficient: the request is for the read-EA right, which KACS's getxattr hook demands on the stratafs dentry before stratafs's own handler runs.

For a merged directory the value names every participating directory, so it discloses more than any one of them. Reading it requires that access on every participating directory and is refused where any refuses — the same intersection §4.6.2 applies to enumeration, and for the same reason: a caller who may not know a restricted directory participates must not learn it from this attribute.

Where the attribute is read through a directory descriptor, the participating set is the one settled when that descriptor was opened (§4.3.4), and the value names that set rather than the current one. The access decision was likewise made at open and is recorded on the dentry, so the read consults a stored verdict rather than re-checking. A stratum that has joined since is not disclosed, because the check that would have covered it was never run; a settled participant that has since ceased to hold the directory is still named, for the same reason.

Where it is read by path, the participating set is the current one, resolved afresh, and the read-EA right is required against each of its members at that moment.

4.8 Failure Modes

Peios / Peios technical reference / PKM / stratafs

This section consolidates the conditions under which a stratafs operation fails and the error each produces. The sections above remain authoritative for the conditions themselves.

4.8.1 Mount-time #

ConditionError
Caller lacks the access to resolve a stratum path or read its attributesEACCES
Empty stratum stack, or strata= absentEINVAL
Stack longer than 16 strataEINVAL
More than one stratum carries createEINVAL
A stratum carries both create and roEINVAL
The same directory appears twice in the stackEINVAL
Any malformed strata= value (§4.2.2)EINVAL
A remount's strata= differs from the mounted stack (§4.2.2)EINVAL
An allocation failure while parsingENOMEM
A create-bearing stack established outside the initial user namespace, or without CAP_SYS_ADMIN thereEPERM
A stratum path names a non-directoryENOTDIR
A stratum is absent without amENOENT
A stratum lies within the mount point, or within another stratafs mount whose strata include this mount pointELOOP
The composed stack reaches the kernel's maximum stacking depthELOOP
Sixteen consecutive collisions allocating a mount cookieEAGAIN

The option-only conditions are decided before any path is touched, and the EPERM admission test before any path is resolved. The per-path conditions follow. §4.2.3 records the one place the specified ordering is not achieved.

4.8.2 Resolution #

ConditionError
No participating stratum holds the nameENOENT
An ancestor of the path is masked by a non-directory providerENOTDIR
Operation requires a non-directory; provider is a directoryEISDIR
Traverse or list refused on any participating directoryEACCES
A stratum walk fails for a reason other than absencethat error
A joined stratum path or child relative path exceeds PATH_MAXENAMETOOLONG
A task re-enters the same superblock while resolvingELOOP

4.8.3 Mutation #

ConditionError
Provider will not accept modification, and no create stratum has higher precedenceEROFS
Any mutation on a read-only-mounted stratafsEROFS
Creation, unnamed-file creation, or unnamed-file link with no create stratum, or it absentEROFS
Create stratum holds a path component as a non-directoryENOTDIR
Exclusive creation where any stratum holds the nameEEXIST
Copy-up cannot preserve an extended attributeEIO
Copy-up cannot preserve the descriptorEACCES, EOPNOTSUPP, EINVAL, ESTALE or ENOMEM, as KACS reported it
Copy-up for a descriptor whose object is no longer the provider, or whose target name is taken at publicationESTALE
Copy-up of a device node, FIFO or socketEROFS
Unlink where the provider will not accept modificationEROFS, or EPERM where the provider inode is immutable
Rmdir where another stratum holds entries withinENOTEMPTY
Rmdir or directory rename where list is refused on a participantEACCES
Modifying a special file's mode, descriptor or extended attributes where its provider will not accept modificationEROFS
Establishing a shared write-capable mapping where routing yields read-onlyEROFS
Replacing disposition where §4.5.4 would refuse the removal, or where a stratum above the create stratum would still hold the nameEROFS
Replacing disposition on anything but a regular fileEOPNOTSUPP
Rename where the source's provider will not accept modificationEROFS
Rename where a stratum above the source's provider provides the destinationEROFS
Rename where the source's provider does not hold the destination's directoryEXDEV
Rename of a directory containing entries from other strataEXDEV
Rename of a non-directory onto a directory providerEISDIR
Rename of a directory onto a non-directory providerENOTDIR
Rename onto a destination directory not empty across every stratumENOTEMPTY
RENAME_NOREPLACE where any stratum holds the destinationEEXIST
RENAME_EXCHANGE where the two names are not provided by one stratum that accepts modificationEROFS
RENAME_EXCHANGE where another stratum holds entries under either directory nameEXDEV
RENAME_WHITEOUTEINVAL
Hard link whose source's provider will not accept modification, or does not hold the destination's directoryEXDEV
Hard link where any stratum holds the destinationEEXIST
Linking an unnamed file into a different mountEXDEV
A dentry whose private state is missing, or whose provider identity no longer matchesESTALE
ioctl on a regular fileENOTTY, or ENOIOCTLCMD for the compat form
remap_file_range with flags outside dedupe and advisoryEINVAL

4.8.4 Interface #

ConditionError
Set or remove of any reserved attributeEPERM
Read of a reserved attribute other than originENODATA
Origin read where read-EA is refused on any participantEACCES
Origin read into an undersized bufferERANGE
Freezing the filesystemEOPNOTSUPP
Reading a mount's policy without TCB privilegerefused by KACS

4.8.5 Intended divergences #

None of the following is a defect. Each is described above, and each follows from stratafs having no way to record that a name should be absent, or from a merged path standing for objects in more than one stratum.

  • Removing a name may not remove it from the view. Where a lower stratum also holds the name, it becomes the provider and the name remains, now resolving to different content (§4.5.4).
  • A name may be unremovable. Where the provider will not accept modification, removal fails however the caller is privileged (§4.5.4).
  • Rename may be refused for an unmodified file. Where the source's provider will not accept modification, rename fails rather than silently copying (§4.5.5).
  • Hard links may be refused within one directory. Where the source's provider will not accept modification, linking fails with EXDEV (§4.5.6).
  • An object's inode number may change. Where the provider for a path changes, a new inode is presented (§4.4.3).
  • Two callers may hold non-contending locks on one path. Locks are held on the object a descriptor resolved to, so callers who opened either side of a change of provider — including one caused by a copy-up neither of them performed — have locked different objects (§4.5.7).
  • Copy-up severs hard links. Two names that shared an object in a lower stratum, and compared equal by inode number, refer to different objects once one of them is written through the mount; only the written one is copied up (§4.5.2).
  • An open may succeed where the first write then fails. Routing happens when a modifying operation is performed, not at open, so an open for writing against a provider that will not accept modification succeeds and the EROFS arrives at the write, the truncate, or the attempt to establish a shared writable mapping (§4.5.1).
  • A copy-up moves an object out from under descriptors already open on it. Only the descriptor whose operation caused the copy-up refers to the copy; others continue to refer to the original, and locks held on it stay there (§4.5.1, §4.5.7).
  • A write may fail ESTALE on a descriptor that was valid when opened. Where another descriptor, another mount, or a direct writer has since caused that name to be provided by a different object, a copy-up cannot proceed and the caller must reopen (§4.5.2).
  • fstat and stat may report different inode numbers for one file. After a copy-up, the descriptor that caused it keeps the inode it was opened against while a fresh resolution of the path yields a new one, so the two disagree for as long as that descriptor lives — though both name the copy (§4.4.3).
  • A directory's link count is always 1. The true count of subdirectories spans strata and cannot be maintained (§4.4.3).
  • Directory positions do not survive a reopen. A readdir offset is an ordinal into a per-descriptor capture, carrying no provider cookie (§4.3.4).

Every one of the first four is the result of refusing to invent a hidden record — a whiteout — that would make an entry in someone else's directory unreachable without their knowledge. The alternative buys POSIX fidelity at the cost of a stratum no longer meaning what its owner wrote in it, which is the property §4.2.1 exists to protect.

Appendix 4.A Constants

Peios / Peios technical reference / PKM / stratafs

Every value below is generated from the source by pkm/tools/gen-stratafs-constants.py. Nothing here is transcribed by hand, and the generator's --check mode fails if the two drift apart.

4.A.1 Filesystem identity #

ConstantValueMeaning
STRATAFS_MAGIC0x53545241Superblock magic, reported by statfs (§4.2.2)
STRATAFS_NAME"stratafs"The name the filesystem registers under
STRATAFS_MAX_STRATA16Longest stratum stack accepted (§4.2.1)

STRATAFS_MAGIC is an alias for STRATAFS_SUPER_MAGIC, which is declared in the header stratafs shares with KACS so that the mount policy class keyed on it cannot drift (§4.6.4).

4.A.2 Stratum flags #

ConstantValueMeaning
STRATAFS_F_CREATE0x1The create flag (§4.2.1)
STRATAFS_F_RO0x2The ro flag
STRATAFS_F_AM0x4The am flag

4.A.3 Extended attributes #

ConstantValueMeaning
STRATAFS_XATTR_PREFIX"system.stratafs."Reserved namespace; never forwarded to a provider (§4.7)
STRATAFS_XATTR_ORIGIN"system.stratafs.origin"Synthetic, read-only, hidden from listings (§4.7)
STRATAFS_XATTR_STAGING"security.peios.stratafs_staging"Copy-up staging marker; also reserved (§4.5.2)

STRATAFS_XATTR_STAGING is an alias for STRATAFS_STAGING_XATTR, declared in the shared header. Note the name it resolves to lies outside the reserved system.stratafs. namespace, yet receives the same treatment (§4.7).

The canonical security-descriptor attribute is KACS's, not stratafs's; stratafs only detects it in order to exclude it from copy-up replication (§4.6.3).

4.A.4 Copy-up and staging #

ConstantValueMeaning
STRATAFS_STAGE_MARKER_MAGIC0x53544731Marker magic
STRATAFS_STAGE_MARKER_VERSION1Marker version
STRATAFS_COPY_BUFFER_SIZE65536Copy-up read/write chunk, in bytes (§4.5.2)
STRATAFS_STAGE_PREFIX".stratafs-stage-"Staged-name prefix
STRATAFS_RECOVERY_BATCH128Names scanned per staging-recovery pass

4.A.4.1 The staging marker #

struct stratafs_stage_marker is packed and 24 bytes, all fields little-endian. It is the value of the staging attribute above.

OffsetSizeFieldType
04magic__le32
42version__le16
62size__le16
88boot_cookie__le64
168mount_cookie__le64

4.A.5 Routing #

The value route_existing returns (§4.5.1), as the Rust decision core names it and as the C glue mirrors it. The discriminants match.

C enumeratorValueRust
STRATAFS_ROUTE_IN_PLACE0InPlace
STRATAFS_ROUTE_COPY_UP1CopyUp
STRATAFS_ROUTE_READ_ONLY2ReadOnly

4.A.6 The decision core #

stratafs-core holds the stack-wide flag rules, provider selection, and routing. Its flag bits match the C ones above exactly.

ConstantValue
MAX_STRATA16
FLAG_CREATE0x1
FLAG_READ_ONLY0x2
FLAG_ABSENT_MAY0x4
FLAG_MASK0x7

The crate distinguishes these configuration errors. The C boundary collapses all of them to EINVAL, so the distinction is not observable to a caller (§4.2.1).

ErrorDiscriminant
Empty1
TooMany2
UnknownFlag3
RepeatedCreate4
CreateReadOnly5

4.A.7 Build configuration #

stratafs is built by CONFIG_STRATAFS_FS, a boolean option, so what it builds is linked into vmlinux rather than loaded. It depends on CONFIG_SECURITY_PKM and selects FS_STACK. Its sources are staged into the kernel tree as fs/stratafs, separate from PKM's own security/pkm. CONFIG_STRATAFS_FS_TEST_HOOKS compiles the test rendezvous and fail points (§4.A.2), inert unless the kernel is booted with stratafs.test_hooks=1. CONFIG_STRATAFS_KUNIT_TEST builds the in-kernel unit tests.

The translation units are:

  • super.o
  • lookup.o
  • inode.o
  • file.o
  • dir.o
  • dir_cache.o
  • xattr.o
  • copy_up.o

Appendix 4.B Test hooks

Peios / Peios technical reference / PKM / stratafs

Several of this chapter's promises are about windows that no arrangement of real filesystems can hold open. A copy-up is never observable in a partial state (§4.5.2) — but a real copy-up publishes within microseconds. A failed install rolls the lower link back (§4.5.6) — but the install fails only under allocation pressure between two steps of one syscall. A dentry whose provider identity no longer matches yields ESTALE (§4.5.5) — but every walk rebuilds the dentry moments before the operation, so the mismatch needs the provider to change inside a single syscall.

The test hooks exist so conformance tests can steer exactly those windows and nothing else. They are not conformance surface: no behaviour in this chapter depends on them, and they carry no named citations.

4.B.1 Gating #

The hooks are doubly gated:

  • CONFIG_STRATAFS_FS_TEST_HOOKS compiles them in. Without it every hook site is an inline constant zero.
  • stratafs.test_hooks=1 on the kernel command line registers them. Without it nothing appears in securityfs and each hook site reduces to reading one never-true flag.

Production images build the option but never pass the parameter; only the provium test profiles do. Arming a hook additionally requires reaching a mounted securityfs, which on a managed system means a caller able to establish the mount and satisfy its policy class.

4.B.2 Interface #

Each hook point is one securityfs file under stratafs/hooks/, alongside KACS's own kacs/ endpoints. securityfs rather than debugfs is forced by kernel lockdown: the Peios kernel builds LOCK_DOWN_KERNEL_FORCE_INTEGRITY, and the integrity set refuses every debugfs open that is not a read of a mode-0444 file — a debugfs hook would be visible and permanently untouchable. Lockdown does not gate securityfs. Writing a hook's file arms or disarms it; reading reports its state:

WriteEffect
holdTasks reaching the point block until the hook is cleared
fail NThe next task to reach the point fails with errno N, once
clearDisarm; every held task is released

A read returns <mode> waiting=<W> hits=<H>, where <mode> is none, hold or fail, W is the number of tasks currently blocked — poll it to know the victim has arrived — and H is the number of arrivals the hook has affected. A held task waits killably, so a fatal signal always removes it. A fail arms exactly one failure and disarms itself; hold persists until cleared.

4.B.3 Points #

FileMoment
copy-up-beginBefore the copy-up context is created — before any staging exists. A failure here is a source descriptor that could not be read (§4.6.3).
copy-up-publishAfter the staged object is complete — content, attributes, metadata — and before publication. A hold here is an in-flight copy-up, staged but unpublished (§4.5.2).
rename-providerIn rename, after the walk-time provider is captured and before the locked re-lookup that verifies it (§4.5.5).
link-installIn link, between the lower link succeeding and the outer inode installing. A failure here exercises the rollback of §4.5.6.
supersede-publishAfter the staged source and destination are checked, immediately before the backing rename publishes a supersede replacement. Failure leaves the original provider untouched.
supersede-removeFor a supersede across distinct strata, after publication and release of the create-stratum directory lock and write hold, before reacquiring write access to the original provider and re-looking-up its name. Failure exercises cleanup of the published replacement.
staging-recoverAfter a staging marker is validated as an orphan, before acquiring backing-mount write access for its removal. A hold lets a test remount the provider read-only while rename is scanning an overwritten directory; failure leaves the orphan in place.

copy-up-begin and copy-up-publish sit on both copy-up shapes — the anonymous regular-file stage and the named directory/symlink stage — so a hold at copy-up-publish pins whichever kind is in flight.

5.1 Overview

Peios / Peios technical reference / PKM / LCS

LCS — the Layered Configuration Subsystem — is the kernel half of the Peios registry: a hierarchical, access-controlled configuration store modelled on the Windows registry. It owns the namespace, the security model, change observation, transactions, and the layer system that gives the registry its name. It owns no storage at all.

Storage belongs to sources: userspace processes that hold registry data and answer questions about it over the Registry Source Interface. A source stores what it is told and returns everything it holds; it never resolves layers, never filters by visibility, never sees the identity of a caller, and never interprets a path beyond the parent and child names it is given. Every decision about what a caller may see or do is made in the kernel. loregd is the first source, and the one that provides Machine\ and Users\ at boot, but nothing in LCS knows that: hive routing is built entirely from what registers.

Userspace never talks to a source. Processes reach the registry through three syscalls and eighteen ioctls, and the fd those syscalls return is a capability — an open key carries the access mask it was granted, and carries it wherever the fd goes.

5.1.1 Where it sits #

LCS is a subsystem of PKM, peer to KACS and KMES, staged into the kernel tree as security/pkm/lcs and built by CONFIG_SECURITY_PKM — a boolean option, so it is linked into vmlinux rather than loaded. Its three syscalls occupy 1100–1102 in the PKM range, added to the syscall table by a patch against arch/x86/entry/syscalls/syscall_64.tbl.

It depends on KACS and KACS does not depend on it. Every access decision LCS makes is a call into the KACS AccessCheck function against a Security Descriptor the source returned; LCS defines no access control mechanism of its own. Security Descriptor inheritance at key creation is likewise KACS's computation, not LCS's. Audit events go to KMES.

A substantial part of LCS is Rust. The crate lcs-core is staged alongside PKM's other cores as security/pkm/lcs/lcs_core and holds the parts where correctness is a matter of pure decision rather than kernel plumbing: layer resolution, the RSI wire codec, the backup stream serialiser, the transaction mutation log, watch dispatch, case folding, and configuration validation. The C half owns fds, the char device, memory, locking, and the syscall boundary.

5.1.2 The semantic core #

Six rules generate the rest of the model. Every behaviour described in this chapter is a consequence of one of them.

  1. Names are layered. A key's presence at a path is per-layer. Different layers can name different keys at the same path, and removing a layer removes its names.
  2. Values are layered. Every write is tagged with a layer; the effective value is the highest-precedence entry. Tombstones can actively mask lower layers.
  3. Key identity is not layered. A key's GUID, Security Descriptor, volatile flag, symlink flag and last write time belong to the key object, not to any layer, and are never automatically reverted.
  4. Security is key-bound, not layer-bound. Modifying a Security Descriptor is a permanent change to the key. Removing a layer does not revert it. Security policy is operational state, not configuration overlay.
  5. Handles are capabilities granted at open. An open key fd carries an access mask computed once, by AccessCheck, at open time. Later operations test the mask, not the descriptor. Changing a descriptor does not affect an fd that already exists.
  6. Sources persist, the kernel decides meaning. A source stores path entries, key records and value entries and returns all of them. Everything else is the kernel's.

The consequence that surprises people most often is the fourth. A layer is a configuration overlay and reverts cleanly; an access control change is not configuration and does not.

5.1.3 What is layered and what is not #

PropertyLayeredResolved bySurvives layer deletion
Path existenceYesHighest precedence, then highest sequenceNo
ValuesYesHighest precedence, then highest sequenceNo
Value tombstonesYesMasking lower precedenceNo
Blanket tombstonesYesMasking all lower-precedence values on the keyNo
Key hidingYesMasking lower precedenceNo
Key GUIDNoDirect on the key objectYes
Security DescriptorNoDirect on the key objectYes
Volatile flagNoDirect on the key objectYes
Symlink flagNoDirect on the key objectYes
Last write timeNoDirect on the key objectYes

A watch is bound to the key object rather than to either, and stays on that object whatever happens to the name (§5.6.3).

5.1.4 registry.pol #

One external format constrains the design. registry.pol is the binary format Active Directory Group Policy uses to deliver configuration to domain-joined machines, and the registry exists so that Peios can consume it without loss. Everything registry.pol can express, the registry can represent.

Three consequences run all the way through:

  • The full Windows value type set is supported, including the three hardware-resource types that carry no Peios semantics at all. They behave exactly as REG_BINARY; they exist so a value copied from a Windows hive round-trips with its type tag intact (§5.2.6).
  • Paths are backslash-separated, case-preserving and case-insensitive. This is not negotiable and it is the reason case folding appears in the kernel at all (§5.2.8).
  • Registry access rights occupy the Windows bit positions, so a Security Descriptor containing registry ACEs is binary-compatible (§5.4.2).

Tombstones and blanket tombstones exist for the same reason: the **Del.ValueName and **DelVals directives express absence, which a purely additive overlay cannot (§5.2.7).

LCS does not parse registry.pol. Parsing is a userspace concern; LCS provides the model that makes a faithful translation possible.

No other parity with Windows is claimed. The binary compatibility of a Security Descriptor is a KACS guarantee, not an LCS one.

5.1.5 Where the model diverges from Windows #

LCS is modelled on the Windows Configuration Manager, and departs from it in seven places. All seven are decisions rather than gaps.

WindowsLCS
Backing storeKernel-internal hive filesUserspace sources over the RSI, so a storage backend is not a kernel change
Hive routingA fixed set of predefined hivesAny source may register any name at runtime
LayersNone; registry.pol is applied by flattening valuesPrecedence-ordered layers with tombstones, resolved at query time, so removal reverts rather than tattoos
Change observationRegNotifyChangeKeyValue, single-shotPersistent watches, closing the re-registration race
Key identityHive cell offsetsGUIDs, stable across storage reorganisation
Forward slashNot acceptedAccepted on input, normalised to backslash
Case comparisonRtlCompareUnicodeStringUnicode Simple Case Folding, pinned to a version

5.1.6 Windows features that are absent #

Four have been evaluated and deliberately excluded.

Key classes. The class parameter of RegCreateKeyEx is documented by Microsoft as reserved, and no consumer of it is known.

RegOverridePredefKey. Per-process key redirection, and specific to COM. It would require unbounded per-process state in the kernel, and private hives and private layers cover the uses that are legitimate.

WoW64 redirection. Splitting keys by pointer width. Peios has no 32-bit compatibility concern to split for.

The HKEY_CLASSES_ROOT merged overlay. A merge of HKLM and HKCU Software\Classes, again COM-specific, with no Peios equivalent.

5.1.7 This chapter #

§5.2 covers the data model — hives, keys, path entries, values, tombstones, names, and what happens to a key that loses its last name. §5.3 covers layers: the model, the base layer, where layer metadata lives and the circularity that implies, who may write into a layer, and the resolution algorithm and the sequence counter that drives it. §5.4 covers security: the access flow, the rights, inheritance, and the audit events LCS emits. §5.5 covers the syscall and ioctl interface and the error model. §5.6 covers watches, §5.7 transactions, and §5.8 the source model — registration, dispatch, validation, and the intricate business of what a late response means. §5.9 covers backup and restore, §5.10 how LCS configures itself out of the registry it is serving, and §5.A the ABI.

Two contracts extracted from LCS are specified rather than described, because a third party implements the other side of each: the Registry Source Interface and the registry backup format. Both are chapters of PSPK.

5.2.1 Hives and Routing

Peios / Peios technical reference / PKM / LCS / The Data Model

A hive is a top-level namespace — the first component of every registry path. LCS keeps a routing table mapping hive names to registered sources, and that table is built entirely from what registers. There is no static configuration.

FieldDescription
NameThe hive name, e.g. Machine, Users. Case-preserving, compared case-insensitively.
Root GUIDThe GUID of the hive's root key, supplied by the source at registration.
SourceThe source slot backing this hive.
StatusActive or Unavailable, tracking source connectivity.

Status is a property of the source slot rather than of the individual hive, which amounts to the same thing: a slot's hives are exactly one source's, and they go Down together.

5.2.1.1 Route identity #

A hive route is identified by the pair (case-folded name, scope), where the scope is either the global namespace or one private scope GUID.

That pair must be unique across every registered source; a source claiming one another source already holds is rejected.

The same name may appear in different scopes, which is precisely how a private hive shadows a global one without colliding with it.

A hive is backed by exactly one source. A source may back many.

5.2.1.2 Routing a path #

LCS takes the first component of a path — everything before the first separator — and looks it up.

  • Registered and active routes to the backing source.
  • Not registered is ENOENT.
  • Registered but Down is EIO.

Before any source registers, the table is empty and every path yields ENOENT (§5.10.1).

5.2.1.3 CurrentUser is not a hive #

CurrentUser\ is a kernel-level alias. When a caller-supplied absolute path starts with it, LCS reads the user SID from the calling thread's effective token and rewrites the path to Users\<SID>\... before routing.

The rewritten path is re-checked against the total-length limit, since a textual SID is longer than the alias it replaced.

Three constraints keep this safe.

It applies only to the first component of a path, and only to a caller-supplied absolute one.

It does not apply to symlink targets: a target beginning with CurrentUser\ is followed literally, routes as a hive of that name, and finds nothing. Without that rule, a symlink containing CurrentUser\ would redirect a privileged service into the service's own user hive — a confused deputy.

And CurrentUser cannot be registered as a hive name. LCS rejects the registration with EINVAL, so the alias can never collide with a real hive.

Symlink targets are subject to ordinary hive routing, private hives included. A sandboxed process resolving a symlink sees the same registry through it that it sees directly, which is the point of sandboxing it.

5.2.1.4 Two names the kernel knows #

LCS is source-agnostic about routing but not entirely ignorant of names. Users is compiled in as the target of CurrentUser\ rewriting. Machine is matched, case-insensitively and only for a global hive, to decide when to run the bootstrap refresh that reads LCS's own configuration (§5.10.2).

Neither is a routing decision — an unregistered Machine routes nowhere like any other name — but the two names do exist in the kernel.

In practice loregd registers Machine and Users at boot. Other sources may register other hives at any time.

5.2.1.5 Names #

Hive names follow the same rules as key name components: no backslash, no forward slash, no null byte, valid UTF-8, non-empty, and no longer than MaxPathComponentLength. They are case-preserving and compared case-insensitively (§5.2.8).

5.2.2 Private Hives

Peios / Peios technical reference / PKM / LCS / The Data Model

A private hive is registered with the RSI_HIVE_PRIVATE flag and a scope GUID.

It is invisible in the global namespace and reachable only by a thread whose token carries that scope GUID.

5.2.2.1 Routing #

Private hives are checked before global ones.

For each scope GUID on the calling thread's token, in the order the token carries them, LCS looks for a private hive with the path's name and that scope. The first match wins. Only if none matches is the global table consulted.

A private hive can therefore shadow a global one, and no name is exempt — a thread with a private Machine\ sees the private one. That is what makes complete registry isolation possible for a container or a sandbox without giving it a differently-named hive to notice.

MaxScopeGUIDsPerToken, default 8, bounds the per-syscall iteration cost; a token above the cap fails every registry operation with E2BIG (§5.3.5). Duplicate scope GUIDs on a token are rejected.

5.2.2.2 Scope GUIDs on a token #

Scope GUIDs reach a thread through the KACS token's LCS credential extension: a versioned block in the token specification carrying the thread's scope GUIDs and its private layer names (§5.3.5). It parses at a fixed offset, and:

  • rejects a nil scope GUID;
  • rejects duplicate scope GUIDs;
  • caps the count at 256 — a hard KACS limit, above the configurable LCS one.

The credentials propagate across fork, duplication and impersonation like the rest of the token.

Attaching them is gated by SeCreateTokenPrivilege, because scope GUIDs can only enter a token when the token is created. That is a blanket privilege rather than a per-scope authorisation: a caller that can create tokens at all can create one claiming any scope. Isolation between private hives therefore rests on who may create tokens, not on who owns a scope.

5.2.2.3 Scope lifecycle #

A scope GUID is an opaque 128-bit value with no lifecycle. LCS neither creates nor tracks one; it only compares. A scope exists as long as some private hive or some token references it, and when the last reference goes it is simply a number nobody uses.

Nothing in the kernel generates scope GUIDs. Key GUIDs and token GUIDs come from the kernel's UUIDv4 generator; scope GUIDs are supplied by userspace, and choosing them unpredictably is userspace's responsibility.

5.2.2.4 Registration rules #

Registration enforces more than the route-identity uniqueness of §5.2.1 — the full list is §5.8.2, and the rules that exist for private hives are these:

  • A hive's root GUID may not be nil.
  • The root GUIDs within one registration request must be distinct.
  • A hive without the RSI_HIVE_PRIVATE flag may not carry a non-nil scope GUID.
  • A hive with the flag may not carry a nil one. No token can carry a nil scope GUID, so such a hive would be registrable and then permanently unroutable — holding its name against other sources in that scope while every lookup returned ENOENT.
  • Unknown flag bits are rejected.
  • Opening /dev/pkm_registry at all requires an enabled SeTcbPrivilege, checked in the device's open() handler (§5.8.2).

5.2.3 Keys

Peios / Peios technical reference / PKM / LCS / The Data Model

A key is a node in the hierarchy: a container holding subkeys and values. The filesystem analogy is an inode. A key carries identity and properties; naming lives in path entries (§5.2.5).

FieldMutableLayeredDescription
GUIDNoNoIdentity, assigned by LCS at creation, persisted by the source.
NameNoNoThe key's own name component. Informational; the path entry resolves the identity. QUERY_KEY_INFO preserves this spelling only when it folds to that resolved leaf, and audits and refuses a mismatch with EIO.
Parent GUIDNoNoThe parent key. Nil for a hive root.
Security DescriptorYesNoComputed from the parent at creation; changed at runtime with WRITE_DAC / WRITE_OWNER.
Last write timeYesNoUpdated when a value is written or deleted or the descriptor changes.
VolatileNoNoThe source stores this key in non-persistent storage only.
SymlinkNoNoThis key is a symbolic link (§5.2.4).

No key property is layer-qualified. Properties belong to the object. Changing a key's structural type through a layer is not a matter of setting a flag; the layer hides the original key and creates a new one at the same path, which is the ordinary overlay pattern.

5.2.3.1 Identity #

A key's identity is its GUID, not its path. Two keys occupying the same path at different times are different objects with different GUIDs. A path is a name that maps to an identity; the identity outlives the name.

GUIDs are assigned by LCS — never by a source — and pushed to the source at creation, where they become the primary key of its storage.

Once a path has been resolved to a GUID at open time, every subsequent RSI operation uses the GUID directly.

The generator is the kernel's version-4 GUID generator: random bytes from the kernel CSPRNG with the version and variant bits set in the PCDS layout — the version nibble in the high nibble of byte 7, because Data3 is little-endian in bytes 6–7, and the variant bits in byte 8.

Freshness is the collision resistance of UUIDv4 plus a check against the keys LCS currently tracks, with a bounded retry.

There is no persistent retired-GUID catalogue, and no code anywhere that would maintain one. A GUID dropped by orphan cleanup is not remembered.

If a source answers RSI_CREATE_KEY for a freshly generated GUID with RSI_ALREADY_EXISTS, that is not a race to retry — the kernel believes the GUID is unused and the source disagrees. LCS treats it as source inconsistency and fails closed with EIO.

It is never retried as an open and EEXIST never reaches userspace from reg_create_key.

A GUID appears at exactly one canonical location. LCS validates that and rejects a source that reports otherwise.

5.2.3.2 Volatile keys #

Volatile is a flag LCS carries and forwards. Storing a volatile key in non-persistent storage is the source's obligation; nothing in the kernel enforces it.

What the kernel does enforce is the containment rule: a non-volatile key may not be created under a volatile parent (EINVAL).

The converse is allowed — a volatile key under a persistent parent is ordinary.

5.2.3.3 Naming #

Three bytes are forbidden in a key name component: backslash and forward slash, both of which are separators, and the null byte.

Every other valid UTF-8 sequence is permitted, spaces and arbitrary Unicode included.

Empty components are forbidden, which rules out a leading separator and consecutive separators (Machine\\System).

A trailing separator (Machine\System\) is likewise invalid.

Length limits are byte counts: MaxPathComponentLength per component, MaxTotalPathLength for the whole path, MaxKeyDepth for nesting.

Forward slash normalisation is achieved by treating / as a separator wherever a separator is recognised, rather than by rewriting the string — so a materialised component never contains either separator, and the canonical form is per-component rather than a canonicalised string.

5.2.4 Symlinks

Peios / Peios technical reference / PKM / LCS / The Data Model

A symlink key uses two mechanisms, and both are load-bearing.

The symlink flag on the key record marks the key's structural type. It is set at creation by REG_OPTION_CREATE_LINK and is immutable afterwards.

RSI_WRITE_KEY can update only the descriptor and the last write time, so there is no operation that could change it.

The default value, of type REG_LINK, supplies the target path.

It is an ordinary layered value, which means a higher-precedence layer can redirect a symlink by writing a different REG_LINK default value, and removing that layer restores the original target.

The flag marks identity; the value provides the target.

5.2.4.1 Resolution #

LCS follows symlinks during path resolution and the fd it returns refers to the resolved target — its GUID, its position in the tree, its ancestor chain — not to the link.

The target is resolved by issuing a separate RSI_QUERY_VALUES for the key's default value (the empty name) and applying ordinary layer resolution to the result, so the target participates in the layer system exactly as any other value does.

If the effective default value is missing, or is not of type REG_LINK, resolution fails with EINVAL.

LCS does not validate the type at write time: a layer that writes a REG_SZ default value over a symlink's target breaks resolution at the next open, and removing that layer fixes it.

The offending value stays in the registry; a failed resolution writes nothing.

5.2.4.2 The target path #

The REG_LINK payload is a length-delimited UTF-8 registry path. No trailing null is required or permitted — the length delimits it, and a null byte inside that length is rejected like any other. Forward slashes are handled as separators as everywhere else.

The target is validated with exactly the same rules as a syscall path: UTF-8, no null bytes, per-component and total length, no empty components, no trailing separator, maximum depth. The only difference is that a syscall path arrives null-terminated and has its terminator stripped first.

A target is always interpreted as absolute — its first component is routed as a hive name.

Resolution has no check that rejects a relative-looking target as malformed. A target of Sub\Key is not an error; it is a request for a hive named Sub, and it yields ENOENT unless such a hive happens to be registered, in which case it resolves there.

Writing one is different. REG_IOC_SET_VALUE of a REG_LINK value routes the target's first component at write time, with the writer's scope GUIDs, and answers EINVAL if it names no registered hive. That is what stops a dangling target being captured later by whoever registers a private hive of that name: a target that cannot be written cannot be captured, and the failure lands on whoever authored it. The cost is that a symlink cannot forward-reference a hive registered after it. A target that arrives in source data or a backup stream is not gated, which is why resolution still has to treat the shape as a hive request.

CurrentUser\ rewriting is not applied (§5.2.1), so a target beginning with CurrentUser\ routes as a hive of that name and cannot be registered, and therefore always fails. Ordinary hive routing does apply, private hives included, for the resolving thread.

5.2.4.3 Depth #

Symlink resolution is bounded by SymlinkDepthLimit, default 16, configurable from 1 to 64. Exceeding it is ELOOP.

5.2.4.4 Opening the link itself #

REG_OPEN_LINK on reg_open_key opens the symlink key rather than following it, which is how a symlink is managed at all — deleted, retargeted, inspected.

It applies to the final path component only. A symlink encountered part-way along a path is followed whether or not the flag is set.

The access check follows the same rule: with REG_OPEN_LINK the check is against the link, otherwise against the target.

5.2.4.5 Creation #

Creating a symlink needs all of:

  • KEY_CREATE_SUB_KEY on the parent, as for any key;
  • KEY_CREATE_LINK on the parent;
  • either an enabled SeTcbPrivilege or membership of Administrators.

The last is a genuine disjunction — either satisfies it — and the privilege branch marks the privilege used.

Failing it is EPERM.

5.2.5 Path Entries

Peios / Peios technical reference / PKM / LCS / The Data Model

Key existence and naming are layer-qualified; key identity is not. The source stores a path entry per layer, which separates the two. The overlay-filesystem analogy is exact: directory entries are per-layer, inodes are shared.

FieldDescription
Parent GUIDThe parent key.
Child nameThe key's name under that parent — one component, not a path.
LayerThe layer this entry belongs to.
TargetThe GUID of the key at this path in this layer, or HIDDEN.
SequenceA monotonic number assigned by LCS at creation, used for tiebreaking within a precedence tier.

On the wire a HIDDEN entry is a target type of 1 with an all-zero GUID (§5.A).

A source returning a non-zero GUID on a HIDDEN entry is returning malformed data.

5.2.5.1 Creating a key #

Creating a key in a layer always assigns a fresh GUID and produces two records: a path entry (parent, name, layer) → GUID with a new sequence number, and a key record carrying that GUID.

LCS sends RSI_CREATE_ENTRY first and RSI_CREATE_KEY second.

That order matters for the race: if another caller got there first, the entry creation returns RSI_ALREADY_EXISTS and LCS retries the whole thing as an open, reporting REG_OPENED_EXISTING. Failure on the second call, for a GUID LCS just minted, is not a race and fails closed (§5.2.3).

If a different layer already has a key at that path, the new layer gets its own distinct GUID. Each layer has its own key object, and resolution decides which is visible.

No operation creates a path entry pointing at an existing GUID. reg_create_key always mints a new one. The namespace is a tree, never a graph.

5.2.5.2 No GUID sharing across layers #

The path table's shape would technically permit two entries referencing one GUID, which would be a hard link. No API exposes it.

Every key has exactly one canonical parent and name, and LCS validates that a source is not reporting otherwise: the key record's parent must be the parent the key was reached through, and its name must fold to the component the key was reached by. A record that says otherwise is malformed key metadata — REG_IOC_QUERY_KEY_INFO answers EIO and the source-data validation failure is audited.

Aliasing has an explicit, visible mechanism, and it is symlinks. Hard links would make parent-GUID semantics, descriptor inheritance, subtree enumeration and watch dispatch all ambiguous at once.

5.2.5.3 Hiding #

A layer can create a HIDDEN entry at a path, making the key invisible regardless of what lower-precedence layers say.

It is the path-level equivalent of a value tombstone, and when the hiding layer is removed the lower key reappears.

A HIDDEN entry gets a sequence number like any other entry, so a conflict between a GUID entry in one layer and a HIDDEN entry in another at the same precedence resolves deterministically: the higher sequence number wins.

5.2.5.4 Hide and replace #

A single layer can hide an existing key and put a new one at the same path, and no special mechanism is needed for it. The layer creates its own path entry pointing at its own new GUID; lower-precedence entries for the same (parent, name) are masked because this layer's entry wins on precedence.

Removing the layer removes both the new key and its masking effect, and the lower key comes back.

This falls out of per-layer path entries and precedence ordering without a line of code that knows about it.

5.2.5.5 Hive roots #

A hive root has no parent GUID and no child name, so there is no (parent, name, layer) tuple to remove or mask.

Deleting or hiding a hive root fd is EINVAL, rejected before source dispatch, before transaction enlistment, before sequence allocation and before any watch event is generated.

5.2.6 Values

Peios / Peios technical reference / PKM / LCS / The Data Model

A value is a named, typed datum inside a key. Values hold the configuration data; keys hold values.

FieldDescription
Key GUIDThe key this value belongs to.
NameThe value's name; the empty string is the default value. Case-preserving, compared case-insensitively.
TypeA registry value type.
DataAn opaque byte array, up to MaxValueSize (default 1 MB).
LayerThe layer this entry belongs to. Every write is tagged.
SequenceAssigned by LCS at write time, for tiebreaking within a precedence tier.

A key holds many values with distinct names, and at most one unnamed one.

5.2.6.1 One name, many entries #

A single (key GUID, value name) pair can have several entries in the source — one per layer that has written to it.

The source stores them all and returns them all.

LCS resolves the effective value at read time (§5.3.6).

That is the mechanism that makes layer deletion revert configuration automatically. Delete the layer, its entries go, and the next-highest-precedence entry becomes effective. Nothing has to be recomputed or rewritten.

5.2.6.2 Types #

The full Windows type set is supported, for registry.pol fidelity: REG_NONE, REG_SZ, REG_EXPAND_SZ, REG_BINARY, REG_DWORD, REG_DWORD_BIG_ENDIAN, REG_LINK, REG_MULTI_SZ, REG_RESOURCE_LIST, REG_FULL_RESOURCE_DESCRIPTOR, REG_RESOURCE_REQUIREMENTS_LIST and REG_QWORD, numbered 0 to 11. Values are in §5.A.

LCS stores the type tag and returns it on read but does not interpret the data. The single exception is REG_LINK read as a symlink key's default value, which triggers target resolution (§5.2.4).

The three hardware-resource types, 8 to 10, describe device resource assignments in the Windows HKLM\HARDWARE hive, which the Windows kernel rebuilds at every boot. Peios has no equivalent: hardware enumeration belongs to the Linux device model, sysfs and /proc/iomem, not to the registry. LCS accepts these tags only so a value carrying one round-trips without loss. They have no semantics, LCS never produces one, and for every operation they behave as REG_BINARY. Rejecting a faithfully-copied value would break the fidelity guarantee that is the reason the registry exists.

Type tags are validated at every write boundary, before sequence allocation, transaction enlistment or source dispatch. An unknown code is EINVAL.

5.2.6.3 REG_TOMBSTONE #

One further type, REG_TOMBSTONE (0xFFFF), is internal. It is never returned to a caller reading a value — a caller whose effective entry is a tombstone gets ENOENT, which is exactly what a tombstone means.

There is no separate tombstone flag in the REG_IOC_SET_VALUE argument. Writing type REG_TOMBSTONE is the explicit tombstone operation.

It must carry zero-length data; non-empty tombstone data is EINVAL, again before sequence allocation, enlistment or dispatch.

5.2.6.4 Naming #

Value names use the same case rules as key names: Unicode Simple Case Folding, case-preserving, case-insensitive.

They differ in one respect. Backslash and forward slash are permitted in a value name. Value names are not hierarchical, so a separator has no meaning in one.

Only the null byte is forbidden, and invalid UTF-8 is rejected. The empty string is reserved for the default value.

That difference is why watch event path components are length-prefixed rather than joined by a separator (§5.6.2) — a value name can contain the separator.

5.2.6.5 Size #

MaxValueSize defaults to 1 MB and is configurable from 4 KB to 64 MB (§5.10.3).

Value data is opaque bytes and is not subject to the UTF-8 validation that applies to every string in the interface.

5.2.6.6 Tool value encodings #

reg, Registry Editor and the peios Rust crate use these representations:

  • REG_SZ and REG_EXPAND_SZ: UTF-8 ending in one null byte, rather than Windows' UTF-16 representation.
  • REG_LINK: the absolute target path in UTF-8, with no null byte. Its length determines its end; a target containing a null is not followed.
  • REG_MULTI_SZ: each string followed by a null byte, with one additional null byte ending the list.
  • Integers: little-endian, except for REG_DWORD_BIG_ENDIAN.

These are data conventions shared by tools. LCS still treats ordinary value payloads as opaque bytes; its special handling of a link key's REG_LINK default value is described in §5.2.4. For choosing and entering a type as an operator, see Keys, values, and types.

5.2.7 Tombstones

Peios / Peios technical reference / PKM / LCS / The Data Model

An overlay that can only add is not enough. registry.pol expresses absence — **Del.ValueName deletes a specific value, **DelVals deletes every value in a key before applying new ones — and a higher-precedence layer that can only override cannot say "this value must not be configured". Tombstones are how absence is expressed.

Both kinds are per-layer, and both vanish with their layer, restoring whatever they were masking.

5.2.7.1 Value tombstones #

A value tombstone is a layer entry that says the value does not exist in this layer and lower-precedence layers are masked.

Resolution treats a winning tombstone as "not found" without falling through.

In the source's storage it is an entry of type REG_TOMBSTONE with no data. A caller who wins with one gets ENOENT.

Removing the tombstone's layer makes the lower-precedence value effective again, which is the whole point.

5.2.7.2 Blanket tombstones #

A blanket tombstone is a per-layer marker on a key that masks every value from lower-precedence layers, whatever its name.

Where a value tombstone names one value, a blanket names none and covers all — including values whose names were not known when the blanket was written, which is exactly what **DelVals requires.

It is stored as a flag on the (key GUID, layer) relationship and occupies no per-name entry. It has its own sequence number.

5.2.7.2.1 How it competes #

A blanket does not short-circuit resolution. It enters the candidate pool as a tombstone candidate for every value name, at its own (precedence, sequence), and the ordinary rule picks the winner (§5.3.6).

So a per-value entry that beats the blanket on the tuple overrides it, and one that loses is masked.

A layer can write a blanket and write specific values in the same layer: the specific values are visible because they were written afterwards and carry higher sequence numbers, and everything else from below is masked. That is **DelVals followed by new writes, expressed without a special case.

An exact tie — same precedence and same sequence — between a blanket and a per-value entry is not resolved in the blanket's favour or anyone's. It is malformed source data and the operation fails with EIO (§5.3.6).

5.2.7.2.2 Enumeration #

Enumerating a key with a blanket applies the same per-name rule to each name, so what a caller sees is the set of names whose winning candidate is not the blanket.

It is not simply "the blanket's layer and above": a different layer at the same precedence with a higher sequence number surfaces, and one with a lower sequence number does not.

5.2.7.2.3 Removal #

Removing a blanket, or the layer holding it, unmasks everything it was hiding.

5.2.7.3 What a watcher sees #

Nothing about tombstones. Writing a blanket produces one VALUE_DELETED per name it newly masks; removing one produces one VALUE_SET per name that became visible. A watcher sees per-value effective state and never has to know the mechanism (§5.6.1).

5.2.8 Names and Case

Peios / Peios technical reference / PKM / LCS / The Data Model

Every string in the LCS interface — key names, value names, hive names, layer names, paths — is UTF-8. Invalid UTF-8 is rejected with EINVAL before parsing, routing, folding, layer resolution or source dispatch.

Null bytes are rejected in all of them. The rejection is EINVAL where the string is length-delimited — ioctl and RSI strings — because there the null byte sits inside the counted length. A syscall argument (a path or reg_create_key's layer name) is a C string, and a null byte there is its terminator: the name the kernel validates ends at it, and nothing after it is read.

The one thing that is not a string is value data, which is opaque bytes.

5.2.8.1 Lengths are byte counts #

Every configured length limit is measured in UTF-8 bytes, not Unicode scalar values and not display characters.

  • MaxPathComponentLength (default 255) bounds one component or one value or layer name.
  • MaxTotalPathLength (default 16383) bounds a whole path.
  • MaxKeyDepth (default 512) bounds nesting.

A syscall path arrives as a null-terminated C string, is copied under a hard bound, and has its terminator stripped before anything is measured — so the terminator is not part of the length.

Ioctl and RSI strings are length-delimited and need no terminator; a terminator byte included in the length is a null byte and is therefore invalid.

5.2.8.2 Separators #

Backslash is canonical. Forward slash is accepted on input and treated as a separator wherever a separator is recognised, so a component can never contain either.

There is no string-rewriting step: normalisation is a property of how paths are split rather than a transformation applied to them.

5.2.8.3 Case folding #

Comparison is case-insensitive and storage is case-preserving.

The algorithm is Unicode Simple Case Folding — the C and S status entries of CaseFolding.txt, with the full (F) and Turkic (T) entries excluded.

It is a fixed one-to-one codepoint mapping with no locale input, applied after decoding from UTF-8, never to raw bytes.

The Unicode version is pinned at 16.0. The table is generated by pkm/tools/lcs/generate_casefold_table.py and checked in with the digest of the source data, so adopting a newer Unicode version means regenerating the table deliberately. It is not something that happens by updating a dependency.

This gives practical compatibility with Windows' RtlCompareUnicodeString without claiming byte-identical behaviour in every edge case.

Unicode normalisation is not performed. NFC and NFD forms of the same visual character are different names, matching Windows.

Case folding is what "identity" means throughout: a layer's identity is its folded name, a hive route's identity includes its folded name, and a duplicate is a folded-equal duplicate. RoleA and rolea are one layer, not two.

Every comparison folds, on both sides of the kernel boundary. The Kernel Access Control Subsystem (KACS) parses private layer names into a token and rejects a duplicate with EINVAL; that duplicate check uses the same folding routine and the same table as LCS, so a pair that LCS treats as one layer cannot occupy two of a token's slots. There were two ASCII-only comparators, the base check on two of its call sites and this KACS check, and both are gone (§5.3.2 for base).

5.2.9 Deletion and Orphans

Peios / Peios technical reference / PKM / LCS / The Data Model

Deletion operates on two levels, because naming and identity are separate things.

5.2.9.1 Deleting a key #

REG_IOC_DELETE_KEY removes one layer's path entry. The key's data — its GUID, descriptor and values — is untouched; only a name is removed.

LCS derives the parent GUID from the fd's ancestor chain and the child name from the last component of its resolved path, and sends RSI_DELETE_ENTRY.

If path entries remain in other layers, the key is still visible through them.

If none remain anywhere, the key is orphaned.

A key with visible children cannot be deleted: ENOTEMPTY.

Visibility is evaluated globally, across all enabled layers, and deliberately ignores the caller's private layer set — so whether a deletion succeeds does not depend on who is asking.

Recursive deletion is a client-side tree walk, not a kernel primitive.

Deleting a key does not delete its values. Values belong to the GUID, not to the path entry, and go when the GUID goes.

Hive roots cannot be deleted or hidden (§5.2.5).

5.2.9.2 Layer deletion #

Deleting a layer removes all of its path entries, value entries and blanket tombstones, across every source.

LCS broadcasts RSI_DELETE_LAYER and each source returns the GUIDs that lost their last path entry as a result.

Effects on live state follow from the model with no special cases:

  • keys named only by that layer become orphaned;
  • where the layer held the winning value entry, the next layer's value becomes effective;
  • blanket tombstones it held are removed and unmask what they were hiding;
  • and Security Descriptors are unchanged, because they were never layered.

Watchers are notified by whatever recovery mechanism applies — per-key events for the orphaned keys, and a source-wide OVERFLOW for the rest (§5.6.3).

Before sending RSI_DELETE_LAYER, LCS aborts every bound transaction whose mutation log touched that layer. Otherwise a transaction could commit writes into a layer that no longer exists.

Those transactions return EINVAL on their next operation or commit attempt.

Layer deletion is what role uninstallation and Group Policy removal are.

5.2.9.3 Orphaned keys #

An orphaned key is a GUID with no path entry in any layer. It follows the Linux unlink model: alive but unnamed.

Existing fds keep working. Operations that address the key by GUID proceed normally:

  • querying, setting and deleting values;
  • setting and removing blanket tombstones;
  • querying and setting the Security Descriptor;
  • querying key metadata;
  • flushing the key's hive;
  • closing the fd.

Namespace operations return ENOENT:

  • creating a child key under it;
  • opening or creating anything relative to it;
  • deleting its path entry;
  • hiding it;
  • backing it up.

The reason is that an orphaned key is no longer a reachable subtree root. Allowing new names beneath an unnamed key would build a subgraph nothing can reach.

5.2.9.4 Watches on an orphaned key #

A watch armed before the key was orphaned stays armed, and the transition delivers KEY_DELETED.

After that the watch may still observe GUID-local changes made through the surviving fds, though the subtree is no longer expanded through the orphaned key.

Arming a new watch on an already-orphaned key is ENOENT.

Re-arming one that is already armed is allowed.

5.2.9.5 Dropping the GUID #

When the last fd to an orphaned key closes and the source is Active, LCS sends RSI_DROP_KEY, which purges the key record, every value entry across every layer, and any remaining blanket tombstones.

It is dispatched before the in-kernel key state is released.

The request is asynchronous: nothing waits for the answer, and a valid response is processed as an ordinary response rather than as a late one (§5.8.5). That distinction is load-bearing — RSI_DROP_KEY is a mutating operation, and without it the arrival of a perfectly normal answer to a caller-less request would look like an unaccounted mutation and tear the source down.

If the source is Down, LCS releases its in-kernel state and does not queue a deferred drop. There is no deferred-drop queue. Recovering the key record then falls to the source's own startup obligation, under the Registry Source Interface specification in PSPK, to purge records with no path entries before it becomes Active.

close() never reports orphan cleanup failure to userspace, and never can: it returns 0 unconditionally.

5.2.9.6 A new key at the same path #

A layer can create a new key where another layer's key already exists. Each layer has its own path entry pointing at its own GUID, and resolution decides which is visible.

Fds referencing the other GUID are completely isolated from it: different identity, different data, different value entries. Nothing observable connects two keys that merely share a name.

5.3.1 The Layer Model

Peios / Peios technical reference / PKM / LCS / Layers

A layer is a named collection of registry writes that can be managed as a unit. Layers have precedence, and the highest-precedence entry wins. They are how role installation, Group Policy and configuration revert all work, and they are the reason removing a role does not leave its settings behind.

FieldMutableDescription
NameNoThe layer's identity — not a GUID, not an integer. Case-preserving, compared with Unicode Simple Case Folding. Bounded by MaxPathComponentLength.
PrecedenceYesHigher wins. Default 0.
EnabledYesA disabled layer is invisible during resolution unless it is attached to the resolving thread's credentials (§5.3.5). Default true.
Owner—The SID of the principal that created the layer. Informational only.

The name being the identity is deliberate. Layer names are code-generated and meaningful by construction — role-jellyfin, gpo-security-baseline — so there is nothing an opaque identifier would add.

Owner is never used for an access check; authorisation is the descriptor on the layer's metadata key (§5.3.4).

It is also not quite immutable as a field: a refresh re-reads the Owner value and re-selects it, so rewriting the value does change what is cached. Because nothing consults it, that has no effect on anything.

5.3.1.1 Precedence tiers #

The base layer and role layers all sit at precedence 0. Within one tier, the most recent write wins — the highest sequence number. Group Policy layers sit above 0 and override both.

Establishing or raising a layer's precedence above 0 requires SeTcbPrivilege (§5.3.4). That is what keeps the tier boundary meaningful.

5.3.1.2 Caps #

MaxTotalLayers, default 1024, bounds the in-memory layer table, the base layer included: a table holding that many entries admits no more.

Creating a layer when it is full returns ENOSPC. The refusal happens at the creation of the metadata key under Layers\, before anything reaches the source, so a full table never leaves a metadata key behind that the table cannot publish.

The table is sized to MaxTotalLayers rather than fixed at build. It is allocated on the first publication and grows, under the table lock, whenever a publication finds every slot taken while the configured bound still admits one more; it never shrinks.

So a value anywhere in the configurable range, 16 to 65536, binds. Raising MaxTotalLayers above the default admits layers past 1024; lowering it below the current occupancy admits no new layer until enough have been deleted, and discards nothing.

MaxLayersPerValue, default 128, bounds how many layers may write to the same (key GUID, value name) pair. It is a guard against amplification — every read of that value has to resolve every entry — not an access control boundary.

It is enforced at REG_IOC_SET_VALUE time, before the source is contacted, by querying the source for the current entry count.

A write that replaces an existing entry in the same layer does not increase the count and is not checked. Exceeding the cap is ENOSPC.

The check is deliberately best-effort admission control, not a storage invariant. It queries and then dispatches without holding anything, so concurrent writers can both observe room and both proceed. Sources are not required to enforce it atomically.

Once LCS observes a count at or above the cap, further new-layer writes are refused.

Blanket tombstones and value deletions are not subject to it.

5.3.1.3 Layers are global; entries are per-source #

There is one authoritative layer table, held by the kernel. Each source stores layer entries — path entries and value writes tagged with layer name strings — for its own hives. Layer metadata is global and lives in one place.

A source never needs the layer table. It stores what it is told, tagged with whatever name it is given, and returns everything on request. A source that has never seen a particular layer name simply stores entries carrying it. Resolution — precedence, enabled state, tombstone evaluation — happens entirely in the kernel, and the layer snapshot is passed into each operation rather than pushed to sources. There is no RSI operation that hands a source the layer list.

5.3.2 The Base Layer

Peios / Peios technical reference / PKM / LCS / Layers

The base layer, named base, is a kernel-reserved implicit layer. It exists unconditionally, before any source registers and whether or not any metadata has ever been persisted for it.

It is a static constant in the kernel: precedence 0, enabled. It is never stored in the dynamic layer table.

It is always emitted first in every layer snapshot, and is handed out even when the dynamic table is empty. A source that registers with a completely empty database is therefore immediately usable, because the one layer that writes need is not in the database.

Four things cannot happen to it:

  • it cannot be deleted;
  • it cannot be disabled;
  • its precedence cannot be changed;
  • a layer table row for it cannot be published at all.

Each of those is enforced in more than one place. Deletion is refused by the layer table, by the resolution core, by the RSI_DELETE_LAYER dispatch path, and by the transaction layer-abort path. Publication of a base row is rejected outright, and the refresh path short-circuits for base before it would read Precedence or Enabled, so persisted values for those are never even consulted.

5.3.2.1 Persisted metadata #

Machine\System\Registry\Layers\base\ may exist, though nothing in a Peios system creates it, and where it does it decorates the base layer rather than defining it. What LCS takes from it is the metadata key's GUID and its cached Security Descriptor — which is to say, who may write into the base layer (§5.3.4).

Its Precedence and Enabled values are ignored.

The internal self-watch also ignores a SUBKEY_DELETED for base: if a higher-precedence HIDDEN entry masks the base layer's metadata key, that is not a layer deletion and is not processed as one. The base layer's existence is hardcoded and layer mechanics cannot reach it.

5.3.2.2 The default target #

A write that names no layer targets the base layer. That is the default for manual administration, for system initialisation, and for what a person keeps under their own Users\<SID>\.

While the base layer's metadata key does not exist, LCS uses a compiled-in default descriptor granting SYSTEM and Administrators KEY_ALL_ACCESS and Authenticated Users KEY_SET_VALUE, so writes into the base layer are possible from the very beginning, by anyone who has authenticated, to whatever the target key's own descriptor lets them write. The compiled-in default is replaced by the key's descriptor as soon as the key is created (§5.3.4).

5.3.2.3 base is matched like every other layer name #

The reserved name is recognised with Unicode Simple Case Folding, the same algorithm and the same table used for every other layer-name comparison.

There was a second, ASCII case-insensitive comparator beside it, used on two call sites. For the literal string base the two agreed — a length pre-check made a non-ASCII case pair fail before folding could matter — so the divergence was latent rather than absent, and would have become live the moment anything about the reserved name changed. It is gone; both call sites now take the folding comparator and propagate its error rather than collapsing it into a bool.

5.3.3 Layer Metadata

Peios / Peios technical reference / PKM / LCS / Layers

Layer metadata lives in the registry, under Machine\System\Registry\Layers\<LayerName>\. Each layer's key holds three values:

ValueTypeDefault if missing
PrecedenceREG_DWORD0
EnabledREG_DWORD, 0 or 1true
OwnerREG_BINARY, a SIDthe creating token's SID for a new layer

Three things are malformed metadata, and are rejected rather than coerced:

  • a value of the wrong type;
  • a REG_DWORD that is not exactly four bytes;
  • an Enabled greater than 1.

Owner selection has a fallback chain: the metadata value; failing that, the creator's SID for a newly created layer; failing that, the previous known-good owner; failing that, the owner SID from the metadata key's own descriptor.

If none of those is available the layer cannot be published. Every one of these is informational and none grants access.

There is no "create layer" or "delete layer" syscall. Creating a key under Layers\ creates a layer; deleting that key deletes it. Creation should be done inside a transaction so that all three values are present when the refresh runs.

5.3.3.1 Circularity #

Layer metadata is stored in the registry, which is itself layered. That is circular, and it is safe, because resolution never re-enters itself.

LCS always resolves using its currently published layer table — including when resolving layer metadata values. When a write to the metadata subtree commits, the refresh reads the affected metadata using the current table and then publishes an updated one.

The table is never re-resolved mid-operation; each operation takes one snapshot and uses it throughout.

So a high-precedence layer can override another layer's precedence, and that is useful and intended. It simply takes effect at the next publication rather than recursively.

5.3.3.2 Publication is atomic #

A layer is not merely a name and a precedence. The published unit is three things together: the layer table entry, the metadata key's GUID, and the cached Security Descriptor of that key.

All three are written under one lock, and a snapshot reader that finds them incomplete returns EIO rather than a half-populated layer.

A layer that has no metadata key GUID and no authorisation descriptor is not visible in the table at all. There is no window in which a layer exists but nobody can be authorised against it.

Creating the metadata key is ordinary key creation, so LCS computes its descriptor from parent inheritance through KACS before the source persists it. On the normal path the key therefore has a descriptor before the layer can be published.

5.3.3.3 When the refresh runs #

Changes under Layers\ mark the affected layer names dirty. After the mutating operation commits, and before the syscall returns to userspace, LCS runs a bounded refresh for those names: it reads the committed metadata key, its values and its descriptor, and publishes the new entry atomically.

For a transaction the refresh runs once, after the source commit succeeds and before REG_IOC_COMMIT returns.

The internal self-watch is what notices the subtree changed, but the watch callback is not the atomicity boundary and must never publish a partial entry. LCS does not perform source round trips while holding the watch-map or layer-table publication locks.

5.3.3.4 When the metadata descriptor will not parse #

If the metadata key's descriptor cannot be read or parsed during a refresh, the source has returned malformed data. LCS emits an audit event, does not publish or update that layer's entry, and keeps the previous known-good one.

The same isolation holds at bootstrap. The refresh that runs on source registration visits every layer under Layers\ in turn, and one whose metadata will not parse — the descriptor, or any of the three values — is skipped, unpublished and counted, while its siblings publish as they would have without it. Only a failure of the source itself, rather than of one layer's data, fails the refresh.

If the refresh was required to complete the operation in hand — creating a layer, say, or exposing one — the syscall fails with EIO.

5.3.4 Writing Into a Layer

Peios / Peios technical reference / PKM / LCS / Layers

Every mutating operation that targets a layer — value writes, value deletions, tombstones, key hides, blanket tombstones, key creation — requires layer write authorization: KEY_SET_VALUE on the layer's metadata key at Machine\System\Registry\Layers\<LayerName>\.

This is a second AccessCheck, against a different object, and it is in addition to the fd's granted mask on the target key. Both must pass.

The descriptor on a layer's metadata key is therefore the answer to "who may write into this layer". The base layer's, where the key has been created, is the one inheritance computes from the Machine hive root, like any other key's. Group Policy layers get restrictive descriptors from the GP client at creation; role layers get theirs from the role installer.

That closes two escalation paths at once. An unprivileged process cannot write into a GP layer, and one role's service cannot write into another role's.

The base layer is the one layer meant to be written by everybody. It is where a write goes that names no layer, a person's own settings under Users\<SID>\ included, so its descriptor lets in anyone who has authenticated and leaves what they may write to the descriptor of the key they are writing. A layer check that refused them would make every key read-only to everyone but SYSTEM and Administrators, whatever the key's own descriptor said.

The layer metadata descriptors are cached alongside the layer table and invalidated by the same self-watch (§5.3.3). A layer that is not in the table is ENOENT for any operation naming it.

5.3.4.1 The base layer before it exists #

While Layers\base\ does not exist, LCS falls back to a compiled-in default descriptor, so base-layer writes work from the start. It is owned by SYSTEM and grants KEY_ALL_ACCESS to SYSTEM and Administrators and KEY_SET_VALUE, the one right this check asks for, to Authenticated Users. Nobody else is granted anything: a principal that has not authenticated cannot write into the base layer, whatever the target key allows.

Nothing in a Peios system creates Layers\base\, so on a system as shipped the compiled-in default is the descriptor in force for as long as the system runs.

It is replaced by the persisted descriptor the moment the key is created.

Warning

Creating Layers\base\ changes who may write the registry. The new key inherits from the Machine hive root, which grants Authenticated Users KEY_READ and not KEY_SET_VALUE, so from that moment no ordinary principal can write anything, its own Users\<SID>\ included. Whoever creates the key has to give it a descriptor that says what they mean, in the transaction that creates it.

5.3.4.2 Layer lifecycle #

OperationRequirement
Create a layer at precedence 0KEY_CREATE_SUB_KEY on Layers\
Create a layer above precedence 0KEY_CREATE_SUB_KEY on Layers\ and SeTcbPrivilege
Write into a layerKEY_SET_VALUE on the layer's metadata key
Modify layer metadataKEY_SET_VALUE on the metadata key; raising precedence above 0 additionally requires SeTcbPrivilege
Delete a layerDELETE on the metadata key's fd

Everything except the precedence rule is controlled purely by the descriptor on the metadata key.

5.3.4.3 The precedence gate #

SeTcbPrivilege is required specifically to establish or raise a layer's precedence above 0. It is defence in depth: compromising the descriptor on Layers\ is not enough to create a Group Policy-tier layer.

The check is synchronous and inline at REG_IOC_SET_VALUE time, and it happens early — before sequence allocation, before transaction enlistment, before the source is contacted.

It runs when three things hold: the target key is a layer metadata key, the value name folds equal to Precedence under the same Unicode folding used for every other value name, and the data is a positive REG_DWORD. A key is a layer metadata key by its path — a direct child of Machine\System\Registry\Layers — or by already being in the set of published layer metadata keys. The path test is what covers the recommended creation flow (§5.3.3), where Precedence is written inside the transaction that creates the metadata key, before the layer has been published at all.

Failing the privilege check is EPERM.

The gate tests for a four-byte REG_DWORD specifically. A Precedence written with some other type slips past it — and then fails at the refresh, which rejects a non-REG_DWORD Precedence as malformed metadata. The precedence never actually rises.

REG_IOC_RESTORE has its own equivalent gate, applied to the backup stream's layer manifest before anything is written (§5.9.3).

5.3.4.4 Deleting a layer #

Deleting the metadata key fires a SUBKEY_DELETED on the internal self-watch. LCS removes the layer from the table and broadcasts RSI_DELETE_LAYER to every registered source, each of which purges every entry tagged with that name and reports the GUIDs that lost their last path entry.

Before the broadcast, LCS aborts every bound transaction whose mutation log touched that layer (§5.2.9).

A SUBKEY_DELETED for base is ignored (§5.3.2).

5.3.5 Private Layers

Peios / Peios technical reference / PKM / LCS / Layers

A private layer is a disabled layer attached to a thread's credentials. It is invisible during ordinary resolution and treated as enabled when resolving on behalf of a thread whose token names it.

That covers three things a shared registry otherwise cannot do: giving one session experimental settings without affecting others; injecting test configuration without touching the shared tree; and giving a container a different view of the registry without a separate hive.

5.3.5.1 Resolution #

A private layer participates in normal precedence ordering. A disabled layer with precedence 5 attached to a thread resolves at precedence 5, competing with everything else at that level. It is not an overlay on top; it is a layer that only that thread can see.

After private-attachment authorization succeeds, a layer is active for a thread if it is globally enabled, or its name appears in that thread's private layer set.

Name matching uses Unicode Simple Case Folding, like every other layer name comparison.

5.3.5.2 Attachment #

Private layer names reach a thread through the KACS token's LCS credential extension — the same versioned block that carries scope GUIDs for private hives (§5.2.2).

LCS reads the credentials from the effective token on each operation and passes them into resolution.

Private layers are therefore per-thread, not per-process: threads in one process can hold different private layer sets through different impersonation tokens.

5.3.5.3 Two things about the caps #

MaxPrivateLayersPerToken, default 16, is described as a limit on attachment. It is not enforced there.

KACS applies its own hard cap of 256 names when it parses the token specification. The configurable LCS limit is applied later, when LCS acquires a thread's private credentials for an operation.

The consequence is that a token carrying seventeen private layers is accepted by KACS and then fails every LCS operation, rather than being refused when it was built. Reading LCS's configured limits from KACS would invert the dependency between the two, so the cap stays where it can be read.

The failure is E2BIG. It was EACCES, which read as an access-control denial and sent anyone debugging it towards descriptors and privileges rather than towards a count that was fixed when the token was assembled, possibly in another process.

MaxScopeGUIDsPerToken shares the check and the errno. A missing token is still EACCES, because that one is an access decision.

KACS deduplicates the private layer names it parses with the same Unicode Simple Case Folding LCS matches them by, so a name that LCS treats as one layer is one layer here too.

It did not always: an ASCII case-insensitive comparison let two names differing only in a non-ASCII case relation — ROLEs and roles, since U+017F folds to s — both sit on one token, spending two of the token's MaxPrivateLayersPerToken slots on a single layer. Resolution still folded correctly on the LCS side, so the layer activated once, but the two sides of one identity contract disagreed.

5.3.5.4 Attachment authority #

Private-layer names can enter a token only at creation, which requires SeCreateTokenPrivilege. Attaching a known layer above precedence 0 additionally requires enabled SeTcbPrivilege on the creator. Without it, creation fails with EPERM, even when the requested child token itself includes TCB privilege. A present but disabled privilege does not authorize attachment.

The kernel retains the creator's authorization as immutable token state. A trusted creator can delegate a private high-precedence view to an ordinary token without giving the consumer TCB privilege. Duplication, restriction, process propagation and impersonation preserve that attachment authority; they do not obtain new authority from the duplicating caller.

Layer names need not exist when attached, and precedence can change later. For each operation acquiring private credentials, LCS checks the retained authority against the same layer snapshot used for resolution. If an attachment made without TCB authorization now names a layer above precedence 0, the operation fails with EPERM. It does not silently choose a lower layer. This applies to queries through already-open key handles as well as new opens; a later promotion or creation cannot upgrade the attachment. Returning the layer to precedence 0 makes the original attachment usable again. Ordinary key and layer access checks still apply.

KACS invokes an LCS-supplied validator before exposing a newly created token. The callback is installed during kernel initialization and then read-only; a private extension cannot be published without its validator. LCS owns the layer lookup, so KACS does not interpret the layer table. Use-time validation closes the race between the creation check and a concurrent metadata change. Successful TCB authorization marks the privilege used on the creator.

This is the PEI-268 behavior; older kernels lack the attachment check.

5.3.6 Resolution

Peios / Peios technical reference / PKM / LCS / Layers

Layer resolution turns several per-layer entries into one effective answer. It is the mechanism that makes the registry layered, and it is the same algorithm for path entries and for values — one resolves existence claims, the other resolves data.

5.3.6.1 The rule #

Every candidate is a tuple (precedence, sequence, entry). The winner is the maximum, ordered by precedence first and sequence second.

Building the candidate list:

  1. For each entry the source returned, look up its layer in the current layer table. An entry naming a layer that is not in the table is skipped, not rejected.
  2. Discard entries from layers that are not active for this thread — a layer is active if it is globally enabled or its name is in the thread's private layer set (§5.3.5).
  3. For values, add every blanket tombstone on the key as a tombstone candidate for the requested name, at its own precedence and sequence (§5.2.7).
  4. If there are no candidates, the answer is not-found.
  5. Take the maximum. A winning HIDDEN path entry, a winning REG_TOMBSTONE value entry, and a winning blanket all mean not-found.

That is the whole algorithm. Everything the layer system does — override, revert, mask, hide, hide-and-replace — is a consequence of it.

5.3.6.2 Unknown layers are latent, not wrong #

An entry tagged with a well-formed layer name that is not currently in the table is a valid latent entry.

It is ignored while the layer is absent, and if a layer with the same folded identity is later created, that entry becomes eligible for resolution under the new metadata.

This is what makes restore, import and boot ordering work: source storage can legitimately hold entries before their metadata has been loaded. It also follows the core rule — sources persist entries, LCS decides their meaning.

Normal operations do not create such entries. A layer-targeting ioctl naming a layer that is not in the table returns ENOENT. Latent entries come from existing storage, from a restore or import, from a previous boot, or from source behaviour. Since sources are trusted, a well-formed latent entry is not malformed merely because its layer is absent right now.

An entry whose layer name is malformed is a different matter, and is rejected as malformed source data.

5.3.6.3 Enumeration #

Enumerating values collects the unique names across all layers and resolves each one, returning only those whose answer is not not-found.

Enumerating subkeys collects the unique child names and resolves each, returning those that map to a GUID rather than HIDDEN.

"Unique" means folded-unique. Two entries whose names differ only in case are one name, which is the only reading consistent with names being case-insensitive.

A caller sees effective state only. Tombstoned values, blanket-masked values and hidden keys are simply absent. Per-layer raw data is never visible through a normal operation.

5.3.6.4 Enumeration is index-based, and that has a cost #

REG_IOC_ENUM_VALUES and REG_IOC_ENUM_SUBKEYS return one entry at an index. Each call re-resolves the full set and returns the entry at that position, so walking 0..N-1 performs N full resolutions — O(n²) work overall.

For a key with a handful of values that does not matter. REG_IOC_QUERY_VALUES_BATCH exists for when it does: one call, the whole effective value set, one resolution.

Enumeration order is not defined, and the index-to-entry mapping can change between calls if the effective set changes. Indices must not be cached across mutations. The batch call is also the way to get a consistent snapshot.

5.3.6.5 Neither party can lie about ordering #

Two rules stop a source manipulating the outcome.

A source-returned entry whose sequence number is greater than or equal to the next sequence LCS would allocate is malformed data. Sources store the numbers LCS assigns them; they cannot legitimately hold a future one. Without this, a compromised source could fabricate a sequence number and win every tie in its own hive.

Every entry in a response is validated this way, whatever layer it names.

Duplicate sequence numbers at the same precedence, where they would actually have to be compared to pick a winner, are also malformed. LCS rejects the response rather than making an arbitrary choice.

Duplicates that never get compared are not an error.

Both produce EIO and an lcs.source.response.rejected audit event (§5.4.4).

5.3.7 The Sequence Counter

Peios / Peios technical reference / PKM / LCS / Layers

LCS keeps one global monotonic counter. Every mutation that creates a layer-qualified entry — a path entry, a value write, a key hide, a blanket tombstone — takes the next number from it.

The counter is never decremented and never reset.

It provides deterministic tiebreaking within a precedence tier (§5.3.6). Wall-clock time is tracked separately, as each key's last write time, for humans.

5.3.7.1 Allocation #

Allocating a number increments the counter. Allocated numbers are never reused, even if the operation later fails, times out, or is part of a transaction that aborts.

Gaps in the sequence space are normal and carry no meaning.

A transactional mutation is assigned its number when the operation is accepted into the transaction, not at commit. That preserves the order in which the caller performed the operations, which is what layer tiebreaking and watch ordering need if it commits (§5.7.2).

5.3.7.2 Initialisation #

Each source reports the highest sequence number it has persisted in its registration handshake, and LCS raises the counter to one above the maximum reported. New writes therefore always outrank anything already in storage, even after a restart.

A source registering later advances the counter the same way, to max(current, source_max + 1).

If that addition would overflow 64 bits the registration fails with EOVERFLOW and the source is not made Active — the failure happens before the slot becomes usable.

The counter itself refuses to hand out U64_MAX, so the value is never allocated.

5.3.7.3 What a sequence number is not #

A sequence number is not a hive generation number.

A sequence number orders layer-qualified entries for resolution and is persisted by sources.

A hive generation number is a volatile, per-hive, kernel-owned change epoch, exposed by REG_IOC_QUERY_KEY_INFO (§5.5.3) so that a watcher recovering from OVERFLOW can tell whether it actually missed anything. Sources never see it and never persist it.

Its baseline is initialised from the source's reported maximum sequence at registration, purely so that observed generations are monotonic relative to persisted entries; after that the two are unrelated.

5.3.7.4 Restore #

A restore does not write the backup's sequence numbers. It reserves a fresh range and remaps into it, so restored entries are newer than everything that was there before while keeping their relative order from the backup (§5.9.3).

5.4.1 The Access Flow

Peios / Peios technical reference / PKM / LCS / Security

The registry's security model is KACS applied to keys. LCS defines no access control mechanism of its own: the same AccessCheck, the same Security Descriptors, the same tokens and SIDs that every other Peios subsystem uses. What this section describes is how those primitives attach to registry operations.

Every open follows the same five steps.

  1. Token capture. LCS takes the calling thread's effective token — the impersonation token if one is set, otherwise the process primary token. This is the same capture KACS performs for every syscall.

  2. Path resolution. LCS walks the path through the layer stack, following symlinks. No access check happens during the walk. Intermediate keys are not evaluated; only the final key matters.

  3. AccessCheck. LCS calls KACS AccessCheck with the captured token, the final key's Security Descriptor as returned by the source, and the desired access mask. Every requested right must be granted or the open fails with EACCES. There is no partial grant: the caller gets what it asked for or nothing.

    MAXIMUM_ALLOWED is the exception, and the only way to ask for whatever is available. AccessCheck computes the full allowed set and that becomes the granted mask.

  4. The granted mask is stored on the fd. It never changes.

  5. Per-ioctl checks are bitmask tests. Each ioctl has a required right; LCS tests the fd's granted mask against it and returns EACCES without contacting the source if it is absent. The Security Descriptor is not re-read and AccessCheck is not re-evaluated.

5.4.1.1 No traverse checking #

LCS checks nothing on the way down. A process can open Machine\System\Services\Jellyfin without holding any access to Machine\System\Services or Machine\System.

This matches the Windows registry and is a deliberate difference from filesystem path semantics, where every directory in a path is checked for traverse. It means a key's Security Descriptor is the whole story about who can reach it, and that an ancestor's descriptor confers no protection on its descendants.

5.4.1.2 Symlinks #

Opening a symlink follows it, and AccessCheck runs on the target key, not the link. REG_OPEN_LINK opens the link itself instead — but only for the final path component. A symlink encountered part-way through a path is followed regardless.

Creating a symlink is privileged: KEY_CREATE_SUB_KEY and KEY_CREATE_LINK on the parent, plus either SeTcbPrivilege or membership of Administrators (§5.2.4).

5.4.1.3 Fds are capabilities #

A key fd can be passed over a Unix socket with SCM_RIGHTS, and it carries its granted mask with it. The recipient gets the access the original opener was granted, whether or not its own token would have passed AccessCheck.

This is explicit delegation and it is consistent with how fds work everywhere else in Peios. Passing a KEY_WRITE fd hands over write access.

Opening relative to a parent fd skips path parsing and AccessCheck for the parent portion — the caller already proved its access when it obtained the parent fd. This is the ordinary way to traverse a subtree.

5.4.1.4 Changing a descriptor does not revoke a handle #

A Security Descriptor change takes effect for future opens. An fd that already exists keeps the mask it was granted at open, because that is what semantic rule 5 says (§5.1). The recourse for genuinely revoking access is to restart the process holding the fd.

Descriptor changes are also not layer-qualified. They are direct mutations on the key object, and deleting a layer does not undo one (§5.1, rule 4).

5.4.1.5 The second check nobody expects #

Every layer-qualified mutation runs a second AccessCheck, against a different object: the metadata key of the layer being written to. That check is for KEY_SET_VALUE and it is in addition to the fd's granted mask on the target key. Both must pass. §5.3.4 covers it.

5.4.2 Access Rights

Peios / Peios technical reference / PKM / LCS / Security

Registry rights occupy the Windows bit positions, so a Security Descriptor carrying registry ACEs is binary-compatible with one written by Windows tooling. That is a registry.pol and Samba requirement, not an aesthetic choice.

The values are in §5.A. In summary: six specific rights in bits 0–5 (KEY_QUERY_VALUE, KEY_SET_VALUE, KEY_CREATE_SUB_KEY, KEY_ENUMERATE_SUB_KEYS, KEY_NOTIFY, KEY_CREATE_LINK), the four standard rights DELETE, READ_CONTROL, WRITE_DAC and WRITE_OWNER, and ACCESS_SYSTEM_SECURITY for the SACL, which is itself gated by SeSecurityPrivilege.

There is no execute right on a key.

5.4.2.1 Convenience masks and generic mapping #

KEY_READ, KEY_WRITE and KEY_ALL_ACCESS are concrete masks, not generic bits — they are unions of the rights above and are usable directly.

Separately, LCS accepts the raw KACS generic bits GENERIC_READ, GENERIC_WRITE, GENERIC_EXECUTE and GENERIC_ALL in a caller's desired_access and in ACE masks in a Security Descriptor. Those are mapped through the registry generic mapping before AccessCheck sees them.

GENERIC_READ maps to KEY_READ, GENERIC_WRITE to KEY_WRITE, GENERIC_ALL to KEY_ALL_ACCESS, and GENERIC_EXECUTE maps to zero, because there is nothing to execute.

5.4.2.2 Validating what a caller asks for #

desired_access is validated before path resolution or AccessCheck.

  • Zero is EINVAL. A caller must ask for something.
  • Any bit outside the valid caller mask is EINVAL.
  • MAXIMUM_ALLOWED may appear alone or combined with anything else.
  • SYNCHRONIZE (0x00100000) is not a registry right, so it is an unknown bit and fails EINVAL.

The valid caller mask is a single constant, REG_VALID_DESIRED_ACCESS_MASK (§5.A): the six specific rights, the four standard rights, ACCESS_SYSTEM_SECURITY, MAXIMUM_ALLOWED and the four generic bits.

5.4.2.3 Validating what a source returns #

Source-supplied Security Descriptors are validated too, because a source is trusted for its data but not for its arithmetic (§5.8.4).

An ACE mask may contain concrete registry rights and raw generic bits. It may not contain MAXIMUM_ALLOWED — that is a request, not a grant, and it is meaningless in an ACE.

After generic mapping, an ACE mask must be a subset of the concrete registry rights plus ACCESS_SYSTEM_SECURITY.

A descriptor that breaks either rule is malformed source data: the operation fails closed with EIO and an lcs.source.response.rejected audit event is emitted (§5.4.4).

Two further constants, REG_VALID_MAPPED_ACCESS_MASK and REG_VALID_ACE_ACCESS_MASK, express those two bounds.

5.4.2.4 Which right each operation needs #

OperationRequired
REG_IOC_QUERY_VALUE, QUERY_VALUES_BATCH, ENUM_VALUESKEY_QUERY_VALUE
REG_IOC_SET_VALUE, DELETE_VALUE, BLANKET_TOMBSTONE, FLUSHKEY_SET_VALUE
REG_IOC_ENUM_SUBKEYSKEY_ENUMERATE_SUB_KEYS
REG_IOC_QUERY_KEY_INFOREAD_CONTROL
REG_IOC_DELETE_KEY, HIDE_KEYDELETE
REG_IOC_NOTIFYKEY_NOTIFY
REG_IOC_GET_SECURITYREAD_CONTROL for owner, group, DACL and label; ACCESS_SYSTEM_SECURITY for the SACL
REG_IOC_SET_SECURITYWRITE_OWNER for owner, group or label; WRITE_DAC for the DACL; ACCESS_SYSTEM_SECURITY for the SACL; raising a label above the caller additionally requires enabled SeRelabelPrivilege
REG_IOC_BACKUPSeBackupPrivilege, no per-key check
REG_IOC_RESTORESeRestorePrivilege, no per-key check
creating a keyKEY_CREATE_SUB_KEY on the parent
creating a symlink keyKEY_CREATE_SUB_KEY and KEY_CREATE_LINK on the parent, plus SeTcbPrivilege or Administrators

Where a REG_IOC_SET_SECURITY or GET_SECURITY request names several components, every right those components imply must be present before the source is contacted.

REG_IOC_FLUSH requires KEY_SET_VALUE because a flush is only meaningful to a caller that wrote something and wants it durable. Requiring a write right stops an unprivileged reader using flush as a disk-I/O amplifier.

5.4.2.5 Enumeration exposes names, not contents #

REG_IOC_ENUM_SUBKEYS performs no per-child access check. Every visible child is returned, whatever the caller's access to it. The caller learns names, and must open each child separately — with a real AccessCheck — to read anything.

Subtree watches work the same way: a watcher is told a descendant was created without a check on that descendant. Structure visibility is deliberately weaker than content visibility, matching RegNotifyChangeKeyValue and RegEnumKeyEx.

5.4.3 Inheritance and Hive Roots

Peios / Peios technical reference / PKM / LCS / Security

5.4.3.1 Inheritance at creation #

When reg_create_key creates a key, its initial Security Descriptor is computed from the parent's by the KACS inheritance algorithm. LCS supplies the parent descriptor, the creating token, the registry generic mapping and the valid-mask bound, and hands the result to the source to persist. It implements no inheritance logic of its own.

Three things about registry inheritance are worth stating.

It is static. The computation happens once, at creation. A later change to the parent's descriptor does not propagate to children that already exist. Re-propagating is an explicit administrative action — a client-side tree walk — not a kernel operation, and there is no code anywhere in LCS that walks a tree to re-propagate.

Only CONTAINER_INHERIT_ACE matters. Every registry object is a container: keys hold subkeys and values. Values are not independent security objects and have no descriptors of their own; they inherit their key's access control.

OBJECT_INHERIT_ACE is never used to select an ACE for inheritance. The child copy clears OBJECT_INHERIT_ACE, CONTAINER_INHERIT_ACE and NO_PROPAGATE_INHERIT_ACE when NO_PROPAGATE_INHERIT_ACE applies.

A parent with no inheritable ACEs falls back to the creating token's default DACL.

That fallback covers the DACL only; there is no default SACL.

5.4.3.1.1 Creator SIDs and generic rights survive to the next generation #

An inheritable ACE naming CREATOR OWNER or CREATOR GROUP, or carrying generic rights, is resolved for each new key. When it also propagates to further subkeys, the child receives two ACEs:

  • An effective copy with the child's owner/group substituted and generic rights mapped to registry rights. It has INHERITED_ACE and no propagation flags.
  • An inherit-only copy retaining the original SID and generic mask, with INHERITED_ACE and its propagation flags. This copy resolves afresh when the next subkey is created.

This applies to both DACL and SACL entries. An ordinary concrete ACE remains one copy. NO_PROPAGATE_INHERIT_ACE ends the rule at the immediate child, so it receives only the effective copy. Existing stored descriptors are not rewritten by upgrading the kernel; an explicit propagation operation is needed to repair an already-created subtree.

5.4.3.2 Hive roots #

A hive root has no parent, so it is the top of every inheritance chain below it and cannot inherit anything itself. Its descriptor is created by the source, on first boot, and LCS enforces whatever the source stored.

LCS holds no template. There are no hardcoded SIDs, no default hive root descriptors, and no code that would construct one — searching for them finds nothing. The defaults that follow are what loregd writes; they are conventions of the source, not properties of the kernel.

Machine\:

PrincipalRightsInheritance
SYSTEMKEY_ALL_ACCESSContainer-inherit
AdministratorsKEY_ALL_ACCESSContainer-inherit
Authenticated UsersKEY_READContainer-inherit

Users\<SID>\:

PrincipalRightsInheritance
the user's SIDKEY_ALL_ACCESSContainer-inherit
SYSTEMKEY_ALL_ACCESSContainer-inherit
AdministratorsKEY_ALL_ACCESSContainer-inherit

These mirror Windows HKLM and HKU. A subsystem needing something tighter — Machine\Security\, say — sets an explicit descriptor on its own subtree root at creation, overriding what it inherited.

Because there is no traverse checking (§5.4.1), a restrictive descriptor high in the tree protects only the key it is on. Protection of a subtree comes from the descriptors its keys inherited at creation, which is exactly why an administrator changing a parent's descriptor and expecting the subtree to follow will be disappointed.

5.4.3.3 Reading and writing descriptors #

REG_IOC_GET_SECURITY and REG_IOC_SET_SECURITY take a security_info bitmask naming which components to act on: owner, group, DACL, SACL, or mandatory integrity label (LABEL_SECURITY_INFORMATION).

Zero is EINVAL, and so is any unknown flag; both are rejected before the source is contacted, before transaction enlistment and before any mutation.

The rights required are computed from every component named. Reading owner, group, the DACL or the label needs READ_CONTROL; reading the SACL needs ACCESS_SYSTEM_SECURITY. Setting owner or group needs WRITE_OWNER, as does setting the label; setting the DACL needs WRITE_DAC; setting the SACL needs ACCESS_SYSTEM_SECURITY. A request naming several components must hold all the corresponding rights.

A set is a merge, not a replacement. LCS reads only the components security_info names from the supplied self-relative descriptor and preserves the existing ones.

Component selection includes its control flags even when the component has no bytes. A null ACL remains PRESENT clear, but its defaulted, inheritance and protection flags survive a query or an update to another component. Replacing a selected DACL or SACL takes that ACL's flags from the input descriptor, including for a null ACL; unselected component flags are preserved. An empty ACL remains present with zero ACEs and is not converted to null. The same preservation rule applies to a defaulted null group.

LABEL_SECURITY_INFORMATION reads only the first effective mandatory-label ACE from the SACL, omitting inherit-only labels and every non-label ACE. If there is no effective label, the returned subset has no SACL. Reading this subset needs READ_CONTROL; writing it needs WRITE_OWNER. Neither operation needs ACCESS_SYSTEM_SECURITY or SeSecurityPrivilege.

A label update removes the existing mandatory-label ACEs and inserts the supplied label before the remaining SACL entries. An input descriptor with no SACL clears the explicit label. Non-label SACL entries, SACL inheritance and protection flags, and all unselected descriptor components are preserved, including flags on a null DACL. Clearing the last SACL entry clears SE_SACL_PRESENT while retaining the other SACL flags.

For a label-only subset, a supplied SACL must contain exactly one mandatory-label ACE with an S-1-16-<level> SID and without INHERIT_ONLY_ACE; an empty ACL, an audit ACE, or multiple labels is EINVAL. SACL_SECURITY_INFORMATION and LABEL_SECURITY_INFORMATION cannot be combined. These input errors are rejected before source contact or transaction enlistment.

An explicit label above the current caller's integrity level requires enabled SeRelabelPrivilege, checked before source contact or transaction enlistment; otherwise the update fails with EACCES. Labels at or below the caller's level do not require that privilege. The same rule applies to an explicit label inside a whole-SACL write: SeSecurityPrivilege does not substitute for SeRelabelPrivilege. Existing fd rights remain cached, but this privilege check uses the caller performing the update.

The result must still have an owner; a merge that would leave the descriptor ownerless is EINVAL.

A null group SID stays valid.

Enlisting a descriptor change in a transaction gives it atomicity with the rest of the transaction's operations. It does not make it layer-qualified: the change is still a direct mutation on the key, is still not reverted by deleting a layer, and is simply not applied at all if the transaction aborts.

5.4.4 Audit

Peios / Peios technical reference / PKM / LCS / Security

LCS emits audit events through KMES. Fifteen events exist.

EventEmitted when
lcs.audit.key.openedA key open matched a SACL audit ACE.
lcs.audit.value.setA value write through a handle whose audit mask covers KEY_SET_VALUE.
lcs.audit.value.deletedA value delete through a handle whose audit mask covers KEY_SET_VALUE.
lcs.audit.key.tombstonedA blanket tombstone set or cleared through a handle whose audit mask covers KEY_SET_VALUE.
lcs.audit.key.deletedA key deleted through a handle whose audit mask covers DELETE.
lcs.audit.key.hiddenA key hidden through a handle whose audit mask covers DELETE.
lcs.audit.key.createdA key create that made a key matched a SACL audit ACE on the parent.
lcs.audit.key.descriptor.changedA descriptor change included the SACL, or its right is covered by the handle's audit mask.
lcs.audit.transaction.committedA transaction that staged an audited write ended.
lcs.audit.backup.startedAfter admission checks and before REG_IOC_BACKUP requests its read-only snapshot or reads any subtree data.
lcs.audit.backup.endedAfter a backup completes or fails after starting.
lcs.audit.restore.startedBefore REG_IOC_RESTORE modifies any source state.
lcs.audit.restore.endedAfter a restore completes or fails after starting.
lcs.source.response.rejectedLCS rejected malformed source data.
lcs.config.value.rejectedLCS rejected an invalid self-configuration value.

A registry open that a privilege contributed to is also recorded, as KACS's kacs.audit.privilege.used; see Privilege use on opens.

Backup and restore are audited unconditionally, whatever the SACL on the target key says. They are privilege-gated bulk operations that bypass per-key access checks entirely, so the audit trail is the only record that they happened.

Every payload is a single MessagePack map with string keys. Each field path is written as nested maps, one per segment, so object.key.guid is {object: {key: {guid: …}}}. A value LCS does not have is left out rather than written as nil, and every map counts only the keys actually written (PGSS §6.4).

The key GUID, object.key.guid, is a 16-byte binary value, and the caller's SID is a binary KACS encoding. The token and process GUIDs are not payload fields at all: they ride in the KMES event header, so the payload never repeats them.

5.4.4.1 The caller summary #

Every event except lcs.source.response.rejected and lcs.config.value.rejected describes the effective token used for the operation under subject.token. It has six fields and no more: sid, integrity, id (the token's LUID), auth-id (its logon session's LUID), type (primary or impersonation) and impersonation, the impersonation level.

The bound is deliberate. Group lists, privilege arrays, claims and default DACLs are unbounded and are never included. The summary carries enough to correlate an event with a caller, and nothing that could make one event arbitrarily large. lcs.source.response.rejected and lcs.config.value.rejected carry no caller: neither is a principal acting.

A primary token reports an impersonation level of 0. An Anonymous impersonation token also reports 0, which is why subject.token.type is recorded beside it.

5.4.4.2 Key opens #

lcs.audit.key.opened carries the caller summary, then object.kind (always key, which names the table the masks decode against), object.key.guid, the requested and granted masks as access.requested and access.granted, outcome.success (true when the open was allowed), and trigger.sacl-match — bit 0 for a success-audit match, bit 1 for a failure-audit match, no other bits.

access.granted is forced to zero on a denial, and that is enforced rather than merely intended: a denied event carrying a non-zero granted mask is rejected as a malformed payload.

access.requested is the mask after registry generic mapping, with MAXIMUM_ALLOWED re-added if the caller asked for it.

SACL evaluation follows the KACS AccessCheck algorithm — the SACL is evaluated alongside the DACL, not separately.

Reading or modifying a SACL requires ACCESS_SYSTEM_SECURITY, which is itself gated by SeSecurityPrivilege.

A request of MAXIMUM_ALLOWED alone maps to a desired mask of zero, so AccessCheck's SACL walk matches each audit ACE against the granted mask instead. An audit ACE says "audit when someone gets this right", and with MAXIMUM_ALLOWED they did get it.

Such an open therefore always audits as a success, which is correct: MAXIMUM_ALLOWED returns whatever is available and never fails, so a failure ACE has nothing to record.

An ACE naming a right the caller did not receive still does not match.

5.4.4.3 Registry writes #

Writes are audited through the key handle, as file operations are through a file handle. When a key is opened, the access check evaluates the key's SYSTEM_ALARM* ACEs against the caller, and the union of the masks of the ones that match becomes the handle's continuous-audit mask. Success and failure SYSTEM_AUDIT ACEs do not contribute; they govern the open itself. This is the continuous auditing of §3.8.9 with LCS as the enforcement point, except in two respects: LCS writes records named for the operation rather than kacs.audit.handle.used, and a record it cannot write does not fail the operation (see below).

The mask is fixed for the life of the handle. A later change to the key's SACL neither adds to it nor takes from it, so a handle that removes the alarm ACE is still audited for the change it makes, and handles opened before an alarm ACE was added are not.

A write through the handle is recorded when the right it needs overlaps the mask, and not otherwise. The right is the one the handle's own gate checks:

EventOperationRight
lcs.audit.value.setREG_IOC_SET_VALUEKEY_SET_VALUE
lcs.audit.value.deletedREG_IOC_DELETE_VALUEKEY_SET_VALUE
lcs.audit.key.tombstonedREG_IOC_BLANKET_TOMBSTONEKEY_SET_VALUE
lcs.audit.key.deletedREG_IOC_DELETE_KEYDELETE
lcs.audit.key.hiddenREG_IOC_HIDE_KEYDELETE

A write that fails is recorded too, with outcome.success false and outcome.errno the negative errno the caller received. That includes a write the handle's own gate refuses because the handle lacks the right: the mask comes from the SACL, not the grant, so it can cover rights the handle was not given.

Every write record carries the caller summary; object.kind (key), object.key.guid and object.key.path, the handle's key and its resolved path; object.key.layer.name, the layer written; the masks access.requested (the right above), access.granted (the handle's grant), access.matched and access.audit-mask; transaction.id when the write was staged in a transaction; and outcome.success, with outcome.errno on a failure. A failure also carries request.timed-out. Value records add object.key.value.name; a value write adds the value's type and length and a digest of its data, and mutation.sequence, with mutation.sequence-expected on a compare-and-swap; a tombstone change adds operation.name (set or clear); a hide adds mutation.sequence. A field the operation had not reached when it failed is left out.

A value's data is never recorded. In its place a record carries the data's type, its length and its SHA-256 digest, so two records can be compared without either holding the data. Every digest LCS records, of a value or of a security descriptor, is SHA-256.

A value write outside a transaction also records the value it replaced, as object.key.value.type-previous, .length-previous and .digest-previous: the effective value before the write, across every layer. A value delete records the value it removed the same way.

A write whose source does not answer before the request timeout fails with ETIMEDOUT, and its record carries request.timed-out true. The source is not told to cancel, so the change may still land; for a value write or descriptor change outside a transaction, LCS also applies the write's kernel-side effects when the late reply arrives, and nothing records who made the write then. Read a timed-out record as "may have been applied later".

A write made in a transaction is recorded when it is staged, with transaction.id. Its outcome.success then says the source accepted it into the transaction, not that it took effect.

5.4.4.3.1 Transaction ends #

A transaction that staged at least one recorded write — a write or a key creation whose record was emitted — writes one lcs.audit.transaction.committed when it ends, and only one: at a REG_IOC_COMMIT that leaves it committed or otherwise finished, at its timeout, or when its fd is closed without a commit. A commit that fails and leaves the transaction open writes nothing yet.

It carries the caller summary, transaction.id and transaction.state, the state it ended in, and outcome.success, true only for committed. A failure adds transaction.commit-outstanding and outcome.reason: aborted (closed without a commit, or a layer it wrote was deleted), timed-out or source-error; and outcome.errno when a REG_IOC_COMMIT call failed.

On REG_IOC_COMMIT the subject is the caller who committed, not the one who staged the writes. A transaction that ended by timeout or by its fd closing has nobody acting, and names the subject that staged its first recorded write. The timer that ends a transaction runs in softirq context, which cannot build a record; the record is written from the work item that aborts the transaction at the source.

A commit whose reply does not arrive in time reports timed-out with transaction.commit-outstanding true: the source may have committed it before the reply was lost.

5.4.4.4 Key creation #

lcs.audit.key.created records a key a create made. Creating a key checks only KEY_CREATE_SUB_KEY on its parent, so the parent's SACL decides, by the rule that decides lcs.audit.key.opened: a success audit ACE on the parent that matches the caller.

Only a key that was made is recorded. A create that found the key already present and opened it instead writes nothing here. The record is written once the key exists, or is staged in a transaction, so a create whose handle then fails to reach the caller is still recorded.

It carries the caller summary; object.kind; the new key's object.key.guid, object.key.path and object.key.layer.name; object.key.created (true), object.key.volatile, object.key.volatile-requested and object.key.symlink; the new key's descriptor as object.sd.length and object.sd.owner; access.requested (KEY_CREATE_SUB_KEY) and access.granted, from the parent check; transaction.id when staged; and outcome.success.

5.4.4.5 Descriptor changes #

lcs.audit.key.descriptor.changed records REG_IOC_SET_SECURITY through a key handle.

A change that includes the SACL is recorded whatever the handle's mask says. It is recorded once the handle's own gate has admitted it, which takes ACCESS_SYSTEM_SECURITY: a handle without that right is refused first, and asking for a SACL change writes no record.

Any other change is recorded when the right it needs — WRITE_OWNER for the owner or group, WRITE_DAC for the DACL — overlaps the handle's mask, and then failures are recorded as writes' are.

The record carries what a write record does, without a layer, and the descriptors: object.sd.components, the security_info bits asked for; and for the descriptor written and the one it replaced, object.sd.length and object.sd.length-previous, their SHA-256 digests object.sd.digest and object.sd.digest-previous, and their owners object.sd.owner and object.sd.owner-previous. access.requested is every right the change needs, and access.matched is zero when only the SACL made the record exist.

5.4.4.6 Privilege use on opens #

A registry open whose access check used a privilege — SeSecurityPrivilege for ACCESS_SYSTEM_SECURITY, SeTakeOwnershipPrivilege for WRITE_OWNER — writes KACS's kacs.audit.privilege.used when the caller's token audit policy asks for privilege use, in the shape KACS uses for its own access checks, with object.kind key. The key's identity is not carried. The checks LCS makes for layer writes and on a create's parent record none.

5.4.4.7 Backup and restore #

lcs.audit.backup.started and lcs.audit.restore.started carry the caller summary, object.key.guid for the subtree root, and operation.fd, the descriptor the stream is written to or read from.

lcs.audit.backup.ended and lcs.audit.restore.ended carry the caller summary, object.key.guid and outcome.success. When the operation failed they also carry outcome.errno, the error as a negative errno; on success it is absent.

5.4.4.8 Source validation failures #

lcs.source.response.rejected carries the source slot as source.rsi.slot and then, where each is known, the hive name as source.rsi.hive, the RSI request id and operation code as request.id and request.op-code, and the key GUID as object.key.guid. outcome.reason names what was wrong. There are twelve:

malformed-security-descriptor, malformed-layer-name, unknown-rsi-status-code, future-sequence-number, duplicate-winning-sequence-tie, malformed-layer-metadata-security-descriptor, malformed-key-name, malformed-value-name, malformed-response-payload, malformed-key-metadata, malformed-value-payload, malformed-delete-layer-orphan-list.

The three name classes are field-specific: layer-name fields, key component or child-name fields, and value-name fields respectively.

The structural classes cover a response whose operation-specific payload has the wrong shape or trailing bytes; a lookup or enumeration whose metadata block is incomplete, duplicated, unreferenced or nil; a value payload with an invalid type, a tombstone/data mismatch or oversized data; and an invalid orphan GUID array from RSI_DELETE_LAYER.

5.4.4.9 Configuration #

lcs.config.value.rejected has the same shape as KMES's kmes.config.value.rejected, with every field under one config map. It carries the offending parameter's key and name as config.key.path and config.name; the expected type and numeric range as config.expected.type, config.expected.min and config.expected.max; what was actually received as config.received.kind — one of missing, wrong-type or out-of-range — with config.received.type for a value of the wrong type or config.received.value for one out of range; and, as config.value, the value LCS retained instead.

Because missing counts as invalid, a first boot before seed restore emits one of these per parameter on each refresh: nineteen events against an empty Registry\ key. That is correct and expected, but it is a noticeable share of the boot audit stream.

5.4.4.10 What happens when emission fails #

The policy differs per event, and the differences are the point.

  • lcs.audit.key.opened. If LCS cannot construct a valid payload — corrupt internal state, allocation failure, anything on the LCS side — the open fails with EIO and no key fd is published. If the payload is valid but KMES cannot retain the event — unavailable, ring drops, capacity pressure, no consumer — the access decision and the fd publication are unaffected. Loss accounting is KMES's problem.
  • lcs.audit.backup.started, lcs.audit.restore.started. Emission failure returns EIO and the operation does not start. Nothing is read and nothing is written.
  • lcs.audit.backup.ended, lcs.audit.restore.ended. The operation has already finished. Emission is attempted; failure does not change the result.
  • lcs.source.response.rejected. The triggering operation is already failing with EIO. Emission is attempted; failure does not change that.
  • lcs.config.value.rejected. The invalid value has already been ignored and the previous known-good value retained. Emission is attempted; failure leaves the retained configuration in force.
  • The write, creation, descriptor-change and transaction records. Each is written after the source has applied or refused the operation, or after the transaction's state is final. Emission is attempted; a record that cannot be built or retained leaves the caller's result as it was. These records fire no lcs:lcs_audit_emit or lcs:lcs_audit_emit_failed tracepoint; the tracepoints' event-type codes cover the other seven.

The rule underneath them all: an audit failure blocks an operation only where the audit record is the point of the operation being permitted. A privileged bulk export whose start could not be recorded does not happen. A key open whose decision could not be recorded does not happen. Everything downstream of an already-determined outcome records what it can.

LCS constructs the payload and attempts to enqueue it before continuing past the audit point. It never waits for a userspace consumer to observe or retain the event.

5.5.1 The Fd Model

Peios / Peios technical reference / PKM / LCS / The Syscall Interface

LCS uses a hybrid syscall/ioctl interface, the same shape KACS uses within PKM.

Syscalls create file descriptors: opening a key, creating a key, beginning a transaction. There are three, numbered 1100 to 1102 in the PKM range.

Ioctls operate on a descriptor that already exists — eighteen of them, all under type byte 'R'.

close() releases both kinds of fd through the ordinary fd lifecycle.

5.5.1.1 Key fds #

A key fd is an anonymous inode created with O_CLOEXEC, holding:

FieldDescription
Source and key GUIDThe identity of the opened key.
Granted access maskComputed once by AccessCheck at open. Immutable.
Resolved pathAfter symlink resolution and CurrentUser\ rewriting.
Ancestor chainThe GUID at each path component from the hive root down. Captured during the open walk, used for subtree watch dispatch (§5.6.3).
Watch stateArmed or not, filter, subtree flag, pending event queue.

Key fds behave like any other fd: close(), close-on-exec, poll/epoll, and passing over Unix sockets with SCM_RIGHTS. That last one is what makes them capabilities (§5.4.1).

5.5.1.2 Open-time checking #

A caller names a desired access mask, and AccessCheck evaluates it against the key's Security Descriptor. All of it is granted or the open fails with EACCES. MAXIMUM_ALLOWED is the only way to ask for whatever is available.

The granted mask lives on the fd, and every subsequent ioctl is a bitmask test against it — not a fresh AccessCheck, and not a fresh read of the descriptor.

Opening relative to a parent fd skips both path parsing and AccessCheck for the parent portion. The caller already proved its access when it obtained the parent fd, and this is the ordinary way to walk a subtree.

5.5.1.3 Transaction fds #

A transaction fd is an anonymous inode holding a transaction id and, once bound, its source and hive. Transaction lifetime is fd lifetime: closing without committing aborts (§5.7.1).

5.5.1.4 Reserved fields #

Every syscall and ioctl argument structure uses natural C layout with fixed-width fields and explicit padding. Nothing is packed.

Fields named _pad, and anything else described as reserved, are ABI extension points. A caller must set them to zero, and a non-zero reserved or padding field fails the operation with EINVAL — before source dispatch, before transaction enlistment, before sequence allocation, and before any output is copied.

In the other direction, LCS zeroes every reserved and padding byte of an output structure or a watch event before copying it to userspace.

Flags fields carry only the bits defined for them. An unknown or reserved flag bit is EINVAL unless a specific field says otherwise.

The point of all this is that a future version can give a reserved field meaning without an older kernel having silently accepted a value it did not understand.

5.5.1.5 Strings #

Strings in ioctl structures are length-delimited, not null-terminated. Each is a (len, ptr) pair where len is a byte count and ptr is a u64 userspace address. LCS reads exactly len bytes. A terminator is neither required nor expected, and one included in the length is a null byte and therefore invalid.

Syscall paths are the exception: they arrive as null-terminated C strings and have the terminator stripped before validation (§5.2.8).

5.5.1.6 Variable-size output buffers #

Six ioctls return variable-size data — REG_IOC_QUERY_VALUE, QUERY_VALUES_BATCH, ENUM_VALUES, ENUM_SUBKEYS, QUERY_KEY_INFO and GET_SECURITY — and all six use one convention.

For each output buffer described by (length, pointer):

  • length 0 is a size probe, whether the pointer is null or not. The pointer is not dereferenced.
  • length greater than 0 requires a non-null pointer writable for that many bytes, or the ioctl returns EFAULT.

If any output buffer is too small the ioctl returns ERANGE and writes every required size it can determine, not just the first one that failed — so a caller with two undersized buffers learns both sizes from one call.

On ERANGE, output buffers are not partially filled; their contents are unspecified.

Output scalar metadata is meaningful only on success, unless an ioctl explicitly documents a field as carrying a required size or count on ERANGE.

Input pointer faults return EFAULT, validated before source dispatch wherever that is possible.

5.5.2 Syscalls

Peios / Peios technical reference / PKM / LCS / The Syscall Interface

5.5.2.1 reg_open_key (1100) #

int reg_open_key(int parent_fd, const char __user *path,
                 u32 desired_access, u32 flags);

Opens an existing key. Fails if it does not exist after layer resolution.

ParameterDescription
parent_fdAn open key fd to resolve relative to, or -1 for an absolute path. No AccessCheck is performed on the parent.
pathA null-terminated registry path — absolute with a hive prefix when parent_fd is -1, relative to the parent key otherwise.
desired_accessRequested rights, raw generic bits, MAXIMUM_ALLOWED, or a combination (§5.4.2). Zero and unknown bits are EINVAL.
flagsREG_OPEN_LINK (0x01) opens a symlink key rather than following it. Every other bit is reserved and must be zero.

The open proceeds as follows.

  1. Parse and canonicalise the path: normalise separators, reject empty components and a trailing separator, check the total length and each component length.
  2. Rewrite a leading CurrentUser\ to Users\<caller SID>\ — only for an absolute path, and only the first component (§5.2.1).
  3. Route. For an absolute path, look the hive name up, private hives before global ones. For a relative one, use the parent key's source and resolve from the parent's GUID.
  4. Walk the path component by component through RSI_LOOKUP, resolving each through the layer stack and following symlinks — except a final component when REG_OPEN_LINK is set. Collect the ancestor chain as you go.
  5. Run AccessCheck against the final key's descriptor.
  6. Publish an fd holding the key GUID, the granted mask, the resolved path and the ancestor chain.

If the path traversed a symlink, the fd stores the resolved path and ancestor chain. It refers to the target object.

ErrnoCondition
ENOENTThe key does not exist after layer resolution.
EACCESAccessCheck did not grant everything requested.
EINVALMalformed path; zero or unknown desired_access bits; a symlink whose effective default value is not REG_LINK; maximum depth exceeded.
ELOOPSymlink depth limit exceeded.
ENAMETOOLONGA component or the total path is too long.
ETIMEDOUTThe source did not answer within RequestTimeoutMs.
EIOThe source failed or is unavailable.
ENOMEMKernel allocation failure.

5.5.2.2 reg_create_key (1101) #

int reg_create_key(const struct reg_create_key_args __user *args);

Opens the key if it exists after layer resolution, creates it with an inherited descriptor if not, and reports which happened.

FieldDescription
parent_fdAs reg_open_key.
path_ptrPointer to a null-terminated path.
desired_accessAs reg_open_key.
flagsREG_OPTION_VOLATILE (0x01), REG_OPTION_CREATE_LINK (0x02). Other bits reserved.
layer_ptrPointer to a null-terminated layer name for creation, or null for the base layer. Names the layer whose entry is created; what "exists" means depends on it (below).
txn_fdA transaction fd, or -1. A non-negative value makes creation a mutating operation that binds or reuses that transaction.
disposition_ptrReceives REG_CREATED_NEW (1) or REG_OPENED_EXISTING (2). May be null.
_pad0, _pad1Reserved; must be zero.

If the key exists, this behaves as reg_open_key and the disposition is REG_OPENED_EXISTING. What "exists" means depends on the layer named. For the base layer (a null layer_ptr) it is the path resolving through any enabled layer. For a named layer it is that layer holding an entry at the path: presence is per layer (§5.2.5), so another layer's key at the same path does not count, and the create makes the named layer's own entry, with its own GUID and REG_CREATED_NEW — which is how hide-and-replace is authored.

The named-layer presence check is read-only and compares layer names without case sensitivity. If that layer already names a key, LCS opens the effective key using normal layer resolution and checks the requested rights on that key. It does not require KEY_CREATE_SUB_KEY on the parent, perform layer write authorization, or send CREATE_ENTRY/CREATE_KEY for an existing key. An unbound transaction remains unbound by this open; a bound transaction uses its own view, including entries it has created but not yet committed. The checks below still apply when the named layer has no key entry there.

If it does not:

  1. Resolve the parent, which must exist. Check that the parent's depth plus one is within MaxKeyDepth.
  2. AccessCheck the parent for KEY_CREATE_SUB_KEY.
  3. Perform layer write authorization against the target layer's metadata key (§5.3.4).
  4. Mint a fresh UUIDv4 GUID.
  5. Compute the new key's descriptor from the parent's, through KACS.
  6. Create the path entry with a new sequence number, then the key record (§5.2.5).
  7. AccessCheck the new key's inherited descriptor against desired_access — an inherited descriptor may not grant everything the creator asked for.
  8. Publish the fd with the granted mask and disposition REG_CREATED_NEW.

Intermediate path components are not auto-created. Only the final one is.

Races. If two callers race, one creates and the other observes the key as existing. RSI_ALREADY_EXISTS on the path entry is retried as an open, reporting REG_OPENED_EXISTING, and EEXIST never reaches userspace from this syscall. RSI_ALREADY_EXISTS on the key record, for a GUID LCS has just minted, is not a race — the source and the kernel disagree about what exists — and fails closed with EIO (§5.2.3).

ErrnoCondition, in addition to reg_open_key's
ENOENTThe parent does not exist, or the named layer is not in the layer table.
EACCESThe parent denied KEY_CREATE_SUB_KEY, the inherited descriptor denied the requested access, or layer write authorization failed.
EPERMREG_OPTION_CREATE_LINK without KEY_CREATE_LINK on the parent, or without SeTcbPrivilege or Administrators.
ENOSPCThe per-value layer cap was exceeded.
EINVALA non-volatile key under a volatile parent; a non-zero reserved field.

5.5.2.3 reg_begin_transaction (1102) #

int reg_begin_transaction(void);

Allocates a transaction id, publishes a transaction fd in state REG_TXN_ACTIVE_UNBOUND, and starts the lifetime timer. It contacts no source and chooses none (§5.7.1). It can fail only with ENOMEM, EOVERFLOW on transaction id exhaustion, or EINVAL.

5.5.3 Key Ioctls

Peios / Peios technical reference / PKM / LCS / The Syscall Interface

Sixteen ioctls act on a key fd. Each checks the fd's granted mask first and returns EACCES without contacting the source if the required right is absent (§5.4.2). Numbers, directions and argument layouts are in §5.A.

Every mutating ioctl accepts an optional transaction fd. So do the four read ioctls — a read inside a bound transaction sees the transaction's own uncommitted writes. A transaction bound to a different hive is EXDEV; a committed, aborted or closed one is EINVAL; a timed-out one is ETIMEDOUT; one whose source went Down is EIO. An unbound transaction fd does not bind on a read, which is simply performed non-transactionally.

Every mutating ioctl that names a layer performs layer write authorization first (§5.3.4).

5.5.3.1 Common errors #

ErrnoCondition
EACCESThe granted mask lacks the required right, or layer write authorization failed.
EFAULTAn input pointer is invalid, or a non-zero-length output buffer pointer is null or unwritable.
EIOThe source is unavailable, failed, or the transaction's source went Down.
ETIMEDOUTThe source did not answer within RequestTimeoutMs, or the transaction had timed out.
ENOMEMKernel allocation failure.
EXDEVThe transaction is bound to a different hive.
ENOENTThe named layer is not in the layer table.
EINVALA non-zero reserved or padding field.
ENOTTYAn ioctl number this fd type does not implement.

5.5.3.2 Reading #

REG_IOC_QUERY_VALUE returns the effective value at a name: its type, data, the sequence number of the winning entry, and the canonical name of the winning layer — which is the layer table's spelling, not whatever string the source stored. A base-layer entry therefore reports base.

A winning tombstone or blanket, or no entries at all, is ENOENT.

REG_IOC_QUERY_VALUES_BATCH returns every effective value on the key in one call: name, type and data for each, with tombstoned and blanket-masked values omitted. This is the call to use when you want them all (§5.3.6).

REG_IOC_ENUM_VALUES and REG_IOC_ENUM_SUBKEYS return the entry at an index, and ENOENT past the end.

Both re-resolve the full set on every call, and enumeration order is undefined — see §5.3.6 for why the batch call usually wins.

ENUM_SUBKEYS performs no per-child access check. It returns every visible child with its last write time, subkey count and value count. The caller learns names and must open each child separately to read anything (§5.4.2).

REG_IOC_QUERY_KEY_INFO returns the key's name, last write time, subkey and value counts, the maximum subkey name length, maximum value name length and maximum value data size, the descriptor size, the volatile and symlink flags, and the hive generation number. It requires READ_CONTROL.

For a non-root key, the name is the stored key record's informational name, with its original spelling, rather than the spelling used to open the fd. The required buffer length is that stored name's UTF-8 byte length, including when the opening spelling folds to it but has a different byte length. The source name must fold to the leaf the fd resolved; an unrelated name is malformed key metadata, audited and refused with EIO, including on a buffer-length probe. Hive roots have no child name and instead report the preserved hive name from source registration, with its own byte length.

It is declared _IOWR, which is what it does: it reads the caller's output-buffer fields out of the argument structure before writing it back.

It was declared _IOR, and because the direction bits are part of the encoded ioctl number and the kernel dispatches on the whole encoded value, correcting that was an ABI break rather than a relabelling. A binary built against the old constant gets ENOTTY from a kernel carrying the new one.

5.5.3.2.1 The hive generation number #

A monotonic per-hive change epoch owned by the kernel. It is not a sequence number and must not be read as one: sources neither report it nor persist it (§5.3.7).

It is incremented once per committed mutation, or once per committed transaction per affected hive, however many operations that transaction contained.

Its baseline is initialised from the source's reported maximum sequence at registration so that observed values are monotonic relative to persisted entries; saturating at U64_MAX is EOVERFLOW.

It is exposed on every key because it is cheap and because a watcher that receives OVERFLOW can compare the generation it last saw with the current one and skip the recovery re-read entirely if nothing has committed (§5.6.4).

Layer operations produce a single generation increment covering the metadata key deletion, RSI_DELETE_LAYER, the recomputation and the resulting watch effects. There is no generation at which the metadata key is gone but the layer's entries are still resolving.

An operation affecting several hives increments each independently.

5.5.3.3 Writing #

REG_IOC_SET_VALUE writes a value entry in a layer. LCS allocates a sequence number and tells the source to store (key GUID, value name, layer) → (type, data, sequence), then updates the key's last write time.

Writing type REG_TOMBSTONE is the explicit tombstone operation and requires zero-length data (§5.2.6).

A non-zero expected_sequence makes the write conditional. It is passed to the source, which atomically verifies that the layer's own current entry carries that sequence number before writing, and answers RSI_CAS_FAILED if not — which LCS returns as EAGAIN. LCS reads the key's entries before every write, for watch dispatch, but there is no kernel-side query-then-write for the condition: the check is the source's, and it is atomic there or nowhere.

The condition is evaluated against the layer's own entry, not against the effective value. A higher-precedence layer overriding a value is not a lost update; it is the layer system working.

Additional errors:

  • EINVAL for an unknown type or a tombstone with data.
  • EAGAIN for a failed conditional write.
  • ENOSPC for the layer cap or oversized data.
  • ENAMETOOLONG for the value name.
  • EPERM for a Precedence above 0 without SeTcbPrivilege (§5.3.4).

REG_IOC_DELETE_VALUE removes one layer's entry at a value name — whether that entry was a value or a tombstone. Removing a layer's opinion lets lower-precedence layers surface.

The operation is meant to be idempotent, and it is idempotent as far as a caller sees, provided the source answers RSI_OK for an entry that was not there. LCS does not mask a source's RSI_NOT_FOUND; that propagates as ENOENT. Idempotency is a source obligation, not a kernel behaviour.

REG_IOC_BLANKET_TOMBSTONE sets or removes a blanket tombstone for a layer on this key (§5.2.7). A new sequence number is assigned for dispatch ordering, and watch events are generated for every value whose effective state changed.

REG_IOC_DELETE_KEY removes this key's path entry from a layer.

The parent GUID comes from the fd's ancestor chain and the child name from the last component of its resolved path. ENOTEMPTY if the key has visible children; EINVAL on a hive root (§5.2.9).

REG_IOC_HIDE_KEY creates a HIDDEN path entry at the same place instead, masking lower-precedence entries. The caller must hold an open fd to the key, which is how it proved access to it. EINVAL on a hive root.

5.5.3.4 Security #

REG_IOC_GET_SECURITY and REG_IOC_SET_SECURITY read and merge Security Descriptor components, selected by a security_info bitmask. §5.4.3 covers the rights, the merge, and the validation.

5.5.3.5 Watches, durability, bulk #

REG_IOC_NOTIFY arms, re-arms or disarms a watch (§5.6.1).

REG_IOC_FLUSH tells the source to persist pending writes for this key's hive, and returns when persistence is confirmed. The hive name comes from the first component of the fd's resolved path. It requires KEY_SET_VALUE (§5.4.2).

REG_IOC_BACKUP and REG_IOC_RESTORE export and replace a subtree. They are privilege-gated with no per-key access check, and they are covered in §5.9.

5.5.4 Transaction Ioctls

Peios / Peios technical reference / PKM / LCS / The Syscall Interface

Two ioctls act on a transaction fd. Everything else on that fd is ENOTTY — there are no savepoint or nesting operations to have.

The common key fd error table does not apply here.

5.5.4.1 REG_IOC_COMMIT #

Commits every operation in the transaction. §5.7.3 covers what happens in each case; in summary:

ErrnoCondition
—Success. The object becomes COMMITTED, poll waiters are woken, watch events are delivered, and further use of the fd returns EINVAL.
EINVALAlready committed, or never bound to a source.
EBUSYThe source could not take the write lock. The transaction stays ACTIVE_BOUND; retry.
EIOThe source failed to commit. The transaction stays ACTIVE_BOUND.
ETIMEDOUTThe transaction timed out before the commit completed.

EBUSY and EIO both leave the transaction usable: the mutation log is retained, no events are emitted, and poll waiters are not woken as though it had become terminal. The caller retries or closes the fd to abort.

5.5.4.2 REG_IOC_TXN_STATUS #

Reports the transaction's state and a terminal errno into a reg_txn_status_args. It reads nothing from the caller, consistent with its _IOR direction, and can fail only with EFAULT on an unwritable output pointer.

Stateterminal_errno
REG_TXN_ACTIVE_UNBOUND0
REG_TXN_ACTIVE_BOUND0
REG_TXN_COMMITTED0
REG_TXN_ABORTEDEINVAL
REG_TXN_TIMED_OUTETIMEDOUT
REG_TXN_SOURCE_DOWNEIO

For the three failed terminal states, terminal_errno is the errno a further operation on the fd would return. For COMMITTED it is not: the transaction reports 0, but using the fd again returns EINVAL.

This ioctl is what makes a poll wakeup useful. A terminal transition reports POLLERR | POLLHUP, which says only that something terminal happened; the status call says what.

5.5.5 The Error Model

Peios / Peios technical reference / PKM / LCS / The Syscall Interface

Syscalls and ioctls follow the ordinary Linux convention: -1 and errno.

The errno is the whole interface — source-specific error detail is never surfaced to a caller.

ErrnoWhat it means here
ENOENTA key or value does not exist after layer resolution; an enumeration index is past the end; a named layer is not in the layer table; a namespace operation on an orphaned key.
EACCESAccessCheck denied a right, the fd's granted mask lacks it, or layer write authorization failed.
EINVALAn invalid path or argument; a non-zero reserved or padding field; a zero or unknown-bit desired_access; an operation on a committed, aborted or closed transaction; a symlink target that is not REG_LINK; maximum key depth exceeded; delete or hide on a hive root.
EFAULTAn invalid userspace pointer. A zero-length output probe ignores its pointer; a non-zero length with a null or unwritable pointer does not.
ENAMETOOLONGA key or value name exceeds MaxPathComponentLength, or a path exceeds MaxTotalPathLength.
ELOOPThe symlink resolution depth limit was exceeded.
ETIMEDOUTThe source did not answer within RequestTimeoutMs, or a timed-out transaction fd was used.
EIOThe source failed, is unavailable, returned malformed data, or a transaction's bound source went Down.
ENOMEMKernel allocation failure.
ENOSPCA layer cap was exceeded, or value data exceeds MaxValueSize.
ENOTEMPTYA key with visible children cannot be deleted.
EXDEVAn operation targeted a different hive from the one its transaction is bound to.
EPERMA privilege the caller does not hold: symlink creation, creating or raising a layer above precedence 0, backup, restore.
EAGAINA conditional write failed — the layer entry's sequence did not match. Re-read and retry.
EBUSYA commit could not take the source's write lock, or MaxBoundTransactionsPerSource or MaxReadOnlyTransactionsPerSource was exceeded.
ERANGEAn output buffer is too small. Every determinable required size is written; buffers are not partially filled. Retry with larger ones.
EEXISTA path entry or key already exists at the source.
ENOTSUPThe source does not support the transaction mode being requested.
EBADFAn fd argument is not valid or not open in the required mode — a backup output fd that is not writable, a restore input fd that is not readable.
EOVERFLOWA counter cannot advance: a source reported a persisted sequence number too large to allocate past, a hive generation saturated, restore sequence remapping would overflow, or transaction ids are exhausted.
ESTALEA source re-registration tried to resume a Down slot with a mismatched hive identity.

5.5.5.1 Two errnos worth reading closely #

ETIMEDOUT means "may or may not have happened." If the deadline expired before an in-flight RSI slot was reserved, no request was sent at all. If it expired after dispatch, the source may still apply the operation and answer later, and LCS will apply the kernel-side effects when it does (§5.8.5). A caller that needs certainty checks state before retrying. The same applies to a transaction commit.

EEXIST is rarer than it looks. It never comes out of reg_create_key, which retries a losing race as an open (§5.5.2). From REG_IOC_RESTORE it arrives only by propagation: the source answers RSI_ALREADY_EXISTS while the stream is being replayed. There is no kernel-side pre-check that a non-root GUID in a backup already exists outside the subtree being replaced, so a collision is detected during the restore rather than before it — inside the restore transaction, which then rolls back (§5.9.3). Its other source is source registration, where a hive identity collides with an Active slot; a collision with a Down slot yields EINVAL or ESTALE instead (§5.8.2).

5.6.1 The Watch Model

Peios / Peios technical reference / PKM / LCS / Watches

A watch is a persistent subscription to changes on an open key. It follows the inotify model rather than the Windows one: once armed, it stays armed until the fd closes, and events keep arriving without re-registration. RegNotifyChangeKeyValue is single-shot, and the window between receiving a notification and re-registering is a window in which changes are missed; a persistent watch has no such window.

A watch is armed by REG_IOC_NOTIFY on a key fd, which requires KEY_NOTIFY in the fd's granted mask. Arming takes a filter — a bitmask of event categories — and a subtree flag. After arming, the fd is pollable: EPOLLIN reports pending events, and read() returns structured records.

Each fd carries at most one watch.

Arming an already-armed fd replaces the filter and the subtree setting and leaves queued events in place.

Arming with a filter of zero disarms: the watch is removed and every pending event is discarded. To watch one key under two different filters, open it twice.

Arming a watch on a key that is already orphaned fails with ENOENT. A watch armed before the key was orphaned stays armed (§5.2.9).

5.6.1.1 What a watch observes #

Events describe changes to effective state, not to layer mechanics. A watcher sees that a value changed; it does not see which layer won, or that a layer was deleted. Removing a hiding entry that was concealing a lower-precedence key produces SUBKEY_CREATED. Removing a whole layer is recovery dispatch rather than a diff, and reaches a watcher as OVERFLOW (§5.6.3). The layer system is not visible through a watch at all.

The events are computed by diffing the effective state before the mutation against the effective state after it, which is what makes this true by construction rather than by careful case analysis.

A change that replaces the key at a child name with a different key object — different GUID, same name — produces SUBKEY_DELETED followed by SUBKEY_CREATED, because that is what the diff says happened.

Only committed state is observable. Operations inside an uncommitted transaction produce nothing; the whole set fires at commit (§5.6.3).

5.6.1.2 Event types #

EventCodeName fieldMeaning
REG_WATCH_VALUE_SET1value nameThe effective value at this name changed or appeared.
REG_WATCH_VALUE_DELETED2value nameThe effective value at this name disappeared.
REG_WATCH_SUBKEY_CREATED3subkey nameA child key became visible.
REG_WATCH_SUBKEY_DELETED4subkey nameA child key became invisible.
REG_WATCH_SD_CHANGED5emptyThe watched key's Security Descriptor was modified.
REG_WATCH_KEY_DELETED6emptyThe watched key itself became invisible.
REG_WATCH_OVERFLOW7emptyEvents were dropped; re-read to recover.

VALUE_SET fires when a value is written, and when a tombstone or blanket tombstone is removed and a lower-precedence value surfaces.

VALUE_DELETED fires when the last entry for a name goes away, when a tombstone masks every entry, and when a blanket tombstone masks this name.

SUBKEY_CREATED and SUBKEY_DELETED cover both halves of the naming model: a path entry appearing or being removed, and a hiding entry being removed or created.

The three no-name events carry no name, and that is enforced: a record constructed with a name for SD_CHANGED, KEY_DELETED or OVERFLOW is rejected rather than emitted.

5.6.1.3 Filters #

The filter selects event categories, not individual event types.

Filter bitValueAdmits
REG_NOTIFY_VALUE0x01VALUE_SET, VALUE_DELETED
REG_NOTIFY_SUBKEY0x02SUBKEY_CREATED, SUBKEY_DELETED
REG_NOTIFY_SD0x04SD_CHANGED
REG_NOTIFY_ALL0x07all three of the above

KEY_DELETED and OVERFLOW are delivered unconditionally. They are not in any category and no filter suppresses them. The first tells a watcher its key is gone, and the second tells it that what it has been told is incomplete. Neither is something a watcher can usefully opt out of.

A filter containing an undefined bit is rejected.

A subtree flag other than 0 or 1, or a non-zero padding byte in the argument structure, is rejected the same way.

5.6.1.4 Blanket tombstones #

A watcher never learns that a blanket tombstone exists.

When one is written, LCS works out which values it newly masks and emits one VALUE_DELETED per name; when one is removed, one VALUE_SET per name that became visible. The per-value view is the only view.

5.6.2 Event Records

Peios / Peios technical reference / PKM / LCS / Watches

Events are read from the key fd with read(). A single call returns as many complete events as fit in the caller's buffer; an event is never split across two calls.

If the buffer cannot hold even the first queued event, read() fails with EINVAL — the buffer is too small to make progress, and the caller has to try again with a larger one.

On an armed fd with an empty queue, read() blocks, or returns EAGAIN under O_NONBLOCK.

Only events that were copied out in full are dequeued.

5.6.2.1 Layout #

Every record begins with the same four fields.

OffsetSizeField
04total_len
42event_type
62name_len
8name_lenname, UTF-8

All integers are little-endian.

total_len is the whole record including this header; it is how a consumer advances to the next event, and it is the only safe way to do so.

A subtree watch's records carry additional fields after the name, locating the key the change happened on relative to the watched key:

SizeField
2path_depth
2 + n eachpath_components: len (u16) then that many UTF-8 bytes

path_depth is the number of components from the watched key down to the changed key; zero means the change was on the watched key itself.

The components are length-prefixed rather than joined with a separator, because registry names can contain any Unicode character and value names can contain backslashes — a concatenated path string would be ambiguous.

The header offsets are ABI, named in uapi/pkm/lcs.h and listed in §5.A.

5.6.2.2 Not every record on a subtree watch has a path #

OVERFLOW records are emitted in the bare eight-byte form, on every watch, whether or not that watch is a subtree watch.

KEY_DELETED is not: a subtree watcher receives it in the subtree form, with a path_depth of its own.

A consumer of a subtree watch therefore cannot assume the subtree fields are present on every record it reads. total_len is the cursor; path_depth is read only when the record is long enough to hold it.

5.6.2.3 Length limits #

name_len and each component length are 16-bit.

If an event's name or one of its path components is too long to be represented, LCS does not emit a truncated or malformed record: it substitutes or preserves an OVERFLOW for that watcher instead, which is a statement the consumer already knows how to act on.

5.6.2.4 Forward compatibility #

Future versions may append fields after path_components. An existing consumer skips them, because it advances by total_len; a newer one compares total_len against what it has parsed to discover whether the optional fields are present. This is the whole extension mechanism, and it is why the length field comes first.

5.6.3 Dispatch

Peios / Peios technical reference / PKM / LCS / Watches

Dispatch answers one question: given a mutation on a key, which armed watches hear about it? The answer is computed from object identity and from ancestry captured at open time, never from a path string resolved at the moment of the event.

5.6.3.1 A watch is bound to an object #

A watch is registered against the GUID on the fd it was armed through. It fires for changes to that object.

If a layer change causes a different key to become visible at the same path, the watch does not move: the new key is a different object with a different GUID, and nothing about the old watch refers to a name.

To follow the path rather than the object, a process detects the change — through KEY_DELETED on the old object, or a subtree watch on the parent — re-opens the path, and arms a new watch. This is the fd model applied consistently: an fd is a capability bound to one identity.

The converse also holds. If a watched key is hidden, KEY_DELETED is delivered, but the watch is not removed.

Should the hiding layer later be deleted, the key reappears at its original path with the same GUID, and events resume. There is no re-emergence event; the watcher simply observes the key being active again.

5.6.3.2 The ancestor chain #

Subtree dispatch needs to know where a key sits in the tree, and it learns that once, at open.

Resolving a path walks it component by component, and the GUID at each level is retained on the resulting fd as the ancestor chain — root GUID through parent GUID to the key itself. It costs nothing extra: the walk had to resolve those components anyway.

A relative open copies the parent fd's chain and extends it with the components the relative walk resolved.

An open that followed a symlink records the chain of the resolved path, so the fd's ancestry reflects the target's real position in the tree, not the link's.

Dispatch uses that captured chain and never re-resolves it. If an ancestor in the chain is later hidden, deleted, orphaned, or replaced at the same path by a different key, watches on the original ancestor GUID still receive subtree events from descendants mutated through fds opened through that chain.

Watches on a new key that later appears at the same path receive nothing from the old one.

5.6.3.3 The algorithm #

Two structures back dispatch. The watch map is a hash map from GUID to the watchers armed on it. The subtree watch set is a refcounted hash set of the GUIDs that have at least one subtree watch, maintained as watches are armed, re-armed, disarmed and closed.

For a mutation on key B, with B's ancestor chain in hand:

  1. Look up B's GUID in the watch map and queue the event, with path_depth 0, to every watcher whose filter admits it.
  2. Walk the ancestor chain outward from B's parent. At each ancestor, skip it unless it is in the subtree watch set — that test is what makes the walk cheap. For an ancestor that is, queue the event to each of its watchers that armed with the subtree flag and whose filter admits it, with the path from that ancestor down to B.

The cost is O(depth) hash lookups, with no RSI round trips, no trie and no string comparison. The path components handed to a subtree watcher are sliced out of the resolved path already stored on the mutating fd.

Dispatch runs under a single global registry lock, so it is serialised across the whole system rather than per hive or per source.

5.6.3.4 Depth #

MaxSubtreeWatchDepth bounds how far below a watched key a subtree watch is told about. The default is 0, meaning unlimited.

A non-zero value suppresses events whose path is longer than it, limiting both noise and dispatch cost for a watch armed high in a tree.

The limit applies to watches held by userspace. LCS's own internal watches are collected before the depth test and are not subject to it (§5.10.4).

5.6.3.5 Transaction batches #

Nothing inside an uncommitted transaction dispatches.

At commit, and only when the source reports success, the transaction's mutation log is walked in operation order and the whole set of events is queued as one batch, under a single hold of the registry lock, so no other operation interleaves with it.

An aborted or failed transaction dispatches nothing and its log is released. A transaction that timed out after its commit was dispatched keeps its log, and a late RSI_OK still emits the batch (§5.7.3).

A large transaction — a role installation writing thousands of values — could otherwise fill a watcher's queue atomically. So the batch is counted per watcher first: any watcher whose share exceeds MaxTransactionWatchEventBurst, default 4096, receives exactly one OVERFLOW and none of its individual events.

That OVERFLOW is queued ahead of the batch, so it arrives before whatever else that watcher is still owed.

5.6.3.6 Recovery dispatch #

Some changes alter effective state for keys whose fds nobody holds and whose descendants the kernel is retaining no context for. Deleting a layer, changing its precedence, enabling or disabling it, and restoring a subtree from a stream are all of this kind. Computing an exact diff would mean walking arbitrary parts of the tree through the source.

LCS does not attempt it. It increments the generation number of every hive the affected source backs and then queues a no-name OVERFLOW to every armed watch on that source, which is the notification for those changes. The watcher re-reads, and can compare the generation number it last saw against the current one (§5.5.3) to tell whether an ordinary write happened in between; after a recovery it cannot, because the bump is deliberately conservative. The kernel does not know which hives a layer operation touched — the source's delete-layer answer names only the keys it orphaned, and a layer holding hiding or masked entries changes effective state without orphaning anything — so it bumps every hive rather than risk a watcher skipping a re-read its hive needed. The consequence is always a spurious re-read, never a missed change.

Two properties of this delivery are worth stating. It is object- semantic like every other dispatch — it walks the watch map, resolves no paths — and it bypasses the filter, because OVERFLOW always does. It does not disarm anything.

The scope is the source, not the hive. A source backing several hives delivers OVERFLOW to watches on all of them, including hives the operation did not touch, and bumps all of their generation counters. The generation counters are maintained per hive for ordinary writes; recovery treats the source as the unit.

For a restore, recovery is published only after the source commit succeeds. A restore that fails or aborts before commit emits nothing.

5.6.3.7 Source restart #

When a source disconnects, watches stay armed and nothing is delivered; operations needing the source return EIO.

OVERFLOW arrives on re-registration, not on disconnect, which is the only ordering that works: a watcher told to re-read needs the source to be there when it does.

Existing fds resume without re-opening, and no watcher has to re-arm.

5.6.4 Queues and Overflow

Peios / Peios technical reference / PKM / LCS / Watches

Each armed fd has its own event queue, bounded by NotificationQueueSize — default 256 events, configurable between 16 and 65536 (§5.10.3).

Delivery is best-effort. A watcher that reads promptly sees every change; one that falls behind is told that it has, rather than being given a partial history it cannot distinguish from a complete one.

5.6.4.1 What happens when the queue is full #

When an event arrives for a full queue and no OVERFLOW is present:

  1. The oldest queued event is dropped.
  2. An OVERFLOW record is queued in its place.
  3. The event that triggered this is discarded, not queued.

Once an OVERFLOW is in the queue, subsequent events queue normally: the oldest non-OVERFLOW event is dropped to make room and the new event is added, so the single OVERFLOW is preserved and the queue continues to carry the most recent history behind it.

A queue therefore holds at most one OVERFLOW at a time, and that is an enforced invariant rather than a convention — an attempt to queue a second is rejected as a bug.

5.6.4.2 What a watcher does about it #

OVERFLOW means the record it has is incomplete. There is no way to learn what was dropped, and no attempt is made to describe it.

The watcher re-reads the watched key, and its subtree if the watch is a subtree watch, and continues from the state it finds. Events after the OVERFLOW are complete again.

REG_IOC_QUERY_KEY_INFO reports a per-hive generation number (§5.5.3) that makes this cheaper than it sounds: a watcher that recorded the generation at its last full read can compare it with the current one and skip the re-read entirely if nothing committed in between.

5.6.4.3 Memory #

There is no registry-specific global cap on watch memory, and none is needed. A watch costs at most NotificationQueueSize queued events, a watch requires an fd, and a process holds at most RLIMIT_NOFILE fds. The product of the two is the bound, and it is enforced by machinery that already exists.

The same reasoning covers open key state: per-fd overhead — GUID, granted mask, ancestor chain, watch state — multiplied by the fd limit.

LCS's own internal watches are outside this. They are delivered synchronously through a kernel callback rather than queued, so the queue limit does not apply to them (§5.10.4).

5.7.1 Scope and Lifetime

Peios / Peios technical reference / PKM / LCS / Transactions

A transaction groups registry operations so that they commit together or not at all. Installing a role writes service definitions, defaults and registry entries; a transaction is what makes that one event rather than a sequence of partially-applied ones.

reg_begin_transaction takes no arguments and returns a transaction fd. It contacts no source: it allocates an id, creates an anonymous inode, starts the lifetime timer, and returns.

A transaction begins in the state REG_TXN_ACTIVE_UNBOUND.

5.7.1.1 Binding #

A transaction binds to a source on its first mutating operation.

There are seven: writing a value, deleting a value entry, setting or removing a blanket tombstone, creating a key, deleting a key's path entry, hiding a key, and changing a Security Descriptor.

Reads never bind.

A read passed an unbound transaction fd is sent to the source with a transaction id of zero and behaves as an ordinary non-transactional read.

Once bound, every operation on that fd must target the same hive. One that does not fails with EXDEV, and it fails in the kernel, before anything reaches a source — a source never sees a cross-hive operation inside a transaction. Cross-source atomicity would require two-phase commit and is not supported.

Binding identity is the pair of source and hive root GUID carried on the transaction object, which identifies the hive exactly.

5.7.1.2 Sources that do not support transactions #

Because reg_begin_transaction chooses no source, it cannot fail for lack of transaction support. The failure surfaces on the operation that would have bound: LCS sends RSI_BEGIN_TRANSACTION, the source answers RSI_TXN_NOT_SUPPORTED, and the operation returns ENOTSUP.

The transaction stays ACTIVE_UNBOUND, its source binding untouched, and the caller can use the fd against a different hive.

There is no advance check of whether a source supports transactions. The answer comes from the source, on the attempt.

If the source is Down before the first bind, the binding operation fails EIO under the ordinary rules and the transaction likewise remains unbound. If a source goes Down after binding, the transaction becomes REG_TXN_SOURCE_DOWN (§5.8.5).

5.7.1.3 States #

StateValueMeaning
REG_TXN_ACTIVE_UNBOUND0Active, no source chosen.
REG_TXN_ACTIVE_BOUND1Active, bound to a source.
REG_TXN_COMMITTED2Commit completed.
REG_TXN_ABORTED3Explicitly or implicitly aborted.
REG_TXN_TIMED_OUT4The lifetime timer fired.
REG_TXN_SOURCE_DOWN5The bound source went Down.

REG_IOC_TXN_STATUS reports the state and a terminal_errno: 0 for COMMITTED, EINVAL for ABORTED, ETIMEDOUT for TIMED_OUT, EIO for SOURCE_DOWN, and 0 while active.

For COMMITTED, terminal_errno is not the errno that a further operation would return. A committed transaction reports 0, but using its fd again returns EINVAL.

5.7.1.4 Reaching a terminal state #

  • Commit. REG_IOC_COMMIT on the transaction fd. The source applies everything atomically, watch events fire, and the object becomes COMMITTED. Later use of the fd returns EINVAL. The fd should be closed.
  • Explicit abort. close() without committing. The source is told to discard, and the object becomes ABORTED during release. No events.
  • Implicit abort. Process death closes the fd, which aborts it. There are no orphaned transactions.
  • Timeout. The lifetime timer fires. See below.

The object stays addressable after reaching a terminal state, until the fd is closed. That is what makes REG_IOC_TXN_STATUS useful.

Transaction fds are pollable. Any terminal transition wakes poll waiters with POLLERR | POLLHUP. A caller that needs a race-free reason for the wakeup queries the status.

5.7.1.5 Timeout #

The lifetime timer starts when the fd is created — not at the first operation — and runs for TransactionTimeoutMs, default 30 seconds.

When it fires, the object becomes TIMED_OUT, poll waiters are woken, and further use of the fd returns ETIMEDOUT.

The timer updates transaction state from softirq context. Task and workqueue callers disable bottom halves while holding the shared state spinlock, so the timer cannot interrupt a holder on the same CPU and wait for that holder to release it. Synchronous timer cancellation runs outside the state lock.

If the transaction was bound and no commit is already in flight, RSI_ABORT_TRANSACTION is sent to the source.

The fd is not removed from the caller's table; close() still releases it normally.

The timeout is not a convenience. Sources serialise writers — loregd does so through SQLite's WAL — so a stalled transaction blocks every other write to that source. The timer is what bounds the starvation window, and MaxBoundTransactionsPerSource (default 16) is what stops colluding processes from extending it indefinitely by binding fresh transactions at each timeout boundary. An operation that would bind past that cap returns EBUSY.

The cap is tested before the source is contacted, but after the transaction's mutation-log entry has been allocated; that entry is freed on the way out.

5.7.1.6 Constraints #

  • No nesting. Transactions are flat: no savepoints, no sub-transactions. The transaction fd accepts only REG_IOC_COMMIT and REG_IOC_TXN_STATUS; every other ioctl is ENOTTY.
  • One transaction per fd. A process may hold many transaction fds at once, but each is exactly one transaction.
  • Reads are permitted. A transaction is not write-only, which is what makes verify-then-write possible.

5.7.2 Isolation and the Mutation Log

Peios / Peios technical reference / PKM / LCS / Transactions

5.7.2.1 Read-your-own-writes #

Within a bound transaction, reads see the transaction's own uncommitted writes. LCS does not implement this; the source does. A read tagged with the transaction id is executed by the source inside its open transaction, which naturally includes the pending writes.

No uncommitted registry data is cached in the kernel for the purpose of resolving reads.

Externally, only committed state is visible. A transaction's writes are invisible to other threads and processes until commit.

5.7.2.2 The mutation log #

LCS does keep something: a per-transaction mutation log, holding what the kernel needs to know after a successful commit and cannot ask the source for afterwards. Each accepted mutating operation records the affected key, value or layer name, its assigned sequence number, the ancestor chain for watch dispatch, and enough context to compute effective-state changes.

The log is not the source of truth for reads and it is not a rollback journal — the source's own transaction state is authoritative for uncommitted data. The log exists so that when the source says "yes", LCS can produce the hive generation updates and the watch events that correspond to what was just committed.

It is bounded at 4096 entries. That bound is a compile-time constant rather than one of the self-configuration parameters.

Exceeding it fails the operation with ENOMEM.

An operation whose log entry cannot be allocated fails before it is sent to the source. That ordering is deliberate: an operation the source applied but the kernel cannot account for is exactly the state the log exists to prevent.

The log is released, with no events emitted, on explicit abort, on a lifetime timeout that fires before a commit is dispatched, on source-down cancellation, on a late commit error, and on source teardown while a post-dispatch commit response is still retained.

It is retained across a post-dispatch commit timeout, because the source may still answer.

5.7.2.3 Sequence numbers #

A transactional mutation is assigned its sequence number when it is accepted into the transaction, not at commit. This preserves the order in which the caller performed the operations, which is what layer tiebreaking and watch ordering need.

If the transaction aborts, those numbers are simply never used. Gaps in the sequence space are normal and mean nothing (§5.3.7).

5.7.2.4 Layer precedence coherency #

Layer metadata lives in the registry, so a transaction can write to it — and that raises the question of whether a precedence change takes effect inside the transaction that made it. It does not.

Resolution always uses the published layer cache, which is refreshed only at commit.

Reading the Precedence value back inside the transaction shows the new number, because that is an ordinary read-your-own-writes read from the source.

Resolving any other value in the same transaction still uses the old precedence order. The two are consistent with each other and with the rule that a transaction's effects become real at commit.

The refresh is deferred to commit, dropped on abort, and performed before REG_IOC_COMMIT returns on success.

5.7.2.5 Conflicts #

Transactions are atomic. They are not conflict-detecting.

If two transactions write the same value, both commits succeed and the one that committed second wins, because its write carries the higher sequence number. There is no read set, no version check, and no per-key validation anywhere in the commit path.

Serialising concurrent writers is the source's responsibility, not LCS's. The RSI requires only that commits are atomic, that writes within a transaction are ordered, and that concurrent commits are serialised.

Conditional writes are the mechanism for the cases where losing an update matters. REG_IOC_SET_VALUE takes an expected_sequence, the source verifies it atomically against the layer's own entry, and a mismatch returns EAGAIN (§5.5.3).

That is a per-operation check, not a transaction-level one, and it is deliberately scoped to a single layer: a higher-precedence layer overriding a value is not a conflict, it is the layer system working.

5.7.3 Commit and Failure

Peios / Peios technical reference / PKM / LCS / Transactions

REG_IOC_COMMIT marks the transaction as having a commit in flight and sends RSI_COMMIT_TRANSACTION. What happens next depends on the answer.

5.7.3.1 Success #

The source's RSI_OK triggers, in order: the layer metadata cache refresh for any layer names the transaction touched, the hive generation increment, orphan tracking for keys that lost their last path entry, and the watch event batch derived from the mutation log.

The object then becomes COMMITTED, the log is released, poll waiters are woken, and the ioctl returns 0.

The hive generation is incremented once per committed transaction per affected hive, however many operations the transaction contained.

5.7.3.2 Failure that leaves the transaction open #

A source that cannot take the write lock answers RSI_TXN_BUSY, which becomes EBUSY; a synchronous commit failure becomes EIO.

In both cases the transaction stays ACTIVE_BOUND:

  • the mutation log is retained;
  • no watch events are emitted;
  • poll waiters are not woken as though the transaction had become terminal.

The in-flight marker is cleared, so the caller may simply retry REG_IOC_COMMIT, or close the fd to abort. Nothing has been lost.

5.7.3.3 Timeout after dispatch #

If the request timeout expires after the commit was dispatched, the caller receives ETIMEDOUT and the object becomes TIMED_OUT, but the mutation log is kept and the request record stays in the source's in-flight table. The source may still answer.

ETIMEDOUT on a commit means may or may not have committed. A caller that needs certainty checks state before retrying.

A late RSI_OK applies the full set of kernel-side effects from the retained log — the same generation updates and the same watch events an on-time commit would have produced. Watchers may therefore observe the effects of a transaction whose caller was told it timed out.

A late error releases the log with no effects.

The transaction object does not move to COMMITTED when a late success arrives. It stays TIMED_OUT, so a caller that queries REG_IOC_TXN_STATUS afterwards is told TIMED_OUT with a terminal_errno of ETIMEDOUT, even though the writes are durable and the watch events have gone out. The state reflects what the caller was told, not what the source did.

5.7.3.4 When the watch events cannot be derived #

Two of the post-commit steps query the source. Working out which keys were orphaned by a key deletion needs a lookup that can only be made after the commit, and expanding a blanket tombstone into per-value events needs the value set.

If that derivation cannot complete exactly, LCS does not reinterpret a successful commit as a failed one and does not emit a partial set of events. It delivers OVERFLOW to the affected watchers instead, releases the retained replay state, and reports the commit as successful, which it was.

A late response arriving afterwards does not resurrect the individual events once overflow recovery has been chosen.

The carve-out is narrower than it might appear. It covers the orphan lookup and the watch batch.

The other two post-commit steps — publishing the layer metadata cache and recording the hive generation — are state updates rather than event derivation, and a failure in either returns EIO and marks the source Down.

5.7.3.5 Abort #

Aborting generates no events, ever, and releases the log.

The source is told to roll back with RSI_ABORT_TRANSACTION if the transaction was bound.

Process death is the same path: closing the fd aborts.

5.8.1 The Source Model

Peios / Peios technical reference / PKM / LCS / Sources

A source is a userspace process that holds registry data and answers LCS's questions about it over the Registry Source Interface. LCS is source-agnostic: it does not know or care how a source stores anything.

The division is the sixth semantic rule (§5.1). A source stores path entries, key records, value entries and blanket tombstones, and returns all of them on request.

It does not evaluate access, resolve layers, dispatch watches, interpret paths beyond the parent and child names it is given, or see the identity of any caller. LCS does all of that.

A source may back several hives; a hive is backed by exactly one source (§5.2.1).

5.8.1.1 The interface is specified elsewhere #

The RSI — its channel, framing, operations, error vocabulary, and the obligations binding on a conforming source — is a normative specification, because the userspace side is a role a third party can implement. It is a chapter of PSPK, and it is where the wire format lives.

This section covers the kernel's side: how LCS admits a source, how it dispatches requests and accounts for them, what it refuses to believe, and what it does when a source dies or answers late.

5.8.1.2 The trust boundary #

Sources are in the TCB. LCS trusts the data a source returns — descriptors, values, symlink targets, key metadata — because it has no independent copy of any of it.

A compromised source therefore has complete control over access decisions for its hives. It can return a permissive descriptor for any key and AccessCheck will grant access that should have been denied. LCS validates that responses are structurally correct, but it cannot detect data that is well-formed and wrong: a descriptor granting Everyone KEY_ALL_ACCESS on a sensitive key is a perfectly valid descriptor.

Three consequences are worth naming specifically.

Layer table poisoning. A compromised source backing Machine\ can fabricate the Precedence and Enabled values under Machine\System\Registry\Layers\, and so control which layer wins every resolution contest system-wide. The SeTcbPrivilege check at write time does nothing about a fabricated read.

Layer authorization bypass. The same source can return permissive descriptors for layer metadata keys, granting any process write access to any layer (§5.3.4).

SeRestorePrivilege implies descriptor control. Restore replaces a subtree including every descriptor in it, so granting SeRestorePrivilege effectively grants WRITE_DAC and WRITE_OWNER over everything in reach of a restore. Operators should understand it that way.

The mitigations are operational rather than architectural: sources run with tightly scoped privileges, protected by descriptors on their service definitions, and managed by peinit with Process Integrity Protection where available. LCS emits audit events for every source data validation failure. A harder guarantee — checksumming descriptors LCS computed during inheritance and verifying them on retrieval — is possible but not implemented.

5.8.1.3 loregd is not special #

loregd is the first source and the one that provides Machine\ and Users\ at boot, which puts it on the critical path to a running system. Nothing in LCS knows that. Its details are its own manual's business; what LCS requires of it is exactly what it requires of any source.

5.8.2 Registration and Slots

Peios / Peios technical reference / PKM / LCS / Sources

A source reaches LCS through /dev/pkm_registry. The device's open() handler checks the calling thread's effective token for an enabled SeTcbPrivilege and returns EPERM without it, so an unprivileged process cannot obtain an fd to the device at all.

Having opened it, the source issues REG_SRC_REGISTER, naming the hives it backs, the root GUID of each, the highest sequence number it has persisted, and per-hive flags and scope GUID. On success it enters the request loop: read() for requests, write() for responses.

5.8.2.1 What registration validates #

  • Every hive name is valid and is not the reserved CurrentUser (§5.2.1).
  • No route identity — the folded name paired with its scope — collides with one held by another Active source.
  • Private hive collisions are scoped: the same name in different scopes is fine (§5.2.2).
  • A hive's root GUID is not nil.
  • The root GUIDs within one request are distinct from each other.
  • A hive without RSI_HIVE_PRIVATE carries no scope GUID.
  • No unknown flag bits are set.
  • The hive count is within MaxHivesPerSource (64), and the source count is within MaxRegisteredSources (32). Either limit is ENOSPC; a zero hive count is EINVAL.
  • The reported maximum sequence can be advanced past without overflowing 64 bits, or registration fails EOVERFLOW and the source is never made Active (§5.3.7).

Root GUIDs are checked for uniqueness within one request, and against every existing slot, Active or Down: a request whose root GUID is already the root of a differently-named hive is refused EEXIST. A same-named hive is decided by the identity rules above instead. The already-registered state is also checked for consistency, and a table that ever held two hives with one root would fail that check on every later registration and path walk, which is why admission refuses it.

5.8.2.2 Source slots #

Successful registration creates a source slot: the kernel object owning one connection and the hive set registered on it.

Each registered hive has a stable identity — its folded name, its visibility, its scope GUID for a private hive, and its root GUID.

Down slots keep their identities reserved. A crash or an fd close marks the slot Down; it does not unregister anything and does not retire any hive identity.

Slots are never freed, and collision checks see Down slots as well as Active ones.

Status is a property of the slot, not of an individual hive. A source's hives go Down together.

5.8.2.3 Resuming a Down slot #

A new process may take over a Down slot if it holds SeTcbPrivilege and registers exactly the same hive set: the same folded name, visibility, scope GUID and root GUID for every hive, and the same number of them. Partial resume is rejected.

The distinction between the failures matters. A request whose only mismatch is stale identity data — a different root GUID for an otherwise-matching hive — fails ESTALE.

Other partial or malformed resume attempts fail EINVAL.

EEXIST is reserved for a collision with an Active slot; it never comes from a Down-slot collision.

When both an Active collision and a stale Down slot apply, EEXIST wins.

New hives cannot be added by mutating a Down slot during a resume — the hive set has to match exactly, so a superset simply fails, and a new hive needs a new slot. There is no implicit retirement of a Down slot; retiring one would need an explicit administrative operation, and none exists.

A replacement source is authenticated by SeTcbPrivilege, not by process identity. Nothing records a pid, and nothing could usefully: process identity does not survive a crash and restart.

5.8.2.4 Coming back #

On a successful resume the slot becomes Active, its restart generation advances, and LCS replays any layer deletions that were pending.

LCS then delivers OVERFLOW to every armed watch on that source (§5.6.3).

Existing key fds resume working without being reopened. Each carries the restart generation it last saw; when it notices a change it re-reads its key and continues.

If the key's GUID no longer exists in the restarted source — the database was restored from an older backup, say — that first operation returns ENOENT and the fd is marked orphaned.

5.8.3 Request Dispatch

Peios / Peios technical reference / PKM / LCS / Sources

The RSI is multiplexed. LCS sends concurrent requests tagged with request ids and matches responses back to the kernel threads waiting on them. A source may process requests in any order.

Request ids are allocated per connection, strictly increasing, and never reused while the connection lives — including after a timeout.

The id is allocated inside the queue lock, after the in-flight limit has been checked, so a caller queued waiting for a slot does not hold one yet.

MaxConcurrentRSIRequests, default 256, bounds how many requests may be dispatched and awaiting a response at once. It is back-pressure for a slow source.

5.8.3.1 One deadline covers three waits #

RequestTimeoutMs, default 30 seconds, is measured from the point a kernel operation first attempts to reserve an in-flight slot, after local validation and access checks have already passed. One deadline is computed there and reused for all three legs: waiting for a slot, waiting for the source to read the queued request, and waiting for the response.

If the deadline expires before a slot is reserved, the caller gets ETIMEDOUT and no request is sent.

If it expires after dispatch, the caller gets ETIMEDOUT and late-response handling applies (§5.8.5).

The deadline is checked before admission is attempted, not only after a contention round is lost. It used to be the latter, which meant a request finding a slot immediately free was dispatched with an already-expired deadline and timed out in the wait leg instead — so the rule above held only under contention, the one case where it is hardest to observe.

5.8.3.2 Timed-out requests keep their slot #

For every dispatched request LCS keeps a request record until a matching response is processed or the connection is torn down.

The record holds the request id, the operation code, the transaction id, the key GUID it concerns, the runtime limits in force, and any retained effect the kernel will need if the source later reports success.

When the deadline expires after dispatch, LCS detaches the waiting caller from the record and returns ETIMEDOUT.

A signal that interrupts the wait after dispatch detaches the caller the same way, and the call fails with EINTR or is restarted. As after a timeout, the source may still apply the request when it answers. A response that completed before the caller could be detached wins: the caller gets that response's result, with the signal still pending.

The record stays in the in-flight table and keeps counting against MaxConcurrentRSIRequests. A timeout does not free a slot; only a response or a teardown does.

A source that accumulates timed-out requests can therefore exhaust its own in-flight slots until it answers or disconnects. That is the intended shape: a source that stops answering stops being usable.

5.8.3.3 Requests with no caller #

LCS dispatches some requests with nobody waiting: RSI_DROP_KEY after the last fd to an orphaned key closes (§5.2.9), and RSI_ABORT_TRANSACTION cleaning up source transaction state.

Such a record occupies an in-flight slot and is retained like any other, and its response is validated normally and released normally.

But it is not a late response, and the retained-effect recovery rules do not apply to it merely because nobody is waiting.

The kernel tracks the difference explicitly, with two booleans: whether a waiter is attached now, and whether one was ever attached. A record that never had a caller is not a timed-out request; only one whose caller was detached after its deadline is.

This is load-bearing rather than pedantic. RSI_DROP_KEY is a mutating operation, so without the distinction a perfectly ordinary answer to a caller-less cleanup request would look like a mutation the kernel could not account for, and would tear the source down.

5.8.3.4 The channel #

/dev/pkm_registry is message-oriented. One read() returns exactly one complete request; a buffer too small for the next one returns EMSGSIZE without consuming it.

An empty queue blocks, or returns EAGAIN under O_NONBLOCK, or returns 0 if the fd is closing.

One write() submits exactly one complete response, and its length must equal the response's own total_len exactly.

poll reports the fd readable when a request is queued, writable while the slot is Active, and POLLHUP | POLLERR when the slot is Down or the fd is closing.

An fd that is open but has not yet registered reports nothing at all.

Any rejected write() — short, over-long, an unknown or duplicate request id, an operation code that does not match the request, a response for another connection — returns EINVAL and tears the connection down. A source that cannot speak the protocol correctly is not one whose other answers are worth believing.

5.8.3.5 Negotiated lifetime control #

Sources may negotiate lifetime extension version 1 with REG_SRC_NEGOTIATE before REG_SRC_REGISTER. Pass version 1, zero flags and zero session; the kernel returns a nonzero session identifier unique to the open source file. The RSI request and response frame layouts are unchanged. Sources that do not negotiate retain the legacy protocol; current loregd requires this extension.

After reading a request and before responding, REG_SRC_REQUEST_CONTEXT takes that session and request id and returns three absolute CLOCK_MONOTONIC nanosecond timestamps: the request deadline, transaction lifetime deadline and cleanup lease deadline. Zero lifetime means an operation-owned transaction with no separate lifetime ceiling. Zero lease means there is no live transaction owner. Request metadata remains queryable after caller detachment until the response is accepted, so cleanup and late-response handling still work.

REG_SRC_RENEW takes the session and transaction id and returns the original lifetime deadline plus a renewed cleanup deadline, at most five seconds from now and never beyond the lifetime deadline. It succeeds only for an existing, nonterminal transaction owned by a live file object or kernel operation on this source session. Unknown, released, expired and foreign-session identities return ESTALE. Once a lease expires, renewal cannot resurrect it. These control ioctls consume no ordinary request slots, so request-queue saturation does not starve renewal.

Final owner release and abort prevent renewal before abort-message delivery. Client lifetime expiration also prevents renewal. Sources must reject new operations after expiry and serialize rollback with operations and commit; an already-admitted commit must resolve its actual outcome. Lease cleanup does not change the kernel's treatment of late commit responses (§5.8.5).

5.8.4 Validation

Peios / Peios technical reference / PKM / LCS / Sources

LCS validates every response before using it, and the failures split into two categories with very different consequences.

5.8.4.1 Malformed data #

The RSI message is structurally valid but its content is not: a descriptor that will not parse, a value type that does not exist, a sequence number that cannot be real, a metadata block that does not cover the GUIDs it should.

  • The request returns EIO to its caller.
  • An lcs.source.response.rejected audit event is emitted, naming the source slot and — where known — the hive, the request id, the operation code, the key GUID, and which of the twelve validation classes applies (§5.4.4).
  • The source stays alive. Corruption may be localised, and one bad key is not a reason to take a hive offline.

What is checked, by category:

  • Security Descriptors, from lookups and from layer metadata refreshes, must parse and must satisfy the ACE mask rules of §5.4.2. A malformed layer metadata descriptor additionally leaves the previous known-good one cached (§5.3.3).
  • Names — layer names, key and child names, value names — must be valid under the ordinary rules for their kind.
  • Sequence numbers must be below the next number LCS would allocate, and must not duplicate at the same precedence in a way that would decide a winner (§5.3.6).
  • Payload shape — an otherwise-matched response whose operation-specific payload is the wrong shape, carries trailing bytes, or encodes a path target invalidly.
  • Metadata closure — a lookup or enumeration whose per-GUID metadata block has missing, duplicate, unreferenced or nil entries. A HIDDEN entry must carry an all-zero GUID and contributes no metadata.
  • Value payloads — invalid types, a tombstone carrying data, data above MaxValueSize.
  • Orphan lists — a nil or duplicated GUID in an RSI_DELETE_LAYER response.
  • Status codes outside the defined vocabulary.

5.8.4.2 Malformed protocol #

The message itself is structurally invalid: bad framing, a truncated response, an unknown request id, a duplicate response, an operation code that does not match the request.

This is treated as a source crash. The connection is torn down, the in-flight table is destroyed with every waiter completed EIO, the slot is marked Down, its hives become unavailable, and bound transactions enter SOURCE_DOWN.

There is one case where malformed data also takes the source down: when the caller had already timed out and the operation was a commit or a replayable mutation. At that point LCS cannot establish whether a mutation was applied, and it cannot account for one it cannot describe (§5.8.5).

5.8.4.3 Asymmetric extensibility #

Requests and responses do not extend the same way.

A request may carry trailing fields a source does not recognise; a source skips them using total_len. That is how a new optional field is added without an RSI version bump.

A response may not. LCS rejects any trailing bytes in a response payload as malformed data. Forward compatibility on the response side comes from new operations, not from extending existing payloads.

5.8.5 Failure and Late Responses

Peios / Peios technical reference / PKM / LCS / Sources

5.8.5.1 When a source dies #

The connection closes unexpectedly, and:

  1. The slot is marked Down and its hives become unavailable.
  2. Every pending request fails EIO, including ones queued but not yet delivered.
  3. Open key fds stay valid. An fd holds a GUID and a granted mask, and neither depends on the source. Operations needing a round trip return EIO until the source comes back.
  4. Bound transactions enter REG_TXN_SOURCE_DOWN, their poll waiters are woken with POLLERR | POLLHUP, their mutation logs are released, and further use of those fds returns EIO.
  5. Watches stay armed. Watch state is kernel-side and does not depend on the source at all. Nothing is delivered during the window, and OVERFLOW arrives on re-registration rather than on disconnect (§5.6.3).

Coming back is covered in §5.8.2.

5.8.5.2 The late response problem #

A caller that times out after its request was dispatched is gone, but the request is not. The source may still apply the operation and answer minutes later, and by then there is nobody to return a value to — while the kernel still has work to do, because a mutation that succeeded has to produce its generation increment and its watch events.

This is the most intricate part of LCS and the part with the most ways to be subtly wrong.

A late response is validated exactly like an on-time one. The rules that follow apply only to a request whose caller was detached after its deadline, never to one that never had a caller (§5.8.3).

5.8.5.2.1 A late error #

The record is released. No watch events, no generation change, nothing.

5.8.5.2.2 A late successful read #

Validated, then discarded. There is nobody to give it to and nothing about it changes kernel state.

5.8.5.2.3 A late successful mutation #

The kernel-side effects that correspond to the mutation are applied from the retained record: the hive generation increment, watch dispatch, the layer metadata cache refresh, and — for a commit — the transaction's batch effects and orphan tracking. The watch dispatch takes the recovery form: a watcher receives an OVERFLOW ahead of the event itself (§5.6.3).

Which mutations can actually be replayed is narrower than the set of operations that count as mutating. A replayable effect is recorded for RSI_SET_VALUE and RSI_WRITE_KEY, and only for non-transactional calls.

For the other mutating operations — creating, hiding or deleting a path entry, creating or dropping a key, deleting a value entry, setting a blanket tombstone, deleting a layer — no effect was retained, so a late success is a mutation the kernel cannot account for. LCS tears the source down and returns EIO rather than silently ignoring it.

That is the conservative direction, and deliberately so: the alternative is a committed change with no watch event and a stale generation number, which nothing downstream could detect.

5.8.5.2.4 A late successful transaction operation #

A late RSI_BEGIN_TRANSACTION has created transaction state in the source that nobody will ever use, so LCS enqueues an RSI_ABORT_TRANSACTION for that id.

A late RSI_ABORT_TRANSACTION or RSI_FLUSH releases the record with no effects.

A late RSI_COMMIT_TRANSACTION is a mutating response and applies the retained commit effects — the same generation update and the same watch events an on-time commit would have produced. Watchers may therefore observe the effects of a transaction whose caller was told it timed out (§5.7.3).

5.8.5.2.5 A malformed late response #

The ordinary malformed-data and malformed-protocol rules apply. But if LCS cannot safely process the kernel-side effects of a possibly-applied mutation because the request metadata it needs is missing or invalid, it tears the source down and marks it Down rather than ignoring the response.

A malformed late commit response takes the source down for the same reason.

5.8.5.3 What a caller should conclude #

ETIMEDOUT means may or may not have completed. A caller that needs certainty reads state back before retrying. That applies to every operation and, especially, to a transaction commit.

5.8.5.4 Fd lifecycle #

Key fds and transaction fds are ordinary file descriptors, subject to RLIMIT_NOFILE. There is no registry-specific fd accounting and no registry-specific leak protection.

Process exit closes them through normal kernel cleanup: key fds released and their watches removed, transactions aborted.

5.8.5.5 Memory #

Registry kernel memory needs no global cap, because everything is bounded by limits that already exist.

  • Watch queues: NotificationQueueSize per queue, times RLIMIT_NOFILE queues per process.
  • Open key state: per-fd overhead — GUID, granted mask, ancestor chain, watch state — times the same fd limit.
  • Layer table: bounded by MaxTotalLayers, and per-value resolution cost by MaxLayersPerValue.
  • In-flight requests: bounded per source by MaxConcurrentRSIRequests, and separately by the number of threads blocked on registry syscalls.

5.9.1 The Stream

Peios / Peios technical reference / PKM / LCS / Backup and Restore

The registry backup format is a streamable binary representation of a key and everything beneath it, with full layer fidelity. It is used by REG_IOC_BACKUP and REG_IOC_RESTORE, by first-boot seeding, by disaster recovery and by offline migration.

It is an LCS-level format. Sources never see it: LCS serialises from source data on the way out and deserialises into RSI operations on the way in.

Because a third party writes and reads these streams directly — that is what migration and recovery mean — the format is a normative specification rather than a description. It is a chapter of PSPK, and every byte layout, record type, ordering rule and validation requirement lives there. This section covers what LCS does with it.

5.9.1.1 What the design buys #

Streamable. It is written to an arbitrary fd — a file, a pipe, a socket — with no seeking, in a single pass, and read back the same way.

Full layer fidelity. Every path entry, value, tombstone and blanket tombstone is stored with its layer tag, so restoring reconstructs the layered state rather than a flattened snapshot of it.

Depth-first pre-order. A parent always appears before its children, so a restore can create keys top-down without buffering a tree.

Descriptors inline. Each key record carries its own descriptor with no deduplication. Redundancy is external compression's problem; piping through zstd handles it.

Self-verifying. A trailer carries a record count and a SHA-256 over everything before it, so truncation and corruption are detected.

5.9.1.2 Versioning #

The header carries a format version and a minimum reader version. A writer that used only older features sets a lower minimum, letting older readers restore the stream.

A reader that finds a minimum above its own supported version rejects the stream outright, before touching anything.

Both are 21 in the current implementation, and the reader supports 21.

Unknown record types are skipped when the minimum reader version allows it, and they still count toward the record count and the checksum. A writer that adds a record type a restore genuinely needs must raise the minimum reader version, so that an older reader refuses the stream rather than restoring an incomplete one.

Extension is by new record types only. Existing record payloads must be consumed exactly; trailing bytes inside a known record are an error. This is the opposite of the RSI's request convention (§5.8.4), and the difference is deliberate: a stream is replayed into mutations long after it was written, and a field silently ignored there is data silently lost.

5.9.2 Backup

Peios / Peios technical reference / PKM / LCS / Backup and Restore

REG_IOC_BACKUP exports the key on the fd and its entire subtree to another fd, preserving parent/child relationships even when traversal must grow its internal storage.

It requires SeBackupPrivilege and performs no per-key AccessCheck whatsoever. The privilege is the whole authorisation, which is why the operation is audited unconditionally (§5.4.4).

The output fd must be writable, or EBADF.

5.9.2.1 The snapshot #

Before reading anything, LCS opens a read-only source transaction — RSI_BEGIN_TRANSACTION with mode RSI_TXN_READ_ONLY — so that the whole export is a point-in-time snapshot. Concurrent mutations do not appear part-way through the stream.

That transaction is released with RSI_ABORT_TRANSACTION and never committed. There is no commit call anywhere in the backup path; a read-only transaction has nothing to commit.

A source that does not support read-only snapshots answers RSI_TXN_NOT_SUPPORTED and the backup fails ENOTSUP.

A source already holding MaxReadOnlyTransactionsPerSource snapshots (default 16) yields EBUSY — which bounds snapshot-holding without treating backups as write-lock holders, since they are not.

Backing up an orphaned key is ENOENT: it is no longer a reachable subtree root (§5.2.9).

5.9.2.2 Audit #

lcs.audit.backup.started is emitted before any subtree data is read, and if it cannot be emitted the backup returns EIO and does not start.

After privilege, output-fd and key validation, START precedes the request for the read-only snapshot. A refused or failed snapshot therefore emits both START and END, with END carrying the failure errno; no subtree is exported and no unopened snapshot is released. A START emission failure prevents the snapshot request itself.

lcs.audit.backup.ended is emitted afterwards, carrying the result, and a failure to emit it cannot change a result that has already happened.

5.9.2.3 What is written #

The stream is a header, the layer manifest, then each key in depth-first pre-order with its path entries, values and blanket tombstones, then the trailer. The exact shapes are in the PSPK chapter.

Two things about the exporter are worth stating here, because they constrain a reader more tightly than the format does.

The exporter writes no GUID-bearing path entries for the backup root. The root's section contains only its hidden entries.

A reader tolerates and skips them if some other writer produces them, but this one does not emit them, because on restore the target key's existing name is authoritative and they would be discarded anyway.

Hidden entries belong to the parent's section, not to a section of their own — a hidden entry has no key to have a section for.

A hidden entry masking a name where no key exists in any layer is still valid; it expresses "this layer hides this name" whether or not anything is there to hide.

The layer manifest is written from the live layer table, and it is a manifest: it records what the layers looked like at backup time so that a restore can validate the stream against them. It is not a backup of the layer definitions.

A layer definition is backed up only when Machine\System\Registry\Layers\<Name>\ is itself inside the subtree being exported, in which case it is ordinary key and value data like anything else.

This includes definitions for unused layers in a whole-hive export. Their metadata remains ordinary registry data; an unused definition does not by itself require an entry in the layer manifest.

5.9.3 Restore

Peios / Peios technical reference / PKM / LCS / Backup and Restore

REG_IOC_RESTORE replaces the key on the fd and its entire subtree from a stream. It is not a merge: the target's contents and descendants are torn down before the stream's contents are written.

It requires SeRestorePrivilege and, like backup, performs no per-key AccessCheck and is audited unconditionally.

The input fd must be readable, or EBADF. Restoring onto an orphaned key is ENOENT.

Because restore rewrites every descriptor in the subtree, SeRestorePrivilege effectively confers WRITE_DAC and WRITE_OWNER over everything within reach of one (§5.8.1).

5.9.3.1 One transaction #

The entire restore — teardown and rebuild together — is wrapped in a single read-write source transaction.

A source that answers RSI_TXN_NOT_SUPPORTED for RSI_TXN_READ_WRITE cannot be a restore target: restore requires atomicity and there is no partial-restore mode.

Every failure path aborts the transaction, so a failed restore rolls back the teardown as well.

5.9.3.2 The target key survives #

The stream's header names a root GUID, and that GUID is remapped to the already-open target key everywhere it appears — in parent references, in child references, in value key references — before anything is validated or dispatched.

The target key object is not replaced. Its GUID, parent, name, volatile flag and symlink flag remain what they were; they are never taken from the stream.

What the stream's root record supplies is the mutable part: the Security Descriptor and the last write time, written to the target with RSI_WRITE_KEY inside the transaction.

The root record's immutable flags must match the target's. A backup of a volatile key restored onto a non-volatile one, or a symlink onto a non-symlink, is EINVAL.

Descendants keep their backup GUIDs. Those are written into the target verbatim.

5.9.3.3 Order of operations #

  1. Validate the whole stream, including the trailer's record count and checksum, and retain every replayable record.
  2. Read the layer manifest and apply the precedence gate (below).
  3. Verify the root record and its immutable flags against the live target.
  4. Tear down the target's contents and descendants — path entries, values, blanket tombstones, and descendant key records — inside the transaction.
  5. Write the root's mutable fields, then replay its section's values and blanket tombstones.
  6. For each non-root key in stream order: create it, write its last write time, then replay its path entries, values and blanket tombstones.
  7. Commit.

The stream is read from the fd exactly once, sequentially. Nothing seeks, so a pipe is a valid input.

Validating the whole stream first is stronger than the format requires: a checksum failure aborts before any source mutation rather than after some. The cost is memory rather than seeking — the replayable records are retained in kernel memory across the teardown.

5.9.3.4 The precedence gate #

Before any key record is written, LCS checks the layer manifest. If any declared layer has a precedence above 0, or any existing cached layer table entry with the same folded identity does, and the caller does not hold SeTcbPrivilege, the restore aborts with EPERM before a single byte reaches the source.

And if the stream contains ordinary key and value records for Machine\System\Registry\Layers\<Name>\, writes that create or raise persisted metadata above precedence 0 hit the ordinary inline SeTcbPrivilege check as well (§5.3.4).

Both exist so that SeRestorePrivilege cannot be used to smuggle a Group Policy-tier layer past the defence in depth that guards precedence.

5.9.3.5 Layers in a restored stream #

Manifest records create, update, delete, enable, disable and authorise nothing.

If restored entries reference a layer that is not in the current table, and the stream does not also restore that layer's metadata subtree as ordinary registry data, those entries become latent unknown-layer entries and are ignored during resolution until real metadata exists (§5.3.6).

If the metadata subtree is included, it is restored through the ordinary path, and those records — not the manifest — are what define the layer.

5.9.3.6 Sequence remapping #

Backup sequence numbers preserve the backup's internal ordering, but a restore is a new mutation and its entries must outrank everything already present. So the numbers are remapped rather than written through.

Before dispatching the first layer-qualified record, LCS takes the global sequence-allocation gate and records the current next sequence as the offset.

Every restored record is then written with offset + backup_sequence, which preserves relative order while placing the whole set above pre-restore state.

The gate is held until the restore reaches a terminal state. Other sequence-allocating mutations wait; reads are not blocked.

A restore with no layer-qualified records at all never takes it.

If a remapped value would overflow, the restore fails EOVERFLOW — and it fails at validation, before teardown, rather than part-way through. The valid remapped range stops just below U64_MAX, which is never handed out (§5.3.7).

When the restore reaches any terminal state — commit, abort, failure or cancellation — LCS advances the global counter past the highest number it dispatched, and does not roll that back. Sequence numbers a failed restore dispatched become unused gaps, exactly like those of any other failed write.

5.9.3.7 GUID collisions #

A GUID that appears twice in one stream, other than the root, is EINVAL. So is a non-root GUID equal to the target root's.

A non-root GUID that already exists outside the subtree being replaced is EEXIST — but it is discovered by the source rejecting the create during replay, not by a check beforehand. LCS has no index of which GUIDs exist elsewhere, so the collision surfaces mid-restore, and the transaction rolls back.

The parent of every path entry, after remapping, must be either the restore root or a key record already processed earlier in the stream. That check is made up front, and it is what stops a crafted backup injecting path entries into arbitrary parts of the existing namespace outside the subtree being replaced.

5.9.3.8 Watches #

A restore is an arbitrary subtree replacement and LCS retains no exact before-and-after diff for it. On a successful commit it publishes the affected hive's generation increment and dispatches a no-name OVERFLOW to the armed watches on that source (§5.6.3).

A restore that fails or aborts before commit emits nothing.

5.10.1 The Bootstrap Problem

Peios / Peios technical reference / PKM / LCS / Bootstrap and Self-Configuration

The registry is the configuration store for the whole system, and it has to configure itself. Three circular dependencies have to be broken before it can serve anyone.

peinit needs the registry to start services, but the registry source is a service. Service definitions live in the registry, and the process that would serve them has not started.

LCS needs operational parameters from the registry, but the registry needs LCS. Timeouts, caps and limits live under Machine\System\Registry\, in a hive LCS itself routes.

A fresh install has no data at all. The source's database is empty; there is nothing to read.

Three rules break all three.

5.10.1.1 Rule 1: compiled-in defaults #

Every operational parameter has a compiled-in default, and LCS runs on those defaults from the moment PKM initialises.

There is no "waiting for configuration" state, no flag, and no wait queue: the limits structure is statically initialised at load, the source char device is registered without consulting configuration, and the first attempt to read configuration happens on a workqueue after a source has already registered.

Before any source registers, the routing table is empty, so every operation that names a hive returns ENOENT. That is the only sense in which LCS is not yet useful, and it is not a distinct state — it is just an empty table.

5.10.1.2 Rule 2: the base layer exists unconditionally #

The base layer is a static constant in the kernel: name base, precedence 0, enabled. It is handed out whenever the dynamic layer table is empty and is always written first into every layer snapshot.

A source that registers with an entirely empty database is fully functional, because the one layer that writes need is not in the database. Persisted metadata under Machine\System\Registry\Layers\base\ may exist and may decorate the base layer, but it is not required and cannot contradict it (§5.3.2).

5.10.1.3 Rule 3: hot-swap, not restart #

When configuration becomes available, LCS reads it, validates it, and swaps the values in place. It does not restart, re-initialise, or block. The whole transition from compiled-in defaults to registry-backed configuration is driven by the internal self-watch (§5.10.4).

5.10.1.4 Source dependencies are the source's problem #

LCS neither knows nor cares what a source depends on. A source that needs the root filesystem, a SYSTEM token, or a particular kernel feature arranges that for itself. LCS's only requirement is that the process can open /dev/pkm_registry and speak RSI.

5.10.2 The Boot Sequence

Peios / Peios technical reference / PKM / LCS / Bootstrap and Self-Configuration

5.10.2.1 Normal boot #

Kernel boots
  → PKM initialises; LCS runs on compiled-in defaults
  → /dev/pkm_registry registered
  → the base layer exists in memory

A source registers — loregd, started by peinit
  → opens /dev/pkm_registry (SeTcbPrivilege checked at open)
  → REG_SRC_REGISTER: hive names, root GUIDs, max persisted sequence
  → LCS initialises or advances next_sequence to max + 1

Bootstrap refresh is queued
  → resolve Machine\System\Registry     → read and hot-swap parameters
  → resolve Machine\System\Registry\Layers → populate the layer table
  → arm internal subtree watches

The bootstrap refresh runs on a workqueue after REG_SRC_REGISTER returns, so registration never blocks on configuration and a source that registers can start answering immediately.

The refresh is triggered by the arrival of a global hive named Machine. That name is matched case-insensitively in the kernel and is one of the two hive names LCS knows about; the other is Users, the target of CurrentUser\ rewriting (§5.2.1). Neither is a routing decision — routing is entirely dynamic — but the claim that the kernel holds no hive names at all would not be true.

5.10.2.2 First boot #

An empty source has no Machine\System\Registry to read.

Source detects an empty database on first startup
  → generates root GUIDs for the hives it backs
  → creates root key records with their default SDs
  → persists them, then registers with LCS

LCS resolves Machine\System\Registry → does not exist
  → compiled-in defaults retained
  → subtree watch armed on the Machine\ hive root instead

peinit notices the registry is empty and restores the seed backup.
That is peinit's decision, not LCS's.

Seed restore populates Machine\ through REG_IOC_RESTORE
  → the fallback subtree watch fires
  → LCS re-runs the bootstrap refresh: resolves the specific GUIDs,
    re-arms targeted watches, validates and hot-swaps the seed values,
    and re-reads layer metadata

Hive root Security Descriptors are set by the source, not by LCS. LCS holds no template for them and enforces whatever the source stores (§5.4.3).

5.10.2.3 Watch arming in practice #

The spec-level description above says the fallback watch is armed instead of the targeted ones. What the kernel actually arms is a mixed set: targeted watches on whichever of the two roots exist, plus the Machine\ root fallback. The fallback is a superset rather than a substitute, and it stays armed until a refresh finds both roots present.

A third internal watch is armed by the same sequence, on Machine\System\KMES, which is not LCS configuration at all — it is how KMES picks up its own parameters from the registry LCS serves.

5.10.2.4 The bootstrap contract #

Four properties hold throughout and are relied on elsewhere:

  1. LCS is always operational with compiled-in defaults. It accepts syscalls from the moment PKM initialises.
  2. The base layer requires no persisted state.
  3. Hot-swap is the only configuration transition. LCS never blocks, restarts, or re-initialises.
  4. Source dependencies are the source's concern.

5.10.3 Operational Parameters

Peios / Peios technical reference / PKM / LCS / Bootstrap and Self-Configuration

LCS reads nineteen parameters from Machine\System\Registry\.

All are REG_DWORD. Each has a compiled-in default and a valid range, and LCS runs on the defaults until the registry says otherwise.

ValueDefaultRangeBounds
RequestTimeoutMs300001000–600000A source round trip (§5.8.3).
TransactionTimeoutMs300001000–600000The lifetime of an open transaction (§5.7.1).
NotificationQueueSize25616–65536Queued events per watcher before overflow (§5.6.4).
SymlinkDepthLimit161–64Symlink resolution depth (§5.2.4).
MaxValueSize10485764096–67108864One value's data, in bytes.
MaxKeyDepth51232–4096Key hierarchy nesting.
MaxPathComponentLength25564–1024One key, value or layer name, in UTF-8 bytes.
MaxTotalPathLength163831024–65535A whole path, in UTF-8 bytes.
MaxLayersPerValue1281–1024Layers writing to one (key, value name) (§5.3.1).
MaxBoundTransactionsPerSource161–256Concurrently bound transactions per source (§5.7.1).
MaxReadOnlyTransactionsPerSource161–256Concurrent backup snapshots per source (§5.9.2).
MaxTotalLayers102416–65536Distinct layers in the in-memory table (§5.3.1).
MaxRegisteredSources321–256Concurrently registered sources.
MaxHivesPerSource641–1024Hives one source may register.
MaxConcurrentRSIRequests2568–4096In-flight RSI requests per source (§5.8.3).
MaxScopeGUIDsPerToken81–256Private hive scope GUIDs on a token (§5.2.2).
MaxPrivateLayersPerToken161–256Private layer names on a token (§5.3.5).
MaxSubtreeWatchDepth00–4096Subtree watch depth; 0 is unlimited (§5.6.3).
MaxTransactionWatchEventBurst4096256–65536Watch events per watcher from one commit (§5.6.3).

Unknown values under this key are ignored. There are exactly nineteen parameters and no undocumented ones; a separate set under Machine\System\KMES\ belongs to KMES.

Two limits are not among them. The transaction mutation log is capped at 4096 entries by a compile-time constant (§5.7.2), and hard ceilings on total path length and key depth exist independently of configuration — set equal to the range maxima above, so they never conflict.

5.10.3.1 Where a configured value does not fully bind #

One of the nineteen does not do everything its range suggests.

MaxPrivateLayersPerToken is described as an attachment-time limit but is not enforced at attachment. KACS applies its own hard cap of 256 and LCS applies the configured value later, at use, with E2BIG (§5.3.5). MaxScopeGUIDsPerToken behaves the same way.

5.10.3.2 Validation #

A value is checked against its range when it is read.

  • Valid — hot-swapped into the in-memory configuration and used by new operations.
  • Invalid — out of range, the wrong type, or missing — the value is ignored and the previously active one is kept: the compiled-in default or the last known-good. An lcs.config.value.rejected audit event is emitted naming the parameter, what was wrong, and the value being retained (§5.4.4).

Values are never clamped or silently corrected. There is no min/max on any configuration path. A write to the registry succeeds, because the source does not enforce kernel semantics, and LCS simply refuses to use it. The registry shows what was written; the audit log shows what LCS is running on.

Because "missing" is invalid, a first boot before seed restore emits nineteen of these events per refresh — provided Machine\System\Registry itself exists. If the key is absent there is nothing to read and nothing is reported; the fallback watch of §5.10.4 waits for it.

5.10.3.3 Hot-swap and in-flight operations #

Configuration is published as a whole structure under a seqlock, and a reader takes a complete copy of it.

A syscall entry point snapshots it once and threads that snapshot through the operation, so in-flight work uses the values that were current when it started and new work uses the updated ones.

That is the rule, and mostly the practice. Some deeper paths take a second snapshot part-way through, and a few call sites read a single live value rather than a snapshot — reg_begin_transaction's timeout, the bound-transaction cap, and the in-flight request cap among them. For those, a hot-swap can be observed mid-operation.

5.10.3.4 Security #

Machine\System\Registry\ inherits the Machine hive root descriptor — SYSTEM and Administrators with KEY_ALL_ACCESS, Authenticated Users with KEY_READ — so an unprivileged process cannot change any of this.

Domain policy at a higher-precedence layer defends against a compromised local administrator, which is the reason SeTcbPrivilege guards precedence above 0 (§5.3.4).

5.10.4 The Self-Watch

Peios / Peios technical reference / PKM / LCS / Bootstrap and Self-Configuration

LCS watches its own configuration and layer metadata subtrees, and it does so through the same machinery userspace uses — but not through the same interface.

An internal watch is an entry in the same watch map, taking a reference on the same subtree watch set, distinguished only by a kind marker. It has no fd, no granted access mask and no filter.

Events reach it through a kernel callback rather than being queued for a read(), and it is therefore not subject to NotificationQueueSize.

It is also not subject to MaxSubtreeWatchDepth, nor to the transaction burst suppressor: internal collection happens before either test.

Because there is no filter, deliverability is decided per target rather than by a bitmask. Each internal target admits only the event types it cares about: value events on the watched key itself for the configuration subtrees, and subkey events at depth 0 or value and descriptor events at depth 1 for the layer metadata subtree.

5.10.4.1 What it drives #

Self-configuration. A change under Machine\System\Registry\ triggers a re-read and validation of the parameters (§5.10.3).

The layer table. A change under Machine\System\Registry\Layers\ marks the affected layer names dirty and drives a bounded refresh of their precedence, enabled state, owner and cached descriptor. Within one delivery — a transaction's commit, say, whose batch creates a layer and then writes its three values — each dirty layer is refreshed once, however many of the batch's events named it.

Layer lifecycle. SUBKEY_CREATED and SUBKEY_DELETED under Layers\ add and remove layers, except for base, which is ignored (§5.3.2).

KMES configuration. A third internal watch, on Machine\System\KMES\, exists for KMES's own parameters. It is not LCS configuration, but the registry is where it lives and this is the mechanism that notices it change.

5.10.4.2 The callback is not the atomicity boundary #

For layer metadata, internal delivery identifies which layer names are dirty. It does not itself publish anything, and it must not: publishing a layer means publishing its table entry, metadata key GUID and cached descriptor together (§5.3.3), and a callback that published a partial entry would create a window in which a layer exists and nobody can be authorised against it.

LCS also does not perform source round trips while holding the watch-map or layer-table publication locks. The refresh runs outside them, after the mutating operation commits and before the syscall returns.

5.10.4.3 Arming #

At bootstrap, LCS resolves the GUIDs for Machine\System\Registry\ and Machine\System\Registry\Layers\ through RSI_LOOKUP and arms targeted subtree watches.

If either does not exist — first boot, empty database — it arms a subtree watch on the Machine\ hive root instead, so that seed restore creating the subtree is noticed.

That fallback event re-enters the whole bootstrap refresh, which resolves the specific GUIDs and arms the targeted watches.

In practice the kernel arms both: targeted watches for whichever roots exist, plus the Machine\ root fallback, until a refresh finds everything present. It is a superset of what is needed rather than a substitute for it.

The same arming covers every key the kernel reads for itself, not only LCS's own: KMES configuration, the port reservation table (Machine\System\Network\TcpIp\PortReservations\, see the KACS chapter on network objects), the network policy key (Machine\System\Network\, whose rules and inventory the packet engine reads — see the network policy chapter) and the event emission policy (Machine\Generic\Events\, see the KMES chapter) are discovered in the same refresh and get the same targeted-or-fallback treatment.

The two policy watches are the depth-unbounded, every-mutation ones. For the network policy, rules are keys and exceptions are subkeys, so anything written anywhere beneath the key may be policy. The emission policy has a key per event-type segment at any depth, and its watch passes on only changes on Events itself, beneath the kernel's own roots, and to Enabled values (KMES §2.8).

A refresh that fails still arms the fallback. A stage can fail transiently — a source answering a lookup while a concurrent write has half-created a key was the case that found this — and if the refresh simply returned, no watch would exist and no kernel-read key would load for the life of the boot. So when any stage before arming fails, LCS arms the Machine\ root fallback alone: the next subkey creation under the hive re-enters the refresh, which then arms the targeted watches as above.

The failure and the fallback arm are both traced by lcs:lcs_bootstrap_refresh.

5.10.4.4 Bootstrap interaction #

  1. A source registers. LCS reads Machine\System\Registry\*; the keys do not exist; compiled-in defaults are retained.
  2. Seed restore populates them. The subtree watch fires, LCS validates and hot-swaps to the seed values.
  3. Subsequent administrative changes fire the watch again, and LCS validates and hot-swaps, or rejects with an audit event.

At no point is there a state in which LCS is waiting for configuration.

Appendix 5.A LCS ABI Reference

Peios / Peios technical reference / PKM / LCS

Every name, value, offset and size in this appendix is generated from pkm/uapi/pkm/lcs.h by pkm/tools/gen-lcs-abi.py, with ioctl encodings and struct layouts measured by compiling a probe against the real header. Regenerate it whenever the ABI changes; do not edit it by hand. The names here are the ones a program actually compiles against.

What a compiler cannot measure -- which properties belong with their operations rather than here, and the kernel configuration -- is in the notes appendix, §5.B, which this generator does not touch.

5.A.1 Syscall numbers #

Signatures are read from the SYSCALL_DEFINE sites in pkm/lcs/. The PKM UAPI exports the uppercase constants in <pkm/syscall.h>. Peios glibc 2.44-5 and later exports the standard lowercase SYS_reg_* aliases in <sys/syscall.h>; each alias has the same number as its PKM constant.

NumberPKM constantglibc aliasSignature
1100SYS_REG_OPEN_KEYSYS_reg_open_keyreg_open_key(int parent_fd, const char __user *path, u32 desired_access, u32 flags)
1101SYS_REG_CREATE_KEYSYS_reg_create_keyreg_create_key(const struct reg_create_key_args __user *args)
1102SYS_REG_BEGIN_TRANSACTIONSYS_reg_begin_transactionreg_begin_transaction(void)

5.A.2 Ioctls #

The type byte is 'R'. Ioctl number namespaces are per fd type, so REG_SRC_REGISTER (number 0 on the source device) and REG_IOC_QUERY_VALUE (number 0 on a key fd) do not collide: the kernel dispatches on the fd's file_operations, not globally. The encoded value is what _IOC produces from the direction, type byte, number and argument size.

Source device fd.

IoctlNumberDirectionArgumentArg sizeEncoded
REG_SRC_REGISTER0_IOWstruct reg_src_register_args240x40185200
REG_SRC_NEGOTIATE1_IOWRstruct reg_src_negotiate_args160xC0105201
REG_SRC_REQUEST_CONTEXT2_IOWRstruct reg_src_request_context_args400xC0285202
REG_SRC_RENEW3_IOWRstruct reg_src_renew_args320xC0205203

Key fd.

IoctlNumberDirectionArgumentArg sizeEncoded
REG_IOC_QUERY_VALUE0_IOWRstruct reg_query_value_args640xC0405200
REG_IOC_SET_VALUE1_IOWstruct reg_set_value_args640x40405201
REG_IOC_DELETE_VALUE2_IOWstruct reg_delete_value_args400x40285202
REG_IOC_BLANKET_TOMBSTONE3_IOWstruct reg_blanket_tombstone_args240x40185203
REG_IOC_QUERY_VALUES_BATCH4_IOWRstruct reg_query_values_batch_args240xC0185204
REG_IOC_ENUM_VALUES5_IOWRstruct reg_enum_value_args400xC0285205
REG_IOC_ENUM_SUBKEYS6_IOWRstruct reg_enum_subkey_args400xC0285206
REG_IOC_QUERY_KEY_INFO7_IOWRstruct reg_query_key_info_args640xC0405207
REG_IOC_DELETE_KEY8_IOWstruct reg_delete_key_args240x40185208
REG_IOC_HIDE_KEY9_IOWstruct reg_hide_key_args240x40185209
REG_IOC_GET_SECURITY10_IOWRstruct reg_get_security_args160xC010520A
REG_IOC_SET_SECURITY11_IOWstruct reg_set_security_args240x4018520B
REG_IOC_NOTIFY12_IOWstruct reg_notify_args80x4008520C
REG_IOC_FLUSH13_IOnone00x0000520D
REG_IOC_BACKUP14_IOWstruct reg_backup_args40x4004520E
REG_IOC_RESTORE15_IOWstruct reg_restore_args40x4004520F

Transaction fd.

IoctlNumberDirectionArgumentArg sizeEncoded
REG_IOC_COMMIT16_IOnone00x00005210
REG_IOC_TXN_STATUS17_IORstruct reg_txn_status_args80x80085211

5.A.3 Structure layouts #

Offsets and sizes are measured, not declared. The header also defines a _SIZE constant for each of these structures; the two agree by construction, and a mismatch fails the build in uapi/smoke_test.c.

5.A.3.1 struct reg_create_key_args #

Total size 48 bytes.

OffsetSizeTypeField
04__s32parent_fd
44__u32_pad0
88__u64path_ptr
164__u32desired_access
204__u32flags
248__u64layer_ptr
324__s32txn_fd
364__u32_pad1
408__u64disposition_ptr

5.A.3.2 struct reg_query_value_args #

Total size 64 bytes.

OffsetSizeTypeField
04__u32name_len
44__u32_pad0
88__u64name_ptr
164__u32type
204__u32data_len
244__s32txn_fd
284__u32layer_buf_len
328__u64data_ptr
408__u64sequence
484__u32layer_len
524__u32_pad1
568__u64layer_ptr

5.A.3.3 struct reg_set_value_args #

Total size 64 bytes.

OffsetSizeTypeField
04__u32name_len
44__u32_pad0
88__u64name_ptr
164__u32type
204__u32data_len
248__u64data_ptr
324__u32layer_len
364__u32_pad1
408__u64layer_ptr
484__s32txn_fd
524__u32_pad2
568__u64expected_seq

5.A.3.4 struct reg_delete_value_args #

Total size 40 bytes.

OffsetSizeTypeField
04__u32name_len
44__u32_pad0
88__u64name_ptr
164__u32layer_len
204__u32_pad1
248__u64layer_ptr
324__s32txn_fd
364__u32_pad2

5.A.3.5 struct reg_blanket_tombstone_args #

Total size 24 bytes.

OffsetSizeTypeField
04__u32layer_len
44__u32_pad0
88__u64layer_ptr
161__u8set
173__u8``[3]_pad1
204__s32txn_fd

5.A.3.6 struct reg_query_values_batch_args #

Total size 24 bytes.

OffsetSizeTypeField
04__u32buf_len
44__u32count
88__u64buf_ptr
164__s32txn_fd
204__u32_pad

5.A.3.7 struct reg_enum_value_args #

Total size 40 bytes.

OffsetSizeTypeField
04__u32index
44__u32name_len
88__u64name_ptr
164__u32type
204__u32data_len
248__u64data_ptr
324__s32txn_fd
364__u32_pad

5.A.3.8 struct reg_enum_subkey_args #

Total size 40 bytes.

OffsetSizeTypeField
04__u32index
44__u32name_len
88__u64name_ptr
168__u64last_write_time
244__u32subkey_count
284__u32value_count
324__s32txn_fd
364__u32_pad

5.A.3.9 struct reg_query_key_info_args #

Total size 64 bytes.

OffsetSizeTypeField
04__u32name_len
44__u32_pad0
88__u64name_ptr
168__u64last_write_time
244__u32subkey_count
284__u32value_count
324__u32max_subkey_name_len
364__u32max_value_name_len
404__u32max_value_data_size
444__u32sd_size
481__u8volatile_key
491__u8symlink
506__u8``[6]_pad1
568__u64hive_generation

5.A.3.10 struct reg_delete_key_args #

Total size 24 bytes.

OffsetSizeTypeField
04__u32layer_len
44__u32_pad0
88__u64layer_ptr
164__s32txn_fd
204__u32_pad1

5.A.3.11 struct reg_hide_key_args #

Total size 24 bytes.

OffsetSizeTypeField
04__u32layer_len
44__u32_pad0
88__u64layer_ptr
164__s32txn_fd
204__u32_pad1

5.A.3.12 struct reg_get_security_args #

Total size 16 bytes.

OffsetSizeTypeField
04__u32security_info
44__u32sd_len
88__u64sd_ptr

5.A.3.13 struct reg_set_security_args #

Total size 24 bytes.

OffsetSizeTypeField
04__u32security_info
44__u32sd_len
88__u64sd_ptr
164__s32txn_fd
204__u32_pad

5.A.3.14 struct reg_notify_args #

Total size 8 bytes.

OffsetSizeTypeField
04__u32filter
41__u8subtree
53__u8``[3]_pad

5.A.3.15 struct reg_backup_args #

Total size 4 bytes.

OffsetSizeTypeField
04__s32output_fd

5.A.3.16 struct reg_restore_args #

Total size 4 bytes.

OffsetSizeTypeField
04__s32input_fd

5.A.3.17 struct reg_txn_status_args #

Total size 8 bytes.

OffsetSizeTypeField
04__u32state
44__s32terminal_errno

5.A.3.18 struct reg_src_register_args #

Total size 24 bytes.

OffsetSizeTypeField
04__u32hive_count
44__u32_pad
88__u64max_sequence
168__u64hives_ptr

5.A.3.19 struct reg_src_negotiate_args #

Total size 16 bytes.

OffsetSizeTypeField
04__u32version
44__u32flags
88__u64session

5.A.3.20 struct reg_src_request_context_args #

Total size 40 bytes.

OffsetSizeTypeField
08__u64session
88__u64request_id
168__u64request_deadline_ns
248__u64lifetime_deadline_ns
328__u64lease_deadline_ns

5.A.3.21 struct reg_src_renew_args #

Total size 32 bytes.

OffsetSizeTypeField
08__u64session
88__u64transaction_id
168__u64lifetime_deadline_ns
248__u64lease_deadline_ns

5.A.3.22 struct reg_src_hive_entry #

Total size 56 bytes.

OffsetSizeTypeField
04__u32name_len
44__u32_pad0
88__u64name_ptr
1616__u8``[16]root_guid
324__u32flags
364__u32_pad1
4016__u8``[16]scope_guid

5.A.4 Constants #

Grouped as the header groups them.

Optional RSI lifetime extension, negotiated before REG_SRC_REGISTER. All timestamps use CLOCK_MONOTONIC nanoseconds. Zero lifetime means an operation-owned transaction with no independent lifetime limit. The session is unique to this open source fd, including across restarts.

ConstantValue
REG_SRC_LIFETIME_VERSION1

Syscall and ioctl argument sizes (Kernel TRM section 5.A, LCS ABI).

ConstantValue
REG_CREATE_KEY_ARGS_SIZE48
REG_QUERY_VALUE_ARGS_SIZE64
REG_SET_VALUE_ARGS_SIZE64
REG_DELETE_VALUE_ARGS_SIZE40
REG_BLANKET_TOMBSTONE_ARGS_SIZE24
REG_QUERY_VALUES_BATCH_ARGS_SIZE24
REG_ENUM_VALUE_ARGS_SIZE40
REG_ENUM_SUBKEY_ARGS_SIZE40
REG_QUERY_KEY_INFO_ARGS_SIZE64
REG_DELETE_KEY_ARGS_SIZE24
REG_HIDE_KEY_ARGS_SIZE24
REG_GET_SECURITY_ARGS_SIZE16
REG_SET_SECURITY_ARGS_SIZE24
REG_NOTIFY_ARGS_SIZE8
REG_BACKUP_ARGS_SIZE4
REG_RESTORE_ARGS_SIZE4
REG_TXN_STATUS_ARGS_SIZE8
REG_SRC_NEGOTIATE_ARGS_SIZE16
REG_SRC_REQUEST_CONTEXT_ARGS_SIZE40
REG_SRC_RENEW_ARGS_SIZE32
REG_SRC_REGISTER_ARGS_SIZE24
REG_SRC_HIVE_ENTRY_SIZE56

Transaction state codes.

ConstantValue
REG_TXN_ACTIVE_UNBOUND0
REG_TXN_ACTIVE_BOUND1
REG_TXN_COMMITTED2
REG_TXN_ABORTED3
REG_TXN_TIMED_OUT4
REG_TXN_SOURCE_DOWN5

Syscall flags and dispositions.

ConstantValue
REG_OPEN_LINK0x01
REG_OPTION_VOLATILE0x01
REG_OPTION_CREATE_LINK0x02
REG_CREATED_NEW1
REG_OPENED_EXISTING2

Registry key access rights.

ConstantValue
KEY_QUERY_VALUE0x00000001
KEY_SET_VALUE0x00000002
KEY_CREATE_SUB_KEY0x00000004
KEY_ENUMERATE_SUB_KEYS0x00000008
KEY_NOTIFY0x00000010
KEY_CREATE_LINK0x00000020
DELETE0x00010000
READ_CONTROL0x00020000
WRITE_DAC0x00040000
WRITE_OWNER0x00080000
ACCESS_SYSTEM_SECURITY0x01000000
MAXIMUM_ALLOWED0x02000000
GENERIC_ALL0x10000000
GENERIC_EXECUTE0x20000000
GENERIC_WRITE0x40000000
GENERIC_READ0x80000000
KEY_READ0x00020019
KEY_WRITE0x00020006
KEY_ALL_ACCESS0x000F003F
REG_VALID_DESIRED_ACCESS_MASK0xF30F003F
REG_VALID_MAPPED_ACCESS_MASK0x010F003F
REG_VALID_ACE_ACCESS_MASK0xF10F003F

Security information flags for REG_IOC_GET_SECURITY / SET_SECURITY.

ConstantValue
OWNER_SECURITY_INFORMATION0x00000001
GROUP_SECURITY_INFORMATION0x00000002
DACL_SECURITY_INFORMATION0x00000004
SACL_SECURITY_INFORMATION0x00000008
LABEL_SECURITY_INFORMATION0x00000010
REG_VALID_SECURITY_INFORMATION0x0000001F

Registry value types.

ConstantValue
REG_NONE0
REG_SZ1
REG_EXPAND_SZ2
REG_BINARY3
REG_DWORD4
REG_DWORD_BIG_ENDIAN5
REG_LINK6
REG_MULTI_SZ7
REG_RESOURCE_LIST8
REG_FULL_RESOURCE_DESCRIPTOR9
REG_RESOURCE_REQUIREMENTS_LIST10
REG_QWORD11
REG_TOMBSTONE0xFFFF

Watch event types and filters.

ConstantValue
REG_WATCH_VALUE_SET1
REG_WATCH_VALUE_DELETED2
REG_WATCH_SUBKEY_CREATED3
REG_WATCH_SUBKEY_DELETED4
REG_WATCH_SD_CHANGED5
REG_WATCH_KEY_DELETED6
REG_WATCH_OVERFLOW7

Watch event raw byte layout.

ConstantValue
REG_WATCH_EVENT_TOTAL_LEN_OFFSET0
REG_WATCH_EVENT_TYPE_OFFSET4
REG_WATCH_EVENT_NAME_LEN_OFFSET6
REG_WATCH_EVENT_NAME_OFFSET8
REG_WATCH_EVENT_MIN_SIZE8
REG_WATCH_SUBTREE_PATH_DEPTH_REL_OFFSET0
REG_WATCH_SUBTREE_PATH_DEPTH_SIZE2
REG_WATCH_SUBTREE_PATH_COMPONENTS_REL_OFFSET2
REG_WATCH_PATH_COMPONENT_LEN_SIZE2
REG_NOTIFY_VALUE0x01
REG_NOTIFY_SUBKEY0x02
REG_NOTIFY_SD0x04
REG_NOTIFY_ALL0x07

RSI common wire layout.

ConstantValue
RSI_REQUEST_TOTAL_LEN_OFFSET0
RSI_REQUEST_ID_OFFSET4
RSI_REQUEST_OP_CODE_OFFSET12
RSI_REQUEST_TXN_ID_OFFSET14
RSI_REQUEST_HEADER_SIZE22
RSI_RESPONSE_TOTAL_LEN_OFFSET0
RSI_RESPONSE_ID_OFFSET4
RSI_RESPONSE_OP_CODE_OFFSET12
RSI_RESPONSE_HEADER_SIZE14
RSI_RESPONSE_STATUS_OFFSET14
RSI_STATUS_SIZE4
RSI_MIN_RESPONSE_SIZE18
RSI_LENGTH_PREFIX_SIZE4
RSI_GUID_SIZE16
RSI_RESPONSE_BIT0x8000

RSI op codes and response op codes.

ConstantValue
RSI_LOOKUP0x0001
RSI_CREATE_ENTRY0x0002
RSI_HIDE_ENTRY0x0003
RSI_DELETE_ENTRY0x0004
RSI_ENUM_CHILDREN0x0005
RSI_CREATE_KEY0x0010
RSI_READ_KEY0x0011
RSI_WRITE_KEY0x0012
RSI_DROP_KEY0x0013
RSI_QUERY_VALUES0x0020
RSI_SET_VALUE0x0021
RSI_DELETE_VALUE_ENTRY0x0022
RSI_SET_BLANKET_TOMBSTONE0x0023
RSI_BEGIN_TRANSACTION0x0030
RSI_COMMIT_TRANSACTION0x0031
RSI_ABORT_TRANSACTION0x0032
RSI_FLUSH0x0040
RSI_DELETE_LAYER0x0050
RSI_LOOKUP_RESPONSE0x8001
RSI_CREATE_ENTRY_RESPONSE0x8002
RSI_HIDE_ENTRY_RESPONSE0x8003
RSI_DELETE_ENTRY_RESPONSE0x8004
RSI_ENUM_CHILDREN_RESPONSE0x8005
RSI_CREATE_KEY_RESPONSE0x8010
RSI_READ_KEY_RESPONSE0x8011
RSI_WRITE_KEY_RESPONSE0x8012
RSI_DROP_KEY_RESPONSE0x8013
RSI_QUERY_VALUES_RESPONSE0x8020
RSI_SET_VALUE_RESPONSE0x8021
RSI_DELETE_VALUE_ENTRY_RESPONSE0x8022
RSI_SET_BLANKET_TOMBSTONE_RESPONSE0x8023
RSI_BEGIN_TRANSACTION_RESPONSE0x8030
RSI_COMMIT_TRANSACTION_RESPONSE0x8031
RSI_ABORT_TRANSACTION_RESPONSE0x8032
RSI_FLUSH_RESPONSE0x8040
RSI_DELETE_LAYER_RESPONSE0x8050

RSI status codes.

ConstantValue
RSI_OK0
RSI_NOT_FOUND1
RSI_ALREADY_EXISTS2
RSI_STORAGE_ERROR3
RSI_NOT_EMPTY4
RSI_TOO_LARGE5
RSI_TXN_BUSY6
RSI_INVALID7
RSI_CAS_FAILED8
RSI_TXN_NOT_SUPPORTED9

RSI path target types.

ConstantValue
RSI_PATH_TARGET_GUID0
RSI_PATH_TARGET_HIDDEN1

RSI_WRITE_KEY field mask bits.

ConstantValue
RSI_WRITE_KEY_FIELD_SD0x01
RSI_WRITE_KEY_FIELD_LAST_WRITE_TIME0x02
RSI_WRITE_KEY_FIELD_KNOWN_MASK0x00000003

RSI transaction modes and source-registration flags.

ConstantValue
RSI_TXN_READ_WRITE0
RSI_TXN_READ_ONLY1
RSI_HIVE_PRIVATE0x01

Backup record types and magic.

ConstantValue
REG_BACKUP_HEADER0x01
REG_BACKUP_LAYER0x02
REG_BACKUP_KEY0x03
REG_BACKUP_PATH_ENTRY0x04
REG_BACKUP_VALUE0x05
REG_BACKUP_BLANKET_TOMBSTONE0x06
REG_BACKUP_TRAILER0xFF
REG_BACKUP_MAGIC"PEIOSREG"

5.A.5 Tracepoint diagnostic codes #

From uapi/pkm/trace.h. These are a diagnostic contract for ftrace, perf and eBPF consumers, letting a tool decode an lcs: event's reason, op or state field without recompiling against a specific kernel. No LCS syscall accepts or returns them, and values are append-only.

lcs_rsi_request op — which of the 18 RSI dispatch verbs a source-side request admission record describes. Emitted by lcs:lcs_rsi_request on successful queue admission and on the admission error rungs; the rung is read from ret (0 == enqueued, -EAGAIN == in-flight at limit / backpressure, -EIO == source gone / fd closing, -EOVERFLOW == request-id space exhausted, other == build reject). The same op enum tags the round-trip begin marker (lcs_rsi_roundtrip). Never records a pathname, key name, GUID, or frame bytes — only this op code, ids, counts and ret.

ConstantValueNotes
LCS_OP_LOOKUP0RSI_LOOKUP
LCS_OP_READ_KEY1RSI_READ_KEY
LCS_OP_ENUM_CHILDREN2RSI_ENUM_CHILDREN
LCS_OP_QUERY_VALUES3RSI_QUERY_VALUES
LCS_OP_SET_VALUE4RSI_SET_VALUE
LCS_OP_DELETE_VALUE5RSI_DELETE_VALUE_ENTRY
LCS_OP_BLANKET_TOMBSTONE6RSI_SET_BLANKET_TOMBSTONE
LCS_OP_DROP_KEY7RSI_DROP_KEY
LCS_OP_CREATE_ENTRY8RSI_CREATE_ENTRY
LCS_OP_HIDE_ENTRY9RSI_HIDE_ENTRY
LCS_OP_DELETE_ENTRY10RSI_DELETE_ENTRY
LCS_OP_CREATE_KEY11RSI_CREATE_KEY
LCS_OP_WRITE_KEY12RSI_WRITE_KEY
LCS_OP_TXN_BEGIN13RSI_BEGIN_TRANSACTION
LCS_OP_TXN_COMMIT14RSI_COMMIT_TRANSACTION
LCS_OP_TXN_ABORT15RSI_ABORT_TRANSACTION
LCS_OP_FLUSH16RSI_FLUSH
LCS_OP_DELETE_LAYER17RSI_DELETE_LAYER

lcs_rsi_response reason — the outcome of accepting/validating a source's RSI response frame, and the late-response effects that silently mark a source DOWN. ACCEPTED is the clean path; DESYNC / OP_MISMATCH / UNKNOWN_STATUS are the accept-time rejects that all surface as -EINVAL/-EIO; MALFORMED_PAYLOAD is a per-op body validation reject; the LATE_* codes mark a response whose deferred effect (commit/mutation/begin bookkeeping) failed and took the source DOWN. Verdict/outcome is also in ret. Never records name/GUID/frame bytes.

ConstantValueNotes
LCS_RESP_ACCEPTED0response matched an in-flight request
LCS_RESP_DESYNC1no matching delivered/unaccepted record
LCS_RESP_OP_MISMATCH2response op != request op | RESPONSE_BIT
LCS_RESP_UNKNOWN_STATUS3rsi_status not a known status code
LCS_RESP_MALFORMED_PAYLOAD4per-op response body failed validation
LCS_RESP_LATE_COMMIT_FAIL5commit late-effect failed; source DOWN
LCS_RESP_LATE_MUTATION_FAIL6mutation late-effect failed; source DOWN
LCS_RESP_LATE_BEGIN_FAIL7begin-txn late-effect failed; source DOWN

lcs_source_fd reason — which source-fd lifecycle transition a record marks.

OPEN is a fresh /dev/pkm_registry fd; the remaining codes are the entry points that drive a source to the DOWN/closing state. source_down_id is the source id that transitioned DOWN (0 if the call was a no-op). The semantic cause of a late-effect-driven DOWN is carried by lcs_rsi_response (LCS_RESP_LATE_*); here EXPLICIT/MARK_BY_ID are the mechanical transitions. No pathname/SD bytes.

ConstantValueNotes
LCS_SRC_OPEN0new source fd issued (post-TCB check)
LCS_SRC_RELEASE1fd .release() teardown
LCS_SRC_MALFORMED2malformed protocol frame -> mark down
LCS_SRC_EXPLICIT3explicit mark-down of this fd
LCS_SRC_MARK_BY_ID4mark-down requested by source id

lcs_in_flight reason — an in-flight RSI request table transition (kept lean; insert on admission, delivered when handed to the source's read(), release on response completion or teardown). in_flight_count is the post-transition depth. Emitted by lcs:lcs_in_flight.

ConstantValueNotes
LCS_IF_INSERT0request inserted into in-flight table
LCS_IF_DELIVERED1request delivered to source read()
LCS_IF_RELEASE2request released from in-flight table

lcs_route op — which resolution the lcs:lcs_route event describes.

ConstantValueNotes
LCS_ROUTE_HIVE_NAME0hive-name -> source/root resolution
LCS_ROUTE_ABSOLUTE_PATH1absolute-path -> source/root resolution
LCS_ROUTE_SYMLINK_TARGET2symlink-target -> source/root resolution

lcs_registration decision — the source registration path.

NEW/RESUME_DOWN are publish verdicts; COPY is the input-copy stage; REPLAY_FAIL/OVERFLOW_FAIL are resume post-publish -EIO paths that mark the resumed source down. Emitted by lcs:lcs_source_register / _registration_publish / _registration_copy.

ConstantValueNotes
LCS_REG_NEW0new source slot admitted
LCS_REG_RESUME_DOWN1down source slot resumed
LCS_REG_COPY2registration input copied from user
LCS_REG_REPLAY_FAIL3resume pending-delete replay failed (EIO)
LCS_REG_OVERFLOW_FAIL4resume overflow dispatch failed (EIO)

lcs_bootstrap stage — the phase of a bootstrap / self-config refresh.

Emitted by lcs:lcs_bootstrap_refresh / _self_config_refresh / _self_config_publish.

ConstantValueNotes
LCS_BOOT_REGISTRY0registry root discover phase
LCS_BOOT_KMES1kmes config root discover phase
LCS_BOOT_LAYERS2layer metadata root discover phase
LCS_BOOT_SELF_WATCH3self-watch arm phase
LCS_BOOT_COMPLETE4bootstrap refresh completed
LCS_BOOT_SELF_CONFIG_REFRESH5self-config refresh-from-key outcome
LCS_BOOT_SELF_CONFIG_PARAM_INVALID6self-config publish rejected a parameter

lcs_runtime_limits field_id — which runtime-limit field a validate reject names, or LCS_LIM_ALL for a successful whole-struct publish. Emitted by lcs:lcs_limits_validate (-EINVAL, value offending) and lcs:lcs_limits_publish.

ConstantValueNotes
LCS_LIM_REQUEST_TIMEOUT_MS0
LCS_LIM_TRANSACTION_TIMEOUT_MS1
LCS_LIM_NOTIFICATION_QUEUE_SIZE2
LCS_LIM_SYMLINK_DEPTH_LIMIT3
LCS_LIM_MAX_VALUE_SIZE4
LCS_LIM_MAX_KEY_DEPTH5
LCS_LIM_MAX_PATH_COMPONENT_LENGTH6
LCS_LIM_MAX_TOTAL_PATH_LENGTH7
LCS_LIM_MAX_LAYERS_PER_VALUE8
LCS_LIM_MAX_BOUND_TRANSACTIONS_PER_SOURCE9
LCS_LIM_MAX_READ_ONLY_TRANSACTIONS_PER_SOURCE10
LCS_LIM_MAX_TOTAL_LAYERS11
LCS_LIM_MAX_REGISTERED_SOURCES12
LCS_LIM_MAX_HIVES_PER_SOURCE13
LCS_LIM_MAX_CONCURRENT_RSI_REQUESTS14
LCS_LIM_MAX_SCOPE_GUIDS_PER_TOKEN15
LCS_LIM_MAX_PRIVATE_LAYERS_PER_TOKEN16
LCS_LIM_MAX_SUBTREE_WATCH_DEPTH17
LCS_LIM_MAX_TRANSACTION_WATCH_EVENT_BURST18
LCS_LIM_ALL19whole-struct publish (success)

lcs_audit event_type_id — which LCS audit event a record describes.

Emitted by lcs:lcs_audit_emit and lcs:lcs_audit_emit_failed. result_errno carries the op-specific numeric. No SD or raw GUID bytes; key GUID is a u64 hash.

ConstantValueNotes
LCS_AUDIT_KEY_OPEN0key-open SACL audit
LCS_AUDIT_BACKUP_START1
LCS_AUDIT_BACKUP_COMPLETE2
LCS_AUDIT_RESTORE_START3
LCS_AUDIT_RESTORE_COMPLETE4
LCS_AUDIT_VALIDATION_FAILURE5source validation-failure audit
LCS_AUDIT_SELF_CONFIG_INVALID6self-config-invalid audit

lcs_txn state — the transaction-fd state machine state carried in old_state new_state.

Emitted by lcs:lcs_txn_begin / _first_bind / _bind_mutation _commit / _abort / _timeout / _source_down.

ConstantValueNotes
LCS_TXN_ST_ACTIVE_UNBOUND0allocated, not yet source-bound
LCS_TXN_ST_ACTIVE_BOUND1bound to a source + root guid
LCS_TXN_ST_COMMITTED2commit round-trip succeeded
LCS_TXN_ST_ABORTED3aborted (close / layer writer abort)
LCS_TXN_ST_TIMED_OUT4deadline timer or commit timeout
LCS_TXN_ST_SOURCE_DOWN5bound source marked down

lcs_key_fd cmd — the key-fd ioctl verb (also stamped on lcs_key_mutation).

LCS_KCMD_NONE is used by publish/release/read. Never records key/name/SD bytes. Emitted by lcs:lcs_key_ioctl / _mutation.

ConstantValueNotes
LCS_KCMD_NONE0no ioctl verb (publish/release/read)
LCS_KCMD_SET_VALUE1
LCS_KCMD_DELETE_VALUE2
LCS_KCMD_BLANKET_TOMBSTONE3
LCS_KCMD_DELETE_KEY4
LCS_KCMD_HIDE_KEY5
LCS_KCMD_QUERY_VALUE6
LCS_KCMD_QUERY_VALUES_BATCH7
LCS_KCMD_ENUM_VALUES8
LCS_KCMD_ENUM_SUBKEYS9
LCS_KCMD_QUERY_KEY_INFO10
LCS_KCMD_GET_SECURITY11
LCS_KCMD_SET_SECURITY12
LCS_KCMD_FLUSH13
LCS_KCMD_BACKUP14
LCS_KCMD_RESTORE15
LCS_KCMD_NOTIFY16

Appendix 5.B LCS ABI Notes

Peios / Peios technical reference / PKM / LCS

§5.A is generated from pkm/uapi/pkm/lcs.h and holds only what a compiler can measure. This appendix holds the rest.

The split is structural rather than editorial. gen-lcs-abi.py overwrites §5.A wholesale on every run, so anything written there is lost the next time the ABI changes.

5.B.1 What is not here #

The header carries names, numbers and layouts. Everything else about the interface is a property of the implementation rather than of the ABI, and is documented with the operation it belongs to: the required access right for each ioctl and the two-pass output buffer convention in §5.5, the error vocabulary in §5.5.5, the RSI payload shapes in the Registry Source Interface specification, and the backup stream's record payloads in the Registry Backup Format specification.

REG_BACKUP_MAGIC in §5.A is the eight-byte header magic; the record type codes are the framing, not the payloads.

5.B.2 Build configuration #

LCS is built by CONFIG_SECURITY_PKM, a boolean option, so it is linked into vmlinux rather than loaded.

CONFIG_RUST=y is required: the resolution core, the RSI codec, the backup serialiser and the transaction log are Rust, staged into the kernel tree as security/pkm/lcs/lcs_core.

CONFIG_SECURITY_PKM_KUNIT compiles in the in-kernel test harness.

The three syscall numbers are added to the syscall table by kernel/patches/arch/syscall-table-pkm.patch, which patches both arch/x86/entry/syscalls/syscall_64.tbl and the copy of it that ships under tools/perf/.

They are registered common, so the same row serves the x32 ABI wherever that ABI is built in; the Peios kernel does not build it.

6.1 Overview

Peios / Peios technical reference / PKM / NTFE

NTFE — the Network Traffic Filtering Engine — is the kernel's packet filter. It executes the packet layers of PNP, the Peios Network Policy: PNP is the policy language and its registry layout under Machine\System\Network, and NTFE is one of the components that carry it out (netd is another, for the interface layer). It stands at the netfilter seats, judges every traversal against the policy it reads from the LCS registry itself, and applies the result: a verdict on the packet, side effects on the flow and machine, and an event for whoever is watching. No userspace process is in the enforcement path; the viewer daemon (pnpd) observes and authors, it never decides.

6.1.1 What it replaced #

Peios ships a clean slate below NTFE. The netfilter hook framework and conntrack (built in, with events, zones and timeouts; helpers off) are kept, as are nf_defrag and the nf_reject machinery NTFE uses to phrase refusals. With helpers off nothing assigns a helper to a flow (only a conntrack entry a privileged process creates over ctnetlink, naming one, could carry it), so no flow is expected by another: ct->master stays NULL, and the Flow layer's Related fact (§6.3) is false on every flow the stack tracks. Everything that was a policy frontend is configured out: nf_tables, the xtables family (iptables, ip6tables, ebtables, arptables), ipset, NFQUEUE, NFLOG, the flow table offload, bridge netfilter, the netfilter BPF link and IPVS. NF_NAT is built but dormant. The kernel config gate (kernel/verify-kernel-config.sh) asserts both halves so a merge cannot quietly bring a frontend back.

One consequence is worth knowing: conntrack's hooks are demand-activated, historically by a ct-using iptables rule. With no frontend left, NTFE pins them itself at init (nf_ct_netns_get(&init_net, NFPROTO_INET)); without that pin nf_ct_get() is NULL on every packet and the FlowState fact reads untracked forever.

6.1.2 Two halves #

The engine is split along the line that kernels are good at and pure code is good at.

net/ntfe/ (C) owns everything that touches the kernel: the hook registrations and the dispatch law (seats.c), building the fact snapshot from an sk_buff (snapshot.c), RCU publication of policy generations (policy.c), walking the registry into the builder (ingest.c), the three machinery stores — flow tags on a conntrack extension (tags.c), counter tables (counters.c), report emission into KMES (report.c) — the Flow layer's sentence cache and dispatch (flow.c), the refusals a REJECT sends (refuse.c), and the verdict event ring behind /dev/peios-ntfe (events.c).

pnp-core (Rust, pkm/crates/pnp-core) owns everything the policy means: the action language, the fact vocabulary and operators, registry-shaped ingestion and validation, the forest, and the evaluation algorithm. It is no_std, allocates only through the fallible PKM wrappers, and has no I/O. The same source compiles twice: under cargo, where a test suite encodes every ratified law by name, and into the kernel, staged by kernel/stage-rust-core.sh as modules of the security/pkm Rust crate and reached over a C ABI (kacs/ntfe_runtime.rs, the bridge). Nothing about a rule's semantics exists in C.

The bridge is the only place the two meet. C hands it a snapshot and a forest; it resolves the forest's machinery facts against the packet by calling back into the stores, evaluates, and applies the effects — after collation, so a COUNT lands after this packet's own reads and a REPORT can carry the verdict.

6.1.3 Terms #

  • Traversal — one packet passing one direction through the machine.
  • Seat — a netfilter hook NTFE stands at: the device ingress and egress seats (per interface) and the two IP seats, inbound (LOCAL_IN) and outbound (LOCAL_OUT).
  • Layer — one of the three rule forests, RawPacket, Packet and Flow, each a registry key under Machine\System\Network\Rules.
  • Flow — what conntrack tracks: a tuple-identified, bidirectional exchange. The unit the Flow layer judges.
  • Sentence — the Flow layer's cached verdict on a flow: verdict, generation, expiry, on the flow's conntrack extension.
  • Snapshot — the immutable set of facts one traversal is judged against at its seat.
  • Generation — one published policy. Advances on every successful ingestion; 0 means nothing was ever ingested and every layer is permissive, loudly.
  • Forest — a layer's rule trees plus the machinery names it mentions: the tags it reads or writes, the counter streams it writes, and the counter views it reads.
  • Effect — a side effect an evaluation yields (TAG, COUNT, REPORT, a prompt), applied by the glue.
  • Confession — a counter in the engine status for something the engine refused or could not do. Nothing in NTFE fails silently.
  • Owner — the identity KACS stamps on an inet socket (§3.12.2): the effective token and process facts at the last act that committed the socket to a role. What the Flow layer's Local.* facts are read from.
  • Endpoint kind — what stands at a local end of a flow: program, kernel, shared or none (§6.9).

The chapter follows a packet: the seats it meets (§6.2), the snapshot taken of it (§6.3), how the forest judges it (§6.4), where that forest came from (§6.5), the stores its effects land in (§6.6), the event that records it (§6.7), what happens when it is the first packet of a flow (§6.8), and who stands at its local end (§6.9). §6.A is the generated ABI of /dev/peios-ntfe; §6.B is what the ABI tables cannot say.

6.2 Seats and dispatch

Peios / Peios technical reference / PKM / NTFE

6.2.1 The seats #

NTFE registers at four kinds of netfilter hook.

  • Device ingress (NF_NETDEV_INGRESS) and device egress (NF_NETDEV_EGRESS), per interface. A netdevice notifier registers both on every NETDEV_REGISTER in init_net and unregisters them on NETDEV_UNREGISTER; because register_netdevice_notifier() replays NETDEV_REGISTER for devices that already exist, boot-time interfaces get their seats too. These seats see frames — the Ethernet header is present, VLAN tags are visible, and on the inbound side conntrack has not yet run.
  • IP inbound (NF_INET_LOCAL_IN, IPv4 and IPv6) at priority NF_IP_PRI_FILTER. Conntrack and defragmentation have run; the packet is whole and its flow is classified — and still unconfirmed, since conntrack confirms at the end of this hook.
  • IP outbound (NF_INET_LOCAL_OUT, IPv4 and IPv6) at filter priority. The first point after conntrack has classified a locally generated packet's flow; the sending socket is attached; the entry is unconfirmed until POST_ROUTING.

Only init_net is instrumented: Peios has no container or network namespace story yet, and the notifier ignores devices in other namespaces. Of netfilter's other hooks, FORWARD is empty on a host that does not forward, and PRE_ROUTING and POST_ROUTING see nothing the IP seats do not, save NAT.

6.2.2 The dispatch law #

The ratified law, with the Flow layer's clause:

RawPacket matches all traffic at the device seats, unconditionally. The Packet layer judges every traversal exactly once, at its proper seat — inbound IP at the IP hook, everything outbound at egress — falling back to the ingress seat iff the traversal will never reach its proper seat. The Flow layer judges every tracked flow once per local endpoint, at the IP seats, and its sentence answers for every later packet of the flow.

"Will never reach its proper seat" is decidable at ingress from two facts, and peios_ntfe_traversal_reaches_ip_seat() decides it: the ethertype (only ETH_P_IP and ETH_P_IPV6 cross the IP hooks) and the device's disposition (netif_is_bridge_port() — a frame on a bridge-enslaved port is switched at L2 and never enters this device's IP stack; a copy delivered to the bridge device itself is a separate traversal on that device, judged there).

So at each seat the hook builds one snapshot and judges an ordered list of layers:

SeatLayers, in traversal order
Ingress, IP frame on a plain portRawPacket only (the Packet layer is deferred to LOCAL_IN)
Ingress, non-IP frame or bridge portRawPacket, then Packet (the fallback judgment)
LOCAL_INPacket, then the flow's sentence or Flow
LOCAL_OUTthe flow's sentence or Flow
EgressPacket, then RawPacket

Traversal order is wire order: RawPacket is wire-proximate, so it is first in and last out; Flow is innermost, last in and first out. Each per-packet layer is evaluated once per traversal, and the first non-PASS verdict ends the traversal. A layer with no published forest is permissive and counted as such — at generation 0 that is every layer.

At the IP seats, a packet goes on to the flow dispatch (§6.8) — at LOCAL_IN once the Packet layer has passed it, at LOCAL_OUT before any Packet judgment, since the Packet layer judges outbound traffic at egress, after it: an untracked packet has no flow to judge and is accepted, so inbound the Packet verdict stands and outbound egress judges it next; a tracked packet with a current sentence gets that sentence, and one without is evaluated by the Flow forest and sentenced.

6.2.3 Applying a verdict #

PASS continues to the next layer, then NF_ACCEPT. DROP is NF_DROP. REJECT is NF_DROP plus a refusal, phrased by the kind the rule chose and the packet's protocol:

KindIPv4IPv6
Refused (default)TCP: RST; else ICMP port-unreachableTCP: RST; else ICMPv6 port-unreachable
ProhibitedICMP ICMP_PKT_FILTERED (type 3, code 13)ICMPv6 ICMPV6_ADM_PROHIBITED (type 1, code 1)

Every seat can refuse IP traffic — the ingress seat only on an Ethernet device. refuse.c builds the answer with the kernel's frame-less reject builders (nf_reject_skb_v4_tcp_reset(), nf_reject_skb_v4_unreach() and the v6 pair — the ones nftables' netdev reject uses), attaches the flow to it (nf_ct_attach(), so conntrack files it as the reply it claims to be), marks it, and delivers it:

  • from the ingress seat, to the peer on the wire — dev_hard_header() with the offending frame's MACs swapped, then dev_queue_xmit(); that needs an Ethernet device (ARPHRD_ETHER) and a link header to swap, so at the ingress seat of any other device — the loopback device, a tunnel — a REJECT always degrades (below);
  • from every other seat, to ourselves — skb_dst_set_noref() from the offending packet's route, ip_route_me_harder() (or the v6 helper), then ip_local_out(). Inbound, that routes the answer out to the peer with our address as its source; to a link-local address it is routed on the device the offending packet arrived on, without the helper, which would scope the lookup to the loopback device the local delivery route names; outbound, the answer is the peer's, addressed to us, so the route lands on the loopback device and the stack's own RST and ICMP-error handlers fail the local socket at once, with the error the kernel maps the answer to: ECONNREFUSED for a reset or a port unreachable, EHOSTUNREACH for IPv4's admin-filtered, EACCES for IPv6's admin-prohibited. TCP takes an ICMP error that reaches a socket its owner is holding as a soft error, and a refused SYN is refused inside connect(), which holds it: so an ICMP answer to a held TCP socket is sent a tick later (one jiffy, on the system workqueue), once the call has let go. A reset needs no wait: TCP queues it on the socket's backlog. The loopback device retains the route the packet was sent with, so the foreign source address never meets source validation.

The builders read the offending packet from skb->data as though it pointed at the network header, as it does at the IP hooks; at the egress seat it points at the link header the device has pushed, so refuse.c pulls the packet to its network header for the build and pushes it back after.

When the refused packet belongs to an established TCP flow, the far end is torn down too: peios_ntfe_teardown_build() turns the refused packet itself into a reset — same addresses, ports, sequence and acknowledgement numbers, RST set, no data — marks it, and sends it where the packet was going by the same routed path (outbound, to the peer on the wire; inbound, to our own socket over loopback). The refused end gets the kind's story; the far end gets the only story TCP listens to mid-connection. Counted in teardowns_emitted. New flows and UDP have no far end; a reset is never answered with a reset; the ingress seat has no flow facts and never tears down.

The answer is not sent, and the REJECT degrades to DROP (counted in reject_degraded, flagged REJECT_DEGRADED in the event, the kind still named), when there is nothing to send: a non-IP frame, a broadcast or multicast destination, an ingress frame on a non-Ethernet device, a builder that declines (a non-first fragment, which has no transport header to answer from; a failed checksum; a refusal of a refusal), a packet with no route to reason from, or an allocation failure. A first fragment passes the builders' fragment check and is declined by their checksum check instead — its transport checksum covers a payload it carries only part of — so one whose checksum the builder does not verify (one the stack has already marked as checked, or a protocol the builders skip: UDP with a zero checksum, SCTP, ESP, AH, GRE, UDP-Lite) is answered. Fragments meet the builders only at the device seats: LOCAL_IN sees reassembled packets. Refusals sent are counted in refusals_emitted.

6.2.4 The refusal law #

NTFE does not judge its own refusals. The answer refuse.c builds carries skb->ntfe_refusal, a bit the ntfe-refusal-bit patch adds to struct sk_buff inside its headers group (so clones and copies keep it); every hook checks it first and returns NF_ACCEPT without a snapshot, an evaluation or an event, counting refusals_bypassed. Refusals are verdict machinery, not traffic: without the bit, an inbound REJECT's RST would cross the egress seat and could be dropped — and attributed — by an outbound rule, and an outbound REJECT's forged peer answer would be judged as inbound traffic on its way to the socket.

6.2.5 Failing closed #

Evaluation can fail: the core allocates GFP_ATOMIC during a walk and an allocation can be refused. The hook then drops the packet, counts fail_closed, and emits an event attributed to fail-closed with the FAIL_CLOSED flag. Totality does not take "no answer" for an answer.

A snapshot that cannot be built — a frame too mangled to describe — is counted (parse_errors) and judged on its seat facts alone; the absent-fact law does the rest.

6.3 The snapshot

Peios / Peios technical reference / PKM / NTFE

Every judgment reads a snapshot: a fixed-size, stack-allocated struct peios_ntfe_snapshot built once per seat by peios_ntfe_snapshot_from_skb() and never mutated during evaluation. That immutability is a ratified law, not an implementation detail — nothing a rule writes is visible to the same evaluation's matching, so temporal feedback (the next packet sees it) is the only feedback there is.

6.3.1 Validity bits #

Many facts have meaningful zero values (port 0, TTL 0, VLAN 0), so presence is carried separately in has, a bitmask of PEIOS_NTFE_HAS_*: ethertype, MACs, source MAC alone, VLAN, TTL, DSCP, fragment, ports, TCP flags, ICMP, time, the flow's start time, and the network context. Address facts use addr_family (0, 4 or 6) as their validity; the protocol is valid iff a family is. Flow state uses its own ABSENT value; flow_related is valid iff flow is. The bridge turns each clear bit into None on the Rust side, and the absent-fact law makes every condition over a None false.

6.3.2 Extraction #

Seat facts first: seat, direction, interface (ifindex and name), the network context of that interface (below), whether the device is the loopback (IFF_LOOPBACK — the flow dispatch's "two endpoints" test), the packet length as the stack sees it (skb->len — not the wire length), and the wall clock (ktime_get_real_seconds() through time64_to_tm(), UTC, with tm_wday re-based so the DayOfWeek fact is ISO: 1 = Monday .. 7 = Sunday), kept alongside as epoch seconds (t_secs) for the time-flip arithmetic (§6.4) and the sentence expiry check (§6.8).

Then the frame: the ethertype from skb->protocol. The VLAN id is the frame's tag if skb_vlan_tag_present(), else the device's if is_vlan_dev() — the VLAN is a fact of the frame at the device seats and of the device at the IP seats, where inbound the VLAN code has already stripped the tag and re-parented the packet onto the VLAN device, and outbound the tag is pushed only when that device transmits. (Before the Flow slice the IP seats read the stripped tag and Vlan was absent on VLAN interfaces there.) The MAC pair if the MAC header is set and the device is ARPHRD_ETHER; failing that, at an IP seat on an Ethernet device — a locally generated packet with no link header yet — the source alone, from the device's own address (HAS_SRC_MAC): present so every flow carries the same fact set, and not useful. The destination is unknown until neighbour resolution, after the seat: absent.

Then IP, from skb_network_offset():

  • IPv4 — addresses, TTL, DSCP (tos >> 2), the fragment flag (IP_MF set or a non-zero fragment offset), the protocol, and the L4 facts from ihl * 4 on. A non-first fragment (non-zero offset) has no L4 facts — its payload begins mid-datagram — though it still reads the protocol its header names.
  • IPv6 — addresses, hop limit, DSCP from the traffic class, and a bounded walk (eight hops) of the extension-header chain: hop-by-hop, routing and destination options are skipped by (hdrlen + 1) * 8; a fragment header sets the fragment fact, and a non-first fragment ends the walk with no L4 facts (there are none to read). The protocol fact is the header the walk stops at — so an MLD report behind a hop-by-hop header reads Protocol = icmpv6, as a rule would expect. Every way out of the walk names one: a non-first fragment reads the protocol its fragment header names, as a later IPv4 fragment reads its own; an extension header too short to read is itself the protocol; and after eight hops the next header is the protocol, with L4 facts if it is not another extension header.

Then L4, by protocol: ports for TCP, UDP and SCTP; the flag byte for TCP (TcpFlags is the TCP header's byte at offset 13, FIN in bit 0 through CWR in bit 7, the same encoding tcp_flag_byte() uses); type and code for ICMP and ICMPv6. Every header read goes through skb_header_pointer(), so a packet whose headers are paged or truncated yields absent facts rather than a fault.

6.3.3 Flow state and the flow #

At the ingress seat conntrack has not run and the flow state is ABSENT; at LOCAL_IN, LOCAL_OUT and egress, nf_ct_get() gives the entry and ctinfo maps to the fact: IP_CT_ESTABLISHED and its reply → established; IP_CT_RELATED and reply → related; IP_CT_NEW → new; anything else, or no entry at all, → untracked. The invalid value is reserved: distinguishing an incoherent packet needs conntrack's own verdict, which this seat does not receive.

The snapshot also carries flow: the struct nf_conn * itself (never a template), or NULL. This is the tag store's and the sentence's scope — TAG writes land on that entry's extension, tag reads come from it, and the Flow layer's verdict is cached there. It is the one pointer in the snapshot that outlives the extraction, and it is valid because the skb holds a reference to the entry for the duration of the hook.

With the flow come two facts that exist only on a flow: flow_related (ct->master != NULL — the flow was expected by another) and the flow's start time, read from the extension's start_secs (stamped when conntrack created the entry) through the same time64_to_tm() lowering as the clock, into the s_* fields (HAS_START). These are the Flow layer's Related and Start.* facts. flow_reply says whether this packet travels in the flow's reply direction — the Flow layer's view builder (§6.8) uses it to turn the packet's tuple back into the flow's.

6.3.4 What the core sees #

NtfeSnapshotC in kacs/ntfe_runtime.rs mirrors the C struct field for field (#[repr(C)]; keep them in lockstep). snapshot_from_c() lifts it into the core's Snapshot — an Option per fact — and the bridge then fills in the two machinery fact tables: tags, as (name hash, value) pairs for every tag name the forest can read that the flow carries, and counter_views, as (view index, value) for every view the forest reads that the store can answer for this packet. Both are resolved before evaluation, against the forest being evaluated, which is why the forest carries its name sets (§6.5).

Three visibility laws are enforced here, and again in the core, which builds a condition over a fact its layer never has never to hold (§6.5): a RawPacket forest is given no tags whatever the flow says (tags flow upward only, and RawPacket is the lowest layer; Packet and Flow forests read them); an ingress snapshot has no flow, so no tags exist to give; and the flow-only facts (Related, Start.*, and the identity facts) are given to a Flow forest alone — everywhere else they are absent by law, exactly as ingestion's lint says (§6.5). The clock is given to every layer, and so is the trace of consulted time conditions (§6.4); only the Flow seat acts on it.

6.3.5 The network context #

Three string fields are not read from the packet either: network_id, network_name and network_trust, the Network.* facts. peios_ntfe_context_fill() (context.c) looks the device's name up in the active context table — the kernel's reading of netd's inventory, one entry per interface standing on an identified network, built by ingestion (§6.5) and published under RCU — and copies the entry's three strings in, setting HAS_NETWORK. The bridge lifts the id whenever the bit is set, and the name and trust only when non-empty (a record the operator has not labelled). No entry, no bit: an interface no network has been identified on carries no context, and every condition over the three facts is false there. The strings are bounded (40, 64 and 32 bytes with their NUL, PEIOS_NTFE_NETWORK_*_LEN); a longer registry value — a record's Name or Trust, or the Network an interface's Status names — is truncated at ingestion and the truncation logged once. The id of a record is its key name under Networks\, not a value, and is not truncated: a name of 40 bytes or more is ignored, record and all (§6.5).

The fields are the three Network.* facts the interface layer — the fourth layer, which netd builds and judges in userspace — conditions on beside its own (see the interface layer): netd fills them from the interface record when it judges an interface, the kernel from the table when it judges a packet, so Network.Trust.Equal reads the same record in either layer.

Flow refreshes these facts on a cache miss after capturing its cache invalidation generation (§6.8), because this packet snapshot can predate a context publication. Packet and RawPacket continue to use their traversal snapshot; their verdicts are not retained as Flow sentences.

6.3.6 The identity fields #

The snapshot's last fields are not extracted from the packet at all. The Flow layer's view builder (§6.8) sets them from the flow's extension: for the local end, and on loopback for the other end, the kind (program, kernel, shared, none), the process GUID, pid and comm, and a borrowed pointer to the KACS token the socket was stamped with — the extension holds the reference for the flow's life, so the pointer is valid for the hook. The bridge lifts a program end into a Principal the core's Local.* and Remote.* conditions question (§6.9); every other snapshot leaves the fields zero, which lifts to absent.

6.4 Evaluation

Peios / Peios technical reference / PKM / NTFE

Evaluation is pnp_core::eval::evaluate(forest, snapshot, ctx): pure, allocation-fallible, and the same code under cargo and in the kernel. Its steps are the ratified design's six, in order.

6.4.1 1. Matching #

A rule matches iff it is enabled and every one of its conditions holds against the snapshot. Conditions are a conjunction; a value written as a list is a disjunction within that one field. A disabled rule never matches, so its subtree is unreachable.

Conditions are evaluated in the order ingestion stored them, which puts live-time conditions (Time.*) last, and the conjunction stops at the first false one. Every live-time condition actually reached — true or false — is consulted, and Rule::matches_traced() records into a MatchTrace the moment it would next flip (Condition::next_flip()): for hour, minute, second and day-of-week, the value sequence is scanned one cycle ahead with the condition's own operator, so Time.Hour.Equal 9-17 at 10:30 flips at 18:00 and Time.Hour.LessThan 24 never does; day-of-month, month and year answer "next midnight", since exact flips need calendar arithmetic nobody has asked for. A false condition is consulted too: a higher-priority rule that missed only on the clock may match later. A rule whose address or port conditions fail first never consults its clock and contributes nothing. The earliest consulted flip is the evaluation's expires_at — the Flow layer's sentence expiry (§6.8); the per-packet layers compute it and ignore it.

6.4.2 2. The trigger set #

The walk descends each tree from its root. A node triggers iff it matches and none of its children match: a matching child shadows its parent. Shadowing exists only within a lineage — two trees never shadow each other, and their order in the forest carries no meaning. The walk returns "matched" to the parent so the parent knows it was shadowed.

6.4.3 3. Resolving a triggered rule #

Every triggered rule's action list is resolved once. Verdict actions fold to the strictest listed; TAG and COUNT push effects; REPORT folds to the highest level listed and pushes one effect if that level is at or above CurrentReportingLevel (level >= CurrentReportingLevel; one report per rule per evaluation); PROMPT pushes a prompt-issued effect and, with no handler transport in this release, resolves its fallback in place (nesting bounded by MAX_PROMPT_CHAIN, 4). Side effects always execute — they are pushed before anyone knows whether the rule's verdict will win.

6.4.4 4. Abstention #

A triggered rule that yields no verdict abstains. The walk then goes up the rule's own parentage — the chain it descended by — to the nearest ancestor whose actions contain a direct verdict (a top-level PASS, DROP or REJECT; a verdict inside a PROMPT fallback does not count), and that ancestor speaks for the region: its full action list resolves, once. A speaker reached by several abstaining descendants in one evaluation executes only the first time (spoken tracks resolved speakers by identity). Intermediate abstaining ancestors execute nothing. If no ancestor bears a verdict, the branch contributes nothing and other trees or the backstop answer.

The attribution of a speaker's verdict is the speaker's path, not the abstaining descendant's.

6.4.5 5. Collation #

Every yielded verdict is a candidate (verdict, priority, path). The winner has the highest priority; at equal priority the strictest verdict: DROP (3) > REJECT(Refused) (2) > REJECT(Prohibited) (1) > PASS (0). The two reject ranks are a deterministic tie-break — the quieter story wins — that exists only because candidate order is meaningless. Priority is the rule's effective priority, resolved at ingestion: a rule's own Priority value, else its parent's, else 0.

6.4.6 6. The backstop #

No candidate at all: the verdict is DROP, attributed to backstop, and the evaluation is flagged so the event can say so. The backstop is compiled in and un-deletable; every permissive statement in a policy is therefore a visible rule.

6.4.7 The evaluation as data #

Evaluation carries the winning verdict, its attribution, the backstop flag, every effect to apply, every candidate (winners and losers alike) for observability, and expires_at. Effects reference their stores by name and hash: Effect::Tag { hash, op }, Effect::Count { hash, amount } with the amount already resolved (Length becomes the packet length, or 0 when the packet has none), Effect::Report { rule, level }, Effect::PromptIssued { rule, handler }.

6.4.8 The bridge #

ntfe_rust_evaluate(forest, snap, layer, reporting_level, out) is what policy.c calls under rcu_read_lock(). It:

  1. lifts the C snapshot (§6.3) and resolves the forest's machinery facts through the stores — peios_ntfe_tag_lookup() for every tag name the forest mentions (Packet and Flow layers; RawPacket reads none), peios_ntfe_counter_read() for every view — and gives a Flow forest its own facts, Related and Start.*;
  2. evaluates with EvalContext { reporting_level };
  3. writes the verdict, the reject kind, the backstop flag, the attribution (cut to 95 bytes), the FNV-1a-64 of the whole attributing path (attributed_hash) and expires_at (0 = never) into struct peios_ntfe_outcome;
  4. then applies the effects — peios_ntfe_tag_apply(), peios_ntfe_counter_add(), peios_ntfe_report_emit() with the verdict it just computed — counting each species into the outcome.

Ordering 4 after 2 is what the snapshot-immutability law requires: a COUNT is applied after this packet's own view reads, so a rule cannot trip its own threshold, and a REPORT can name the verdict. Every allocation in 1–2 is GFP_ATOMIC (pnp-core's pkm_alloc in kernel mode); a failure returns -ENOMEM and the hook fails closed (§6.2). The store calls never sleep.

6.5 Ingestion and generations

Peios / Peios technical reference / PKM / NTFE

The kernel reads its own policy. There is no daemon that compiles rules and pushes them down; ingest.c walks the registry through the LCS source interface, feeds what it finds to the pnp-core builder over the bridge, and publishes the result as a generation — or keeps the previous one, loudly. The same walk reads netd's inventory beside the rules into the network context table (§6.3), because to a flow the two change together: a network being recognised on an interface is as much a policy change as a rule being written.

6.5.1 Discovery and change notification #

At LCS bootstrap, NTFE's key is discovered fifth alongside the other kernel-owned subtrees: pkm_lcs_walk_absolute_components() resolves Machine\System\Network (the leading Machine hive component is resolved locally against the hive root, not round-tripped), and its absence is not an error — no key, no policy and no context, generation stays where it is. When the key exists one internal watch is armed on it, depth-unbounded, for every change of content at any depth — LCS delivers it a value set, a value deleted, a subkey created, a subkey deleted and a key deleted; a security-descriptor change is not delivered: Rules\, Interfaces\ and Networks\ beneath it are what NTFE reads; Profiles\ and Dns\ are netd's and resolvd's, and a write there fires the watch and costs a walk that publishes nothing.

Watch events arrive per key and uncoalesced. peios_ntfe_network_registry_changed() records the source and key, sets a pending flag, and mod_delayed_work()s a re-walk with a 50 ms debounce: a burst of writes (a transaction touching a rule and its values, an autoapply seeding a whole policy, netd syncing every interface's Status after a link event) yields one re-walk after the burst goes quiet. The walk itself runs in process context on system_wq, one at a time under a mutex, since the bootstrap refresh and the deferred work may overlap.

6.5.2 The walk #

peios_ntfe_network_refresh_from_key() snapshots the LCS runtime limits, sequence and layer view, enumerates the Network key's children for Rules, Interfaces and Networks, and runs two independent stages — the rules, then the context; a failure in the first does not skip the second, and the walk reports the first error. No Rules key leaves the previous generation standing, exactly as a walk that refused would.

The rules stage:

  1. reads the Rules key's own values for CurrentReportingLevel (REG_DWORD, REG_DWORD_BIG_ENDIAN or REG_QWORD; 1..6; absent = 1; anything else refuses);
  2. enumerates its children for the layer keys Packet, RawPacket and Flow (Interface is netd's — the interface layer is built and judged in userspace with the same pnp-core, and the kernel never reads it; any other name is ignored);
  3. for each present layer, opens a builder and walks the layer key's subkeys as rule roots. Each rule is one RSI_QUERY_VALUES round trip (its effective, layering-resolved values, delivered in the batch record format u32 name_len | name | u32 type | u32 data_len | data) followed by one RSI_ENUM_CHILDREN round trip for its exceptions, recursively, bounded by depth 12 and 4096 rules per layer: a root rule is depth 0, so a chain of 13 keys is the deepest read, and a rule deeper than that or a 4097th rule refuses the walk.

Registry types are lowered as the builder ABI expects: REG_SZ and REG_EXPAND_SZ to strings (NUL termination stripped), REG_DWORD and REG_DWORD_BIG_ENDIAN to integers, REG_QWORD to a signed 64-bit integer, REG_MULTI_SZ to a list of strings. Any other type in a rule refuses the walk: atomic transitions prefer a loud rejection over a silently half-read rule. So does an integer of the wrong length — a REG_DWORD or REG_DWORD_BIG_ENDIAN whose data is not 4 bytes, a REG_QWORD not 8 — in a rule or in CurrentReportingLevel.

Everything the stage feeds the builder — each layer and whether its key exists, rule name and depth, value name, type and bytes — also goes through an FNV-1a digest, and so does the reporting level's value (not its bytes: the same level re-written as another integer type digests the same). When the digest equals the one of the last walk that published (none before the first), the stage publishes nothing: the built forests are freed and the generation does not move. The active generation already is this policy — even when that policy is no forests at all — and republishing would only make every sentence stale for nothing. A walk fired by an inventory write therefore publishes no rules; it moves the generation only if the context stage finds the table changed (below). The writes of a transaction reach the watch together at its commit and, like any burst, coalesce into one walk.

6.5.2.1 The context stage #

The inventory is read into one struct peios_ntfe_context_table, at most 64 entries of interface name, network id, name and trust:

  1. every child of Networks\ is a record; its key name is the id (a UUID — a name of 40 bytes or more, which no id field can hold, is ignored with its record, once loudly), and one RSI_QUERY_VALUES round trip reads its Name and Trust (REG_SZ; absent or unreadable is empty, and the id is still a fact), up to 256 records;
  2. every child of Interfaces\ is an interface; its Status subkey is found by one RSI_ENUM_CHILDREN and read by one RSI_QUERY_VALUES for Name (the kernel interface name) and Network (the id netd identified on it). Both present make an entry, joined to the record of that id for its name and trust; either absent — no link, no offer yet, an IGNOREd or DOWN interface — makes none.

Nothing in the stage refuses a generation. A record that cannot be read is logged and skipped — an interface key whose subkeys cannot be listed, or whose Status cannot be read, carries no context; a network record whose values cannot be read keeps its id with no name and no trust; an interface beyond the 64th carries no context, a value longer than its field is truncated with one warning. If the list of Networks\ or Interfaces\ itself cannot be read, the stage publishes nothing and the previous table stands: a table built from half a list would strip every network after the failure point of its name and trust, and with them every rule that names them. That failure is the walk's error — it shows in last_ingest_error — and the rules stage's outcome is untouched by it. Otherwise the table goes to peios_ntfe_context_publish() (§6.3): if it equals the active one entry for entry it is freed and nothing happens; otherwise it is rcu_assign_pointer()ed into place, the generation counter advances, the old table is freed after grace, and the kernel log says how many interfaces carry a context. The generation advance is the whole mechanism by which a context change reaches running flows: every sentence is now stale and is re-judged on its flow's next packet (§6.8), the same path a rule change takes.

6.5.3 Building and validating #

The builder (ntfe_rust_builder_*) accumulates RuleInput trees; build runs pnp_core::ingest::build_forest, which parses every condition key and action expression, orders each rule's conditions with the live-time ones last (§6.4), resolves priority inheritance, lints layer-impossible facts (a FlowState or tag condition in a RawPacket forest; a per-packet fact — Length, TcpFlags, Fragment, Ttl, Dscp, EtherType, DstMac, FlowState — in a Flow forest; Related or Start.* anywhere but Flow — all legal, and built never to hold; the kernel drops lints, the authoring surface shows them), and collects the forest's name sets: every tag name mentioned in a TAG action, a PROMPT fallback, or a Tag.<n> condition, split into the names the forest writes and the first rule reading each; every stream a COUNT writes; every distinct counter view a Counter.<n>(...) condition reads, with the first rule and value that mentioned it. Views are deduplicated and conditions refer to them by index — that index is what the bridge uses when it fills counter_views (§6.3).

Refusals, each carrying the offending rule's path (BuildError): unknown fact, unsupported operator, unparsable pattern, a counter view that does not parse (bad duration, unknown key fact, duplicate arguments, a window over the one-day horizon), a non-list Actions, an unparsable action, a REJECT kind that is not Refused or Prohibited, a PROMPT chain nested deeper than MAX_PROMPT_CHAIN (4), refused as an unparsable action, an action the layer does not speak (JOIN, IGNORE or DOWN, the interface layer's verdicts, in a kernel layer, directly or as a PROMPT fallback), a Present condition on a fact that never exists at the rule's layer (every other operator over such a fact is only linted, below; Present looks through the absent-fact law, so X.Present = 0 there would always hold), a Priority that is not an integer, an Enabled that is not 0 or 1, a rule name that is empty or contains a path separator (a backstop: LCS refuses such a key name before the walk could read it) — and, over the name sets, two distinct tag names (or stream names) whose 64-bit hashes collide.

After all three layers build, ntfe_rust_forests_check() runs the checks that span forests, because the stores are machine-wide: tag and stream hashes must be distinct across every forest; every counter view must have a writer in some forest — a view over a stream no rule writes is statically dead (it can only ever read absent) and is refused with the rule and key that read it; and no forest may read a tag a higher forest writes (RawPacket < Packet < Flow) — tags flow strictly upward, and since rules are the only source of tag names the downward read is refused statically, with the reading rule and the name.

6.5.4 Publication #

peios_ntfe_policy_publish(packet, raw, flow, reporting_level) is process context under a mutex. It runs the cross-forest check, then materializes the counter store for the union of every forest's views (§6.6) — so a store that cannot be built (allocation, more than eight windows on one table) refuses the generation before anything is swapped. Only then does it advance the generation counter, allocate the new struct peios_ntfe_policy (three opaque forest pointers plus the reporting level) and rcu_assign_pointer() it into place. Hook-path readers dereference it under rcu_read_lock() and never block; they see the old generation or the new one, never a mix. The old policy is released by call_rcu(), and its forests by ntfe_rust_forest_free() in the callback after grace.

Every walk records its outcome: last_ingest_error (0, or the positive errno of the last failed walk) and last_ingest_t_ns, both in the status. A refusal leaves the previous generation active and says so in the kernel log. contexts in the status is the number of interfaces in the active context table.

6.5.4.1 In the event stream #

NTFE writes its own lifecycle to KMES, origin class KMES_ORIGIN_NTFE (4), beside the reports of §6.6. Every publication writes one ntfe.policy.published, after the swap; a refusal writes none. Its payload is one policy map: generation, the generation now in force; generation-previous, the generation of the policy it replaced, 0 for the first since boot; layers, the layers that have a forest, in the order raw-packet, packet, flow, and an empty list for a policy of no forests; and report-threshold and report-threshold-previous, the new and the replaced CurrentReportingLevel, the latter 1 when nothing was replaced. A walk whose digest is unchanged publishes nothing, and so writes nothing. A context-table change advances the generation without a policy and writes no ntfe.policy.published either, so the generations between two of these events are the earlier policy under a changed context.

A refused rules walk writes one ntfe.policy.rejected. Its policy map holds the generation still in force and previous-retained, always true. Its outcome map holds errno, the walk's negative errno, and reason. When the refusal is in, or names, one rule, a rule map follows. It holds layer when the refusal arose building one layer's forest, and action-error for a bad-action. It holds name, the rule's path relative to its layer key; a path longer than 255 bytes is cut at a character boundary, with name-truncated.

The errno is the walk's, unchanged. The reason is new: the bridge's ntfe_rust_builder_build_why() and ntfe_rust_forests_check_why() fill a struct peios_ntfe_build_why from pnp-core's BuildError. A builder refusal keeps its own name and its rule's path, such as unknown-fact, bad-action with its action error, bad-priority, or tag-downward-read from the cross-forest checks, which name the rule but no layer. The walk names the refusals it makes itself:

  • rule-too-deep and too-many-rules, with -E2BIG;
  • bad-value-type, bad-value-length and not-utf8, in the rule that holds the value;
  • bad-reporting-level, counter-store-refused and out-of-memory;
  • registry-read-failed for a source round trip that failed.

A walk that finds no Rules key writes the event too, with reason no-rules-key, no errno and no rule.

Every walk re-reads the policy, including the walks netd's inventory writes cause, so a broken policy is refused again and again until it is fixed. The event is written once per distinct refusal: a walk whose digest, errno and reason equal those of the last refusal written writes none. A walk the rules stage accepts, published or unchanged, clears that memory, so the next refusal is written whatever it is. A missing Rules key is therefore written once each time the key goes missing. The kernel log line is written for every refusal, as before, and now names the reason. Both events are built on the stack in process context, under the publication mutex or the walk mutex; neither is ever written from the packet path.

6.5.4.2 In force #

A registry write that has returned is delivered, not enforced: the walk that reads it runs after the debounce. Two status counters say which a writer is looking at. changes_noted is incremented by peios_ntfe_network_registry_changed() for every watch event, and LCS delivers its internal watches inside the write that caused them, after the commit and before the syscall returns, so a writer that reads the status after its write sees its own change counted. The deferred work reads changes_noted under the same lock that clears the pending flag, before it walks, and stores that value in changes_walked when the walk returns, whether it published or was refused.

So a writer is in force once changes_walked reaches the changes_noted it read after writing: the walk that satisfied it started after the write committed. A change that lands mid-walk raises changes_noted again and re-arms the work, so the pair stays unequal until a later walk covers it. A refused walk still advances changes_walked; the writer then reads last_ingest_error to learn that what it wrote is not what is enforced. The walk records its error before it stores changes_walked, and the status reads them in the other order, so a status that shows a change walked shows that walk's error or a later one's, never an older one. The bootstrap walk is not a noted change and moves neither counter. net policy wait is this loop.

6.5.5 Generation 0 #

Until the first successful ingestion there is no policy at all, and every layer is permissive: judged stays 0, permissive counts traversals, no events are emitted (there is no decision to attribute), and the status reports enforcing = 0. The init log line says it plainly. This is the ratified loud default — the alternative, a compiled-in policy the registry cannot see, was rejected.

A new generation does not touch the flows: every sentence written under the old one is stale by generation and is re-judged on its flow's next packet (§6.8). That is how a policy change — and a context change, which advances the same counter — reaches running connections without a walk of the conntrack table.

6.6 The stores

Peios / Peios technical reference / PKM / NTFE

Effects need somewhere to land. Three stores, each with a small C surface the bridge calls during evaluation, each confessing what it refuses into the engine status.

6.6.1 Identities #

Tag and stream names cross into the stores as 64-bit FNV-1a hashes (pnp_core::hash::name_hash). The stores never see a string on the packet path, and never a generation: a flow's tag table holds (hash, value) pairs, a counter table is keyed by its stream's hash. Two consequences: tags survive policy reloads by construction (the table knows nothing to invalidate), and a hash must be a deterministic identity, not a probabilistic one — which ingestion guarantees by refusing any generation whose distinct names collide (§6.5). Within a running policy a collision is impossible; across generations the residual is a 64-bit birthday bound over a handful of names, documented and not defended.

6.6.2 Flow tags #

A tag is a named unsigned integer on a conntrack entry. The store is the tags pointer of NTFE's conntrack extension — NF_CT_EXT_NTFE, struct peios_ntfe_ct (include/linux/peios_ntfe.h), added to every flow at creation (init_conntrack(), by the ntfe-conntrack-ext.patch), with the pointer NULL until the flow's first TAG. The extension block of a confirmed conntrack entry is immutable (upstream removed post-confirm resizing as an RCU-reader race), and NTFE's egress seat runs after confirmation, so the extension must exist before it is needed; a fixed extension on every flow is the cheapest way to guarantee that. Since the Flow slice the extension also holds the flow's start time and its two sentences (§6.8) — 88 bytes per flow, most of it the sentences — while untagged flows, nearly all of them, still pay nothing for a table.

The first TAG allocates (GFP_ATOMIC) a table of eight (hash, value, present) entries; a full table is replaced by one twice the size — copy, rcu_assign_pointer(), kfree_rcu() the old — up to the tripwire of 64 distinct tags per flow. Readers walk the table under RCU with no lock (the hook path already holds the read lock); writers serialize on the flow's own ct->lock. Clear tombstones an entry (present = 0) rather than compacting, so a concurrent reader never sees the table shift under it, and a later Set reuses the slot. Add saturates at U64_MAX. The entry's identity is published before the length that exposes it (smp_wmb() then WRITE_ONCE(len)), so a reader that sees the new length sees a complete entry.

When the flow dies, nf_conntrack_free() calls peios_ntfe_ct_destroy() (the same patch), which kfree_rcu()s the table — a reader that found the entry under RCU may still be walking it.

Confessions: tag_writes (ops applied), tag_untracked (a TAG on a packet with no flow — untracked traffic, or the ingress seat — is a no-op), tag_refused (the tripwire, an allocation failure, or a flow whose extension could not be allocated at creation). Clearing an absent tag is a no-op, not a refusal.

6.6.3 Counters #

COUNT(Name[, amount]) emits into a stream; every Counter.Name([window][, key]) a rule reads is a view. Nothing is declared: views are compile-time constants (rules are their only source), so at publication (§6.5) the store receives the complete view set of all three forests — Packet, RawPacket and Flow — and materializes exactly that — one table per distinct (stream hash, key-spec), each answering every window any view of that pair asks for, plus the cumulative total. A COUNT increments every table of its stream; the amplification is bounded by what policy authors wrote, the trusted side of the trust asymmetry.

A table is a 1024-bucket hash of cells (jhash over the cell key) under a per-table spinlock (_bh, since the publisher runs in process context). A cell key is built from the packet by the table's key-spec: address family, source and destination address (16 bytes each, v4 in the first four), interface index — only the facts the spec names, the rest zero. A packet lacking a keyed fact (an ARP frame for a SrcAddr-keyed table) has no cell: its COUNT no-ops into that table (count_key_absent) and its view reads absent — the absent-fact law on both sides.

A cell holds a cumulative total, the seconds of its last write, and one ring per table window: eight buckets, each stamped with the period it belongs to (now / bucket_secs, where bucket_secs = max(1, window / 8)). A write zeroes a bucket whose stamp is stale before adding; a read sums the buckets whose stamp is within the last eight periods. Advancement is lazy — there are no timers — and the window is an approximation up to one bucket wide, by design.

The keyspace of a keyed table is chosen by whoever sends packets, so tables are hard-capped at 4096 cells. At the cap the store first reaps cells idle for longer than the table's longest window (floor 60 s); if none are idle the new key is refused and confessed (count_refused). Never silent eviction.

The store outlives generations. Re-publication keeps tables whose window set did not change, creates new ones, migrates tables whose windows changed (each cell is re-allocated in the new ring layout; the total and any window both sets share carry over, new windows start empty and converge), and retires tables no forest views any more — list_del_rcu() then free after grace, cells included. Reads on the packet path walk the table list and the cell lists under RCU; the publisher holds a mutex.

PEIOS_NTFE_IOC_COUNTERS dumps every cell of every table for the viewer: stream name, key-spec, the key, the total, the last-write time, and the current value of each window. It is a best-effort snapshot, which is fine for counters that are approximate by design: the cells are gathered into a kernel batch under one RCU read section and copied out after it, and a store with more cells than a batch holds (4096) takes further passes, each resuming after the cells already written, so a cell created or retired between passes may be missed or seen twice.

6.6.4 Reports #

REPORT(level) at or above CurrentReportingLevel becomes one KMES event: origin class KMES_ORIGIN_NTFE (4), event type ntfe.verdict.reported, and a msgpack payload laid out as the event catalogue's ntfe fragment says: nested string-keyed maps, one per path segment. rule carries the attribution (name, the path, and hash, the FNV-1a-64 of the whole path), the report-level, and where the judgment stood (layer: raw-packet, packet or flow; seat). outcome carries what it said (verdict: pass, reject or drop; and reason, prohibited or refused, when it was a reject). The packet is network (direction, interface with its name and index, ether-type, family as the AF_* number, protocol, length), source and destination (each an address as text and a port), and flow (state). policy carries the generation. The time is the KMES header's; the payload does not repeat it. A packet key whose fact can be absent is present only when the packet has it: network.protocol, source and destination when it has an address family, their ports when it has ports, flow when the flow-state fact is present. network.direction, network.interface.index, network.ether-type, network.family and network.length are always emitted — an interface index of 0 and a family of AF_UNSPEC (0) are the catalogue's own values for none. network.interface.name is left out when no device was there to name; no key is ever written as an empty string.

The payload is built on the stack (512 bytes) because the packet path runs in softirq and pkm_kmes_emit_kernel() is a preempt-disabled per-CPU ring write with no allocation of its own. Every map's size is known before it is written, save rule's, whose one-byte header is settled once the name's fate is. Every value but rule.name is bounded, so rule goes in last and name last within it: a path too long for the room left is cut at a character boundary to fit, and rule.name-truncated (true, absent when the name is whole) says so. The event is always emitted, and rule.hash still names the whole path, so a reader resolves the rule against the policy. Flood control is the author's by design — the level gate — with KMES's own ring accounting as the backstop. reports_emitted counts what reached the ring.

6.7 The event stream

Peios / Peios technical reference / PKM / NTFE

Every real evaluation — a published forest judged the traversal, or a fail-closed drop — appends one struct peios_ntfe_event to a bounded ring. Permissive traversals emit nothing: there is no decision to attribute, and the status tells that story instead. A packet answered by its flow's cached sentence emits nothing either: there was no evaluation, and flow_cached counts it. The layouts are in §6.A; this page is what they mean.

6.7.1 One event #

An event is the decision and enough of the snapshot to find the packet it was about: sequence number, CLOCK_REALTIME nanoseconds, the seat and layer, the verdict and — when it was a reject — its kind, flags (BACKSTOP, FAIL_CLOSED, REJECT_DEGRADED, REJUDGED for a Flow evaluation that replaced a stale sentence, IDENTITY_UNRESOLVED for one whose endpoint could not be attributed), direction, address family, protocol, flow state, interface index, ports, ethertype, both addresses, the stack-view length, the effect counts the evaluation yielded packed eight bits each (tags, counts, reports, prompts, saturating), and the attributing rule's path — the winning rule, or backstop, or fail-closed — in a 96-byte field (PEIOS_NTFE_EV_ATTR_LEN), so a longer path is cut to 95 bytes, at a byte rather than a character boundary, and NUL-terminated. A Flow event also carries both endpoints' identities as the judgment read them (§6.9): the kind, the process GUID, pid and comm, the user SID and the service SID — binary SIDs, so the viewer, not the kernel, turns them into names.

The path is relative to the layer key: no-inbound/ssh, not Machine\System\Network\Rules\Packet\no-inbound\ssh.

6.7.2 The ring and its reader #

The ring holds 4096 events under a spinlock (irqsave: emission happens in softirq). A full ring overwrites the oldest event and counts the loss in events_dropped — the honesty rule: a slow reader loses data and is told so, in the status and by the gap in sequence numbers.

/dev/peios-ntfe is a misc device, mode 0600. Any number of files may be open on it, because the status and dump ioctls are asked by tools while a viewer holds the stream, but the ring has one drain: the first file to read() claims it until it closes, and another file's read() returns -EBUSY meanwhile. read() returns whole records only, up to 64 per call, and blocks on an empty ring unless O_NONBLOCK; poll() raises POLLIN when events wait. A reader that reconnects resumes from whatever the ring still holds, and catches the gap from the sequence numbers.

The viewer daemon reads it with a small wrinkle worth knowing about: its own HTTP responses to a viewer are judged traffic too, so every event it sends produces another event — a feedback loop. pnpd hides events about its own TCP port from what it serves and counts them (own_verdicts_hidden), which is the same treatment the wire tap gives its own frames.

6.7.3 Status #

PEIOS_NTFE_IOC_STATUS fills struct peios_ntfe_status: the ABI version (check it before trusting the rest — the ABI is experimental and versioned, currently 5), the generation, whether any layer is enforcing, the ring's confessed drops, and the engine counters. The counters are plain 64-bit atomics rather than per-CPU — legibility over throughput while the engine is young, to be revisited with a compiled evaluator.

GroupCounters
Seatsseen_ingress, seen_egress, seen_local_in, seen_local_out, deferred (ingress traversals left for the IP seat), fallback_judged (ingress traversals the Packet layer judged there)
Evaluationjudged, permissive, parse_errors, fail_closed
Verdictsverdict_pass, verdict_drop, verdict_reject, reject_degraded
Effects yieldedfx_tags, fx_counts, fx_reports, fx_prompts
Ingestionlast_ingest_error, last_ingest_t_ns, reporting_level, changes_noted and changes_walked (§6.5, in force), contexts (interfaces in the network context table)
The storestag_writes, tag_untracked, tag_refused, count_writes, count_key_absent, count_refused, reports_emitted, counter_cells
The Flow layerflow_judged (evaluations, sentences written), flow_cached (packets answered by a current sentence), flow_rejudged (stale by generation), flow_expired (stale by time edge), flow_uncached (flows with no extension to hold a sentence, evaluated per packet)
Refusalsrefusals_emitted (answers built and sent), refusals_bypassed (own refusals waved through a seat), teardowns_emitted (far-end resets for refused established TCP flows)
The identity factsidentity_unresolved (endpoints that could not be attributed at resolution: a socket with no KACS state, an inet socket nobody stamped, a loopback sender the inbound seat could not see)

Two invariants a reader can check: judged equals the number of evaluations against a live forest at any layer (Flow evaluations included, so judged − flow_judged is the per-packet count), and permissive counts layer evaluations that found no forest — at generation 0, every one.

PEIOS_NTFE_IOC_COUNTERS is the counter dump described in §6.6: the caller supplies a buffer of struct peios_ntfe_counter_rec, the kernel fills as many as fit and reports both how many it wrote and how many cells exist, so a short buffer is visible. PEIOS_NTFE_IOC_FLOWS is the flows dump described in §6.8, with the same short-buffer contract over struct peios_ntfe_flow_rec.

6.7.4 Confessions, collected #

Everything NTFE declines to do is counted somewhere in the status, and the viewer shows every one of them. A reader should never have to infer a refusal from a missing effect:

  • a REJECT it could not send → reject_degraded, and the event's flag;
  • an evaluation it could not finish → fail_closed, and an event;
  • a frame it could not describe → parse_errors;
  • a tag it could not write → tag_untracked or tag_refused;
  • a count it could not land → count_key_absent or count_refused;
  • a sentence it could not keep → flow_uncached;
  • an endpoint it could not attribute → identity_unresolved, and the event's flag;
  • a packet it did not judge because a sentence answered → flow_cached;
  • a refusal it did not judge because it was its own → refusals_bypassed;
  • a generation it could not accept → last_ingest_error, and the log;
  • an event it could not keep → events_dropped.

6.8 The Flow layer

Peios / Peios technical reference / PKM / NTFE

The Flow layer (Rules\Flow, the rung-2 design on PEI-598) is the same rule atom, verbs and evaluation as the per-packet layers, applied to a different unit: the flow, what conntrack tracks. It exists because the per-packet evaluator has no early exit — collation and side-effects-always mean every packet walks the whole forest, so an Established → PASS rule saves nothing — and because flows carry facts (a start time, relatedness, one day an owner) and effect units (a connection, not a packet) that packets do not. The Packet layer becomes the cheap filter in front; the decisions move here and run once per connection.

6.8.1 One judgment per local endpoint #

peios_ntfe_flow_dispatch() (flow.c) is called at the IP seats: inbound, at LOCAL_IN, for every packet the Packet layer passed; outbound, at LOCAL_OUT, for every packet, before any Packet judgment — the Packet layer judges outbound traffic at egress, after it (§6.2). An untracked packet (snap->flow == NULL) has no flow to judge and is accepted, NF_ACCEPT: inbound the Packet verdict stands, outbound the Packet layer judges it next at egress. A tracked packet reads its flow's sentence:

  • a current sentence — its policy object and policy/context invalidation generation are both current, and its expiry (if any) has not passed — is applied without evaluation (flow_cached);
  • otherwise the Flow forest is evaluated against the snapshot (peios_ntfe_policy_snapshot_eval(), counted in judged and flow_judged), the outcome is written as the new sentence, an event is emitted (with REJUDGED when a stale sentence was replaced, flow_rejudged or flow_expired saying why), and the verdict is applied. A refusal goes out before the event, so the event can confess a degradation.

No Flow forest at all (generation 0, or no Flow key) is permissive, counted, and caches nothing.

Flow dispatch holds one RCU policy snapshot while checking layer presence, preparing identity facts and evaluating. The sentence retains that policy object's generation as an internal stamp alongside the existing invalidation generation, which also changes when network context changes. Both must match before either endpoint's cached verdict can be reused. A packet already evaluating an old policy may finish; its old verdict cannot remain current after a new policy is published. The extra policy stamp does not change the flow-dump ABI.

On a cache miss, Flow refreshes Network.* from the active context table for the packet's traversal interface after capturing the invalidation generation. It clears the old fields first, so removing a context makes those facts absent. Context publication swaps the table before advancing the generation; the reader's acquire of that generation precedes its context lookup. Thus a packet snapshot taken before publication cannot leave an old network verdict cached under the new generation. A context published after the captured generation makes the resulting sentence stale on the next traversal, even if that evaluation already saw the new facts. A current cached sentence needs no extra context lookup.

What the Flow forest judges is the flow view, not the packet's snapshot: a Flow fact is one identical for every packet of the flow, so ntfe_flow_view() builds it from the flow. A reply-direction packet's addresses and ports are swapped back to the original tuple (and its ICMP type replaced by the tuple's); the direction is the originator's, recorded at the first judgment along with the interface, the VLAN, the peer's MAC and the endpoints' identities (§6.9), so a re-judgment on a reply sees exactly the facts the first judgment saw. (Found live before the fix: an inbound viewer flow re-judged on its reply packet as out, and matched outbound-ok.) A loopback flow's view takes the slot's direction. The refusal, when the verdict is one, answers the packet in hand; the event describes the flow as judged.

A normal flow has one local endpoint and one sentence, slot 0, written at the originator's seat on the first packet; the reply direction, and every later packet, reads it. Direction in the judgment is the originator's side. A loopback flow has two local endpoints and two sentences: the outbound one (slot 0) at LOCAL_OUT and the inbound one (slot 1) at LOCAL_IN, both on the same first packet, and every packet of it answers to the stricter of the two (DROP > REJECT(Refused) > REJECT(Prohibited) > PASS). The comparison is made before any refusal is sent: a REJECT overruled by the other end's DROP sends nothing, and one overruled by the other end's stricter REJECT sends that one's kind. Loopback-ness is the seat's device (IFF_LOOPBACK, snap->loopback); a stale other-endpoint sentence is not applied — it is that seat's to refresh when it next sees the flow.

6.8.2 The sentence #

struct peios_ntfe_sentence lives in NTFE's conntrack extension (include/linux/peios_ntfe.h), two per flow: the invalidation generation that judged (0 = empty), the generation of the policy object that judged, expires_at (epoch seconds, 0 = never), the FNV-1a-64 hash of the attributing rule's whole path (the outcome's attributed_hash: the event's attributed text is cut short, the hash never is; the same identity the tag and counter stores use for names, so the viewer resolves it against the policy), the verdict and the reject kind. Alongside: start_secs, stamped when conntrack created the entry (peios_ntfe_ct_ext_add()) — the Start.* facts — and, from the first judgment, the interface, the direction and whether the flow is loopback, for the dump.

NTFE shares conntrack extension storage with accounting and other kernel features. Its allocation sizes and offsets use 16-bit fields; the build-time capacity guard includes the NTFE payload and alignment padding for every allocation order. Adding another extension must not wrap the total length or overwrite an existing extension. These fields are kernel-internal and do not change the flow-dump ABI.

Writes take the flow's lock (ct->lock, _bh), zero the generation first, write the fields, and publish the generation last with a release store. Each slot also has a modification sequence, advanced at the start and end of every write, including a refresh under the same policy generation. The policy generation identifies the policy; it cannot detect every cache update.

Reads are lock-free on the hook path and in the flow dump. They make one attempt: an active writer or a changed modification sequence makes the sentence read as absent. Readers neither spin nor acquire the flow's lock. On a cache miss the flow is evaluated as usual. Two packets of a new flow racing on two CPUs may both evaluate; the second sentence write wins (both judged the identities the first resolution recorded, §6.9), and the effects ran twice — the only place NTFE tolerates that, because the alternative is a lock on the fast path for a race that needs a flow's first two packets to arrive concurrently.

The cache holds the verdict only. Effects run at every evaluation of the flow and never per packet. DROP and REJECT sentences persist for the life of a flow conntrack has confirmed: a cached REJECT refuses every subsequent packet of it. A DROP or REJECT on a flow's first packet at a seat that stands before confirmation — LOCAL_IN for an inbound flow, LOCAL_OUT for an outbound one — leaves nothing to persist: nothing kills the entry, but its packet is dropped before conntrack confirms it (the refusal is filed as the entry's reply, which confirms nothing), so the unconfirmed entry dies with the packet, sentence and all. A retransmitted SYN is then a fresh flow, judged again — effects included — and refused again. So the cached refusal answers only on a confirmed flow: one re-judged mid-life, or a loopback flow's inbound end, judged at LOCAL_IN after POST_ROUTING confirmed the entry. A Refused reset to a confirmed TCP flow also moves it to conntrack's CLOSE state (nf_ct_set_closing()), whose short timeout ends it. A flow whose extension could not be allocated has nowhere to hold a sentence and is evaluated on every packet (flow_uncached).

6.8.3 Staleness #

A sentence is stale when its invalidation generation is not the current one (a policy published, or a network context published — §6.5 — both advance the one counter), when the policy object that judged it is not the current policy object, or when t_secs >= expires_at. Both are checked lazily, on the flow's next packet — an idle flow past a policy change is killed when it next speaks, or conntrack times it out; there are no timers and no walk of the table at publication. Grandfathering was rejected: the registry must not lie about what is enforced, and a REJECT rule must be able to reject something already running. A refused packet of an established TCP flow tears down both ends at once (§6.2): the end that sent it is refused with the kind's story, and the packet, turned into a reset, is sent on to the other end. Before the teardown existed, a killed viewer stream froze at the local end while the silent host peer waited for its own next packet to meet the cached REJECT sentence — correct under the lazy law, and half a kill.

The expiry is the evaluation's expires_at (§6.4): the earliest moment any live-time condition the judgment consulted would flip. A forest with no time conditions never expires a sentence; Start.* conditions never contribute, which is the point of them.

6.8.4 The flows dump #

PEIOS_NTFE_IOC_FLOWS walks conntrack's table the way ctnetlink does — local_bh_disable(), each bucket under its nf_conntrack_locks lock, original-direction entries of init_net that are neither expired nor dying — and fills struct peios_ntfe_flow_rec per flow: conntrack's id, family, protocol, the original tuple (ports, or ICMP id and type/code), seen_reply/assured/related, the remaining lifetime, packet and byte counts (NTFE turns conntrack accounting on at init — the net.netfilter.nf_conntrack_acct sysctl, init_net.ct.sysctl_acct — since it is conntrack's consumer now), and the extension: start time, first-judgment interface and direction, loopback, both sentences, both ends' identities, and up to eight tags by hash with the flow's total tag count. Records are batched in kernel memory (32 to a batch) and copied to user between passes over the buckets, never under a lock; a bucket holding more flows than the batch has room for is walked again under its lock from where the batch filled, rather than losing the rest. The walk counts every live flow it saw so a short buffer is visible, and is best-effort against a table that changes under it.

6.8.5 What was decided against #

  • Backstop inheritance (the flow's verdict as the Packet layer's compiled-in default): unnecessary once Packet is the filter in front and passes tracked traffic by shipped configuration.
  • Two layers, Inbound and Outbound: direction-blind rules are common and shadowing gives the direction-specific exceptions; the posture is a visible root rule; per-seat direction pruning is a future evaluator optimisation, invisible to authors.
  • Effects on first judgment only: a kill by REJECT, REPORT(n) must report; the COUNT noise is per generation, not per packet.
  • Identity in the sentence. The sentence caches the verdict; the identities the judgment read are recorded beside it (§6.9), not re-derived per packet.

6.9 The identity facts

Peios / Peios technical reference / PKM / NTFE

The identity facts (rung 3 on PEI-598) answer the question the address facts cannot: not where a flow goes but who on this machine is speaking or listening. They are Flow-layer facts by the same law as Related and Start.* — a flow has one answer for its whole life — and they are the first facts NTFE reads from somewhere other than the packet. KACS provides them; the engine resolves them once per flow.

6.9.1 What KACS stamps on a socket #

Every inet socket carries a governing identity, recorded by KACS in the socket's security state (§3.12.2): the caller's effective token — so a service thread impersonating a client attributes the socket to the client, as audit does — and the process facts of that moment: the process GUID, the thread-group id and the comm of the thread that made the stamp (a thread's own name, which need not be its process's). The stamp is taken at every act that commits the socket to a role: creation, bind, listen, connect, inheritance at accept, and KACS_SO_RESTAMP. The last stamp governs, which is how a listener handed to another program (socket activation, descriptor passing) is governed as that program's. A kernel socket is stamped as the kernel's, with no token.

The engine reads the stamp through one accessor, pkm_kacs_socket_owner() (<linux/peios_ntfe.h>), which hands it a counted reference to the token and a copy of the process facts. The facts outlive the process; the reference outlives the socket.

6.9.2 What stands at an end #

identity.c classifies each local end of a flow by whether anyone answers, not by whether a socket structure exists. The result is the Local fact (program, kernel, shared, none) and, for a program, the principal behind Local.*.

Outbound, at LOCAL_OUT: the sending socket's stamp. A stamped program socket is program; a kernel socket, or no socket at all (resets, ICMP errors, IGMP) is kernel.

Inbound, at LOCAL_IN, a transport lookup for the receiver of this very packet:

  • UDP to a multicast or broadcast destination is shared before any lookup: the stack delivers it to every socket bound to the port — one flow, many endpoints — and the per-program question belongs to the join, not to the packet. Local.* is absent.
  • TCP, UDP and UDP-Lite are looked up by tuple, the way the netfilter socket match does — listeners included, and through the same hash a SO_REUSEPORT group steers by, so the sentence names the socket that will actually receive. Early demux may already have found it, and its socket is used in place of the lookup — for UDP only once the datagram has passed the shared test above, since early demux also attaches the one socket a multicast datagram happens to match. An early result is used only if its socket still has a live reference that NTFE can pin. UDP early demux keeps the pointer valid under RCU but does not itself hold a reference; if the socket's last reference has gone, NTFE falls back to the ordinary tuple lookup without reviving the socket. A missing receiver then gives the same none result as an ordinary lookup miss. The temporary socket reference is released after reading the identity. Any other protocol is looked up among raw sockets bound to it.
  • A socket found is program (a request minisock stands for its listener; a TIME_WAIT minisock is nobody's: kernel).
  • Nothing found: kernel if the stack has a handler registered for the protocol — ICMP and ICMPv6 (neighbour discovery, router advertisements, MLD), IGMP, the tunnel and IPsec outers that are decapsulated and re-enter the ingress seat as their inner packet — else none, which the stack will answer with a reset or an unreachable.

The classification is a rule, not a list of protocols. Two consequences are documented rather than special-cased: SCTP, a socket transport whose table lives inside its module, reads kernel while the module is loaded; and a ping socket's echo reply pairs with the outbound request in conntrack and inherits the outbound program sentence, so the direct lookup is deferred.

The lookup runs once per flow, on the first judgment, and only when a Flow forest is published: a permissive machine pays nothing.

6.9.3 Loopback: both ends #

A loopback flow has two local ends and two sentences (§6.8). The outbound seat resolves both on the first packet — its own end from the socket, the other by running the receiver lookup early on the loopback-destined packet — and records them on the flow's extension. The inbound seat's judgment, on the same packet, reads both from there: Local.* is its own end and Remote.* the sender's. The inbound seat cannot see a loopback packet's sender itself (loopback transmission orphans the buffer), so a flow whose extension could not be allocated reads Remote as absent there, confessed. Off loopback Remote is always absent: nothing is provable about a peer yet.

6.9.4 Fixed for the flow's life #

The identities are recorded on the conntrack extension at the first judgment, per slot, beside the direction and the interface, and never replaced: a re-judgment after a policy change or a time edge sees the same principal, and a later restamp of the socket, a fork after accept, or a SO_REUSEPORT sibling taking over the port changes nothing for flows already judged. The extension holds one counted token reference per slot and releases it when conntrack frees the flow. Two CPUs racing on a new flow's first packets may both resolve; the first record stands — unlike the sentence, where the second write wins (§6.8) — and the loser releases its reference and judges the recorded identity instead of its own.

6.9.5 Across the bridge #

The flow view carries, per end, the kind, the process facts and a borrowed token pointer. The bridge (kacs/ntfe_runtime.rs) implements pnp-core's Principal trait over the token — user SID, enabled-group membership (deny-only groups are invisible to policy), integrity level, confinement SID and capabilities, the per-service SID found among the enabled groups, the process GUID as text (PCDS's string form — Data1, Data2 and Data3 read as little-endian numbers and written most significant digit first, Data4 bytes in order — lowercase and unbraced, 8-4-4-4-12; ingestion lowercases a Local.Process or Remote.Process pattern and strips braces from a braced one to match) — without copying the group list: a token may carry a thousand groups and the judgment runs in softirq context, so the snapshot borrows a view and asks membership questions of it. The view is lock-free: group SIDs are fixed at token creation and each group's attributes are an atomic.

The judgment compares SIDs and nothing else; what NTFE knows of a SID's meaning is only what pnp-core compiles into it — the service-SID derivation and a table of well-known names — used once, at ingestion, to turn a name a rule wrote into a SID. The engine holds no directory of principals and resolves no SID back to a name. Local.Service.Equal = resolvd is turned into the service's SID at ingestion by pnp-core, with the same derivation peinit and authd use to mint it (the SHA-1 of the uppercased UTF-16LE name under S-1-5-80); Local.User.Equal = LocalService by a table of well-known names. The viewer resolves the other way, from the service definitions in the registry.

6.9.6 What the stream says #

A Flow event and a flow record carry both ends: the kind, the process GUID, pid and comm, the user SID and the service SID (ABI 4, §6.A, §6.B). An end that could not be attributed — a socket with no KACS state, an inet socket nobody stamped, a loopback sender the inbound seat could not see — is confessed: identity_unresolved in the status, PEIOS_NTFE_EV_F_IDENTITY_UNRESOLVED on the event, and the flag on the flow record's slot.

6.9.7 At rest: the listeners dump #

The same stamp, read without a packet: PEIOS_NTFE_IOC_LISTENERS (listeners.c) walks the listening TCP hash and the bound UDP and UDP-Lite tables of the root namespace the way /proc/net/tcp and /proc/net/udp do — each bucket under its lock, records copied out between buckets — and reports every socket prepared to receive with the identity that governs it. It is the attack surface as a list, by whom, and it exercises the stamp before a single flow is judged.

6.9.8 What was decided against #

  • Identity in the per-packet layers. A packet carries no owner; the socket does. Local.* in a Packet rule is linted as never present, and Present on it refuses the generation.
  • Many sentences for a shared receiver. Inbound multicast is one flow with many endpoints; modelling N judgments would have made the join-time question a per-packet one. shared and a later gate on IP_ADD_MEMBERSHIP (C11) instead.
  • Copying the group list into the snapshot. Up to 1024 SIDs per token, in softirq: the Principal view answers membership instead.
  • Program.Trust and the image hash. PIP is under review, and unsigned binaries are not hashed at exec today; both wait.

Appendix 6.A NTFE ABI Reference

Peios / Peios technical reference / PKM / NTFE

Every name, value, offset and size in this appendix is generated from pkm/uapi/pkm/ntfe.h by pkm/tools/gen-ntfe-abi.py, with struct layouts measured by compiling a probe against the real header. Regenerate it whenever the ABI changes; do not edit it by hand. The names here are the ones a program actually compiles against.

What a compiler cannot measure -- the device's read and poll semantics, what each ioctl expects, the error vocabulary, and the bounds that are not in the header -- is in the notes appendix, §6.B, which this generator does not touch.

6.A.1 Ioctl requests #

Request numbers on /dev/peios-ntfe, packed as <linux/ioctl.h> packs them (direction, argument size, type 'N', number).

ConstantValueDefinition
PEIOS_NTFE_IOC_STATUS0x81704E01_IOR(PEIOS_NTFE_IOC_TYPE, PEIOS_NTFE_IOC_STATUS_NR, struct peios_ntfe_status)
PEIOS_NTFE_IOC_COUNTERS0xC0184E02_IOWR(PEIOS_NTFE_IOC_TYPE, PEIOS_NTFE_IOC_COUNTERS_NR, struct peios_ntfe_counters_query)
PEIOS_NTFE_IOC_FLOWS0xC0184E03_IOWR(PEIOS_NTFE_IOC_TYPE, PEIOS_NTFE_IOC_FLOWS_NR, struct peios_ntfe_flows_query)
PEIOS_NTFE_IOC_LISTENERS0xC0184E04_IOWR(PEIOS_NTFE_IOC_TYPE, PEIOS_NTFE_IOC_LISTENERS_NR, struct peios_ntfe_listeners_query)

6.A.2 Structure layouts #

Offsets and sizes are measured, not declared.

6.A.2.1 struct peios_ntfe_event #

Total size 456 bytes.

OffsetSizeTypeField
08__u64seq
88__u64t_ns
161__u8seat
171__u8layer
181__u8verdict
191__u8flags
201__u8direction
211__u8addr_family
221__u8protocol
231__u8flow_state
244__u32ifindex
282__u16src_port
302__u16dst_port
322__u16ether_type
341__u8reject_kind
351__u8_pad0
3616__u8[16]src_addr
5216__u8[16]dst_addr
684__u32length
724__u32effects
7696__u8[PEIOS_NTFE_EV_ATTR_LEN]attributed
1724__u32_pad1
1761__u8local_kind
1771__u8remote_kind
1781__u8local_unresolved
1791__u8remote_unresolved
1804__s32local_pid
1844__s32remote_pid
18816__u8[PEIOS_NTFE_GUID_LEN]local_guid
20416__u8[PEIOS_NTFE_GUID_LEN]remote_guid
22016__u8[PEIOS_NTFE_COMM_LEN]local_comm
23616__u8[PEIOS_NTFE_COMM_LEN]remote_comm
25268__u8[PEIOS_NTFE_SID_LEN]local_user
32068__u8[PEIOS_NTFE_SID_LEN]remote_user
38832__u8[PEIOS_NTFE_SERVICE_SID_LEN]local_service
42032__u8[PEIOS_NTFE_SERVICE_SID_LEN]remote_service
4524__u32_pad2

6.A.2.2 struct peios_ntfe_status #

Total size 368 bytes.

OffsetSizeTypeField
08__u64abi
88__u64generation
168__u64enforcing
248__u64events_dropped
328__u64seen_ingress
408__u64seen_egress
488__u64seen_local_in
568__u64deferred
648__u64fallback_judged
728__u64parse_errors
808__u64judged
888__u64permissive
968__u64fail_closed
1048__u64verdict_pass
1128__u64verdict_drop
1208__u64verdict_reject
1288__u64reject_degraded
1368__u64fx_tags
1448__u64fx_counts
1528__u64fx_reports
1608__u64fx_prompts
1688__u64last_ingest_error
1768__u64last_ingest_t_ns
1848__u64tag_writes
1928__u64tag_untracked
2008__u64tag_refused
2088__u64count_writes
2168__u64count_key_absent
2248__u64count_refused
2328__u64reports_emitted
2408__u64counter_cells
2488__u64reporting_level
2568__u64seen_local_out
2648__u64flow_judged
2728__u64flow_cached
2808__u64flow_rejudged
2888__u64flow_expired
2968__u64flow_uncached
3048__u64refusals_emitted
3128__u64refusals_bypassed
3208__u64teardowns_emitted
3288__u64identity_unresolved
3368__u64changes_noted
3448__u64changes_walked
3528__u64contexts
3608__u64[1]_reserved

6.A.2.3 struct peios_ntfe_counter_rec #

Total size 232 bytes.

OffsetSizeTypeField
064__u8[PEIOS_NTFE_COUNTER_NAME_LEN]name
648__u64hash
721__u8keyspec
731__u8family
742__u8[2]_pad0
764__s32ifindex
8016__u8[16]src_addr
9616__u8[16]dst_addr
1128__u64total
1208__u64last_secs
1284__u32n_windows
1324__u32_pad1
13632__u32[PEIOS_NTFE_COUNTER_MAX_WINDOWS]window_secs
16864__u64[PEIOS_NTFE_COUNTER_MAX_WINDOWS]window_value

6.A.2.4 struct peios_ntfe_counters_query #

Total size 24 bytes.

OffsetSizeTypeField
08__u64buf
84__u32buf_len
124__u32count
164__u32total
204__u32_pad0

6.A.2.5 struct peios_ntfe_flow_rec #

Total size 568 bytes.

OffsetSizeTypeField
04__u32id
41__u8family
51__u8protocol
61__u8direction
71__u8loopback
81__u8seen_reply
91__u8assured
101__u8related
111__u8judged
124__s32ifindex
164__u32timeout_secs
2016__u8[16]src_addr
3616__u8[16]dst_addr
522__u16src_port
542__u16dst_port
561__u8icmp_type
571__u8icmp_code
581__u8n_tags
595__u8[5]_pad0
648__u64start_secs
7216__u64[2]packets
8816__u64[2]bytes
10416__u64[PEIOS_NTFE_FLOW_SENTENCES]sentence_generation
12016__s64[PEIOS_NTFE_FLOW_SENTENCES]sentence_expires_at
13616__u64[PEIOS_NTFE_FLOW_SENTENCES]sentence_rule_hash
1522__u8[PEIOS_NTFE_FLOW_SENTENCES]sentence_verdict
1542__u8[PEIOS_NTFE_FLOW_SENTENCES]sentence_reject_kind
1564__u8[4]_pad1
16064__u64[PEIOS_NTFE_FLOW_MAX_TAGS]tag_hash
22464__u64[PEIOS_NTFE_FLOW_MAX_TAGS]tag_value
2882__u8[PEIOS_NTFE_FLOW_SENTENCES]owner_kind
2902__u8[PEIOS_NTFE_FLOW_SENTENCES]owner_unresolved
2924__u8[4]_pad2
2968__s32[PEIOS_NTFE_FLOW_SENTENCES]owner_pid
30432__u8[32]owner_guid
33632__u8[32]owner_comm
368136__u8[136]owner_user
50464__u8[64]owner_service

6.A.2.6 struct peios_ntfe_flows_query #

Total size 24 bytes.

OffsetSizeTypeField
08__u64buf
84__u32buf_len
124__u32count
164__u32total
204__u32_pad0

6.A.2.7 struct peios_ntfe_listener_rec #

Total size 168 bytes.

OffsetSizeTypeField
01__u8family
11__u8protocol
21__u8reuseport
31__u8connected
41__u8v6only
51__u8owner_kind
61__u8owner_unresolved
71__u8_pad0
82__u16port
102__u16_pad1
124__s32ifindex
1616__u8[16]addr
324__s32owner_pid
3616__u8[PEIOS_NTFE_GUID_LEN]owner_guid
5216__u8[PEIOS_NTFE_COMM_LEN]owner_comm
6868__u8[PEIOS_NTFE_SID_LEN]owner_user
13632__u8[PEIOS_NTFE_SERVICE_SID_LEN]owner_service

6.A.2.8 struct peios_ntfe_listeners_query #

Total size 24 bytes.

OffsetSizeTypeField
08__u64buf
84__u32buf_len
124__u32count
164__u32total
204__u32_pad0

6.A.3 Constants #

Grouped as the header groups them.

NTFE — the Network Traffic Filtering Engine: the verdict event stream and engine status.

The engine (net/ntfe) judges every traversal at its standing seats and appends one event per evaluation to a bounded ring. /dev/peios-ntfe (mode 0600; any number of openers, one reader of the stream at a time) drains it: read() returns whole events only — never a partial record — and blocks when the ring is empty unless O_NONBLOCK; poll() raises POLLIN when events are waiting. A slow reader loses the OLDEST events, and the loss is confessed in peios_ntfe_status.events_dropped (the honesty rule: drops are counted, never silent).

Events are emitted for real evaluations (a published forest judged the traversal) and for fail-closed drops; permissive traversals (a layer with no forest — all of them at generation 0) emit nothing, because there is no decision to attribute. Status tells that story instead: generation 0 means "not enforcing", loudly.

This ABI is EXPERIMENTAL while NTFE grows: no stability promise until the design ships (PEI-598). Check abi before trusting the rest.

ConstantValue
PEIOS_NTFE_ABI_VERSION5

Which standing seat judged the traversal.

ConstantValue
PEIOS_NTFE_EV_SEAT_INGRESS1
PEIOS_NTFE_EV_SEAT_EGRESS2
PEIOS_NTFE_EV_SEAT_LOCAL_IN3
PEIOS_NTFE_EV_SEAT_LOCAL_OUT4

Which rules layer.

ConstantValue
PEIOS_NTFE_EV_LAYER_PACKET0
PEIOS_NTFE_EV_LAYER_RAWPACKET1
PEIOS_NTFE_EV_LAYER_FLOW2

The verdict, in strictness order.

ConstantValue
PEIOS_NTFE_EV_VERDICT_PASS0
PEIOS_NTFE_EV_VERDICT_REJECT1
PEIOS_NTFE_EV_VERDICT_DROP2

The story a REJECT told (meaningful iff verdict == REJECT).

ConstantValueNotes
PEIOS_NTFE_EV_REJECT_REFUSED0RST / port-unreachable
PEIOS_NTFE_EV_REJECT_PROHIBITED1admin-prohibited

Traversal direction.

ConstantValue
PEIOS_NTFE_EV_DIR_IN0
PEIOS_NTFE_EV_DIR_OUT1

Flow state as the snapshot carried it (0 = the fact was absent).

ConstantValue
PEIOS_NTFE_EV_FLOW_ABSENT0
PEIOS_NTFE_EV_FLOW_NEW1
PEIOS_NTFE_EV_FLOW_ESTABLISHED2
PEIOS_NTFE_EV_FLOW_RELATED3
PEIOS_NTFE_EV_FLOW_INVALID4
PEIOS_NTFE_EV_FLOW_UNTRACKED5

Event flags.

ConstantValueNotes
PEIOS_NTFE_EV_F_BACKSTOP0x01nothing yielded; DROP
PEIOS_NTFE_EV_F_FAIL_CLOSED0x02evaluation failed; DROP
PEIOS_NTFE_EV_F_REJECT_DEGRADED0x04REJECT emitted as DROP
PEIOS_NTFE_EV_F_REJUDGED0x08Flow: a stale sentence re-judged
PEIOS_NTFE_EV_F_IDENTITY_UNRESOLVED0x10Flow: an endpoint could not be attributed

What stood at an endpoint: the Flow layer's Local / Remote facts (ABI 4).

ConstantValueNotes
PEIOS_NTFE_EV_LOCAL_ABSENT0not a Flow event / not local
PEIOS_NTFE_EV_LOCAL_PROGRAM1a process's socket
PEIOS_NTFE_EV_LOCAL_KERNEL2the stack itself
PEIOS_NTFE_EV_LOCAL_SHARED3inbound multicast / broadcast
PEIOS_NTFE_EV_LOCAL_NONE4nothing receives it
PEIOS_NTFE_EV_ATTR_LEN96

A SID's binary form: revision, sub-authority count, a 48-bit authority, up to 15 sub-authorities. Self-sized by its count byte; all zero = absent.

ConstantValue
PEIOS_NTFE_SID_LEN68

A per-service SID (S-1-5-80 + five sub-authorities) is exactly this.

ConstantValue
PEIOS_NTFE_SERVICE_SID_LEN32
PEIOS_NTFE_COMM_LEN16
PEIOS_NTFE_GUID_LEN16

One counter cell, as the counters dump reports it: the stream and key- spec of its table, the key it holds (only the facts the key-spec names are meaningful; the rest are zero), the cumulative total, and the value of every window the table answers.

ConstantValue
PEIOS_NTFE_COUNTER_NAME_LEN64
PEIOS_NTFE_COUNTER_MAX_WINDOWS8

Key-spec bits.

ConstantValue
PEIOS_NTFE_KEY_SRC_ADDR0x01
PEIOS_NTFE_KEY_DST_ADDR0x02
PEIOS_NTFE_KEY_INTERFACE0x04

One live flow, as the flows dump reports it (ABI 3): conntrack's view of the flow (original-direction tuple, state, remaining lifetime, accounting), NTFE's extension (start time, the interface and direction at first judgment, the sentences, the tags). Tags are reported by hash; the policy names them.

ConstantValue
PEIOS_NTFE_FLOW_MAX_TAGS8
PEIOS_NTFE_FLOW_SENTENCES2

The listeners dump: fills buf with as many records as fit; count is how many were written, total how many sockets the walk saw.

ConstantValue
PEIOS_NTFE_IOC_TYPE0x0000004E
PEIOS_NTFE_IOC_STATUS_NR0x00000001
PEIOS_NTFE_IOC_COUNTERS_NR0x00000002
PEIOS_NTFE_IOC_FLOWS_NR0x00000003
PEIOS_NTFE_IOC_LISTENERS_NR0x00000004

Appendix 6.B NTFE ABI Notes

Peios / Peios technical reference / PKM / NTFE

§6.A is generated from pkm/uapi/pkm/ntfe.h and holds only what a compiler can measure. This appendix holds the rest.

6.B.1 Stability #

The ABI is experimental while NTFE grows (PEI-598): no stability promise until the design ships. peios_ntfe_status.abi carries PEIOS_NTFE_ABI_VERSION; a consumer checks it before trusting any other field or record layout. Version 2 (the machinery slice) added the event's reject_kind, the store confessions and counter_cells and reporting_level in the status, and the counters ioctl. Version 3 (the Flow layer) added the LOCAL_OUT seat and Flow layer ids, the REJUDGED event flag, the Flow and refusal counters in the status, and the flows ioctl. Version 4 (the identity facts) added the endpoints' identities to the event and the flow record, the IDENTITY_UNRESOLVED event flag and the identity_unresolved counter (in a reserved slot, so the status kept its size). Version 5 renamed the device and every symbol from pnp to ntfe, let more than one file hold the device open, and added changes_noted, changes_walked and contexts to the status: the first took one of the two reserved words version 4 left, the other two are new, so the status grew by two words and keeps one reserved. pnpd and the kernel ship together on the experimental edition, so the check is a guard, not a negotiation.

6.B.2 /dev/peios-ntfe #

A misc device, created mode 0600 and owned by uid 0 — but on Peios mode bits and uids decide nothing (DAC is neutralised): KACS decides an open() by the opener's token against the node's security descriptor (§3.9.5). On devtmpfs that descriptor is inherited from the seeded root's, which grants GENERIC_ALL to SYSTEM alone, so the device is SYSTEM's unless trusted userspace sets another.

OperationSemanticsErrors
open()Any number of openers.—
read(buf, len)Returns whole struct peios_ntfe_event records only — never a partial one — up to 64 per call, oldest first, consuming them. Blocks on an empty ring unless O_NONBLOCK.EINVAL — len smaller than one record; EBUSY — another open file is the stream's reader; EAGAIN — empty and non-blocking; EINTR; ENOMEM; EFAULT — the records it took are lost (below)
poll()POLLIN | POLLRDNORM when at least one event waits.—
ioctl(PEIOS_NTFE_IOC_STATUS, struct peios_ntfe_status *)Fills the status snapshot. Cumulative counters since boot.EFAULT
ioctl(PEIOS_NTFE_IOC_COUNTERS, struct peios_ntfe_counters_query *)buf/buf_len describe a user buffer of struct peios_ntfe_counter_rec; on return count is how many were written and total how many cells exist. A short buffer is not an error — the two numbers disagree. Best-effort snapshot: cells may change between records.EFAULT; ENOMEM
ioctl(PEIOS_NTFE_IOC_FLOWS, struct peios_ntfe_flows_query *)Same contract over struct peios_ntfe_flow_rec: count written, total live flows the walk saw. Records are copied out between hash buckets, so the dump is a best-effort picture of a table that changes under it.EFAULT; ENOMEM
ioctl(PEIOS_NTFE_IOC_LISTENERS, struct peios_ntfe_listeners_query *)Same contract over struct peios_ntfe_listener_rec: every TCP socket in the listening state and every bound UDP / UDP-Lite socket of the root network namespace, with the identity KACS stamped on it (§6.9). total is how many the walk saw. A hash bucket holding more sockets than one copy-out batch is walked again from where the batch filled, so with room for every record count equals total.EFAULT; ENOMEM
other ioctls—ENOTTY

Sequence numbers are monotonic per boot. A gap between consecutive records read is the number of events the ring overwrote while the reader was away, plus any a faulting read() consumed; events_dropped in the status is the running total of the overwrites. A read() that fails with EFAULT has already taken its records off the ring: they are lost, and counted nowhere but in that gap.

6.B.3 Event fields #

  • attributed is the winning rule's path relative to its layer key, UTF-8, NUL-terminated, truncated to PEIOS_NTFE_EV_ATTR_LEN − 1 bytes (95), which may split a multi-byte character. Two reserved values: backstop (nothing yielded) and fail-closed (evaluation failed).
  • effects packs the effect counts the evaluation yielded — tags | counts << 8 | reports << 16 | prompts << 24, each saturating at 255. What the stores then applied is in the status confessions, not in the event.
  • reject_kind is meaningful only when verdict is PEIOS_NTFE_EV_VERDICT_REJECT; a degraded reject (flags & PEIOS_NTFE_EV_F_REJECT_DEGRADED) still carries the kind the rule chose.
  • PEIOS_NTFE_EV_F_REJUDGED marks a Flow-layer evaluation that replaced a stale sentence (policy change or time edge). A packet answered by a current sentence produces no event at all.
  • layer 2 is Flow; seat 4 is LOCAL_OUT. A Flow event's direction is the endpoint judged: the originator's side for a normal flow, each seat's own for a loopback flow's two judgments.
  • Addresses: the first 4 bytes when addr_family is 4, all 16 when 6, undefined when 0. Ports are 0 when the fact was absent — check protocol.
  • flow_state 0 means the fact was absent (the ingress seat), not that the flow was untracked; untracked is 5.
  • The identity fields (ABI 4) are set on Flow events only and zero elsewhere. local_kind / remote_kind are PEIOS_NTFE_EV_LOCAL_*: ABSENT (0) for a non-Flow event, and for remote whenever the other end is not local; remote is filled only on a loopback flow. For a PROGRAM end, *_guid, *_pid and *_comm are the process facts at the socket's stamp, *_user the token's user SID and *_service its per-service SID, both binary and self-sized (byte 1 is the sub-authority count; all zero = absent — a user program has no service SID). *_unresolved says the end could not be attributed and was reported as the kernel's or as absent.

6.B.4 Counter records #

  • name is the stream name (a rule's COUNT(Name)), NUL-terminated; hash is its FNV-1a identity as the kernel keys tables.
  • keyspec says which of src_addr, dst_addr, ifindex are meaningful for this cell's key; the others are zero. family is 4, 6, or 0 when no address fact is keyed.
  • window_secs[i] / window_value[i] for i < n_windows are the table's windows and this cell's current value in each; total is cumulative since the cell was created (or migrated — see §6.6); last_secs is CLOCK_REALTIME seconds of the last write, for staleness.
  • The dump lists every table's cells consecutively; group by (name, keyspec) to reconstruct tables.

6.B.5 Flow records #

  • id is conntrack's own id for the entry (nf_ct_get_id()), stable for the flow's life and the key to use across dumps.
  • src_addr/dst_addr, the ports and the ICMP fields are the original-direction tuple — the originator first. For ICMP, src_port carries the echo id and icmp_type/icmp_code the type and code; dst_port is 0.
  • direction, ifindex and loopback are meaningful only when judged is 1: they were recorded at the Flow layer's first judgment. A flow with judged == 0 has had no Flow judgment recorded: every packet of it that reached the flow dispatch (§6.8) found no Flow forest (a permissive generation) or failed closed, or none did — or it has no extension at all, and so nowhere to record one (flow_uncached; its sentences and identities read empty too).
  • The sentences are parallel arrays indexed by slot (UAPI records hold scalars only): slot 0 is the flow's sentence; slot 1 is only ever filled for a loopback flow (its inbound endpoint). A slot with sentence_generation == 0 is empty. sentence_expires_at 0 means never. sentence_rule_hash is FNV-1a-64 (offset 0xcbf29ce484222325, prime 0x100000001b3) of the whole attributing path relative to the layer key, however long — not of the event's truncated attributed. backstop hashes like any other path; fail-closed is never a sentence's, since a failed evaluation is not cached.
  • packets/bytes are conntrack's accounting, original then reply; NTFE turns conntrack accounting on at init (net.netfilter.nf_conntrack_acct).
  • timeout_secs is the entry's remaining lifetime as conntrack sees it; start_secs is CLOCK_REALTIME seconds when conntrack created it.
  • tag_hash/tag_value hold up to PEIOS_NTFE_FLOW_MAX_TAGS (8) present tags by name hash and value; n_tags is the flow's total, so a value above 8 means some are not listed.
  • The identities (ABI 4) are per sentence slot, recorded at the flow's first judgment and fixed: owner_kind[slot] is PEIOS_NTFE_EV_LOCAL_* (ABSENT = not resolved; ABSENT with owner_unresolved[slot] set = judged but unattributed, such as a loopback sender the outbound seat never recorded); the per-slot arrays are flattened at a fixed stride — owner_guid 16 bytes per slot, owner_comm 16, owner_user 68, owner_service 32 — so slot 1's user SID starts at byte 68. Slot 1 is filled only for a loopback flow.

6.B.6 Listener records #

  • One record per socket prepared to receive: TCP in LISTEN, and UDP / UDP-Lite bound to a port (connected when it also has a peer and so receives from one address only). addr all zero is the wildcard; ifindex is SO_BINDTODEVICE, 0 for any; reuseport marks a member of a SO_REUSEPORT group, of which each member is listed. v6only says an AF_INET6 socket refuses v4-mapped traffic — a v6 socket without it answers on both families.
  • The owner fields are those of the flow record's slot, for the socket's current stamp: owner_kind is PROGRAM or KERNEL (a socket nobody stamped reads KERNEL with owner_unresolved set).
  • The walk is the root namespace's tables only, copied out between hash buckets: a best-effort list of a set that changes under it.

6.B.7 Bounds not in the header #

BoundValue
Event ring4096 records, overwrite-oldest
Records per read()64
Counter cells per table4096 (PEIOS_NTFE_COUNTER_MAX_KEYS, kernel-internal)
Distinct tags per flow64 (PEIOS_NTFE_TAG_MAX_PER_FLOW, kernel-internal)
Rule depth / rules per layer12 (roots are depth 0: 13 levels) / 4096 (ingestion)
Longest counter window86 400 s
Sentences per flow2 (slot 1 only for loopback flows)
Flow records batched per copy-out32 (kernel-internal)
Listener records batched per copy-out32 (kernel-internal)

6.B.8 Build configuration #

CONFIG_PEIOS_NTFE (bool) depends on SECURITY_PKM, NETFILTER, NETFILTER_INGRESS, NETFILTER_EGRESS and NF_CONNTRACK=y — NTFE is built in and reads flow facts on the packet path, so conntrack must be too. CONFIG_PEIOS_NTFE_KUNIT builds the kernel-resident tests (pkm_kunit_ntfe), defaulting to SECURITY_PKM_KUNIT. The production fragment (build/config/pkm.fragment) enables NTFE and configures the nf_tables/xtables family out; kernel/verify-kernel-config.sh asserts both.

1.1 Overview

Peios / Peios technical reference / peinit / Introduction

peinit is PID 1. It is the only service manager on a Peios system: every supervised process on the machine — a platform daemon, an application service, a startup hook, a health probe, a job some other service asked for on a user's behalf — is forked by peinit and watched by peinit until it exits.

It is a single-threaded Rust process. That is the constraint the rest of its design answers to. A blocking syscall in PID 1 stops everything: child reaping, watchdog expiry, shutdown signals, the control socket. So peinit keeps a complete in-memory model of every service it knows about, reads the registry synchronously only at boot and when it reloads — whether an administrator asked for the reload or a registry change notification set it off, which takes the same path (§10.4) — and pushes anything that could block off the main loop into a forked helper or a pollable descriptor.

1.1.1 What makes it not systemd #

Two things, and both run deep enough to change the shape of the daemon.

Services are securable objects. A service carries a Security Descriptor that says who may start it, stop it, query it, or reload it, and peinit evaluates that descriptor against the caller's KACS token on every control request. There is no "root can do anything" path, because there is no root — there is a token, and AccessCheck is the only thing that decides.

Service identity is a token, not a user. peinit never sets a UID, a GID, or a Linux capability on a service process. It obtains a KACS token — minted from its own for the platform daemons that start before an authority exists, requested from authd for everything else — restricts its privileges to what the definition asked for, and installs it on the child before exec. Every service also carries a per-service SID derived from its name, so two services sharing an identity are still distinguishable to an access check.

Configuration follows from the second one: service definitions live in the registry, under Machine\System\Services\, where they are protected by the registry's own descriptors rather than by file permissions. There are no unit files, no generators, and no translation layer.

1.1.2 The shape of the daemon #

Boot is two phases with registryd as the boundary. Phase 1 is compiled in and has no registry dependency at all: it confirms the root is writable, mounts what is missing, restores entropy, settles the clock, and starts registryd. Phase 2 reads the service graph out of the registry and boots the system from it. When Phase 1 cannot complete, there is no Phase 2 to fall back to, and peinit drops into a recovery shell.

Once booted, peinit is an event loop over a handful of descriptors: a signalfd, the control socket, the jobs socket, the notify socket, one timerfd per armed timer, a pidfd per supervised process, a pipe pair per supervised process's output, and the registry's change-notification descriptor. Work arriving on most of them turns into an operation — a queued, observable request to move a service through its state machine — and executing an operation eventually forks a job. Work arriving on the jobs socket skips the operation: a submitted job has no service and no state machine to contend for, so a submission forks a job directly.

Those two objects are how peinit stays comprehensible under concurrency. Operations exist so that two administrators issuing conflicting commands get a defined answer instead of a race. Jobs exist so that "what actually ran" is a thing with an identifier, a token summary, an exit status and a log correlation key, rather than a PID that may already have been reused.

peinit keeps no history of either. A job or an operation that reaches a terminal state is emitted as a structured event into the KMES kernel ring buffer and dropped. eventd is the historian.

1.1.3 What peinit is not #

It does not assemble storage. The initramfs delivers a mounted, writable root, and peinit neither decrypts, nor assembles, nor checks it. It has no mount feature beyond the fixed Phase 1 set — mounting a data partition is a Oneshot service's job.

It does not store logs. It holds the pipes at birth, tags each line, and forwards it to eventd; before eventd exists it buffers, and when the buffer fills it drops the oldest.

It does not authenticate anyone. authd mints tokens; peinit installs them. It does not resolve identities, and it does not know or care whether a principal is local or from a domain.

And it does not support forking daemons. It tracks the process it spawned, through a pidfd obtained at fork, and there is no mechanism for a service to point supervision somewhere else.

1.2 What This Manual Covers

Peios / Peios technical reference / peinit / Introduction

This manual describes peinit as it is built: the boot sequence, the service model and its registry schema, how a service acquires its identity, the exact sequence between "start this" and "the binary is running", the state machine and its causes, dependencies, jobs and operations, timers, output handling, shutdown, and the security model.

1.2.1 What is a contract and what is not #

Three of peinit's interfaces are specified separately. PSPU §4 gives the control socket, spoken by administrative tools and by any program that manages services or observes jobs, and the notification socket, spoken by every service and every submitted job that reports readiness, status, keepalives, or a stored descriptor. PSPU §7 gives the jobs socket, spoken by any process that asks peinit to run and supervise a program on its behalf. Those are contracts. A third party implements one side of each, and what is written there binds both.

This manual covers the other side of that boundary — how peinit fulfils them, and everything that is not a contract at all:

  • how a command becomes an operation, and what happens when two of them collide (§8.2, §8.3)
  • what a command does to a service in each state (§10.3)
  • how a notification is authenticated and applied (§10.5, §10.6)
  • how a submission becomes a running job, and what it is checked against afterwards (§8.5, §10.7)

Where a chapter touches the contract it references PSPU §4 rather than restating it.

The service definition schema is here rather than in PSPU. It is registry configuration, protected by the registry's descriptors and administered by the same tools as the rest of the registry, and it is documented for the people who write service definitions rather than specified as a wire format. §3.2 is the reference.

1.2.2 What this manual does not cover #

  • KACS — tokens, Security Descriptors, AccessCheck, and the per-service SID algorithm peinit reproduces. Peios Kernel TRM §3.
  • LCS — the registry syscalls, watches, and layer resolution peinit reads through. Peios Kernel TRM §5.
  • KMES — the ring buffer peinit emits its events into. Peios Kernel TRM §2.
  • loregd — the storage behind registryd. Its own TRM.
  • eventd — where the logs and events go. Its own manual.
  • authd — token minting and identity routing. peinit's requirements of it are described in §4.3; its interface is authd's own.
  • How a token is conveyed on a socket, and what the kernel checks when it is — peer-token capture, KACS_SCM_TOKEN, the impersonation gates and the level ratchet that job submission relies on. Peios Kernel TRM §3.5.
  • Using peinit — writing service definitions, the svctl command surface including svctl job, and everyday administration are covered in Using Peios.

1.2.3 Constants and keys #

Registry keys and compiled-in constants are collected in the appendices, so a chapter's reference material does not interrupt the prose that explains it. Where an appendix defines a value the body references it rather than repeating it.

1.3 Terminology

Peios / Peios technical reference / peinit / Introduction

Terms defined where they are introduced — activation generation, boot generation, transition cause, cgroup generation, start generation — are not repeated here.

Service. A named, supervised unit of execution: a definition in the registry, a runtime state, a Security Descriptor, and at most one running main process. Services are the primary unit of management and the thing dependencies are expressed between.

Job. One supervised process execution. Every fork peinit performs is a job — a service's main binary, a pre-exec hook, a health check invocation, a submitted job — with a GUID, a lifecycle, a token summary and a log correlation key. Jobs are the observable unit of what actually ran.

Submitted job. A job whose definition arrived on the jobs socket rather than from a service definition. It has no service, no operation and no policy; it belongs to its submitter and is managed under its own Security Descriptor. §8.5.

Submitter. The identity a jobs socket connection carried when a submit was read: recorded on the job, counted for the quota, and the owner of the job's descriptor.

Job identity. The identity a submitted job's process runs as — the submitter's own primary token, or a token the kernel attached to the submit message. Never named by a field. §8.5.

Operation. A first-class object representing a requested state machine action on a service. Control commands do not mutate state directly; they create operations that are validated, queued, resolved against whatever else is in flight, and executed by the event loop.

Trigger. A rule in a service definition saying when the service starts automatically: at boot, once the boot set has settled, or on a schedule. A service with no triggers starts only when something asks for it.

Phase 1. The compiled-in bootstrap: root writability, the remaining virtual filesystems, the persisted random seed, the local machine ID, the clock, registryd, boot-time path provisioning, and infrastructure setup. No registry access happens before registryd is serving.

Phase 2. The registry-driven boot: read the service definitions, build and validate the dependency graph, and start the boot-triggered services in dependency order.

ErrorControl. The per-service policy for an irrecoverable failure. Normal leaves the service Failed; Critical syncs the filesystems and reboots.

Token. The per-thread KACS identity object — a user SID, group SIDs, a privilege bitmask, an integrity level, and metadata. Tokens are the sole identity mechanism: peinit sets no UIDs, GIDs, or Linux capabilities on service processes. Peios Kernel TRM §3.2.

Security Descriptor (SD). The KACS structure controlling access to a securable object. peinit evaluates three kinds: a ServiceSecurity descriptor per service, controlling who may manage it; its own control descriptor, controlling system-level operations; and a descriptor per submitted job, controlling who may observe, stop or signal it. Peios Kernel TRM §3 and PCDS §5.

AccessCheck. The KACS function that evaluates a token against a descriptor to produce a decision. peinit calls it for every control request. Peios Kernel TRM §3.8.

Per-service SID. A SID under authority S-1-5-80 derived deterministically from the service name, carried in the group list of every service token. Two services running as the same principal are still distinguishable to an access check. §4.4.

registryd. The userspace registry source daemon serving the persistent hives. peinit starts it in Phase 1 from a compiled-in definition and treats it as opaque thereafter. Its implementation is loregd, a distinction visible only in recovery mode.

Registry. The configuration system LCS and its sources provide together. peinit reads service definitions from Machine\System\Services\, boot configuration from Machine\System\Boot\, and its own parameters from Machine\System\Init\.

KMES. The kernel-mediated event subsystem. peinit emits its structured events — job and operation lifecycle, audit records — into its ring buffer, where they survive eventd restarts and reboots. Peios Kernel TRM §2.

pidfd. A descriptor referring to one specific process, obtained atomically at fork through clone3(CLONE_PIDFD). Every process peinit supervises is tracked by one, which is what makes supervision immune to PID reuse.

cgroup. peinit uses cgroups v2 for process tracking and clean kill only — not for resource accounting or limits. Every service gets its own tree under /sys/fs/cgroup/peinit/, and every submitted job one under /sys/fs/cgroup/peinit/jobs/. §5.1.

sd_notify. The datagram protocol services and submitted jobs use to report readiness, status, progress, keepalives and stored descriptors, over the socket named by NOTIFY_SOCKET. Specified in PSPU §4.

TCB. The Trusted Computing Base: the kernel, KACS, LCS, KMES, peinit, registryd, authd, lpsd and eventd. A compromise of any of them compromises the system.

1.4 Compatibility and Prior Art

Peios / Peios technical reference / peinit / Introduction

peinit is not a port or a reimplementation of anything. The choices that give it its shape — identity as a token, configuration in the registry, operations as objects, jobs as observable executions — were made for Peios. But several interfaces deliberately match existing conventions, because the convention is good and breaking it buys nothing.

1.4.1 sd_notify #

peinit speaks systemd's sd_notify datagram protocol: a service reports readiness, keepalives, status and stored descriptors by sending KEY=VALUE lines to the socket named in NOTIFY_SOCKET. Existing software that supports sd_notify works unmodified.

The compatibility is not total. MAINPID= is not supported, because peinit does not supervise forking daemons — it tracks the process it forked through a pidfd, and there is no way to redirect supervision somewhere else. BUSERROR= is not supported because Peios has no D-Bus.

The protocol as peinit speaks it, including which fields it accepts and how a sender is authenticated, is specified in PSPU §4.

1.4.2 The fd store #

FDSTORE=1, FDNAME=, FDSTOREREMOVE=1 and FDPOLL=0 work as they do under systemd, and descriptors come back to a restarted service at fd 3 onwards with LISTEN_FDS and LISTEN_FDNAMES set. A daemon written to survive a restart without dropping its listening sockets keeps working.

1.4.3 Calendar expressions #

Timer schedules use systemd's OnCalendar format, including weekday names, lists, ranges, repetition, the ~ last-day-of-month form, IANA timezone suffixes and the named shortcuts. §9.1 gives the grammar peinit actually parses; the one deliberate subtraction is sub-second precision, which service scheduling has no use for.

1.4.4 Windows Service Control Manager #

The service model owes its architecture to the Windows SCM: services as securable objects carrying their own descriptors, identity as a token rather than a user account, an access-controlled control interface, and a structured state machine. The debt is architectural. peinit implements none of the SCM's RPC protocol, none of its service types, and none of its control codes.

1.4.5 What is deliberately absent #

There is no systemd unit file support: no reader, no parser, no generator, no migration path. Service definitions are registry keys. Roles are how a package declares them.

There is no socket activation in the systemd sense — peinit does not listen on a service's behalf and hand it a connection. The fd store covers the case that matters, which is a service keeping its own listener across its own restart.

There is no resource control. peinit uses cgroups for tracking and for clean kill, and sets RLIMIT_NOFILE and RLIMIT_CORE if a definition asks, but it does not do accounting, slices, or limits.

1.4.6 Features that belong to other components #

ConcernComponent
Authentication and token mintingauthd
The local identity databaselpsd
Log storage, indexing and querieseventd
Registry storageregistryd, over LCS
Packaging and installationpeipkg and the role system
Device managementeudev
Network configurationa dedicated service
File access control enforcementFACS

1.5 Service Manager Boundaries

Peios / Peios technical reference / peinit / Introduction

These design notes explain the boundaries behind the operator workflows. The implementation chapters specify each mechanism in detail.

1.5.1 peinit in one sentence #

peinit is the sole service manager and PID 1 — it reads service definitions from the registry, starts them in dependency order under per-service identities, supervises them through a defined state machine, and tears them down in reverse at shutdown.

Everything in this topic is an elaboration of that sentence: what a service definition is, what dependency order means, what the state machine looks like, how identity is materialised, and how you drive the whole thing from a shell.

1.5.2 Three kinds of object #

peinit is easiest to understand through the three first-class objects it works with. Keeping them straight is most of the battle when you read a status output or an event log.

ObjectWhat it isLifetime
ServiceA definition — a named unit of execution with identity, policy, and configuration, stored in the registry. It has a runtime state (the state machine) and, when running, a process.Long-lived. Persists across restarts and reboots.
JobA single process execution. Every time peinit forks — a service's main process, a hook, a health check, a submitted job — that is one job, with its own GUID and exit result.Short-lived. A restart creates a new job.
OperationA requested action on a service — start, stop, restart, reload, reset. Control commands create operations that are validated, queued, and executed.Short-lived. Resolves to a terminal state, then is dropped.

A service is the what; a job is what actually ran; an operation is what someone asked for. A single restart command, for example, is one operation, which stops the current job and starts a new one, all against one service. Jobs and operations both carry GUIDs and are emitted to eventd as they complete, which is how the history of a service is reconstructed after the fact. They get their own page: Jobs and operations.

Underneath all three sits the fourth thing peinit is always handling — the token, the identity a service runs under. Tokens are not peinit's invention; they are the system-wide identity object. peinit's role is to obtain the right token for each service and install it before the process runs. That is the subject of Service identity and privileges.

1.5.3 What peinit is not #

peinit borrows vocabulary from init systems you may already know, and the familiarity is a trap. Clearing three wrong mental models is most of understanding what peinit actually is.

It is not systemd. There are no unit files. peinit does not read, parse, or translate .service files, and there is no migration shim. A service definition is a key in the registry under Machine\System\Services\, made of typed registry values — not a text file under /etc. Some interfaces are deliberately compatible where that helps: peinit speaks the sd_notify readiness protocol, and timer schedules use systemd's OnCalendar calendar-expression format. The compatibility stops at those wire formats; the model underneath is different.

It is not Windows SCM. The influence is real — services as securable objects with per-service descriptors, token-based service identity, an access-controlled control interface, a structured state machine — but it is architectural, not interface-level. peinit does not implement the SCM RPC protocol, Windows service types, or Windows control codes.

It is not a logging system. peinit holds a service's stdout/stderr pipes at birth, so it controls where output goes — but storage, indexing, and queries are eventd's job. peinit forwards; eventd is the historian. The same split applies to job and operation history: peinit emits structured events and forgets them; eventd keeps them. See Service output and logging.

A fourth, smaller correction: peinit does not supervise forking daemons. A service that double-forks to "daemonise" is solving a problem that does not exist when the manager tracks the process it spawned. peinit tracks every process by pidfd; a legacy binary that insists on double-forking must be wrapped at the package layer.

1.5.4 The one operational invariant #

peinit is single-threaded PID 1, and that one fact explains a surprising amount of its design. In a single-threaded PID 1, a blocking system call blocks everything — child reaping, watchdog expiry, shutdown signals, every other event stops while the call is stuck. So peinit's hard rule is that its event loop never blocks on a userspace service.

Two consequences you will meet repeatedly:

  • peinit operates on an in-memory snapshot of the registry, not a live view. It reads service definitions at boot and on configuration reload, and thereafter works from an in-memory model that it updates from change notifications. Supervision never waits on the registry being available. This is why a configuration change does not always take effect immediately — see Defining a service.
  • Anything that could block is pushed off the main loop. Filesystem condition checks run in a short-lived forked helper rather than a stat() on the event loop; token requests to authd are driven by a timed state machine, not a blocking call; log delivery is best-effort and never exerts back-pressure on peinit.

You do not need to think about the event loop to operate peinit, but it is the reason behind several behaviours that would otherwise look arbitrary.

1.5.5 Where peinit sits in the system #

peinit is part of the Trusted Computing Base: a compromise of peinit is a compromise of the whole machine. It runs as SYSTEM with all privileges, and it never drops that identity. It depends on, and is depended on by, the other TCB components:

peinit relies onFor
KACSService identity (installing tokens on children), control-interface authentication (peer token + AccessCheck), and per-service access control. See Access decisions.
LCS / the registryService definitions, boot configuration, and its own parameters. See The registry.
registrydThe registry source daemon — the first service peinit starts. peinit treats it as an opaque dependency. (registryd is an interface; the default implementation is loregd — see Boot and boot modes.)
authdMinting tokens for non-platform services. peinit requests; authd mints.
eventdService logs (forwarded over a socket) and job/operation/audit events (emitted through KMES).
KACS, againConveying a job identity: when a process submits a job over peinit's jobs socket and attaches a token, the kernel gates the attach — so peinit can run a program as a service's client without ever judging the identity itself. See Jobs and operations.

The bootstrapping puzzle — authd needs the registry, the registry needs to be started by something, and that something is peinit running before any of them exist — is resolved by the boot model in Boot and boot modes.

1.5.6 How a token is materialised #

The Identity field decides where the token comes from:

Identity valueToken source
SYSTEMMinted by peinit from its own SYSTEM identity.
Any other principal name or SIDRequested from authd.
Absent or emptyauthd, defaulting to LocalService.

1.5.6.1 The SYSTEM path #

For a SYSTEM service, peinit mints an independent SYSTEM token using its own token as the template: it reads its own identity (user SID S-1-5-18, the group list, the privilege set) and mints a fresh primary token with the same identity, adding the service's per-service SID. The minted token is fully independent — the privilege trimming that follows affects only this token, never peinit's.

This path exists because of a bootstrapping problem: the platform services that make the normal token flow possible cannot use it, because they have to start before it exists. registryd and authd use this bootstrap path during boot. Standard eventd and lpsd deployments now use Identity=Service, take the authd path, and start after authd (§4.2).

Note

There is no allow-list restricting which services may be SYSTEM. The security boundary is the registry key descriptor on Machine\System\Services\ — an administrator who can create service definitions is already trusted to assign any identity, so a second gate would add nothing. The dangerous case is never implicit, though: SYSTEM must be written out explicitly; an empty Identity defaults to the minimal LocalService, never to SYSTEM.

1.5.6.2 The authd path #

For any other identity, peinit asks authd for a token: it sends the Identity value verbatim, and authd routes it to the right source —

  • Well-known principals (LocalService, NetworkService, …) → a built-in identity with a predefined minimal privilege set;
  • Local service accounts → lpsd, the local principal store;
  • Domain accounts → the directory connector;

— resolves the principal's SIDs, mints a token, creates a logon session, and hands the token back to peinit to install. peinit neither knows nor cares whether the identity is local or domain; routing is authd's job.

authd records each request as an authd.service.attested event: the service (object.service.name), the identity it got (object.token.sid), its session and the privileges policy granted it. A refused request is recorded too, with the reason, such as account-restricted for an identity that may not be used for a service logon. The event is standard, so the emission policy can switch it off on a machine that restarts services often.

Important

Every non-SYSTEM service start depends on authd. peinit interacts with it over a non-blocking, timed channel — if authd is unreachable or unresponsive, the service start fails rather than hanging PID 1. A broken authd therefore prevents non-SYSTEM service starts, including any platform service configured that way, while SYSTEM-minted services do not need this request. The authd interface is ServiceAttest on /run/logon.sock, specified in PGSS §2.19 and used through libauthd (§4.3).

1.5.7 The per-service SID #

Every service token — minted or authd-issued — carries a per-service SID in its group list. It uses authority S-1-5-80 and is derived deterministically from the service name: the SHA-1 of the uppercased name (as UTF-16LE), with the digest split into five sub-authorities. authd adds it automatically for the tokens it mints; peinit computes and adds it itself for SYSTEM tokens, no authd involvement needed.

The point of the per-service SID is fine-grained access control even when services share an identity. A dozen services might all run as LocalService, but each has a unique service SID, so an ACL can grant access to exactly one of them without inventing a dedicated account. It also keeps services configured as SYSTEM distinguishable to AccessCheck. See Well-known principals for the SID landscape this fits into.

1.5.8 A clean context #

A service inherits only what peinit explicitly hands it — its standard streams and any stored file descriptors. Every other descriptor peinit holds — the control socket, the notify socket, the event-loop fd, the registry and authd and eventd connections — is opened close-on-exec, so it closes automatically at exec and can never leak into a service. peinit also resets the child's signal state to defaults (it runs with all signals blocked for its own signalfd; a service must not inherit that). The guarantee is that a service starts from a clean slate, not from peinit's privileged one.

1.5.9 The cgroup tree #

Every service runs in its own cgroup tree under /sys/fs/cgroup/peinit/, with separate sub-cgroups for the main process, hooks, and health checks:

/sys/fs/cgroup/peinit/<service>/
                       ├── main/     the main process
                       ├── hooks/    pre/post hooks
                       └── health/   health checks

peinit uses cgroups for two things only: tracking every process a service spawns (so a child that forks its own children is still accounted to the service), and clean kill (terminating the entire tree at once, so nothing is orphaned). It does not use cgroups for resource accounting or limits.

The split into sub-cgroups serves a real purpose: it satisfies cgroup v2's "no internal processes" rule, and it lets peinit kill a service's hooks or health checks without touching the main process.

When an old tree cannot be removed — because a process survived SIGKILL in D-state and leaked it — peinit creates a fresh generational tree (<service>.gen<N>) for the next start, so a stuck old instance never blocks a new one. Leaks are surfaced in the service's warnings; they are a sign of an underlying I/O fault, covered in Keeping services running.

2.1 The Initramfs Contract

Peios / Peios technical reference / peinit / Boot

peinit starts with the root filesystem already assembled. Everything that makes a root mountable — LUKS decryption, LVM activation, RAID assembly, any filesystem check — belongs to the initramfs and has happened before peinit exists. peinit performs no root assembly, no decryption, no repair, and no fsck, and it does not assume one has been done.

2.1.1 The handoff #

The initramfs transfers control by chroot-ing into the assembled root and exec'ing peinit there. Not switch_root, and not pivot_root: the kernel refuses to relocate onto the initramfs rootfs, so that route is closed. The consequence is that the initramfs rootfs does not go away. It remains the mount-namespace root — emptied, and unreachable from peinit's view, but present. peinit therefore never assumes a clean single-root mount topology, and never attempts pivot_root.

peinit is installed in package storage at /usr/bin/peinit2 and reached through the fixed runtime path /bin/peinit2. The boot-image tooling sets the kernel init= to the runtime path. Before the transfer, the initramfs has assembled the base StrataFS topology, including the /bin and /sbin views, because peinit reaches every binary it execs through them.

At handoff:

  • the real root is mounted read-write at /;
  • /proc, /sys and /dev are mounted and have been moved into the real root;
  • the environment holds TERM and nothing else, and argv is just peinit's own path.

The read-write requirement is registryd's, not peinit's. loregd's storage backend needs to write its write-ahead log and shared-memory files even to answer a read, so a read-only root cannot support Phase 2 at all. Delivering the root writable is the initramfs's job; peinit only confirms it.

peinit inherits nothing from that environment. It does not rely on having been given anything, and it does not pass its own near-empty startup environment through to services — the environment a service receives is constructed from scratch (§5.5).

2.1.2 What stays outside #

Non-root storage is not peinit's concern. Data partitions and additional filesystems are mounted at the services layer, typically by a Oneshot service that runs mount. peinit has no mount feature beyond the fixed Phase 1 set.

Note

The initramfs itself is assembled by mkirf from hook scripts that packages contribute; mkirf resolves the ordering and bakes the sequence its PID 1 runs. What peinit depends on is the handoff contract above, not how the initramfs arranged to satisfy it.

2.2 Bootstrap Identity

Peios / Peios technical reference / peinit / Boot

The steady-state identity flow is: peinit asks authd for a token, authd mints it, peinit installs it on the child. That flow cannot start the system, because authd needs a token of its own, and registryd has to be running before it. The bootstrap model breaks the circle.

2.2.1 Platform services run as SYSTEM #

A service whose definition says Identity=SYSTEM gets a token peinit mints from its own, with kacs_create_token (§4.2). No authd interaction is involved — which is the point, since authd does not exist when the first of these services starts.

Two services use it:

ServiceWhy
registrydStarts before authd exists at all.
authdNeeds SeTcbPrivilege and SeCreateTokenPrivilege; it is the minter for everything else.

lpsd and eventd used it too, until each moved to an account of its own (Identity=Service). Both now require authd, start after it, and take the authd path like any other service.

Nothing restricts which services may declare Identity=SYSTEM. There is no allowlist, because an allowlist would be enforcing a boundary that is already enforced somewhere better: the Security Descriptor on Machine\System\Services\. Anyone who can create a service definition is by definition trusted to choose its identity, and adding a second list to maintain would only create a way for the two to disagree.

Every SYSTEM token peinit mints carries the service's per-service SID in its group list, computed by peinit itself from the service name (§4.4). That is what keeps platform services distinguishable to an access check despite all of them running as S-1-5-18.

Note

This is the same arrangement Windows uses: the SCM, LSASS and the core platform services all run as LocalSystem. The trust anchor is that peinit is SYSTEM, and the kernel guarantees that by handing PID 1 the boot token.

2.2.2 After authd #

Once authd is running, every subsequent service gets its token through the ordinary authd flow (§4.3). A definition with no Identity field defaults to LocalService — a well-known principal with a minimal privilege set — and authd adds the per-service SID to the token it mints.

2.3 Phase 1

Peios / Peios technical reference / peinit / Boot

Phase 1 is compiled into peinit. It does not change at runtime and has no registry dependency, because its whole purpose is to reach the point where a registry exists. It does the minimum needed to make Phase 2 possible, and most of its failures are fatal to the boot.

2.3.1 Step 1: Confirm the root is writable #

The initramfs delivers the root mounted read-write. peinit does not remount it — mount flags belong to the initramfs, and a redundant remount of an already-writable or overlay root can fail for reasons that have nothing to do with the root being usable.

Instead peinit probes. It creates /.peinit/ if it is absent, writes a uniquely named file there — the name is derived from peinit's own PID and a namespace identifier, so two probes cannot collide — writes to it, and removes it. If any part of that fails, the root is not usable for Phase 2 and peinit enters recovery mode.

2.3.2 Step 2: Mount what is missing #

/proc, /sys and /dev are already mounted. peinit does not blindly mount them again: a redundant mount stacks a second filesystem over the populated one, and on some kernel and flag combinations returns EBUSY instead.

peinit reads /proc/self/mountinfo to find out what is already there, and mounts only what is not:

Mount pointFilesystemFlagsProvided by
/procprocnosuid, nodev, noexecinitramfs
/syssysfsnosuid, nodev, noexecinitramfs
/devdevtmpfsnosuidinitramfs
/dev/ptsdevptsnosuid, noexecpeinit
/dev/shmtmpfsnosuid, nodevpeinit
/runtmpfsnosuid, nodevpeinit
/sys/fs/cgroupcgroup2nosuid, nodev, noexecpeinit
/sys/kernel/securitysecurityfsnosuid, nodev, noexecpeinit

Each mount(2) passes the filesystem name as both the source and the filesystem type, passes only the listed flags, and passes null mount data. Mount points that do not exist are created first.

/dev/pts additionally gets a KACS mount policy immediately after it is mounted : SYNTHESIZE_EPHEMERAL, with a template descriptor granting SYSTEM and Administrators full control and Authenticated Users read, write and traverse. devpts cannot store security descriptors, and its slave nodes are materialised by the kernel when a terminal opens /dev/ptmx — never through a create path that could stamp or inherit one — so under the default DENY_MISSING every pseudo-terminal on the machine is unopenable. The policy is set through SYS_KACS_SET_MOUNT_POLICY on an O_PATH descriptor of the mount point. The template applies to every SD-less inode of the mount alike, so any authenticated principal may open any slave; per-owner slave descriptors need kernel support for stamping the opener's identity at materialisation, and are recorded as future work, not provided here.

/sys/kernel/security gets the same kind of policy, for the same reason: securityfs cannot store descriptors either. Its template grants SYSTEM full control and Authenticated Users read and traverse :

O:SYG:SYD:(A;;GA;;;SY)(A;;GRGX;;;AU)

KACS publishes kacs/self and kacs/sessions there (see Logon sessions), and each makes its own check when read, so opening sessions is allowed to anyone signed in while reading it stays with Administrators and SYSTEM. Only SYSTEM may write: lockdown is the one writable file on the mount, and raising the kernel's lockdown level is not routine administration.

There is a bootstrap wrinkle in reading mountinfo at all: the file lives in /proc, which is one of the things being checked for. If the read fails with ENOENT or ENOTDIR, peinit mounts /proc from the table and retries. Any other failure to read or parse mountinfo sends peinit to recovery.

For the three initramfs-provided rows, an already-mounted filesystem is success, and so is an EBUSY from an attempted mount. For the five peinit owns, a mount failure sends peinit to recovery.

After successful mount setup, peinit ensures /run/lock exists as a directory. Mounting /run hides the directory shipped by fsbase, so peinit recreates it in the live filesystem before starting services. A newly created directory inherits /run's descriptor; an existing directory is preserved. This step also runs when /run was already mounted. A creation failure enters recovery.

2.3.2.1 Seeding descriptors on the new filesystems #

Three of the five filesystems peinit mounts are fresh and empty: /dev/shm, /run and /sys/fs/cgroup. Under KACS an inode with no Security Descriptor is denied to every caller, and there is nothing on a newly mounted tmpfs for a new inode to inherit from — so peinit stamps the mount root with a descriptor that grants SYSTEM and Administrators full control, inheritable by both containers and objects, and grants Everyone read and execute, inheritable by containers only:

O:SY G:SY D:(A;OICI;GA;;;SY)(A;OICI;GA;;;BA)(A;CI;GRGX;;;WD)(A;OICIIO;GA;;;S-1-3-0)

Everything created underneath — the notify socket, per-service runtime directories, the cgroup hierarchy — inherits from it; the two sockets that need a different population are stamped explicitly in step 9. This is why peinit never sets mode bits on the sockets it creates; under KACS they would mean nothing, and the descriptor is the thing that does the work. It is the same descriptor the initramfs seeds onto the root filesystem but for the inheritance flags on that last ACE, and the two are kept in step on purpose: this one inheritable ACL is, in practice, the access policy of everything under these mounts, so an ACE missing here is missing from every per-service directory under /run/services.

The Everyone ACE grants execute as well as read because on Peios execute is traverse — FILE_TRAVERSE and FILE_EXECUTE are the same bit — and an explicit chdir is the one traversal that does not get the SeChangeNotifyPrivilege bypass. A service running as anything other than SYSTEM cannot otherwise reach its own runtime directory. SYSTEM and Administrators keep GenericAll rather than read/write/execute because GenericAll carries WRITE_DAC, WRITE_OWNER and DELETE, and peinit re-stamps descriptors throughout /run.

The last ACE is CREATOR OWNER, and it is what makes the container-only Everyone ACE survivable. A file created under these mounts inherits the SYSTEM and Administrators ACEs and nothing else — and that is a non-empty DACL, so the creating token's default DACL is never consulted. Without it a service would create runtime state it could not read back. It is inherit-only, so it grants nothing on the directory it sits on; it is resolved against whoever creates each object, and carried onward down each container.

The Everyone ACE is inheritable by containers only, which is the one place this descriptor differs from the root filesystem's. What a service needs here is to walk to its own directory, and container inheritance gives it that by itself. Object inheritance would additionally put Everyone-read on every file anything creates beneath these mounts — a service's runtime state, and shared memory segments, readable by any principal through inheritance alone. The root filesystem needs object inheritance because a service has to read and execute the binaries beneath it; nothing under these three mounts is a binary.

This descriptor is not the one prelude's seed-sd writes by default. That one is narrower, and stays narrower: it also stamps /dev, where every ACE is inherited by the next hot-plugged block device, so a read ACE for Everyone there would be every filesystem descriptor on the machine bypassed by opening the raw disk.

Failure to apply the descriptor sends peinit to recovery. Without it every file peinit later creates on that filesystem would be unreachable to everything, including peinit.

/proc, /sys and /dev are not stamped: they arrive from the initramfs already populated.

2.3.2.2 Device node policy #

/dev arrives with that same inheritable descriptor on its root and on every node, which is the right default — whatever the root grants, a disk hot-plugged later inherits, so the root must be acceptable on a raw block device — but it leaves /dev/null unusable by anyone who is not an administrator. A single inherited descriptor cannot say "/dev/null for everyone, the disks for administrators", so peinit enumerates the exceptions. Once the mounts are up it stamps each of these nodes with a descriptor of its own:

NodeDACL
/dev/null, /dev/zero, /dev/full, /dev/random, /dev/urandom, /dev/tty, /dev/ptmxD:(A;;GA;;;SY)(A;;GA;;;BA)(A;;FRFW;;;WD)

Everyone may open the node for reading and writing and may stat it; nobody but SYSTEM and Administrators may change its descriptor. Only the DACL is replaced — owner and group stay as the seed left them — and the ACEs carry no inheritance flags, because a device node has no children. /dev/console is deliberately not on the list: it is the SYSTEM console.

The step is advisory. A node that cannot be stamped is reported as a warning and stays on the inherited default, usable by administrators and denied to everyone else; a node that does not exist is noted and skipped. Neither sends peinit to recovery.

2.3.3 Step 3: Restore the persisted random seed #

peinit restores the seed at /var/state/peinit/random-seed once /dev is available and before registryd starts. The seed is a machine-local entropy cache for the kernel CSPRNG. It is not configuration, and shipping one in a packaged image, live ISO or VM template would hand every instance of that image the same starting entropy.

If the file is absent, that is an ordinary first boot or a stateless live boot, and peinit continues silently. When a seed is present peinit mixes it into the kernel pool, preferring the interface that credits entropy for a locally persisted seed; if that fails it mixes the bytes without crediting and records the failure. A seed file that is empty, or larger than 4096 bytes, is treated as an error.

Nothing in this step can send peinit to recovery. A system with no entropy cache still boots; it just starts with less entropy, which is a problem for the image builder to solve with a hardware or virtio RNG rather than with a seed baked into the image.

The initramfs may perform the same restore earlier, once the persistent root is mounted. peinit's restore stays as the fallback for initramfs images that do not participate and for boots that have no initramfs.

2.3.4 Step 4: Ensure the local machine ID #

/lcl/etc/machine-id holds a stable local install identifier used for software compatibility, log correlation and instance identity. It is not a security principal: not a credential, not a SID, not an account, and not an input to any authorisation decision.

The format is 128 bits as exactly 32 lowercase hexadecimal characters followed by one newline. A valid existing file is left alone. A file that is absent, empty, all zeroes, the wrong length, not hexadecimal, or missing its trailing newline is replaced : peinit draws 128 bits from the kernel CSPRNG and writes a valid file atomically, through a temporary file and a rename, with the result flushed.

The write creates /lcl/etc/ if it is not there. This step runs before the path-provisioning machinery of step 7, and /lcl is a StrataFS view the initramfs assembled, so whether the directory exists is a property of the image rather than something peinit can assume.

Only a CSPRNG failure sends peinit to recovery. If the kernel cannot produce random bytes, nothing else on the machine can be trusted either.

An unreadable file or an unwritable path does not. The boot continues with an identifier valid for this boot only, and says so :

peinit warning: machine-id not persisted (<reason>); using an identifier
for this boot only

A read failure takes the same path as finding no file, because it leaves peinit in the same position — unable to say whether a valid identifier is on disk. It is reported rather than treated as a clean generation, because it may have replaced something valid.

This is deliberately fail-soft. The identifier is a local opaque install ID and not an authorisation input, so failing a boot over it would be disproportionate — and an ephemeral ID is already the defined behaviour for a stateless live boot.

Images and templates are expected to ship with no machine ID, or with an empty file as a reset marker. Clone tooling that wants a new identity removes or truncates the file and lets the next boot generate one. Stateless live boots without a persistent overlay get an ephemeral ID for that boot.

2.3.5 Step 5: Set the clock from the hardware RTC #

peinit reads the hardware clock and calls clock_settime() before registryd starts, so that timestamps on registry operations, log entries and the boot attempt counter mean something.

It opens /dev/rtc, falling back to /dev/rtc0 if that device is absent , and reads it with RTC_RD_TIME. The returned struct rtc_time is interpreted as UTC and converted to CLOCK_REALTIME seconds with zero nanoseconds.

Every failure in this step sends peinit to recovery: no openable RTC device, a failed read, a value that is invalid or before the Unix epoch, a failed clock_settime, and — deliberately — a failure to close the descriptor after a successful read. Leaking a descriptor in PID 1 during bootstrap is a symptom of something being badly wrong, not a detail to swallow.

Note

NTP corrects the clock properly later in the boot. This step only gets it into the right decade, so that early timestamps are not absurd and the timer subsystem has a wall clock to anchor to.

2.3.6 Step 6: Start registryd #

peinit holds a compiled-in definition for registryd — the only compiled-in service definition there is :

FieldValue
ImagePath/sbin/registryd
ArgumentsMachine=/var/state/loregd/Machine.hive, Users=/var/state/loregd/Users.hive
IdentitySYSTEM
ReadinessNotify
ErrorControlCritical

The hive paths are peinit's choice, not registryd's: where the machine registry lives is a boot-policy decision, and it is made here because this is the one service start that cannot consult configuration.

peinit mints a SYSTEM token including registryd's per-service SID, creates the cgroup tree, forks with the token installed, and execs /sbin/registryd through the runtime StrataFS view. Two separate timeouts bound the start, both 30 seconds: one on process setup, driven synchronously because there is no event loop yet, and one on readiness.

registryd's READY=1 means "accepting and serving registry requests", not "the process is alive" — it does not signal until its storage backend is open, its schema is validated and it can answer a read.

2.3.6.1 The schema-version guard #

After readiness, peinit ensures the base registry structure exists and then probes it. Ensuring comes first: peinit creates Machine\System, Machine\System\Services and Machine\System\Init if they are absent and stamps Machine\System\Services\SchemaVersion with the current schema version, 1. Only then does it read the value back.

The read is what verifies registryd is genuinely serving. A value that is present but not a REG_DWORD, or that is a REG_DWORD of the wrong length, fails the probe. A key or value that is absent reads as zero and passes — the structure was just created, so absence at this point means the write did not take effect, and the failure that matters is the provisioning failure, which is reported directly.

The consequence is that the guard is self-healing. An unprovisioned first boot, or a registry cleared by the recovery tools, comes up with an empty Machine\System\Services\ and boots into a Phase 2 with no services rather than into recovery.

2.3.6.2 Keeping registryd #

When registryd passes readiness and the probe, peinit retains the activation as an ordinary runtime service instance: its state, its pidfd-tracked main process, its cgroup generation and paths, its output pipe ownership, its notify generation, its job identity, and any cleanup evidence. Ownership is not dropped at the Phase 2 boundary.

During Phase 2 the registry's own definition of registryd, if there is one, is merged onto the retained activation. peinit does not create a second inactive record and does not restart registryd because a definition has appeared. If the registry definition is absent or invalid, the ordinary graph validation rules apply.

If registryd fails to start, its readiness times out, or the probe fails, peinit enters recovery. There is no Phase 2 without a registry.

2.3.7 Step 7: Autorun scripts #

Between registryd starting and path provisioning, peinit runs every non-directory entry in /lcl/policy/autorun.d, in sorted order, by absolute path , with the working directory / and PATH=/sbin:/bin . Each runs under peinit's own SYSTEM token.

The step is fail-open at every point: a missing directory, an unreadable directory, a spawn failure and a non-zero exit are all console warnings and none of them stops the boot. Its console output bypasses the quiet policy (§2.6), because a script that ran this early and went wrong needs to be visible.

2.3.8 Step 8: Provision boot-time paths #

Covered in §2.4.

2.3.9 Step 9: Infrastructure setup #

Three things, before Phase 2 begins:

  1. The control socket at /run/services/peinit/control.sock, which serves every runtime command for the lifetime of the system, stamped so that any authenticated principal may connect and every command is then checked on its own (§10.1).
  2. The jobs socket at /run/services/peinit/jobs.sock, on which any authenticated principal may submit a job once Phase 2 runs, stamped so that connecting is the submission permission (§10.7).
  3. Loopback: peinit brings up lo over netlink, because services that bind 127.0.0.1 need it.

Either socket failing to bind or to take its descriptor sends peinit to recovery — without the first there is no way to administer the system, and a jobs socket with the wrong descriptor is either unreachable or open to everything. A loopback bring-up failure is a warning that lets Phase 2 proceed.

2.3.10 Failure summary #

FailureResponse
Root writability probe failsRecovery
A mount point cannot be createdRecovery
A peinit-owned filesystem fails to mountRecovery
A mounted filesystem cannot be stamped with its descriptorRecovery
/proc/self/mountinfo unreadable or unparseableRecovery
/proc, /sys or /dev already mounted, or EBUSYTolerated as success
A device node in the policy list cannot be stampedWarning; node keeps the inherited default
A device node in the policy list does not existNoted; boot continues
Random seed absent, oversized, empty, or unrestorableWarning; boot continues
Machine ID generation fails (CSPRNG)Recovery
Machine ID unreadable, or unwritable at its pathWarning; boot continues with an identifier for this boot only
Machine ID absent, empty, or malformedRegenerated; boot continues
Any RTC or clock failureRecovery
registryd fails to start, or setup times outRecovery
registryd readiness times outRecovery
Base registry provisioning failsRecovery
Schema-version probe returns a wrong type or lengthRecovery
An autorun script is missing, unspawnable, or exits non-zeroWarning; boot continues
A provisioning entry is malformedWarning; entry skipped
An optional provisioned path failsWarning; boot continues
A required provisioned path failsRecovery
Control socket creation or stamping failsRecovery
Jobs socket creation or stamping failsRecovery
Loopback bring-up failsWarning; boot continues

2.4 Path Provisioning

Peios / Peios technical reference / peinit / Boot

Some filesystem objects belong to no single service. A directory two packages both write into, a state file created before anything runs, a path that has to exist with a particular Security Descriptor before the first service that uses it starts — none of these has an owner in the service model, and creating them from a service's pre-exec hook makes their existence depend on start ordering.

Boot-time path provisioning is the registry-backed answer, and the equivalent of the tmpfiles.d role elsewhere. peinit applies it after registryd is serving and before any Phase 2 service is planned or started.

2.4.1 Entries #

Each child key under Machine\System\Init\ProvisionedPaths\ is one entry. Unknown values on an entry are ignored.

ValueTypeRequiredDefaultMeaning
Kindstringyes—directory or file.
Pathstringyes—Absolute path to create or verify.
Securitybinarynobuilt-inThe Peios file Security Descriptor to apply.
Requireddwordno0If 1, failing this entry prevents Phase 2.

For Kind=directory peinit ensures the path exists as a directory; for Kind=file it ensures the path exists as a regular file. In either case a path that exists with a different file type fails the entry. An existing file is opened rather than created, so provisioning never truncates one.

peinit does not create parent directories. The parent is checked, and has to already be a directory. A package that needs a hierarchy declares each directory explicitly, or depends on the package that owns the parent — which keeps the ownership of every directory traceable to a package rather than to whichever entry happened to run first.

2.4.2 Descriptors #

When Security is present, peinit applies the supplied binary descriptor. A malformed or rejected descriptor fails the entry.

When it is absent, peinit applies a built-in default :

O:SY G:SY D:(A;;GA;;;SY)(A;;GA;;;BA)(A;;FR;;;BU)

SYSTEM and Administrators get full control; ordinary users get FILE_GENERIC_READ.

2.4.3 Failure #

Entries with Required=0 are fail-soft: peinit logs and continues. Entries with Required=1 are fail-closed: peinit logs and enters recovery before Phase 2 starts.

An entry that is malformed — a missing or unrecognised Kind, a missing or relative Path, a value of the wrong type — is logged as a warning and skipped, regardless of Required. Required marks a path as essential to boot; it does not make a broken entry more dangerous than a missing one.

2.5 Phase 2

Peios / Peios technical reference / peinit / Boot

With registryd serving, peinit reads the service graph and boots the system from it. Phase 2 is entirely registry-driven.

2.5.1 Reading the definitions #

peinit reads every key under Machine\System\Services\. The reads are bounded by LCS's request timeout: if registryd hangs mid-read, peinit receives ETIMEDOUT and enters recovery.

Decoding is per key. A definition that fails to decode — an invalid service name, a malformed trigger, an unclosed quote in a command, a registry: check naming an uncacheable key, a duplicate known field, an unrecognised value for an enumerated dword — fails that service, which is marked Failed with cause ValidationError. The boot proceeds with every other definition, and anything that depended on the failed service fails in turn through the ordinary dependency propagation.

peinit holds no definition for such a service, so the entry behind it is a placeholder: status reports it Failed with cause ValidationError, marked definition-removed (§3.8), with the decode error as its description. start, restart, reload and reset are refused with UNKNOWN_SERVICE, as for any definition-removed service. The placeholder leaves with its key: a reload-config that re-reads a repaired key restores the entry, and one that finds the key deleted discards it, since Failed does not retain a definition-removed entry.

Only services carrying a boot trigger are root candidates. A service with no triggers is demand-only and is not a root, though it can still be pulled into the boot transaction as somebody's dependency. A service with Disabled=1 is excluded from the boot graph entirely, but its definition is still loaded into the in-memory model so it can be started by hand later.

2.5.2 Building and validating the graph #

The boot graph is every boot-triggered root candidate plus the transitive closure of their Requires, BindsTo, and existing non-disabled Wants dependencies. Requires and BindsTo pull their target in even if the target has no boot trigger. Wants targets come in as best-effort members; missing or disabled ones are ignored. A missing or disabled Requires or BindsTo target blocks the dependent with cause DependencyFailure, and that blocking propagates.

peinit topologically sorts the graph and validates it before starting anything. The rules are in §7.2; the outcomes that matter here are:

  • A cycle fails every service in it. A cycle involving a Critical service downgrades the boot to Safe mode without rebooting.
  • An unresolvable conflict fails both services. Same downgrade if either is Critical.
  • A missing Requires target fails the dependent.
  • Warnings — a Readiness=Alive service with dependents that require it — are logged and do not prevent boot: a [ WARN ] peinit warning: line on the console and a peinit.graph.validation.warned event under phase boot, whether or not the same graph also has findings.

2.5.3 Starting #

peinit walks the graph and starts services, parallelising wherever the graph allows, up to a configurable limit:

KeyDefaultMeaning
Machine\System\Boot\MaxParallelStarts10Services starting concurrently.

An absent key uses the default. A value of zero, a type mismatch, or a malformed payload is invalid boot configuration and sends peinit to recovery — running the scheduler with an effective limit of zero would hang the boot rather than fail it.

As each service reaches a dependent-satisfying state — Active for Simple, Completed for Oneshot with or without RemainAfterExit, Skipped for a service whose conditions did not hold — its dependents become eligible and join the start queue. A Oneshot without RemainAfterExit passes through Completed, releasing its dependents, and then goes Inactive.

Dependents blocked on a Requires or BindsTo target wait for that target to reach a satisfying state. Dependents blocked on a Wants target wait only for it to reach any terminal state, satisfying or not — which is what makes Wants ordering rather than dependency.

2.5.3.1 The typical order #

Nothing below is hardcoded. It falls out of the dependency graph that the standard role definitions produce, and an administrator who changes a dependency gets a different order.

  1. eudev — device management. SYSTEM, with RequiredPrivileges stripped to the minimum it needs. Starts before authd exists.
  2. lpsd — the local identity database. Depends only on registryd.
  3. authd — the identity authority. Depends on registryd and lpsd. Once it is ready, token minting is available.
  4. eventd — logging and audit. Services started before it log into peinit's pre-eventd buffer.
  5. Networking — the first service to receive a token from authd.
  6. Application services, in dependency order.
  7. Login services, last, so the system is operational before it accepts a session.

2.5.3.2 The bootstrap matrix #

ServicePhaseIdentityToken fromReadinessErrorControl
registryd1SYSTEMminted by peinitNotifyCritical
eudev2SYSTEMminted by peinit, privileges strippedAliveNormal
lpsd2SYSTEMminted by peinitNotifyCritical
authd2SYSTEMminted by peinitNotifyCritical
eventd2SYSTEMminted by peinitNotifyCritical
networking2service tokenauthdNotifyNormal
sshd2service tokenauthdAliveNormal
application services2service tokenauthdper-serviceNormal

2.5.4 Deferred starts #

A service whose trigger is boot:settled is not part of the boot plan at all. It is not a root, it does not consume the parallel-start budget, it is not counted towards boot success, and it cannot block or delay anything. peinit starts it after the plan, once the boot has stopped moving.

"Stopped moving" is precise: every service in the plan — those that were started and those that were blocked — is in a state it will not leave without help. Active, Completed, Failed, Skipped, Abandoned and Inactive all count as settled. Starting, Reloading, Stopping and Backoff do not, because a service between restart attempts is going to produce more output.

A deadline bounds the wait:

KeyDefaultMeaning
Machine\System\Boot\SettleTimeout5Seconds before deferred services start regardless.

The deadline is measured from the moment the plan was observed. An absent key uses the default; a type mismatch or a bad length sends peinit to recovery. Zero is legal and means "start on the next turn, settled or not".

Whichever comes first — the set settling or the deadline expiring — the deferred services start, once, each independently. A start that fails is recorded and dropped: one refusing service does not stop the others and does not affect the boot. The dispatch carries a flag saying whether the deadline expired rather than the set settling, so a service that cares whether the boot was still moving can be told.

Note

The motivating case is a console login prompt being scribbled over by peinit's own progress messages. That is a scheduling preference, not a dependency — the prompt does not require anything to have started, it just wants the console to itself. Expressing it as a dependency would mean naming every service that might print, which changes every time the system does.

2.5.5 Boot success #

A boot is successful once every Critical service has held a dependent-satisfying state continuously for a grace period:

KeyDefaultMeaning
Machine\System\Boot\BootSuccessGrace30Seconds of held health before the boot counts.

The criterion is satisfying, not Active. A Critical Oneshot reaches Completed and never reaches Active, so a test for Active would make such a service unable to ever mark a boot successful. Skipped counts too.

Success resets the boot attempt counter to zero (§2.7). The control socket's boot command says where a boot stands: which Critical services it is waiting for, or when it will count, or that it has (§10.8).

2.5.6 Failure summary #

FailureResponse
A service definition fails to decodeThat service Failed with cause ValidationError; the rest of the boot proceeds
A registry read times outRecovery
Invalid MaxParallelStarts or SettleTimeoutRecovery
A dependency cycleAll services in it Failed; Safe mode if any is Critical (see below)
An unresolvable conflictBoth Failed; Safe mode if either is Critical (see below)
A missing Requires targetThe dependent Failed
A Critical service fails during bootRestart budget, then reboot
A non-Critical service failsFailed; its Requires dependents fail; the rest continue
authd unavailable when a service needs a tokenThat service Failed

The two Safe-mode rows carry a caveat. When the downgrade fires, peinit rebuilds the graph in Safe mode and discards the Full-mode one, so the services that caused it are not marked Failed — they are never entered into the blocked set at all. What records them is the boot-level downgrade finding described in Boot modes.

2.6 Boot Modes

Peios / Peios technical reference / peinit / Boot

peinit boots in one of three modes, forming an escalation path from normal operation to last-resort maintenance.

Full boot ---+-- success ----------------> counter reset, operational
             |
             +-- cycle w/ Critical ------> Safe mode (no reboot)
             |
             +-- conflict w/ Critical ---> Safe mode (no reboot)
             |
             +-- Critical failure -------> sync + reboot --+
                                                           |
Safe boot ---+-- success ----------------> counter reset,  |
             |   operational (reduced)                     |
             +-- Critical failure -------> sync + reboot --+
                                                           |
                             counter increments <----------+
                                        |
                             counter >= N ---------> Recovery mode
                             Phase 1 failure ------> Recovery mode

2.6.1 Full mode #

The default. Every boot-triggered service starts in dependency order, as §2.5 describes.

2.6.2 Safe mode #

Safe mode starts a reduced set. Eligibility is a filter within the boot-triggered set, not a replacement for it: a service with no boot trigger does not auto-start in Safe mode whatever its SafeMode or ErrorControl says, and remains demand-only. Within the boot-triggered set, two categories are eligible:

  • Critical services (ErrorControl=Critical) start. If one fails, the ordinary Critical failure path applies — restart budget, reboot, counter increment, eventually recovery.
  • SafeMode=1 services are attempted best-effort. If one fails, Safe mode continues without it.

ErrorControl=Critical implies SafeMode, so a Critical service does not have to declare both.

2.6.2.1 What caused the downgrade #

The rebuild discards the Full-mode graph, so a service that forced Safe mode and is then excluded from the rebuilt graph is never entered into the blocked set and never marked Failed. That is deliberate: Safe mode was never going to start it, and a Failed state would say something about its own health that is not true. status should keep meaning "this service is broken".

A service that survives into the rebuilt graph gets no such protection. Two Critical services that require each other are both eligible in Safe mode, so the rebuild contains the same cycle and blocks them both, exactly as it would anywhere else — and there is no order that could have started either.

The reason is therefore recorded at boot level rather than per service. Every finding that forced the downgrade — each critical cycle, each critical boot conflict — is written to the console and emitted as a peinit.boot.downgraded KMES event naming the services involved, an essential type the emission policy cannot switch off.

All of them are reported, not just the first. A machine can be downgraded by a cycle and a conflict at once, and an operator who fixed only the one they were shown would reboot straight back into Safe mode.

They stay available for the rest of the boot: the control socket's boot command reports the mode, why, and every finding (§10.8).

peinit rebuilds the dependency graph from scratch using only the eligible services. Dependencies on excluded services are dropped: if A depends on non-Critical B and B is excluded, A's dependency on B does not exist in the Safe mode graph. This is what makes Safe mode useful — it is a graph in which the broken parts of the configuration are simply not present, rather than a graph in which they are present and failing.

A successful Safe boot resets the boot attempt counter.

Note

Safe mode is purely a boot-sequencing concern. Once booted, an administrator starts anything by hand exactly as in Full mode. It is for a system whose configuration is broken but whose TCB is healthy.

2.6.2.2 Entry #

  • A cycle involving a Critical service at boot. Graph validation detects it and peinit downgrades in place, without rebooting — the cycle is a configuration error, and rebooting would find it again.
  • An unresolvable conflict involving a Critical service at boot. Same reasoning.
  • peios.safemode=1 on the kernel command line.

Safe mode is not entered because a Critical service crashed at runtime. That follows the ordinary path: restart budget, reboot, counter increment, recovery.

2.6.3 Console output #

peios.quiet=N bounds what peinit writes to the console:

ValueBehaviour
0Write unconditionally.
1Do not write to a terminal held as the controlling terminal of a running service, except to announce loss of the system. This is the default.
2Additionally drop ordinary progress everywhere, while still emitting errors.

The two rules are independent, and an error is never less visible at 2 than at 1. A terminal is matched by device rather than by path, since /dev/console and /dev/ttyS<n> can name the same device ; where the device cannot be determined, peinit treats the terminal as free and writes to it. Guessing wrong in that direction costs a scrambled line, which is recoverable; guessing wrong the other way costs the operator their console output at the moment the machine is least able to explain itself. Suppressed messages are discarded rather than buffered.

The autorun step (§2.3) bypasses the policy: a script that ran that early and went wrong is worth interrupting a login prompt for.

2.6.4 Kernel command line #

ParameterEffect
peios.safemode=1Force Safe mode.
peios.recovery=1Force recovery mode regardless of the counter.
peios.bootattempts=NSet the recovery threshold; 0 disables the check.
peios.quiet=NConsole verbosity, as above.
peios.notifysocket=PATHOverride the notification socket path.

A malformed value is ignored in favour of the default rather than failing the boot. Nothing exists this early to report a diagnostic to.

2.7 The Boot Attempt Counter

Peios / Peios technical reference / peinit / Boot

The counter is what turns a repeated failure into an escalation. peinit keeps it at /.peinit/boot-attempts, as a plain decimal integer in a file on the root filesystem — deliberately not in the registry, because the registry may be the reason the boot is failing.

2.7.1 The cycle #

peinit reads the counter at startup, before selecting a boot mode. The recovery threshold is evaluated against this pre-increment value, so a default threshold of 3 admits exactly three boot attempts before recovery.

  • Absent file: treated as 0.
  • Unreadable, empty, non-decimal, carrying trailing non-whitespace data, or overflowing the counter representation: recovery. A counter that cannot be read cannot be trusted to escalate.

peinit increments once per boot, after the root is known writable and before Phase 2 begins. Incrementing before the root is known writable would silently lose the increment on a read-only root and defeat escalation entirely.

The increment happens after the mount, seed, machine ID and clock steps rather than immediately after the writability probe, because reading the kernel command line requires /proc. One consequence is that a recovery entered from a mount failure, a machine ID failure, or an unreadable command line does not advance the counter.

A write failure — a full disk, say — is treated as a counter of 0 and the boot continues. A failure to record an attempt is not itself a reason to escalate.

The counter resets to 0 on a successful Full or Safe boot, after the grace period.

The control socket's boot command reports the count this boot began with, the threshold, and whether this boot has reset the counter yet (§10.8).

2.7.2 Recovery threshold #

The threshold is peios.bootattempts=N on the kernel command line, defaulting to 3. It is a command-line value rather than a registry one because the check runs in Phase 1, before registryd is serving.

peios.bootattempts=0 disables the check entirely — the escape hatch for a system whose counter is itself the fault.

peios.recovery=1 forces recovery without consulting the counter, but does not suppress the increment.

Note

The counter catches boots where peinit runs but the system never reaches health — a crash-looping Critical service being the usual case. It deliberately does not try to catch a peinit too broken to reach its own increment. Recovery mode is itself a peinit mode, so a peinit that cannot start cannot deliver recovery either, and a higher count could not help. That failure belongs to binary integrity, not to the counter.

2.8 Recovery Mode

Peios / Peios technical reference / peinit / Boot

Recovery mode is the last resort. There is no TCB guarantee and no degraded boot to speak of — it is a maintenance environment that hands the administrator an unrestricted SYSTEM shell on the console.

2.8.1 Entry #

  • The boot attempt counter reaching the threshold (§2.7).
  • peios.recovery=1 on the kernel command line.
  • Any of the Phase 1 failures listed in §2.3, most importantly a registryd that will not start or will not serve.
  • A Phase 2 registry read that fails or times out, or invalid boot configuration.
  • A required provisioned path that cannot be created or secured.
  • The runtime loop failing on an error about supervision itself: the event wait, event source registration, a listener, the event ring refusing even the small peinit.event.dropped, shutdown finalisation. An error peinit can attribute to one service is contained to that service instead and does not enter recovery (§8.2).

2.8.2 What peinit does #

peinit records the reason as a peinit.recovery.entered KMES event — essential, so no emission policy switches it off — with the reason in outcome.reason and what went wrong, in the error's own words, in outcome.detail; a Phase 2 plan that would not build adds its finding as a peinit.graph.validation.failed under phase phase2-boot , then:

  1. Completes Phase 1 steps 1–5 if they have not been reached yet.
  2. Ensures the base registry structure exists, so the shell sees a normal layout even on a system that has never been provisioned.
  3. Attempts to start registryd. A failure here is ignored — recovery delivers a shell whatever registryd's state.
  4. Skips all Phase 2 services.
  5. Starts a shell on /dev/console from a compiled-in definition, with no registry dependency: /bin/recsh if it is present and executable, otherwise /bin/sh , running as SYSTEM with a fixed environment of PATH=/sbin:/bin, TERM=linux and HOME=/. peinit does not care where either binary comes from.
  6. Logs the failure reason to the console.

If the shell exits, peinit respawns it. Recovery never exits to an unmanaged PID 1.

If neither /bin/recsh nor /bin/sh can be exec'd, peinit cannot deliver a shell at all. It logs the reason to the console, syncs, and halts — PID 1 exiting would panic the kernel. A missing shell is a binary-integrity failure and sits outside the boot-attempt machinery's remit.

The shell receives /dev/console duplicated onto its standard streams, but peinit does not call setsid() or acquire a controlling terminal for it. The shell is not a session leader, so job control is not available in the recovery shell.

Step 1 runs on every entry. The steps are individually idempotent, so completing them and ignoring the failures is what "if they have not been reached yet" amounts to in practice — including the step that failed, which by the time the operator has a shell may well succeed.

Step 3 runs only if Phase 1 has not already reached its own registryd start, and it runs at most once. A recovery entered from a Phase 2, provisioning or runtime failure starts no registryd: Phase 1 already did, and its activation is retained for the runtime. A recovery entered from the registryd start failing also starts none — the attempt has been made, and a process may have forked before the failure was reported. In both cases a second daemon would bind over the first's notify socket, which succeeds rather than reporting EADDRINUSE because the bind unlinks the path first, and would then open the same hive files behind the first daemon's back.

Where recovery does start one, it uses the settings this boot parsed, so peios.notifysocket= and peios.quiet apply to it.

Note

Recovery mode is not a degraded boot; it is a maintenance environment. There are no security protections beyond what the kernel provides. The administrator has a SYSTEM shell and the corresponding responsibility.

2.8.3 Registry diagnosis and recovery limits #

Recovery provides a console maintenance shell when one is available as described above; it does not define an offline storage-repair interface for the registryd provider. Preserve the console failure reason and source startup output, then identify the deployed provider and version, its hive declarations, and the actual database paths. See LCS and sources.

Entering Recovery does not establish that the source is stopped. As explained above, a registryd started or attempted earlier in the boot can remain active. The loregd exit contract also permits a source to keep running after storage-operation failures. Do not launch a second source against the same hive files.

The source-checked loregd implementation takes one or more HiveName=DatabasePath declarations and rejects arguments without =. It has no offline inspection, backup-recovery or database-clearing switches and does not create an automatic startup backup. Startup WAL recovery and orphan cleanup are not backup restoration or a database-corruption repair facility. The linked references pin these findings to the implementation revision checked; identify the installed provider and revision before choosing a recovery procedure.

Offline inspection, repair or database replacement therefore needs a procedure supported by the installed provider and version. Establish the source's state and verify the existence and coverage of a recovery copy before planning a replacement. This manual supplies no offline stop, repair or replacement sequence. Role definitions can re-supply service configuration; they do not constitute a backup of every registry value.

The ordinary Backup and restore workflow is separate: LCS coordinates operations through a working source. Backup requires a read-only snapshot transaction; restore replaces a target subtree, including descriptors, in a read-write transaction. Neither is a way to repair a source that cannot serve those operations.

Note

/bin/recsh exists so a system can ship a purpose-built recovery shell — one that bundles the offline registry tools, presents guidance, or curates a command set — without making it mandatory. /bin/sh is the floor where it is absent. peinit treats both as opaque executables.

2.8.4 Remote recovery #

Recovery requires console access: physical, IPMI or serial. Two post-v1 features address headless servers — rolling the registry back to a last-known-good state from the recovery shell, and an emergency sshd started without registry involvement.

3.1 Services

Peios / Peios technical reference / peinit / The Service Model

A service is the primary unit of management: a definition in the registry, a runtime state, a Security Descriptor, and at most one running main process. Definitions live under Machine\System\Services\<name>, where the key name is the service name. peinit reads them at Phase 2 boot and on an explicit reload-config.

3.1.1 Names #

A service name is 1 to 128 bytes drawn from [A-Za-z0-9._-]. Any other byte makes the name invalid.

Two exclusions are deliberate. / is out because names map directly onto cgroup identifiers (§5.1) and onto registry key names, and a name containing a separator would mean something different in each. : is reserved for peinit's own synthetic naming.

3.1.2 The two types #

3.1.2.1 Simple #

A long-running daemon. peinit forks, installs a token, and execs the binary; the process is the service. When it exits, the service has stopped. This is the default and covers nearly everything — registryd, authd, sshd, application services.

Readiness comes from the Readiness field. Notify, the default, waits for READY=1. Alive treats the process as ready the moment it exists.

The service goes Active on readiness and stays Active until the process exits or something stops it.

3.1.2.2 Oneshot #

A run-to-completion task: database initialisation, a schema migration, a directory that has to exist. peinit forks, installs a token, execs, and waits for the exit.

Readiness is ignored. A Oneshot's readiness is always "it exited successfully", because READY=1 is meaningless from a process whose job is to finish. Success means exit code 0, or any code listed in SuccessExitCodes.

The differences from Simple are:

  • A successful exit goes to Completed. With RemainAfterExit=1 it stays there; without, it passes through Completed to release dependents and then goes Inactive.
  • A non-zero exit goes to Failed.
  • ExecStartPost runs after the successful exit rather than after a readiness signal, and does not run at all if the Oneshot failed.
  • StartTimeout covers the entire execution, from the first pre-hook to the process exiting.

RemainAfterExit matters when the Completed state itself is the useful information — so a status query shows a migration as finished rather than as inactive.

3.1.3 Forking daemons #

peinit does not support them. A service that double-forks to daemonise itself is working around a problem that does not exist when the service manager tracks the process it spawned, and peinit tracks its child through a pidfd obtained at fork. There is no MAINPID=, and no way to point supervision at a different process.

A legacy binary that insists on double-forking is wrapped by whoever packages it — a script with a --no-daemon flag, typically. That is a packaging concern.

3.2 The Definition Schema

Peios / Peios technical reference / peinit / The Service Model

Every field a service definition can carry, with its registry type and its default. The semantics of each are in the section named alongside.

Value names are matched case-insensitively, so ImagePath and imagepath are the same field — and therefore a definition carrying both is a duplicate, not two fields.

FieldTypeDefaultMeaning
ImagePathstringrequiredAbsolute path to the service binary.
Argumentsmulti_string—Arguments passed to the binary.
Typedword0 (Simple)0 Simple, 1 Oneshot. §3.1
Triggersmulti_string—When the service starts automatically. §3.4
Disableddword0If 1, no trigger activates the service.
SafeModedword0If 1, attempt this service in Safe mode. Implied by ErrorControl=Critical. §2.6
IdentitystringLocalServiceSYSTEM, shared LocalService/NetworkService, Service for the service’s own virtual account, or a designated principal. §4.1
RequiredPrivilegesmulti_string—Privileges to keep; all others are removed. §4.5
Requiresmulti_string—Hard dependencies. §7.1
Wantsmulti_string—Soft dependencies. §7.1
BindsTomulti_string—Runtime coupling. §7.1
Conflictsmulti_string—Mutual exclusion. §7.1
Providesmulti_string—Roles this service fills: virtual names a dependency may target (network), and that peinit derives edges to. A role is a bare name — the level in network:routed comes from the depending service's readiness requirement, not from this value. §7.6
OnFailurestring—Service to start when this one fails. §6.3
ErrorControldword0 (Normal)0 Normal, 1 Critical.
RemainAfterExitdword0Oneshot only: stay Completed after a successful exit.
SuccessExitCodesmulti_string—Non-zero exit codes treated as success.
ExecStartPremulti_string—Commands run before the main binary, sequentially. §5.3
ExecStartPostmulti_string—Commands run after readiness or successful exit. §5.3
HookIdentitystring—Principal for the hook processes. Falls back to Identity. §4.1
ExecReloadstring—Reload command, or signal:<NAME>. Absent means SIGHUP. §6.5
PreStartCheckTimeoutdword5Seconds before a filesystem check helper is killed. §3.5
StartTimeoutdword30Seconds for the entire start sequence. §5.3
StopTimeoutdword10Seconds after SIGTERM before SIGKILL.
WatchdogTimeoutdword0Seconds between expected WATCHDOG=1 pings; 0 disables. §6.6
HealthCheckstring—Command run periodically. Exit 0 is healthy. §5.6
HealthCheckIntervaldword30Seconds between health checks.
HealthCheckTimeoutdword5Seconds before a health check is killed and counted failed.
HealthCheckRetriesdword3Consecutive failures before the service is unhealthy.
RestartPolicydword1 (OnFailure)0 Never, 1 OnFailure, 2 Always. §6.4
RestartMaxRetriesdword5Consecutive restarts before Failed. §6.4
RestartWindowdword120Seconds of sustained health that reset the restart counter.
RestartDelaydword1Seconds before a restart; doubles each consecutive failure, capped at 60.
Readinessdword0 (Notify)0 Notify, 1 Alive. Ignored for Oneshot.
NotifyAccessdword0 (Main)Who may send notifications. Main is the only mode. §10.5
FdStoreMaxdword0Maximum descriptors held for the service; 0 disables the store. §10.6
TimerPersistentdword1Catch up a missed timer run after a reboot. §9.3
TimerJitterdword0Maximum random delay added to each firing. §9.4
Environmentmulti_string—KEY=VALUE pairs added to the environment. §5.5
WorkingDirectorystring/Working directory for the process.
TTYPathstring—Terminal to attach as the standard streams and controlling terminal. §5.4
TTYPrecedencedword0Who wins that terminal when several services want it at once. Higher wins. Refused without a TTYPath. §11.6
RuntimeDirectoriesmulti_string—Private directories under /run, created before the main process.
LimitNOFILEdword—RLIMIT_NOFILE.
LimitCOREdword—RLIMIT_CORE, in bytes.
Conditionsmulti_string—Start-time conditions; failure skips the service. §3.5
Assertsmulti_string—Start-time assertions; failure fails the service. §3.5
DisplayNamestring—Human-readable name for status display.
Descriptionstring—What the service does.
ServiceSecuritybinaryinheritDescriptor controlling runtime operations on the service. §4.6

3.2.1 Registry types #

Schema typeRegistry type
stringREG_SZ, UTF-8
multi_stringREG_MULTI_SZ, an ordered list
dwordREG_DWORD, 32-bit unsigned
binaryREG_BINARY

A value whose registry type does not match the field's is a decode error. So is a dword carrying a value outside an enumerated field's range.

3.2.2 Schema version and forward compatibility #

Machine\System\Services\SchemaVersion is a dword, currently 1. peinit creates it if it is absent (§2.3).

Unknown values on a service key are ignored, which is what lets the schema grow additively: a definition written for a newer peinit still loads on an older one, minus the fields it does not understand. A newer schema version does not prevent boot.

Known fields are the opposite. A known field appearing more than once in a collected definition is a decode error rather than a last-one-wins, because a definition that says two different things about the same field has no defensible reading. Since names match case-insensitively, this catches ImagePath and imagepath in the same key.

3.2.3 What a decode failure costs #

A definition that fails to decode fails that one service, and the answer differs by caller.

At boot the key is marked Failed with cause ValidationError and the boot proceeds with every other definition. Anything that depended on the failed service fails in turn through the ordinary dependency propagation (§7.4), so the cost is bounded by what actually needed it.

On reload-config the same: that service is Failed with cause ValidationError — or, if it is running, left running with its definition marked removed — and every other definition loads (§10.4).

3.3 Field Formats

Peios / Peios technical reference / peinit / The Service Model

Rules that apply to how a field's value is written, as distinct from what it means.

3.3.1 Strings #

A string field that is present is non-empty, unless this section says otherwise for that field. Three fields treat the empty string as absence:

  • Identity — empty is the same as absent, and defaults to LocalService.
  • HookIdentity — empty is the same as absent, and falls back to the service's Identity.
  • DisplayName and Description — empty is the same as absent.

WorkingDirectory, when present, is a non-empty absolute path. Whether it exists, is a directory, and is reachable is checked when the service starts, not when the definition is read.

TTYPath is checked for emptiness before absoluteness: an empty value means no terminal, and a non-empty relative path is rejected.

3.3.2 Identity and HookIdentity #

Either a well-known principal name — SYSTEM, LocalService, NetworkService — matched case-insensitively and canonicalised, or a literal SID string such as S-1-5-18. Anything else is passed to authd verbatim to resolve (§4.3).

3.3.3 RequiredPrivileges #

Privilege names, matched case-sensitively against the published privilege table. A name that does not match exactly fails token materialisation and therefore the service start, so SeTCBPrivilege does not start a service that SeTcbPrivilege would.

3.3.4 RuntimeDirectories #

Each entry names one directory directly under /run. Entries are non-empty relative names; an entry equal to . or .. is rejected, as is any entry containing /, \, a NUL, or a control character. A dot inside a name is fine — app.sock.d is a valid entry.

For an entry foo, peinit creates /run/foo immediately before launching the service's main process, with a descriptor granting full access to SYSTEM, Administrators, and the service's own SID. Hook processes inherit the service's environment and identity rules but do not cause provisioning — the directories belong to the main start.

If creation or descriptor assignment fails, the start fails with ParentSetupFailure.

peinit does not remove runtime directories when a service stops. /run is a boot-scoped tmpfs and the next boot clears it.

3.3.5 Environment #

Each entry is KEY=VALUE, with a non-empty key containing no NUL. An entry that does not split into that shape is a decode error.

3.3.6 SuccessExitCodes #

Each entry is a decimal integer from 0 to 255 — a process exit code. Signal names and ranges are not accepted. Code 0 is always success and does not need listing. Duplicates are collapsed.

3.3.7 Dependency and handler names #

Entries in Requires, Wants, BindsTo and Conflicts, and the value of OnFailure, are validated when the definition is read. A dependency naming something outside [A-Za-z0-9._-] is a decode error rather than an unresolved dependency discovered later — the difference being that a typo containing an illegal character is caught immediately, while a typo that is still a legal name is caught at graph validation as a missing target.

An entry in the four list fields may carry a readiness level, written after a colon (netd:routed). The name and the level are validated separately, so the colon is not one of the illegal characters there. OnFailure takes a bare service name and no level: it names a handler to run, not a state to wait for.

3.3.8 Timeouts and intervals #

Every timeout and interval in the schema is in whole seconds unless the field says otherwise. The two sd_notify fields that carry durations, WATCHDOG_USEC and EXTEND_TIMEOUT_USEC, are in microseconds because that is what the protocol specifies.

3.3.9 Identifiers peinit generates #

Job identifiers, operation identifiers, and every other GUID peinit mints are UUIDv7. UUIDv7 is time-ordered, so identifiers sort by creation time — which is what keeps eventd's time-range and recency queries over jobs and operations cheap.

3.4 Triggers

Peios / Peios technical reference / peinit / The Service Model

A trigger says when a service starts by itself. Triggers are independent of service type: a Simple service can have a timer, and a Oneshot can start at boot.

Triggers is a multi_string, each entry either type or type:argument.

TriggerFormMeaning
BootbootStart during the Phase 2 boot sequence.
Deferred bootboot:settledStart once the boot set has settled, or the deadline expires. §2.5
Terminal handovertty:releasedStart when this service's TTYPath is let go of. §11.6
Timertimer:<schedule>Start on a schedule. §9.1

A service with no triggers is demand-only: it starts only when something asks for it, whether an administrator, a dependency, or an OnFailure handler.

Multiple triggers of the same type are allowed. A service with ["timer:*-*-* 02:00:00", "timer:*-*-* 14:00:00"] runs at 2am and 2pm, and each trigger is independent — its own timerfd, its own next-firing computation, its own history.

3.4.1 Arity is enforced #

boot takes no argument. boot:<something> accepts only the sub-triggers in the table above, and any other value is malformed rather than an unknown trigger type to be ignored. tty carries the same rule: the bare word names no event, and tty: accepts only released. timer with no schedule is malformed, as is timer: with an empty one.

That strictness is the point. Unknown values on a service key are ignored for forward compatibility, and if unknown trigger types were ignored too, boot:setled would be silently accepted and the service would simply never start. Instead it fails to decode, loudly.

3.4.2 Triggers that need a field #

tty:released is about the terminal this service names, so a definition carrying it without a TTYPath is refused rather than ignored — it describes a service that would silently never start, and starting is the entire point of the trigger. TTYPrecedence is refused on the same grounds. Emptying TTYPath (§3.3) takes both down with it.

3.4.3 Disabled #

Disabled=1 suppresses automatic activation and nothing else. No trigger fires: not boot, not boot:settled, not tty:released, not a timer, and not any trigger type added later. A disabled service's timers are neither armed nor serviced.

The definition is still loaded into the in-memory model, and an explicit start still works. To stop a service being started at all, deny SERVICE_START in its ServiceSecurity descriptor — that is an access control question, not a trigger question, and answering it with the Disabled flag would mean a flag that anyone who can write the key can clear.

Enabling and disabling are registry writes performed by administrative tools, not peinit commands. peinit picks the change up through a registry change notification, or on the next reload-config.

3.4.4 Extensibility #

The type:argument shape is designed to grow. Path, device and event triggers slot into the same array with no schema change, because a trigger is a string in a list rather than a field of its own.

3.5 Conditions and Asserts

Peios / Peios technical reference / peinit / The Service Model

Both are start-time checks in the same type:argument form, over the same four check types. What differs is the consequence of a failure.

CheckFormPasses when
pathpath:<path>The path exists, of any type.
filefile:<path>A regular file exists there.
directorydirectory:<path>A directory exists there.
registryregistry:<key>The registry key exists.

Conditions describe when a service applies. A failed condition skips the service: it transitions to Skipped, which satisfies its dependents, because a service that does not apply has succeeded by not needing to run.

Asserts describe what a service needs. A failed assert fails the service, with cause AssertionError — the service was expected to run and a precondition it depends on is missing.

All entries of a kind are AND'd. Conditions are evaluated first; asserts only if every condition passed. Both are evaluated before dependency resolution and before any pre-exec hook, and an entry with an empty argument or an unrecognised type is a decode error.

3.5.1 Evaluation without blocking #

peinit is single-threaded PID 1 and its event loop cannot block while a check runs. Two constraints follow, and they are why the check types behave differently from one another.

3.5.1.1 Registry checks are cache-only #

A registry: check is evaluated against the in-memory model, never by a live registry read. Accepted paths are exactly Machine\System\Services, Machine\System\Init, and Machine\System\Services\<name> with one nonempty service-name component. Init descendants, deeper Services paths, and trailing separators are decode errors, caught at load rather than silently failing at start. The same rule applies to Conditions and Asserts.

The two roots resolve as present. A registry:Machine\System\Services\<name> check is true when that service exists in the model; it does not read an arbitrary registry key. A missing service is a valid check whose result is false.

3.5.1.2 Filesystem checks run in a helper #

stat() can block uninterruptibly on hung I/O — a dead NFS mount, a failing disk controller — so peinit does not call it from the event loop. It forks a short-lived helper into a dedicated checks/ cgroup under the service's tree, using the same clone3 path as any other child. The helper stats the paths and reports over a non-blocking pipe; peinit waits on the pipe and the helper's pidfd through epoll, and never blocks.

PreStartCheckTimeout, default 5 seconds, bounds the helper. A check that does not report in time is treated as not satisfied — the fail-safe direction, so a condition skips the service and an assert fails it. peinit then SIGKILLs the helper's cgroup and unregisters the result descriptor, and the event loop is never held up by a hung check.

3.5.2 When results are computed #

Checks are evaluated once, before the service's dependencies start, and the result is cached for the rest of that activation. A service that waits a long time for a dependency starts on the answer that was true when the wait began, not on a fresh one.

3.6 Command Strings

Peios / Peios technical reference / peinit / The Service Model

Four fields hold executable commands: ExecStartPre, ExecStartPost, the command form of ExecReload, and HealthCheck. All four are parsed the same way.

3.6.1 Parsing #

The string is split on whitespace into an argv, with double quotes grouping. There is no shell — no expansion, no substitution, no globbing, and peinit never invokes one. A command that needs shell features is wrapped in a script.

Whitespace means exactly six characters: space, horizontal tab, line feed, carriage return, form feed and vertical tab. Every other Unicode whitespace code point is an ordinary argument character, which keeps the split independent of the Unicode version peinit was built against.

Double quotes group text into one argv element and are not retained in the result. Grouping may happen inside an argument, so --name="hello world" becomes the single entry --name=hello world. An empty quoted string is preserved as an empty argv entry.

Backslash has no escape semantics and is copied literally. A single quote is an ordinary character.

An empty or whitespace-only command is invalid, and so is an unclosed double quote.

3.6.2 The executable #

For all four fields, argv[0] is the executable path and begins with /. Relative names, empty names and PATH-searched execution are decode errors. Everything after argv[0] is an opaque string and is not path validated.

3.6.3 ExecReload signals #

ExecReload may instead name a signal, as signal:<NAME>. The name is an exact canonical Linux signal name. Numeric values, realtime signal expressions, aliases, lowercase spellings and names with surrounding whitespace are all invalid.

SIGKILL and SIGSTOP are invalid for reload, because a service cannot handle either as a request to re-read its configuration.

The accepted set is the standard non-realtime Linux signals other than those two:

SIGHUP, SIGINT, SIGQUIT, SIGILL, SIGTRAP, SIGABRT, SIGBUS, SIGFPE, SIGUSR1, SIGSEGV, SIGUSR2, SIGPIPE, SIGALRM, SIGTERM, SIGSTKFLT, SIGCHLD, SIGCONT, SIGTSTP, SIGTTIN, SIGTTOU, SIGURG, SIGXCPU, SIGXFSZ, SIGVTALRM, SIGPROF, SIGWINCH, SIGIO, SIGPWR, SIGSYS.

Names are validated when the definition is read and again before delivery, and mapped to host signal numbers at that point. ExecReload absent means SIGHUP.

3.7 Configuration Generations

Peios / Peios technical reference / peinit / The Service Model

peinit operates on snapshots, not on a live view of the registry. Two generation concepts govern when a registry change takes effect.

3.7.1 The in-memory model #

peinit maintains a full in-memory model of every service definition. The registry is read synchronously exactly twice: during Phase 2 boot, before meaningful supervision has started, and during a reload-config, which an administrator initiated and which is bounded. At all other times peinit works from the model.

Note

The model exists because the event loop cannot block on a userspace service during normal supervision. In single-threaded PID 1 a blocking syscall blocks everything — child reaping, watchdog expiry, shutdown signals, every other event stops while the syscall is stuck. Reading the registry means waiting on registryd, and registryd is a service peinit supervises.

Change notifications arrive as events on a pollable descriptor. peinit subscribes to Machine\System\Services\ and Machine\System\Init\ at boot, using the LCS watch mechanism — a persistent subscription that delivers change events on a key descriptor, with an OVERFLOW event when the kernel-side queue is exceeded (Peios Kernel TRM §5).

Any drained watch event triggers a full reload of the configuration, not a targeted re-read of the changed key. That covers the OVERFLOW case by construction, and it is why an administrator writing one value causes every definition to be re-read.

3.7.2 The boot generation #

At the start of Phase 2, peinit reads all definitions, builds the dependency graph and validates it. The plan and the graph are fixed at that point, and the boot executes against them.

The watches are armed as the event loop starts, which is after the plan is fixed but while boot-plan services are still starting. A registry write during that window — from an install script, a post-hook, a package transaction — triggers a reload like any other, and the reload applies like any other, with one exception: a boot-plan service whose launch has not yet been attempted keeps the definition the plan was built from, and the new one is recorded as pending, exactly as for a running service — the boot executes against its snapshot (§2.5). Such services are listed under deferred in the reload's summary, said on the console, and recorded as peinit.config.reload.deferred. Once every planned launch has been attempted or decided against — each service terminal, or in Backoff with its dependents held for it (§6.1) — peinit runs one more reload that applies what was deferred, recorded as peinit.config.reload.applied naming the deferred services in graph.services; nothing is emitted when nothing was deferred. A service already running keeps its pinned definition as always.

3.7.3 The activation generation #

When peinit starts a service, it snapshots that service's definition. The snapshot governs the whole start lifecycle: pre-exec hooks, the token request, the readiness timeout, the initial health checks. A field changed while the service is Starting does not take effect until the next start.

A service in Inactive or Failed has no activation snapshot, so starting it uses the current model. That gives the expected behaviour for the common edits:

  • A new service entry is available once the change notification is processed.
  • A timer change on an inactive service takes effect at the next trigger evaluation.
  • A dependency change takes effect on the next start for an inactive service, and on the next restart for an active one.

3.7.4 Field mutability #

Which class a field falls into depends on when its value is consumed.

3.7.4.1 Pinned to the running definition #

A change takes effect only when the service is restarted:

ImagePath, Type, Identity, RequiredPrivileges, ErrorControl, RemainAfterExit, Triggers, Disabled.

Triggers and Disabled are pinned only while the service is running. On a service that is not running, both take effect as soon as the notification is processed — which is what arms a timer added to an inactive service.

3.7.4.2 Applied on the next start #

A change takes effect at the next start or explicit graph reload, not while services are running:

Requires, Wants, BindsTo, Conflicts, OnFailure, Conditions, Asserts.

3.7.4.3 Reloaded at runtime #

A change takes effect at the next relevant event, with no restart:

Arguments, SuccessExitCodes, every timeout and retry value (StartTimeout, StopTimeout, WatchdogTimeout, RestartDelay, RestartMaxRetries, RestartWindow, PreStartCheckTimeout), HealthCheck and its three parameters, RestartPolicy, Environment, WorkingDirectory, ExecStartPre, ExecStartPost, ExecReload, HookIdentity, Readiness, NotifyAccess, LimitNOFILE, LimitCORE, FdStoreMax, TTYPath, TTYPrecedence, RuntimeDirectories, Provides, TimerPersistent, TimerJitter, SafeMode, DisplayName, Description, and ServiceSecurity.

ServiceSecurity is the one whose reload is immediately observable: a change takes effect on the very next control request against that service (§4.6).

3.8 Service Removal

Peios / Peios technical reference / peinit / The Service Model

When a definition disappears from Machine\System\Services\, peinit learns of it through the ordinary change-notification path. What happens next depends on whether anything is running.

3.8.1 Not running #

An entry in Inactive, Failed, Completed, Skipped, Abandoned or Backoff is discarded immediately. There is no process to consider.

Backoff belongs in this list rather than the next one, which is not obvious: a service between restart attempts looks like it has something pending. It does not. It has no process, and once the definition is gone there is nothing left to restart it from, so there is neither an instance to supervise nor a restart to wait for.

3.8.2 Running #

An entry in Active, Starting, Reloading or Stopping is not killed. The running process is a job, and a job's lifecycle is independent of the definition that produced it — removing a definition stops future management, it does not terminate work in progress.

peinit marks the entry definition-removed and keeps the cached definition, solely to go on supervising the instance it already has. When that instance exits it is not restarted — RestartPolicy is moot, because there is nothing left to restart from — and peinit then discards the entry.

A definition-removed entry never has dependents. A read in which one service Requires another the read does not define is refused whole, with MissingHardDependency — so a removal that would leave a dangling hard dependency does not land at all, and the administrator has to remove the dependents first.

While an entry is definition-removed:

  • stop is accepted, so an administrator can drain it cleanly, using the cached StopTimeout;
  • start, restart and reload are rejected with UNKNOWN_SERVICE — there is no definition to work from;
  • status reports the current runtime state with definition_removed set, so the draining instance is visible rather than silent.

definition-removed is a flag on the existing runtime state, not a state of its own. The state machine (§6.1) and the command × state matrix (§10.3) are unchanged by it.

A restart already in its stop phase is a case of its own. The stop phase still drains the existing instance; the start phase does not begin, because there is nothing to start from. When the instance exits, the operation is aborted with the reason definition_removed_during_restart_stop_leg and the entry takes the ordinary removal discard. This is the window a package upgrade opens — remove the definition, then restart the service.

Once the instance exits or is stopped, the entry — including anything in its fd store (§10.6) — is discarded. A crash counts: the exit is routed to Failed rather than into a restart, because there is no policy left to apply. The fd store goes with it, which a crash would otherwise preserve for the restart that is not going to happen.

4.1 Token Materialisation

Peios / Peios technical reference / peinit / Service Identity

Every service process runs with a KACS token that determines its identity and its access rights. peinit obtains or creates that token and installs it on the child before exec. It never shares its own token — even an Identity=SYSTEM service receives a separately materialised token of its own.

Which route the token comes from depends on the Identity field:

IdentitySourceMechanism
SYSTEMMinted by peinit from its own identitykacs_create_token. §4.2
ServiceauthdA virtual account unique to this service. §4.3
LocalService / NetworkServiceauthdA shared service account, with this service’s SID as a group. §4.3
Another principalauthdA principal designated for service logon. §4.3
Absent or emptyauthdDefaults to LocalService.

peinit reads its own token with kacs_open_self_token requesting the real token rather than any impersonation, and opens it query-only: it is a template to copy from, never a thing to hand out.

4.1.1 Where a token is materialised #

Materialisation happens at the point of use, per launched process, not once per service. A service that runs a pre-exec hook, a main process, and then a health check materialises three tokens.

ContextIdentity used
Main processIdentity
ExecStartPre / ExecStartPostHookIdentity if set, otherwise Identity
Health checksIdentity, always
ExecReload external commandIdentity, always
Submitted jobsThe prepared primary token: the submitter's own, or the token the kernel attached to the submission (§8.5)

Health checks and reload commands deliberately do not honour HookIdentity. A health check reports on the service's own health and should see what the service sees; a reload command acts on the running service. HookIdentity exists for setup work — creating directories in privileged locations, running a migration — which is a different job from either.

Note

HookIdentity typically grants more than the service itself. peinit does not validate filesystem permissions on hook binaries, so where it grants elevated privileges the administrator is responsible for the hook binary and its parent directories not being writable by anything lower-privileged.

If materialisation fails at any point — authd unreachable, an identity that cannot be resolved, a KACS error — no child exists yet, and the start fails with ParentSetupFailure for the main process or PreHookFailure for a hook.

4.2 The SYSTEM Path

Peios / Peios technical reference / peinit / Service Identity

For Identity=SYSTEM, peinit mints a token itself. This is what breaks the bootstrap circle: registryd and authd need tokens, and authd — the thing that mints tokens — is one of them. eventd and lpsd took this path too until they moved to accounts of their own (Identity=Service); they now take the authd path (§4.3) and start after authd.

4.2.1 Minting #

peinit reads its own token as a template and builds a new primary token carrying the same identity: user SID S-1-5-18, the same group list, the same privilege set, the same integrity level. The mint requires SeCreateTokenPrivilege, which the boot SYSTEM token carries; the kernel refuses the call with EPERM otherwise.

Two details of the copy matter.

The logon session comes from the token's statistics. peinit takes the auth_id from the source token's TokenStatistics — not from the independent interactivity_scope field, and not from a hard-coded well-known SYSTEM LUID. The minted token therefore stays associated with the real SYSTEM logon session peinit was given at boot, while carrying its own interactivity scope, which is zero for a platform service. Substituting either of the other two values would associate platform services with a session that does not exist.

The logon SID group is dropped from the copy. The kernel re-appends the session's logon SID when it creates the token, and rejects a create whose group list already contains it. So peinit filters that group out of the template before building.

Two groups are added that the template does not carry. The per-service SID (§4.4), which is what keeps platform daemons distinguishable while they all run as SYSTEM; and the Service group S-1-5-6, which every token authd mints for a service logon carries by derivation from the logon type.

The second is load-bearing rather than cosmetic. S-1-5-6 is the grantee on anything reachable by services as a class — peinit's own notification socket first among them (§10.5) — so a SYSTEM service minted here without it would be unable to report itself ready, while an otherwise identical service on an authd-minted token could. A service is no less a service for having been started before the authority was.

The minted token carries a default DACL the template cannot supply. A token's default DACL is the DACL an object gets when the token creates it with no parent to inherit from — an abstract socket, a key under a container written without inheritable ACEs. The kernel has no fallback of its own: a token whose default DACL is empty leaves such an object with a null DACL, which grants everything to everybody. The bootstrap SYSTEM token carries D:(A;;GA;;;SY)(A;;GA;;;BA) (see Bootstrap tokens), but a token's default DACL is not a field peinit can read back from its own handle, so peinit states the same value itself and sets it on every SYSTEM token it mints. Non-SYSTEM services do not pass through here; their default DACL is the authority's, from the DefaultDacl value of the principal's policy record (§4.3).

peinit also asserts that its own token is a primary token and that its user SID really is S-1-5-18 before minting, and fails the start with a message naming what it found otherwise. PID 1 minting from something that is not the boot SYSTEM token is not a situation to proceed from.

The minted token is fully independent. The privilege restriction that follows (§4.5) operates on it alone and cannot affect peinit's own.

Note

Minting is an interim mechanism. The intended model is for peinit to derive the token from its own handle, through a KACS duplicate-with-additions operation — kernel-attested as a descendant of peinit's real token, and requiring no token-minting privilege at all. That operation does not exist in KACS yet, so peinit mints.

4.3 The authd Path

Peios / Peios technical reference / peinit / Service Identity

For any identity other than SYSTEM, the token comes from authd. peinit does not resolve identities, does not know whether a principal is local or from a domain, and does not want to: routing is authd's whole purpose.

The division is not that peinit lacks the privilege to mint — it plainly has it, and uses it for the SYSTEM path (§4.2). It is that the privilege set and integrity level an identity carries are policy, and policy is authd's. A second component deciding it in parallel is a disagreement waiting to happen, and peinit has already shipped one: its hand-written privilege bit table had four wrong entries, silently stripping privileges a service had asked to keep.

4.3.1 The request #

peinit sends a ServiceAttest on /run/logon.sock, specified as PGSS Logon §2.19. It carries two strings:

FieldValue
identityThe definition's Identity value, verbatim
serviceThe name of the service being started

authd answers with AccessGranted carrying the token as an SCM_RIGHTS descriptor, or AccessDenied. There is no credential exchange, because a service identity has no credential and must never acquire one — a machine that could authenticate its own services unattended would have to hold their secrets.

authd derives the per-service SID from service and adds it to the token, so the SID that distinguishes one service from another comes from the authority on this path rather than from peinit (§4.4). Note that authd cannot verify service: the process it names does not exist yet, so there is no token to interrogate. peinit is trusted on it, which is the reason for the restriction below.

4.3.2 This request must come from PID 1 #

authd authorises a ServiceAttest on two facts: that the peer's token names SYSTEM, and that the peer is PID 1. The second is not redundant. SYSTEM alone is every platform daemon on the machine, so without it, compromising any one of them would yield a token for any service identity authd would mint.

That makes it a standing constraint on this crate: the connection is opened by peinit's own process and must never be opened by a forked helper. Nothing in the code enforces it. If it is ever broken the symptom is every non-SYSTEM service failing to start at boot with an authorisation error, which reads like a fault in authd rather than like a refactor.

4.3.3 The token is weaker than a user's, deliberately #

An attested token is minted one rung lower on the impersonation ratchet than a credentialled logon: Impersonation rather than Delegation.

The evidence behind it does not travel. A credential a principal source verified is something another machine could in principle have checked too; peinit's word is evidence on this machine only. A service token that could be forwarded off the box on the strength of it would let one system's PID 1 act as an arbitrary account on another.

Because the level is a ratchet, nothing derived from a service's token can exceed it either.

4.3.4 Failure is a failure #

Every non-SYSTEM service start depends on authd being reachable, and every one of them fails if it is not. Only services using the SYSTEM path are unaffected; platform services such as eventd and lpsd use the authority too.

peinit orders around that dependency rather than merely suffering it. The authority declares Provides = ["authn"], and peinit derives a hard dependency on whoever fills that role for every service whose Identity — or whose HookIdentity, where it has hooks — is not SYSTEM (§7.6). Nothing is written on the consumer side: the edge comes from the same field that creates the requirement, so a new service cannot forget it.

Until that existed the ordering was left to each definition, and all three LocalService definitions in the tree omitted it — so a service needing a token routinely reached this code before the socket was bound and failed its launch with ENOENT, on most boots, recovering only through its restart policy.

This is the intended behaviour rather than a limitation. The client that preceded it returned a freshly minted SYSTEM token whenever it could not do better, so a service declaring LocalService ran with peinit's entire privilege set — and status output reported it as LocalService, which is what kept the problem invisible from outside. A service that does not start is visible; a service running as the wrong principal is not.

summarize_token now refuses to describe a token as an identity it does not carry: where the declared identity predicts a user SID, a token carrying a different one fails the launch rather than being reported under the name that was asked for. It is only checked where the identity predicts a SID — a principal name is authd's to resolve, and peinit has nothing to compare it against.

4.3.5 What may be attested #

authd accepts the platform's own service identities — SYSTEM, LocalService, NetworkService, and the per-service virtual account selected by Service — which no principal source holds and for which no credential could exist.

Anything else must be designated in the principal's own record as usable for a service logon, and until that designation exists authd refuses it. So a definition naming an ordinary principal does not start. That is the property the design exists to guarantee: without it, the service manager would be an oracle that mints a credential-free token for anybody on the machine.

4.3.6 The service’s own virtual account #

Identity=Service asks authd to use the per-service SID (§4.4) as the user SID, as well as including it in the token’s groups. The spelling Service is case-insensitive. Each service name therefore selects a separate virtual account without creating an account in a principal source or enabling a credentialled logon.

The derived groups include Everyone (S-1-1-0), Authenticated Users (S-1-5-11), Local (S-1-2-0), and Service (S-1-5-6), together with the kernel’s per-session logon SID. It does not acquire SYSTEM or LocalService membership. Privileges, integrity, owner and default DACL still come from authd’s policy evaluation; choosing a distinct user SID grants no extra privileges by itself.

With Identity=LocalService, the user SID is the shared S-1-5-19 instead. The service’s own SID remains a group, so ACLs can still distinguish two services that share that account. Use Service when their user identities should also be distinct.

Service takes the same derived hard dependency on the providers of the authn role as other non-SYSTEM identities (§7.6). On the standard platform that provider is authd; an explicit Requires=authd is unnecessary.

4.3.7 The interface is authd's #

peinit speaks the protocol through libauthd, which is its definition, rather than encoding the message itself. A second implementation of a wire format that mints identity is the kind of duplication that fails silently and in the worst place.

4.4 Per-Service SIDs

Peios / Peios technical reference / peinit / Service Identity

Every service token carries a SID derived from the service's name, in its group list, alongside whatever the principal's own identity brings.

The derivation is the one KACS defines (Peios Kernel TRM §3.2). The authority is S-1-5-80. The service name is uppercased and encoded as UTF-16LE, SHA-1 is taken over those bytes, and the 20-byte digest is split into five little-endian 32-bit sub-authorities:

S-1-5-80-<sub1>-<sub2>-<sub3>-<sub4>-<sub5>

peinit computes this itself, from the name alone, with no involvement from anything else. authd computes the same value independently when it mints a token, and the two implementations are pinned against each other by a shared test vector.

4.4.1 Why they exist #

Per-service SIDs are what make access control useful when services share a principal. Every platform daemon runs as SYSTEM, and every service with no Identity runs as LocalService; without something to tell them apart, an ACL could grant a right to "LocalService" and thereby grant it to a dozen unrelated services.

With a per-service SID, an ACE can name one specific service. It costs nothing — no account, no registry entry, no allocation — because it is a hash of a name that already has to be unique.

They are load-bearing in more than access checks. peinit uses the service SID directly when it provisions a service's runtime directories, stamping /run/<name> with a descriptor that grants full access to SYSTEM, Administrators, and that service's SID and nothing else (§3.3).

4.4.2 The uppercasing rule #

The name is uppercased before encoding, using full Unicode case mapping — the one that expands ß to SS and fi to FI rather than mapping each code unit in place. peinit uppercases in the code-point domain, before the UTF-16 encoding, so any expansion happens first.

For an ASCII service name, which is every real service, the choice is invisible. It becomes visible only for a name containing a character whose uppercase form is longer than itself.

4.5 Privilege Restriction

Peios / Peios technical reference / peinit / Service Identity

RequiredPrivileges is a list of the privileges a service needs. Everything else is removed from its token before exec.

4.5.1 Subtractive only #

peinit removes privileges. It never adds one, on either path — there is no code that constructs anything but a removal. A service cannot acquire a privilege by naming it in RequiredPrivileges; if the token it was given does not have it, listing it changes nothing.

The removal targets the token's present bitmask, through KACS_IOC_ADJUST_PRIVS on the token descriptor — one kacs_priv_entry per removed privilege, carrying the privilege-removed attribute. The right required on the descriptor is KACS_TOKEN_ADJUST_PRIVS, which a freshly minted token always has.

peinit does not use KACS_IOC_RESTRICT. That builds a restricted-SID token — a different mechanism with different semantics — and does not touch the privilege bitmask at all. It is available in the bindings and would be a plausible-looking mistake.

Removing a privilege clears its present, enabled and enabled-by-default bits together, and irreversibly. The privileges that survive keep the enable state their source gave them: peinit does not enable, disable or re-order anything. Enable policy belongs to whoever minted the token.

peinit iterates all sixty-four privilege bits rather than only the ones it has names for, so a privilege this build does not know about is stripped along with the rest. The safe direction is to remove what was not asked for, including what cannot be named.

If RequiredPrivileges is absent, peinit does not query or adjust the token at all, and the source's default privilege set stands unchanged.

4.5.2 Names #

Privilege names are matched case-sensitively against the published privilege table. A name that does not match exactly is not silently ignored: it fails token materialisation, and therefore fails the service start.

Two privileges KACS enforces are absent from the published table — SeTakeOwnershipPrivilege and SeRelabelPrivilege — and so cannot be named in RequiredPrivileges at all. A service that needs either declares nothing and takes the source token's defaults, or fails to start if it tries to name one.

4.6 Service Security Descriptors

Peios / Peios technical reference / peinit / Service Identity

A service carries two independent descriptors, and they answer different questions.

The registry key descriptor on Machine\System\Services\<name> controls who may read and write the service's definition. It is enforced by LCS at key-open time and is not peinit's concern — peinit reads definitions as SYSTEM.

The ServiceSecurity descriptor controls who may perform runtime operations on the service through the control interface. It is stored as a binary ServiceSecurity value on the same registry key, but enforced by peinit rather than by LCS.

The two are genuinely independent. An administrator might be able to query a service's status without being able to read its configuration, or the reverse. Runtime control and configuration access are separate concerns and there is no reason for one to imply the other.

4.6.1 Access rights #

RightBitGrants
SERVICE_QUERY_STATUS0x0001Query state, PID, cause, health, warnings.
SERVICE_START0x0002Start the service.
SERVICE_STOP0x0004Stop the service.
SERVICE_INTERROGATE0x0008Reload the service.
SERVICE_ALL_ACCESS0x000FThe union of the four.

Restart requires SERVICE_START and SERVICE_STOP together. Reset requires SERVICE_STOP, because clearing a Failed or Abandoned state is the tail of stopping something rather than the head of starting it.

The generic mapping peinit passes to AccessCheck:

Generic rightMaps to
GENERIC_READSERVICE_QUERY_STATUS
GENERIC_WRITESERVICE_START | SERVICE_STOP | SERVICE_INTERROGATE
GENERIC_EXECUTESERVICE_START | SERVICE_STOP | SERVICE_INTERROGATE
GENERIC_ALLSERVICE_ALL_ACCESS

4.6.2 Inheritance and the default #

A service whose definition carries no ServiceSecurity value takes the one on Machine\System\Services itself. The lookup is a single step to that key, not a walk up the hierarchy, which is exact for the flat layout definitions actually use. That includes the compiled-in registryd, which takes it once Phase 2 has read the key and on every reload after; a registry definition of registryd (§2.3) may give it a descriptor of its own like any other service.

If that key has no ServiceSecurity either, peinit applies a built-in default:

O:SY G:BA D:(A;;0x000F;;;SY)(A;;0x000F;;;BA)(A;;0x0001;;;AU) S:(AU;FA;0x000F;;;WD)

SYSTEM and Administrators both get SERVICE_ALL_ACCESS. The SACL audits every refusal, for everyone, which is what records a denial (below). An administrator who may stop any service and shut the machine down gains nothing by being unable to start one, so the default does not attempt a narrower grant; a service that wants one carries its own ServiceSecurity value.

Every authenticated principal gets SERVICE_QUERY_STATUS: what a service is doing is not, by default, a secret from the people using the machine, and a service whose state is a secret carries a ServiceSecurity that says so.

4.6.3 The check #

When a control command arrives, peinit:

  1. Takes the caller's token, captured when the connection was accepted.
  2. Resolves the target service and its ServiceSecurity descriptor. A command naming no definition and no addressable definition-removed entry returns UNKNOWN_SERVICE; peinit does not invent a descriptor to check against.
  3. Calls AccessCheck with the caller's token, that descriptor, the generic mapping above, the right the command needs, and an audit context naming the service, {kind: "service", service: {name}}.
  4. On denial, returns ACCESS_DENIED. The attempt is recorded by KACS, not by peinit: when the descriptor's SACL asks for it — and the built-in default's does — the check writes a kacs.audit.access.checked record with object.kind service, object.service.name, the caller's token, and the access requested and granted (§8.4).
  5. On grant, proceeds.

4.6.4 The SACL is the audit policy, and who may drop it #

A service's SACL decides which of its decisions are recorded. A descriptor written to the registry is used as it is: one with no SACL records nothing, and one that narrows the default's audits less.

ServiceSecurity is a whole registry value. Whoever may write it — on the service's key, or on Machine\System\Services, whose value every service without its own takes — may replace the descriptor, SACL included, without holding SeSecurityPrivilege, which is what changing an object's SACL otherwise needs. So those keys must be writable only by principals trusted to change audit policy. By default they are: both inherit the Machine hive root, which grants write to SYSTEM and Administrators alone, and Administrators hold SeSecurityPrivilege. Delegate a service through its ServiceSecurity DACL, never by loosening the key's own descriptor; and a tool that changes only the DACL must write the SACL back unchanged. The same holds for the control descriptor in Machine\System\Init (§10.2).

4.6.5 Hot reload #

ServiceSecurity changes take effect on the next control request, with no restart. A registry change notification triggers a configuration reload, and the reload re-reads every descriptor. There is no cached decision to invalidate — the check runs against the current descriptor every time.

4.6.6 Filtering, not denying #

list returns only the services the caller has SERVICE_QUERY_STATUS on. Services the caller cannot query are omitted, not denied: a caller with no query rights anywhere receives an empty list and a successful response. The denials are not surfaced to the caller; each service's SACL decides whether KACS records them.

What the filtering protects is a service's state, which is what SERVICE_QUERY_STATUS grants. That a service exists is not protected: a command naming one is answered UNKNOWN_SERVICE or ACCESS_DENIED according to whether it exists, and the definitions under Machine\System\Services are readable. A client that can read the definitions may therefore show a service list left out as one whose state the caller may not see.

4.7 The Control Descriptor

Peios / Peios technical reference / peinit / Service Identity

Three commands are not about any one service: shutting the system down, re-reading the configuration, and asking how the machine booted. They are checked against peinit's own descriptor, stored at Machine\System\Init\ControlSecurity as a binary value.

4.7.1 Access rights #

RightBitGrants
SYSTEM_SHUTDOWN0x0001Initiate poweroff, reboot or halt.
SYSTEM_RELOAD_CONFIG0x0002Re-read all definitions from the registry.
SYSTEM_QUERY_STATUS0x0004Read peinit's own status: how this boot went, with boot (§10.2).

The generic mapping:

Generic rightMaps to
GENERIC_READSYSTEM_QUERY_STATUS
GENERIC_WRITESYSTEM_RELOAD_CONFIG
GENERIC_EXECUTESYSTEM_SHUTDOWN
GENERIC_ALLall three

GENERIC_READ maps to the one right that changes nothing, as it maps to SERVICE_QUERY_STATUS on a service. Before boot existed the control descriptor governed two actions and no queries, and GENERIC_READ on it conveyed nothing; a descriptor written then that grants GENERIC_READ now lets its holder ask how the machine booted, and nothing more.

4.7.2 The default #

Absent a value in the registry, peinit applies:

O:SY G:BA D:(A;;0x0007;;;SY)(A;;0x0007;;;BA)(A;;0x0004;;;AU)

SYSTEM and Administrators both get every right, as they both get every service right in the ServiceSecurity default (§4.6). An administrator who can stop services one at a time can already stop the system, so withholding shutdown would be theatre.

Authenticated Users get SYSTEM_QUERY_STATUS and nothing else. How a machine booted is machine status, like what /proc shows, not a secret; and it is what a person signed in to the machine needs to tell that it came up in Safe mode, or is a failed boot or two from recovery. The ServiceSecurity default lets the same group query every service for the same reason.

The default applies only while ControlSecurity is absent or malformed. A descriptor already written to the registry is used as it stands: one written before SYSTEM_QUERY_STATUS existed grants it only where it grants GENERIC_READ, GENERIC_ALL or the bit itself, and a machine that wants every signed-in user to read its boot status adds an ACE for them.

4.7.3 Loading #

peinit loads the descriptor during Phase 2 boot and hot-reloads it on registry change notification, on the same path as the service descriptors. Until it is loaded — during Phase 1 and the early part of Phase 2 — the built-in default applies, which matters because the control socket exists from Phase 1 infrastructure setup onwards.

5.1 The Cgroup Tree

Peios / Peios technical reference / peinit / Starting a Service

peinit uses cgroups v2 for exactly two things: knowing which processes belong to a service, and killing all of them at once. It does no resource accounting and sets no limits.

Every service gets a tree:

/sys/fs/cgroup/peinit/<cgroup-id>/          service root
/sys/fs/cgroup/peinit/<cgroup-id>/main/     the main process
/sys/fs/cgroup/peinit/<cgroup-id>/hooks/    hooks and reload commands
/sys/fs/cgroup/peinit/<cgroup-id>/health/   health check invocations
/sys/fs/cgroup/peinit/<cgroup-id>/checks/   pre-start filesystem check helpers

The sub-cgroups satisfy cgroups v2's "no internal processes" rule, which applies whenever controllers are enabled, and give hooks and probes containment of their own so that killing one does not touch the service.

They are not all created at once. The root and hooks/ are created when the first thing needs them — the first pre-exec hook, if there is one — and main/ and health/ when the main process launches.

5.1.1 The cgroup id #

<cgroup-id> is the service name with every byte outside [A-Za-z0-9._-] percent-encoded as % plus two uppercase hex digits. Service names are already restricted to that set (§3.1), so in practice the id equals the name.

The encoding is a defensive guarantee that distinct names always map to distinct, cgroup-safe ids. % is not itself in the safe set, so it escapes to %25 and the encoding is prefix-free. That is what makes it injective, unlike a plain substitution of / for -, under which a/b and a-b would collide.

The id is internal. The name a user sees is unchanged.

5.1.2 Generations #

A cgroup whose processes survived SIGKILL cannot be removed — rmdir on it fails with EBUSY. peinit records the leak and moves the service to a fresh tree:

/sys/fs/cgroup/peinit/<cgroup-id>%gen<N>/

Old leaked trees persist until the next reboot.

Two conditions record a leak, and both are needed because they answer different questions. cgroup.events reporting populated at the post-kill deadline says live processes remain. rmdir returning EBUSY says the tree cannot be given up, which covers that case and the ones where nothing is running but the directory still will not go. Acting on only the first left the second reusing a tree that already existed and was not empty.

The generation advances once per tree, not once per leak recorded against it. A single failed start records two cleanup deadlines — one for hooks/, one for the service tree — and a tree cleanup can report main/, hooks/, health/ and the root separately; all of those are the same tree, so the first advances the generation and the rest find themselves already behind it. Leaks are still deduplicated by path and kind, so each is recorded once.

The separator is %gen, not .gen. . is in the safe set and service names permit it, so a service literally named app.gen1 would have shared a directory with generation 1 of a service named app. % self-escapes and the encoder only ever emits it followed by two uppercase hex digits, so %g cannot occur inside an encoded id — which makes the whole path injective, not just the id.

5.2 Pre-Start Evaluation

Peios / Peios technical reference / peinit / Starting a Service

Before anything is forked, peinit evaluates the service's conditions and asserts (§3.5). The definition comes from the in-memory cache, so this never touches the registry.

  1. If the service names a TTYPath and another service is holding that terminal, it goes to Skipped with cause TtyUnavailable and the start is abandoned (§11.6). Asked first because a terminal is a fact about the machine right now, and evaluating conditions ahead of it would fork a check helper for a start that was never going to happen.
  2. Conditions are evaluated. Any failure transitions the service to Skipped and abandons the start. Skipped satisfies dependents.
  3. If every condition passed and the service has asserts, they are evaluated. Any failure transitions the service to Failed with cause AssertionError and abandons the start.

Only when all three pass does the pre-exec sequence continue.

5.2.1 Where the transition happens #

For a fresh start from Inactive, the evaluation gates the transition: the service becomes Starting only after the checks pass, so a skipped service goes straight Inactive → Skipped.

For an activation that is already Starting — the start leg of a restart, for instance — the transition has already happened, and the evaluation gates further progress instead. A restart whose conditions no longer hold therefore passes through Starting on its way to Skipped, where a fresh start would not.

An assert failing takes the same route to Failed on both paths, and on both it propagates: dependents in the same graph context hear that the dependency failed (§7.3). This matters more on the restart leg than elsewhere, because an AssertionError is never restarted — where a running dependent is deliberately unaffected by a dependency crashing (§7.1), since a crash is transient and the restart policy handles it, a failed assert means the dependency is not coming back without intervention.

5.2.2 Two check kinds, two mechanisms #

registry: checks resolve against the in-memory model. Since a non-cacheable key is rejected when the definition is read, this evaluation never needs a live read.

Filesystem checks — path:, file:, directory: — run in a forked helper. peinit clones it with CLONE_PIDFD | CLONE_INTO_CGROUP into the service's checks/ sub-cgroup, exactly as it launches anything else. The helper stats the paths and writes the results to a non-blocking pipe; peinit watches the pipe and the helper's pidfd through epoll.

The terminal is re-checked when the helper's results come back, rather than the earlier answer being carried over : the helper ran in between, and a terminal can change hands while it did.

PreStartCheckTimeout bounds the helper, at 5 seconds by default. On expiry every check still outstanding is marked not satisfied — the fail-safe direction — and re-evaluated on that basis, so a condition skips the service and an assert fails it. peinit then kills the helper's cgroup and unregisters the result descriptor.

A helper that survives the kill, stuck in uninterruptible sleep, has its cgroup abandoned and recorded exactly as a leaked hook or health cgroup is (§5.7).

5.2.3 Caching #

The results are computed once, before the service's dependencies are started, and reused for the rest of that activation. A service that waits a long time on a dependency starts on the answer that was true when the wait began.

Filesystem checks are gathered in one helper run, from the conditions and the asserts together. There is only ever one run — the completion path evaluates both lists against the results it gets back, and there is no mechanism to ask for a second — so both lists' paths have to be stat'd before it. A path named in both is stat'd once.

A check with no result counts as not satisfied, which is the fail-safe direction for a timeout and is why the gather has to be complete: an omitted path is indistinguishable from a path that is not there.

5.3 The Pre-Exec Sequence

Peios / Peios technical reference / peinit / Starting a Service

Everything between "peinit decides to start service X" and "X's binary is running". The service is in Starting throughout.

5.3.1 Step 1: Arm the start timeout #

StartTimeout covers the entire remaining sequence — pre-hooks, fork, exec, and the readiness wait. A single deadline, measured from the operation's creation rather than from this point, bounds all of it.

Expiry aborts the start and kills the service's cgroup tree. The cause recorded depends on where the deadline landed : a timeout during pre-hooks is PreHookFailure; one during the readiness wait or the main process's execution is ReadinessTimeout.

5.3.2 Step 2: Provision runtime directories #

If the definition lists RuntimeDirectories, peinit creates each one under /run and applies its descriptor (§3.3) before anything else in the launch. Failure classifies as ParentSetupFailure.

5.3.3 Step 3: Run pre-exec hooks #

Each ExecStartPre command runs in sequence, forked into hooks/. A token is materialised for each at the point of use, from HookIdentity if set and Identity otherwise (§4.1); a failure there fails the hook and the service with PreHookFailure.

Any hook exiting non-zero kills the entire service cgroup tree — cleaning up whatever grandchildren the hook left — and fails the service with PreHookFailure.

When every hook has succeeded, peinit kills the hooks/ sub-cgroup before the main process starts, so a hook that forked something into the background does not become part of the service.

5.3.4 Step 4: Materialise the service token #

The main process's token, per §4.1. A failure means no child exists and the service fails with ParentSetupFailure.

If a later parent-side step fails before the fork, peinit closes the token descriptor exactly once. A failure to close is retained as cleanup evidence and does not change how the start is classified — the classification describes what went wrong with the start, and a failed close is a separate fact about the same failure.

5.3.5 Step 5: Create the cgroups and the error pipe #

The main/ and health/ sub-cgroups are created, and a pipe2(O_CLOEXEC) error pipe. The parent keeps the read end, non-blocking; the child will hold the write end.

The pipe is how the child reports a failure it hits after fork but before exec. If exec succeeds, the write end closes automatically — that is what O_CLOEXEC is for — and the parent reads EOF, which means setup succeeded. If setup fails, the child writes a structured error first.

The payload is exactly eight bytes, written with one write(2):

BytesContent
0–3little-endian u32, the child setup step identifier
4–7little-endian i32, a positive Linux errno

The child writes at most one payload — the first reportable failure — and then exits. The parent treats a non-EOF payload that is not exactly eight bytes, or carries an unknown step identifier, or an errno of zero or less, as malformed evidence and fails closed with PreExecFailure.

The step identifiers:

IdStep
1Close the read end of the error pipe
2Set the standard streams
3Reset the signal environment
4Install the service token
5Set the resource limits
6Set oom_score_adj
7Set the working directory
8reserved
9Confirm NOTIFY_SOCKET
10Inject stored descriptors
11Exec the binary
12Create a session
13Acquire the controlling terminal

Identifier 8 is reserved and never emitted : the environment is built in the parent and applied by execve, so there is no step in the child that could fail. Identifiers 12 and 13 are the two terminal steps, which occur only for a service with a TTYPath.

If pipe2 fails, no child exists and the service fails with ParentSetupFailure.

5.3.6 Step 6: Fork #

clone3(CLONE_PIDFD | CLONE_INTO_CGROUP), targeting main/. This does two things atomically: it returns a pidfd for the child, and it places the child directly into main/ at creation. There is no window in which the child exists without a pidfd, and none in which it runs or execs in peinit's own cgroup.

Placing the child at creation also avoids the alternative, which would be having the child write its own PID into cgroup.procs — something its post-installation token could not do.

A clone3 failure means no child exists: ParentSetupFailure, with EMFILE/ENFILE, EAGAIN and ENOMEM the usual causes.

peinit does not take the atomicity on trust. If clone3 reports success without setting the pidfd, or the pidfd cannot be made close-on-exec, peinit writes to cgroup.kill through the main/ directory descriptor before returning ParentSetupFailure — because by then a child does exist, running in the service's cgroup with the service's token, and the parent has no handle on it. Walking away would leave a process peinit cannot supervise, cannot stop, and has no record of, which the zero-delay cleanup that follows a ParentSetupFailure would then record as a leaked tree rather than kill.

Immediately after clone3 returns, in the parent and on either outcome, peinit closes the token descriptor and the main/ cgroup descriptor, exactly once each. The child relies on close-on-exec and its own exit instead. A failure to close is cleanup evidence and does not change how the start is classified.

5.3.7 Step 7: The parent after the fork #

  1. Close the write end of the error pipe.
  2. Register the read end with the event loop. peinit does not block on it — signals, control traffic, timers and log pipes stay serviceable while the child's setup is pending.
  3. When the read end becomes readable or hangs up, read it non-blocking:
    • Would block: leave the source registered, the job stays in pending setup.
    • EOF: exec succeeded. Record the pidfd on the job, emit peinit.job.started, and only then apply readiness side effects such as Simple/Alive activation.
    • Data: setup failed. Parse the step and errno, log the specific failure, fail the service with PreExecFailure.

While setup is pending the job is not Running, and no dependent that waits on this service's readiness is released.

5.3.8 Steps 8 and 9 #

The child's own path is §5.4. What follows exec is the readiness wait and the post-readiness work:

  • Simple with Readiness=Notify: wait for READY=1, then Active.
  • Simple with Readiness=Alive: Active as soon as exec succeeds.
  • Oneshot: wait for the exit. Success is Completed; without RemainAfterExit it then goes Inactive once dependents are released. A non-zero exit is Failed.

On readiness or successful exit, peinit runs ExecStartPost in sequence into hooks/, releases the service's dependents, kills hooks/, and — for a Simple service only — arms the watchdog if WatchdogTimeout is non-zero and the health check timer if HealthCheck is set.

A post-hook that fails is logged and does not fail the service. The state transition to Active or Completed happens before the post-hooks run, so a service is already Active while its post-hooks are executing.

5.3.9 Failures before the fork #

Steps 2, 4, 5 and 6 can all fail with no child in existence. peinit handles all four entirely in the parent: clean up whatever cgroups were created, fail the service with ParentSetupFailure, and return the error to the caller. These are system-level resource problems — file descriptor limits, PID limits, memory, cgroup filesystem errors — rather than anything about the service.

5.4 The Child Path

Peios / Peios technical reference / peinit / Starting a Service

Between clone3 returning in the child and execve, peinit runs a straight line of setup. The path is kept minimal — no logging, no complex library calls — because it runs after a fork, where very little is safe to do.

Each step reports through the error pipe with the identifier from §5.3 if it fails, then exits: _exit(126) for a setup failure, _exit(127) for a failed exec.

The steps, in the order the child runs them:

#StepId
1Close the read end of the error pipe1
2setsid() — only with a TTYPath12
3Set the standard streams2
4ioctl(TIOCSCTTY) — only with a TTYPath13
5Reset the signal environment3
6Set RLIMIT_NOFILE and RLIMIT_CORE5
7Set oom_score_adj6
8Install the KACS token, then close its descriptor4
9Change the working directory7
10Confirm NOTIFY_SOCKET is present in the environment9
11Inject stored descriptors from fd 3 upward10
12execve11

5.4.1 The terminal steps #

setsid() comes first, and its position is load-bearing in two directions. It has to precede the stream setup, because setsid() drops any controlling terminal the child inherited — doing it afterwards would throw away the terminal just attached. And it has to precede TIOCSCTTY, which requires a session leader that does not already own a controlling terminal.

TIOCSCTTY is passed a literal zero argument, which is what makes it unable to steal a terminal already owned by another session.

Without a TTYPath neither step runs and the service stays in peinit's session.

5.4.2 The standard streams #

Without a TTYPath: stdin from /dev/null, stdout and stderr onto the write ends of the service's output pipes, and every inherited pipe end that is no longer needed closed.

With a TTYPath: all three streams onto the opened terminal, and the /dev/null descriptor and both pipe pairs closed. A terminal-attached service's output is therefore not captured for logging — it goes to the terminal, which is the point of asking for one.

5.4.3 The signal environment #

peinit blocks every signal for its signalfd (§12.3) and the child inherits that mask across the fork. Step 5 empties the mask and resets every resettable disposition to SIG_DFL. A service starting with signals blocked, or with PID 1's handling in place, is one of the classic ways for a daemon to behave inexplicably.

5.4.4 oom_score_adj #

-1000 — OOM-immune — for an ErrorControl=Critical service, and 0 for everything else. A Critical service is one whose loss reboots the machine, so letting the OOM killer choose it would convert memory pressure into a reboot.

5.4.5 Resources before the token #

The resource limits and oom_score_adj are set while the child still holds peinit's credentials, and only then is the service token installed. Both are peinit's policy rather than authority the service holds: raising a hard limit or lowering oom_score_adj needs CAP_SYS_RESOURCE, which KACS projects from SeIncreaseQuotaPrivilege. Setting them after the install would make every Critical service, and every service with a raised limit, depend on that privilege. A RequiredPrivileges list that leaves it out would then stop the service from starting at all.

The step identifiers are unchanged by this order. They name steps, not positions.

5.4.6 The environment #

There is no step that sets the environment, which is why identifier 8 is reserved and never emitted. peinit builds the environment in the parent (§5.5) and hands it to execve as envp, so it arrives with the exec rather than being installed beforehand. Step 10 is a check rather than a set: it confirms NOTIFY_SOCKET is present in the prebuilt environment and fails with a synthetic EINVAL if it is not.

5.4.7 What the child does not inherit #

A service inherits only what peinit hands it: its standard streams and any descriptors injected from the fd store. Everything else peinit holds is created close-on-exec — the control socket and every accepted connection, the notification socket, the epoll instance, the signalfd, every timerfd, both pidfds, every pipe, and every stored descriptor until it is deliberately un-marked at injection.

The signal reset and the close-on-exec discipline together are what make a service start from a clean context rather than from PID 1's privileged one.

A descriptor opened through the Peios native file interface returns without close-on-exec set, so it does not get the discipline for free. peinit sets the flag by hand on each of those — the input devices it opens for the power button, the main/ cgroup directory descriptor, /dev/null, and the service and recovery consoles — immediately after opening them. The child duplicates its intended streams onto descriptors 0, 1 and 2 without close-on-exec, then explicitly closes the original descriptors. The streams survive exec; the original handles do not.

5.5 The Base Environment

Peios / Peios technical reference / peinit / Starting a Service

peinit constructs every service and hook process's environment from scratch, in four layers, lowest precedence first. Nothing is inherited : peinit's own startup environment holds TERM and nothing else (§2.1), and none of it is passed through.

5.5.1 Layer 1: the compiled-in base #

One variable:

VariableValue
PATH/sbin:/bin

Executables are addressed through the root-level StrataFS runtime views. Package storage paths under /usr are deliberately not on the default search path.

5.5.2 Layer 2: global environment variables #

Each value under Machine\System\Init\EnvVars\ becomes a variable: the value name is the variable name, the REG_SZ data is the value. An EnvVars\PATH overrides the compiled-in PATH; every other name adds.

A malformed entry — an empty name, or a name containing = — fails the whole layer, which at boot means recovery mode.

registryd does not receive this layer. The exemption is a trust rule, not an availability one. Write access to EnvVars\ is equivalent to compromising every service peinit starts: LD_PRELOAD, LD_LIBRARY_PATH and their relatives are not filtered, because the key's Security Descriptor is meant to be the control boundary. A key that could inject into the daemon that enforces who may write it would make that boundary self-referential.

The exemption is narrow, and matches on two things at once: the job's resolved identity is SYSTEM and its service name is registryd. A non-platform service that happens to be called registryd receives the ordinary layering. It matches on the job, so a hook of registryd's running under a non-SYSTEM HookIdentity would receive the layer; and it matches the resolved identity string, so a definition naming S-1-5-18 literally rather than SYSTEM would not be exempt.

registryd is launched in Phase 1, before EnvVars has been read at all, so the exemption is only observable on a restart.

5.5.3 Layer 3: the service's own Environment #

The definition's Environment entries, overriding both layers below.

5.5.4 Layer 4: protocol variables #

NOTIFY_SOCKET, always. LISTEN_FDS, LISTEN_FDNAMES and LISTEN_PID, only when descriptors are being injected — from the fd store for a service, or from a submission's attachments for a submitted job (§8.5).

These have the highest precedence. The first three are inserted after both configurable layers, and all four names are dropped from those layers before insertion, so a service cannot override NOTIFY_SOCKET and break its own notification protocol, and an EnvVars\LISTEN_FDS=3 cannot point an fd-store-less service's sd_listen_fds at whatever happens to sit at descriptor 3. The name filter is what protects the LISTEN_* set when it is not being set.

LISTEN_PID, which a conforming sd_listen_fds implementation checks against its own PID before trusting LISTEN_FDS, is appended by the child itself after the clone, because only then is the PID known; the name filter keeps a configured value from shadowing it.

5.5.5 What peinit does not set #

Not HOME, USER, LOGNAME, SHELL or TERM. Peios identity is a KACS token — a SID — rather than a passwd entry, so there is no canonical home directory or login shell to populate. A service that needs one supplies it through EnvVars\ or its own Environment; a submitted job's submitter supplies it in the submission's environment, which occupies layer 3 for a job.

5.5.6 Hooks and probes #

Hooks, health checks and reload commands are built through the same path, so they receive the identical environment: the same layers, the same NOTIFY_SOCKET, and the service's WorkingDirectory, LimitNOFILE, LimitCORE and RequiredPrivileges. They never receive LISTEN_FDS — stored descriptors go to the main process only.

5.5.7 When changes apply #

The global layer is a snapshot refreshed at boot and on reload-config, and both it and the per-service Environment take effect at a service's next start. Neither is applied to a running process.

Note

Write access to Machine\System\Init\EnvVars\ is equivalent to compromising every service on the system. peinit does not filter variable names, consistent with the registry's write-authority threat model — the key's descriptor is the boundary. The recommended default is SYSTEM full control and Administrators read-only.

5.6 Health Checks

Peios / Peios technical reference / peinit / Starting a Service

A watchdog tells peinit that a service is still ticking. A health check tells it that the service still works. They address different failures: a process can be alive and responsive to its own event loop while having lost its database connection, wedged in a bad state, or started returning errors to everyone.

5.6.1 Execution #

The health check command runs with the service's own token — never HookIdentity — so it checks the service's health from the service's own vantage point.

Each invocation runs in an ephemeral health/ sub-cgroup under the service's tree, as a child of peinit rather than of the service. When the check completes or times out, peinit kills the whole sub-cgroup, which cleans up anything the check spawned.

5.6.2 Overlap #

If the previous check is still running when the next interval fires, the new one is skipped and nothing is counted. A check exceeding HealthCheckTimeout has its sub-cgroup killed and is counted as a failure.

A launch failure — a token that could not be materialised, a fork that failed — is not counted. The invocation is recorded and the next interval schedules normally, and nothing about the service changes.

The distinction is between "the probe ran and said the service is unhealthy" and "the probe could not be run". Only the first is evidence about the service. Collapsing them meant a transient authd unavailability could kill a service outright: with HealthCheckRetries=1, a reasonable setting for a probe an operator trusts, one failed token materialisation exhausted the budget and restarted it.

5.6.3 Failure #

HealthCheckRetries consecutive failures mark the service unhealthy. An unhealthy service is restarted through the ordinary restart policy: RestartPolicy, exponential backoff and throttling all apply, exactly as for a crash. The failure count resets the moment a check succeeds.

Escalation kills the service's root cgroup rather than just main/, so it takes hooks and probes with it.

5.6.4 The flap constraint #

Restart throttling is what stops a service flapping — failing checks, restarting, passing initial checks, failing again. But it only works if the failure cycle is shorter than RestartWindow, because otherwise the service stays healthy long enough between failures to reset the restart counter, RestartMaxRetries is never reached, and it restarts forever.

So this relationship has to hold:

HealthCheckRetries × HealthCheckInterval < RestartWindow

It is enforced as an error rather than a warning, in both places a definition can arrive. At boot, a violating service is blocked with cause ValidationError and never started. On reload-config, it is a validation finding, and a finding rejects the entire reload.

The constraint applies only where a check will actually run, which means Simple services. A Oneshot cannot flap through health checks because it never runs one.

A Oneshot that declares a HealthCheck is still rejected — but for declaring one at all, not for its timing. That is the thing actually wrong with the definition; reporting it as timing arithmetic let an operator adjust RestartWindow, see the definition validate, and still have a HealthCheck that does nothing.

Note

Active health checks on ErrorControl=Critical services deserve caution. A false positive eventually exhausts the restart budget and reboots the machine. Critical services — registryd, authd, lpsd, eventd — are usually better served by a passive watchdog with a generous timeout: the service knows whether it is healthy and can stop sending keepalives when it decides it is not. peinit does not prevent health checks on Critical services and applies identical semantics to them, but the escalation path ends at a reboot.

5.7 Leaked Sub-Cgroups

Peios / Peios technical reference / peinit / Starting a Service

A process in uninterruptible kernel sleep — D-state, typically a hung NFS mount or a failing disk controller — does not die when it is SIGKILLed. Its cgroup cannot be removed while it is there.

peinit detects this through cgroup.events: after sending the kill it arms a post-kill deadline, 5 seconds by default, and checks whether populated is still 1 when the deadline fires.

5.7.1 What happens depends on which cgroup it is #

For the main process, a survivor is fatal to supervision: the service transitions to Abandoned with cause ProcessUnkillable and peinit stops supervising it (§6.1).

For a health check or a hook, it is not. Those are diagnostic and setup processes; they hold no service resources — no ports, no file locks, no database connections — so a stuck one does not make the service unmanageable. peinit orphans the sub-cgroup instead:

  1. Marks it leaked, recording the path, the kind and the time.
  2. Increments the service's cgroup generation, so the next start builds a fresh tree (§5.1).
  3. Carries on supervising the service normally.

A leaked pre-start check helper is treated the same way: its checks/ sub-cgroup is recorded and the generation bumped, which matters because otherwise the next start would build into a tree that still contains the unkillable process.

The leaked cgroup stays in the hierarchy until the next reboot.

A tree the service's next instance is already running in is not checked. A restart can relaunch before the post-kill deadline of the instance it replaced — the default restart delay is one second, the post-kill deadline five — and it relaunches into the same tree, since nothing has leaked yet to move the generation on. When the deadline fires, the tree is populated by the live instance, not by a survivor, so peinit drops the check rather than record a leak. A survivor of the killed instance that shares that tree goes unreported until the tree is next killed and checked.

5.7.2 Visibility #

Leaks are not silent. peinit both pushes one when it is detected and keeps it queryable afterwards.

The push happens once, on first detection — recording is idempotent, so a leak that is re-examined on a later cleanup pass is not announced again:

  • A peinit.cgroup.leaked event on the event stream, carrying the service, the sub-cgroup path (object.cgroup.path) and its kind (object.cgroup.type: service-tree, health, hooks or helper). When it was found is the event's own time.

  • A console line naming the same service, kind and path.

The pull side survives the moment of detection, for anyone who was not watching:

  • A status query includes a warnings array, one entry per leak, each an object with the sub-cgroup path, its kind, and the time of detection:

    {"path": "/sys/fs/cgroup/peinit/jellyfin/health", "type": "health", "detected_at": "2026-06-01T12:34:56.123456789Z"}
    
  • A start command on a service with leaks returns a warning in its acknowledgement, saying the service has leaked sub-cgroups from a previous generation and that this indicates an I/O problem needing investigation.

The type is the same vocabulary everywhere : health for a leaked health/ sub-cgroup, hooks for a leaked hooks/ one, helper for a pre-start check helper's checks/, and service_tree for a leaked service root — the last being the most serious, since it means the whole tree including main/ could not be reclaimed.

Whichever way it reaches you, what the leak means is the same: something underneath the service is not responding to the kernel, and no amount of restarting the service will fix it.

6.1 States

Peios / Peios technical reference / peinit / The State Machine

Every service is in exactly one state. There are ten.

StateProcess?Satisfies dependents?Meaning
InactiveNoNoNot started, or stopped cleanly and not set to restart.
StartingMaybeNoActivation in progress: checks, hooks, fork, or the readiness wait. The process may not exist yet.
ActiveYesYesRunning and ready.
ReloadingYesYesRe-reading configuration. Still satisfies dependents.
StoppingBrieflyNoSIGTERM sent, awaiting exit or SIGKILL escalation.
CompletedNoYesOneshot only. Exited successfully.
BackoffNoNoA restart is pending; waiting out the backoff delay.
FailedNoNoExited abnormally with the restart policy exhausted or absent.
AbandonedYes, unkillablyNoSIGKILL sent and processes survived. Supervision has stopped, the cgroup is leaked.
SkippedNoYesConditions were not met. The service does not apply.

6.1.1 Dependent satisfaction #

Exactly four states satisfy dependents: Active, Reloading, Completed and Skipped. Nothing else does, and a dependent blocked on a Requires target in any other state does not start.

Completed satisfies regardless of RemainAfterExit — a Oneshot without it passes through Completed to release its dependents on the way to Inactive, rather than skipping the state.

Skipped satisfies because a service whose conditions do not hold has succeeded by not needing to run. Treating it as a failure would make every conditional service a hazard to everything that depends on it.

Reloading satisfies because the process is still there and still serving; a reload is a service telling itself to re-read a file, not an outage. So a reload does not restart the RestartWindow clock (§6.4), clear a readiness level the service has published (§7.5), or count as its target going away to a BindsTo dependent (§7.1).

Backoff does not, and the distinction from Failed matters: a service in Backoff is going to start again, and its dependents wait rather than failing. It is the state that makes a restart something other than a transit through Failed. A dependent held this way looks exactly like a start held on a readiness level — Inactive, its start operation Pending (§8.2) — and is released when the service next reaches a dependent-satisfying state. It fails with dependency_failure only when the service gives up: its restart budget is exhausted, or it is stopped or its definition withdrawn while in Backoff.

6.1.2 Invariants #

  1. A service is in exactly one state at any moment. There is one state field and exactly one place in the implementation that assigns to it.
  2. Only the transitions in §6.2 are performed. The assignment is gated by a central whitelist, so an unlisted transition is not merely avoided by convention — it cannot be written.
  3. Only peinit transitions a service. The control socket produces operations; nothing outside peinit writes state.
  4. A service object is securable independently of its process token. The ServiceSecurity descriptor governs who may manage the service; the process token governs what the service may reach. Neither implies anything about the other.
  5. Readiness is per start generation. The generation increments on every transition into Starting, and a READY=1 carrying a stale generation is rejected — so a notification from a previous incarnation can never be mistaken for this one's.

6.2 Transitions

Peios / Peios technical reference / peinit / The State Machine

Every transition peinit performs. Anything not listed here is not performed.

FromToTrigger
InactiveStartingStart command, dependency resolution, or a timer trigger.
InactiveSkippedA condition check failed.
InactiveFailedValidation, assertion, cycle or dependency failure — before any process existed.
StartingActiveReadiness. Simple only: READY=1 received, or the process exists under Readiness=Alive.
StartingCompletedOneshot exited successfully. Without RemainAfterExit it passes through, releasing dependents, then goes Inactive.
StartingSkippedA condition failed after the activation had already entered Starting.
StartingFailedAn assert failed after entering Starting; or a timeout, hook failure, setup failure, or an exit before readiness.
StartingBackoffA startup failure with the restart policy allowing a retry and budget remaining.
StartingStoppingAn explicit stop cancelling an in-progress start. A restart on a Starting service is queued rather than cancelling.
StartingFailedThe shutdown wave SIGKILLed a starting service.
ActiveReloadingExecReload issued, or SIGHUP delivered.
ActiveStoppingExplicit stop, conflict eviction, a bound dependency stopping, or shutdown.
ActiveBackoffA crash, a watchdog timeout, or health check failure, with a restart allowed and budget remaining.
ActiveFailedThe same three, with RestartPolicy=Never or the budget exhausted.
ActiveInactiveA Simple service exited successfully and RestartPolicy is not Always. Cause CleanExit.
ActiveBackoffA Simple service exited successfully and RestartPolicy=Always. Cause CleanExitRestart.
ReloadingActiveReload resolved: READY=1, the detection window expiring, the extended wait expiring, or the reload command exiting — success or failure.
ReloadingStoppingThe same triggers as Active to Stopping. The reload is cancelled.
ReloadingBackoffThe main process exited during the reload, with a restart allowed.
ReloadingFailedThe same, with no restart available.
StoppingInactiveThe process exited after an explicit stop or a shutdown.
StoppingFailedThe process exited after a conflict eviction or a bound dependency stopping.
StoppingAbandonedSIGKILL sent and main/ still populated after the post-kill deadline.
StoppingStartingAn explicit start queued against a service that is still stopping. Cause ExplicitStart.
BackoffStartingThe backoff delay elapsed. Cause RestartPolicy.
BackoffInactiveAn explicit stop cancelled the pending restart.
BackoffSkippedThe backoff delay elapsed and the relaunch's pre-start checks skipped the service: its terminal is now held (§11.6), or a condition no longer holds.
BackoffFailedThe backoff delay elapsed and peinit could not execute the relaunch. Cause InternalError.
Starting, Active, Reloading, StoppingFailedpeinit could not carry out a step on the service and contained the error to it (§8.2). Cause InternalError.
CompletedInactiveRemainAfterExit=0 and dependents released; or an explicit stop; or shutdown clearing it.
CompletedStartingA start command or timer trigger re-running the Oneshot.
FailedStartingAn explicit start, a bound dependency recovering, or a timer trigger.
FailedInactiveA reset command clearing the state.
FailedAbandonedA service SIGKILLed by the shutdown wave whose cgroup stayed populated past the post-kill deadline.
AbandonedInactiveA reset command.
SkippedInactiveA reset command, or an explicit start clearing Skipped before it re-evaluates the conditions.

6.2.1 Things the table settles #

A restart never passes through Failed. A restart-eligible failure goes to Backoff, waits, and goes to Starting. Failed is reached only when there will be no retry: RestartPolicy=Never, an invalid policy for the cause, or an exhausted budget. This is why OnFailure (§6.3), which fires on entry to Failed, does not fire on each retry — only when the service finally fails out.

A clean exit is not a crash. A Simple service exiting zero goes to Inactive under cause CleanExit, consulting neither the restart policy nor the budget. It goes to Backoff only under RestartPolicy=Always, and then with the distinct cause CleanExitRestart, so status and events say plainly that the process succeeded and was restarted by policy rather than that anything went wrong.

A forced stop remembers why. Stopping to Failed carries the cause from the transition that started the stop — ConflictEviction or BindsToPropagation — rather than a generic failure. A service that lost a conflict and a service whose binding target went away are distinguishable afterwards, which is what makes bound-dependency recovery (§7.1) possible at all.

A restart detours through Inactive. The stop leg of an administrator's restart ends in Inactive, and the start leg begins from there. The two legs meet within one turn of the event loop, and peinit answers no request mid-turn, so no query ever sees the service Inactive in between.

A crash before readiness may be retried. A Simple process that exits before signalling readiness is a ProcessCrash from Starting, and is restart-eligible like any other, rather than a terminal startup failure.

A process can outlive its service's state. Most states are reached by the main process exiting, but not all of them: a watchdog timeout, a health check escalation and the post-kill give-up all move a service on while its process is still running. The exit that follows arrives for a service in Backoff, Abandoned or another state that no longer expects one.

peinit records that exit and performs no transition. The service keeps the state it had, any pending restart deadline stands, and the console reports it:

peinit: service <service> main process exited in state <state>; no action taken

This is deliberately not a fatal condition. A supervised service producing an unexpected sequence costs that service; it does not cost the machine. Treating it as a broken invariant of the runtime loop meant one crash-looping service dropped PID 1 into recovery mode and killed every session on the box.

6.2.2 Reset from Abandoned #

Resetting an Abandoned service re-checks its main/ sub-cgroup. If it has finally emptied, peinit cleans up the whole service tree — main/, hooks/, health/, then the root — and transitions to Inactive.

If it is still populated, peinit leaves the cgroup leaked, transitions to Inactive anyway, and returns this warning in the operation acknowledgement:

abandoned main cgroup for service <service> is still populated after
reset -- cgroup remains leaked; underlying D-state process requires
investigation

The warning is also written to the console, so a reset issued without reading the response still leaves a trace of the still-leaked cgroup.

The re-check targets main/. Note that the two paths into Abandoned probe different cgroups: an explicit stop checks main/, while the shutdown wave checks the service root.

6.3 Transition Causes

Peios / Peios technical reference / peinit / The State Machine

Every transition carries a cause recording why it happened. peinit tracks both the current state and the cause of the most recent transition, and the cause determines restart eligibility, OnFailure behaviour, and what an administrator is told.

6.3.1 The taxonomy #

CauseLeads toMeaning
ExplicitStartStartingAn administrator, an OnFailure handler, or a boot plan started it.
ExplicitStartInactiveAn explicit start on a Skipped service, clearing it so the conditions are re-evaluated.
DependencyStartStartingStarted to satisfy another service's dependency.
RestartPolicyStartingAn automatic restart after a backoff delay.
BindsToRecoveryStartingA bound dependency returned to Active.
TimerStartingA timer trigger fired.
ExplicitStopStoppingAn administrator requested a stop.
ExplicitReloadReloading, ActiveA reload was issued and resolved.
ExplicitResetInactiveAn administrator cleared Failed, Abandoned or Skipped.
ConflictEvictionStoppingA conflicting service started; this one lost.
BindsToPropagationStoppingA bound dependency stopped.
ShutdownWaveStopping, FailedThe system is shutting down. Active and Reloading services go to Stopping; Starting services go straight to Failed.
ProcessCrashFailed, BackoffThe main process exited unexpectedly.
CleanExitInactiveA Simple process exited successfully with a policy other than Always.
CleanExitRestartBackoffA Simple process exited successfully under RestartPolicy=Always.
ReadinessTimeoutFailed, BackoffStartTimeout expired before readiness.
WatchdogTimeoutFailed, BackoffA keepalive did not arrive in time.
HealthCheckFailureFailed, BackoffHealthCheckRetries consecutive failures.
PreHookFailureFailed, BackoffAn ExecStartPre hook exited non-zero, or its token failed, or the start timed out during hooks.
ParentSetupFailureFailed, BackoffA parent-side failure before the fork. No child was created.
PreExecFailureFailed, BackoffPost-fork setup failed before exec, reported through the error pipe.
DependencyFailureFailedA Requires dependency entered Failed.
RestartBudgetExhaustedFailedRestartMaxRetries reached.
CycleDetectedFailedThe service is part of a dependency cycle.
ValidationErrorFailedThe definition failed graph validation.
AssertionErrorFailedA start-time assert failed.
ConditionSkippedSkippedA start-time condition failed.
TtyUnavailableSkippedAnother service was holding the terminal this one names in TTYPath. §11.6
ProcessUnkillableAbandonedProcesses survived SIGKILL.
InternalErrorFailedpeinit could not carry out a step on the service — a relaunch, a job terminal, a setup status, a health probe, a deadline action. The service did nothing wrong; its operation fails with the internal_error result (§8.2).

6.3.2 Restart eligibility #

Causes fall into four classes, and the class decides whether the restart policy is consulted at all.

Restart-eligible. peinit consults RestartPolicy and the budget. If a restart is allowed and the budget holds, the service goes to Backoff and then to Starting; otherwise Failed.

ProcessCrash, WatchdogTimeout, HealthCheckFailure, ReadinessTimeout, PreHookFailure, PreExecFailure, ParentSetupFailure.

Note

The startup failures are restart-eligible because a transient problem during startup should get another go. A pre-hook that failed because a network mount was briefly unavailable deserves a retry rather than an immediate give-up.

Always-only. CleanExitRestart applies when a Simple service exits successfully and the policy is Always. It uses the same backoff and the same budget as a failure, which is what stops a daemon that exits cleanly in a tight loop from bypassing throttling entirely. It is never treated as a ProcessCrash, and both status and events make clear the process succeeded and was restarted only because the policy says so.

CleanExit is its non-restarting counterpart: straight to Inactive, consulting neither policy nor budget.

Budget-exempt. BindsToRecovery takes a service from Failed to Starting when its binding target returns. It is not subject to the policy or the budget, because the service did not fail on its own — it was stopped because its dependency went away.

Never-restart. peinit does not consult the policy at all: ExplicitStop, ExplicitReset, ShutdownWave, ConflictEviction, BindsToPropagation, ProcessUnkillable, RestartBudgetExhausted, ValidationError, CycleDetected, DependencyFailure, AssertionError, ConditionSkipped, TtyUnavailable, InternalError. Retrying cannot help with any of them.

6.3.3 OnFailure #

When a service enters Failed and its definition names an OnFailure service, peinit starts that service — with the exceptions below. OnFailure fires on entry to Failed, and Failed to Failed is not a transition, so it fires at most once per failure.

It does not fire for:

  • ShutdownWave — no new service starts during shutdown, so a fallback would be both impossible and pointless.
  • ValidationError, CycleDetected, DependencyFailure, AssertionError — these are definition or graph breakage rather than runtime degradation. A broken definition cannot meaningfully trigger a fallback, and the fallback would probably sit in the same broken graph.

It fires for everything else, including ProcessCrash, WatchdogTimeout, HealthCheckFailure, the startup failures, and RestartBudgetExhausted on a non-Critical service.

For a Critical service exhausting its budget, the reboot takes precedence and no fallback is started. The suppression asks whether a reboot is owed rather than inspecting ErrorControl itself, which is the same question §6.4 puts.

OnFailure is for graceful degradation — the main web interface fails, so start a minimal emergency endpoint. It is not for monitoring or alerting, which is eventd's job.

6.3.3.1 A handler that is already running #

If the named handler is already Active — or Reloading, on its way back to Active — the failure starts nothing. A running service has no state to transition into on being started again, and it is already doing the job the failure would have asked of it, so the start resolves as satisfied: no operation is created, and no loop-guard chain entry is recorded for a cascade that did not happen.

This is the same answer the control interface gives an administrator who starts an already-running service, and it is deliberately the same test. A handler in any other state is started normally — so a Oneshot handler that ran for an earlier failure runs again for a new one, and a handler that has since failed is restarted.

6.3.3.2 The loop guard #

An OnFailure handler can fail and carry its own OnFailure, so a misconfiguration where A's handler is B and B's is A could run forever. peinit bounds the chain originating from one failure two ways: it tracks the set of services already started as handlers for that failure and will not start one already in the set, and it will not follow the chain past a fixed depth of 16. When either trips, peinit records a peinit.on-failure.suppressed event naming which (outcome.reason cycle or max-depth), and stops.

A chain entry is retired two ways.

The handler stopped needing tracking. Completed, Inactive, Skipped or Abandoned — it ended, gave up, or became unkillable.

The handler arrived. It has been dependent-satisfying for its RestartWindow — the same window that resets a restart budget (§6.4). That is peinit's existing test for a service having genuinely taken over rather than merely come up, and it is what ends the originating failure: a later failure of that handler is a new one, starting a fresh chain.

The distinction matters in both directions. Without the second rule, a handler that runs healthily for days holds a slot forever, so a degradation path handing off through several healthy layers exhausts its own depth budget by working. But retiring on Active alone would break the guard entirely: Readiness=Alive reports Active the instant the process spawns, so two services naming each other as handlers would hand off forever, with no delay and no cost — an OnFailure start is not a restart and spends no restart budget. Only a handler that held is treated as having arrived.

6.3.4 The logging contract #

A transition into Failed, Skipped or Abandoned is written to the console, naming the service and its cause: what failed, and why. There is no per-transition event — a transition is visible through the peinit.job.* and peinit.operation.* events that carried it, not as a record of its own.

Cryptic failure messages are a defect. A reboot loop caused by a configuration error with an opaque message is the worst outcome the system has, and the cause taxonomy exists so that the "why" is never a guess.

6.4 Restart

Peios / Peios technical reference / peinit / The State Machine

When a restart-eligible cause occurs, or a Simple clean exit produces CleanExitRestart, peinit evaluates the restart policy. The outcome is either Failed, or Backoff followed by Starting.

evaluate_restart(service, cause):
    // 1. Policy.
    if cause is never-restart:
        return STAY_FAILED
    if cause == CleanExitRestart and policy != Always:
        return INVALID_CAUSE_FOR_POLICY
    if policy == Never:
        return STAY_FAILED
    if policy == OnFailure:
        // A termination counts as success only when the cause is a
        // process exit whose code is in SuccessExitCodes. Only
        // ProcessCrash and CleanExitRestart carry an exit code, and
        // CleanExitRestart was handled above. Every other eligible
        // cause has no exit code and is always a failure here.
        if cause == ProcessCrash and exit_code in success_exit_codes:
            return STAY_FAILED
    // A clean terminal logout is not a failure.
    if cause == CleanExitRestart and TTYPath is set:
        consecutive_failures = 0
        return RESTART_AFTER(min(restart_delay, 60))
    // Other Always exits fall through.

    // 2. Budget.
    if consecutive_failures >= restart_max_retries:
        cause = RestartBudgetExhausted
        if error_control == Critical:
            sync and reboot
        return STAY_FAILED

    // 3. Delay.
    delay = min(restart_delay << consecutive_failures, 60)

    // 4. Schedule.
    return RESTART_AFTER(delay)

RESTART_AFTER puts the service in Backoff for the delay; when it elapses the service transitions to Starting and the ordinary activation sequence begins, with its own fresh StartTimeout.

The elapsed backoff timer is consumed when the relaunch is admitted. If a pre-start check needs an asynchronous filesystem helper, the service remains in Backoff until that check finishes, retaining its terminal reservation. The pending check drives progress; the expired timer cannot admit another restart while it waits.

The relaunch runs the pre-start checks like any other start, and the machine can have changed while the service waited. If its terminal is now held (§11.6) or a condition no longer holds, the service goes from Backoff to Skipped, with the cause the check gives, and its pending operation completes as a skip. A relaunch that peinit itself cannot execute — its bookkeeping refuses the start — takes the service from Backoff to Failed under InternalError (§6.3), fails the operation waiting on the restart with the internal_error result, and reports both on the console; it does not cost the machine.

The exit code is available only when peinit observed a process exit, and it is carried into the evaluation wherever there was one — including the pre-readiness exit, where a Simple service exits before signalling READY=1. So SuccessExitCodes means the same thing on both sides of readiness: a service that legitimately concludes "nothing to do" during startup and exits with a listed code is not restarted.

Failures with no process exit behind them — a hook that never ran, a readiness deadline, a dependency failure — carry no code, and the SuccessExitCodes branch cannot apply to them. That is not a gap: a success code is a statement about how the service's own process ended.

6.4.1 The policies #

PolicyValueBehaviour
Never0Never restart. The service stays Failed.
OnFailure1Restart on a non-zero exit or a runtime failure. An exit matching SuccessExitCodes is not restarted.
Always2Restart on any failure regardless of exit code, and for a Simple service also on a successful clean exit.

For a Oneshot with RestartPolicy=Always, a successful exit is not restart-eligible. RestartPolicy governs the response to failures; a Oneshot that succeeds has done its job. It goes to Completed — and then Inactive without RemainAfterExit — whatever the policy says, and only a non-zero exit reaches the restart evaluation at all. Timer triggers are the mechanism for re-running a Oneshot on a schedule.

6.4.2 Backoff #

The delay doubles on each consecutive failure, starting from RestartDelay and capped at 60 seconds. The arithmetic is overflow-safe, so a large RestartDelay or a long failure run saturates at the cap rather than wrapping.

While a service is in Backoff it is down and does not satisfy dependents. An explicit start creates or merges into a deferred start operation, which honours the remaining delay rather than short-circuiting it; a stop cancels the pending restart and takes the service to Inactive.

For a terminal-attached service, a successful Always exit is a normal session logout. It resets the failure counter and uses the base delay (capped at 60 seconds), without spending or exhausting the crash budget. Failed exits and startup failures still use the ordinary budget and exponential backoff.

6.4.3 The budget, and when it resets #

consecutive_failures counts consecutive restart-eligible failures. It resets to zero only after the service has stayed Active for RestartWindow seconds. It is not a count of restarts within a trailing window, and the difference is what the mechanism turns on.

peinit stamps the moment a service becomes dependent-satisfying, and clears that stamp on any transition to a non-satisfying state — so a crash restarts the clock. The reset fires when the stamp plus RestartWindow is reached with the service still Active.

A service that recovers and stays Active longer than RestartWindow between crashes therefore never exhausts its budget: each crash starts from a counter of zero. Only failures recurring faster than the service can sustain a window of health accumulate.

Two other events also zero the counter, both of which mean the service is no longer in a failure run: a clean exit to Inactive, and an administrative reset. An explicit stop while in Backoff does not — the accrued failures are preserved.

Because the reset requires the service to be Active, a service that happens to be Reloading when its window boundary passes misses that reset and gets it on returning to Active.

6.4.4 Exhaustion #

Once RestartMaxRetries restarts have happened without the service sustaining a window of health, the next failure is not restarted: Failed with cause RestartBudgetExhausted. peinit then applies ErrorControl:

  • Normal — the service stays Failed.
  • Critical — peinit syncs the filesystems and reboots immediately. The reboot takes precedence over OnFailure.

The reboot does not depend on how the budget was exhausted. The paths that observe a terminal outcome for a running service — the main job ending, a health check failing or timing out, the watchdog expiring — raise it directly. Every other route reaches it through a check made once per runtime turn, which asks a single question of the service table: is a Critical service Failed with cause RestartBudgetExhausted? That covers a budget exhausted purely by startup failures — repeated ReadinessTimeout, PreHookFailure, ParentSetupFailure — where a service that can never get as far as running would otherwise have settled in Failed with neither escalation.

The OnFailure suppression asks the same question rather than inspecting ErrorControl itself, so the handler is skipped exactly when a reboot is owed and never on the strength of one that was not scheduled.

A failure during a shutdown reboots nothing (§12.2 step 2), and the handler is not started either — the machine is already going down.

6.5 Reload

Peios / Peios technical reference / peinit / The State Machine

Reload tells a service to re-read its configuration without restarting. peinit issues the reload, moves the service to Reloading, and resolves it one of three ways.

A failed reload never takes a running service out of Active. And reload never gets stuck: every path has a timeout.

6.5.1 Choosing a path #

ExecReload absent means SIGHUP to the main process. A signal:<NAME> value means that signal instead. Anything else is a command, forked into the service's hooks/ sub-cgroup under the service's own identity — never peinit's token, and HookIdentity does not apply.

6.5.2 The signal path #

There is no command exit to observe, so completion is inferred from the main process's own notifications.

start the detection window (2 seconds)

on READY=1, at any time:
    -> Active, "confirmed"

on RELOADING=1 within the window:
    cancel the window
    start the extended wait (StartTimeout)

on the window expiring with no RELOADING=1:
    -> Active, "advisory"

on the extended wait expiring with no READY=1:
    -> Active, "advisory"

The two-second window is a constant and is not configurable through the registry, directly or otherwise. It is not clamped to the operation's own deadline: a service configured with a short StartTimeout, which is a reasonable thing to do for something that starts fast, still gets its full two seconds to answer a reload. The reload operation's own lifetime already bounds the whole thing.

The extended-wait expiry means the service announced a reload and never finished one. That is what the detection protocol exists to catch — the service has wedged mid-reload or lost its handler — so it is reported rather than only returned:

peinit: service <service> signalled RELOADING=1 but never completed reload

and audited as a peinit.service.reload.timed-out event. The outcome is still carried in the operation's result for a wait=true caller, but a reload issued without waiting — the default — no longer resolves silently.

A detection window expiring is a different thing and stays quiet. A service that does not implement the handshake lets it expire on every reload, and reporting that would drown the case above.

6.5.3 The command path #

The command's exit gates failure; the main process's READY=1 gates confirmation.

on the command exiting non-zero:
    -> Active, "failed"

on the command exceeding StartTimeout:
    SIGKILL the hooks/ sub-cgroup, taking its descendants
    -> Active, "failed"

on the command exiting zero:
    -> Active, "confirmed" if the main process sent READY=1 during the
       reload, otherwise "advisory"

6.5.4 Auto-detection #

The protocol needs no per-service configuration. A service that implements the notification handshake — RELOADING=1 then READY=1 — gets real lifecycle tracking. One that does not gets a brief Reloading state that resolves itself when the detection window expires. Neither has to declare which it is.

6.5.5 Interruptions #

The main process crashes while Reloading. That is a ProcessCrash, and the restart policy is consulted: Reloading to Backoff if a restart is allowed and the budget holds, Reloading to Failed otherwise. Both reload timers are cancelled and any in-flight reload command is killed.

This is a different event from an external reload command exiting non-zero, which is the "failed" outcome above and leaves the main process — and the service — running.

A stop arrives while Reloading. peinit cancels the reload immediately: it drops the reload deadlines, kills any reload command's cgroup, and sends SIGTERM in the same turn, without waiting out the window or the extended wait. The service goes to Stopping and the reload operation is aborted.

6.6 The Watchdog and Timeout Extension

Peios / Peios technical reference / peinit / The State Machine

Two notification fields let a running service adjust the deadlines it is held to. Both are authenticated exactly as any other notification (§10.5), and both carry microseconds.

6.6.1 The watchdog #

WatchdogTimeout sets the interval peinit expects WATCHDOG=1 pings at. Zero, the default, disables it. Missing a ping is a WatchdogTimeout cause and takes the ordinary restart path.

A service may change the interval at runtime by sending WATCHDOG_USEC=<value>:

  • A value greater than zero updates the interval and re-arms immediately — the current timer is cancelled and a fresh one starts from the moment the message was received, rather than the new interval applying only from the next ping.
  • A value of zero disables the watchdog entirely, equivalent to WatchdogTimeout=0.

The runtime value does not persist. On a restart the interval reverts to the definition's WatchdogTimeout converted to microseconds, and if that is zero the watchdog starts disabled whatever the previous incarnation had set.

WATCHDOG_USEC is honoured only while the service is Active. A service that sends it while still Starting — before its own READY=1 — is ignored and gets the definition's value.

Note

Runtime watchdog updates suit a service whose phases have genuinely different latency. A database engine might want a tight five-second watchdog in normal operation and sixty seconds during a compaction pass. The service knows its own phases better than whoever wrote its definition does.

6.6.2 Timeout extension #

A service may ask for more time during a start, stop or reload by sending EXTEND_TIMEOUT_USEC=<value>.

peinit sets the current phase's deadline to expire that many microseconds from now. The extension replaces the deadline rather than adding to it — each message sets an absolute deadline computed from its own arrival — and may be sent repeatedly.

Because it replaces, a small value shortens the remaining time rather than being ignored, and a value of zero sets the deadline to now.

6.6.2.1 The caps #

The extended deadline cannot exceed four times the phase's base timeout:

PhaseBaseCeiling
StartingStartTimeoutStartTimeout × 4
StoppingStopTimeoutStopTimeout × 4
ReloadingStartTimeoutStartTimeout × 4

A value beyond the cap is clamped, not rejected — the message succeeds and the deadline becomes the maximum permitted. Because the cap is anchored to when the operation started rather than to the previous deadline, repeated messages cannot creep past it.

During shutdown an additional cap applies: the deadline cannot exceed the time remaining in the global ShutdownTimeout, and where both caps apply the stricter wins.

6.6.2.2 Where it does not apply #

A message arriving while the service is in a non-transitional state — Active, Completed, Failed — is ignored. There is no deadline to extend.

During shutdown, as at any other time, an extension moves only a deadline the service already has. A service still in a transition when the shutdown begins — starting, reloading, or stopping under an explicit stop — extends that transition's deadline, and one already stopping keeps its stop deadline, extension included, into its wave (§12.2). A service that is Active until its stop wave reaches it has no deadline to extend before then, so a request it sends earlier is ignored like any other in a non-transitional state. A service that expects its stop to take longer sends the request once its stop has begun.

Note

Timeout extension is for a service doing variable-duration work in a transition — replaying a write-ahead log on startup, say. It sends periodic messages to prove it is making progress; if it stops sending, the last deadline fires and peinit escalates as usual. The fourfold cap is what stops a buggy service extending forever.

7.1 Relationships

Peios / Peios technical reference / peinit / Dependencies

Four relationship types. Each says something different about how two services interact at start, at stop, and on failure.

7.1.1 Requires #

A hard dependency. If A Requires B:

  • Start. B has to reach a dependent-satisfying state before A starts. If B is not running, peinit starts it with cause DependencyStart. If B enters Failed, A goes to Failed with cause DependencyFailure without attempting to start.
  • Stop. Stopping B does not stop A. This is a start-ordering constraint, not a runtime coupling.
  • Runtime failure. If B crashes while A is Active, A is unaffected and keeps running. B's own restart policy handles B.
  • Missing target. A goes to Failed with DependencyFailure, detected at graph validation.

7.1.2 Wants #

A soft dependency. If A Wants B, peinit starts B before A — with cause DependencyStart — provided B exists and is not disabled. If B fails to start, or does not exist at all, A starts anyway. There is no stop or failure effect in either direction.

The waiting rule is where the difference from Requires actually lives: a dependent blocked on a Requires target waits for it to reach a satisfying state, while a dependent blocked on a Wants target waits only for it to reach any terminal state, satisfying or not. That is what makes Wants ordering rather than dependency — "start this first if you can, but I will work without it".

7.1.3 BindsTo #

A runtime coupling. If A BindsTo B:

  • Start. Identical to Requires.
  • Stop. If B stops for any reason — explicit stop, conflict, crash, shutdown — A stops too, transitioning to Stopping with cause BindsToPropagation.
  • Recovery. When B returns to Active, peinit automatically restarts anything sitting in Failed with cause BindsToPropagation from B's stop. This is reactive rather than polled: peinit watches for the transition into Active from a non-satisfying state, and reacts to it. These restarts do not consume the restart budget — the dependent never failed on its own, it was stopped because its binding target went away.

BindsTo implies Requires. A definition may list both for clarity, and if it does, the BindsTo semantics apply.

7.1.4 Conflicts #

Mutual exclusion. Starting A while B is Active creates a stop operation for B — source ConflictResolution, cause ConflictEviction — and A does not start until B has left every state in which it could still be running.

Conflicts are symmetric. If A declares Conflicts = ["B"], starting either one stops the other; B does not have to declare it reciprocally, and peinit scans both a starting service's own conflicts and everything that declares a conflict against it.

If both A and B are boot-triggered and they conflict, graph validation detects an unresolvable conflict and fails both with ValidationError.

A missing conflict target is silently dropped — there is nothing to conflict with.

Note

Conflicts is for real mutual exclusion: two services binding the same port, or two implementations of one role where exactly one must run. It is not a way to solve resource contention that has a better answer.

7.1.5 Ordering and self-reference #

Dependencies imply start ordering, and — except for BindsTo — imply nothing about stopping. peinit starts dependencies before dependents; during shutdown it reverses the same graph and stops dependents before dependencies (§12.2), derived from the one graph rather than from a separate stop-ordering configuration.

A service may not name itself in any dependency field. peinit rejects a self-reference at graph validation as a CycleDetected failure, which is what it is — a cycle of length one.

7.2 Graph Validation

Peios / Peios technical reference / peinit / Dependencies

peinit validates the dependency graph before executing it. Validation runs once per graph build — at boot, on an administrator-requested on-demand start's transitive closure, and on a reload-config — and is never incremental.

7.2.1 Cycles #

peinit topologically sorts the graph; if the sort fails, a cycle exists. Detection returns all cycles rather than stopping at the first: each detected cycle's members are removed and the search re-run until the graph is clean.

Every service in a cycle is failed with cause CycleDetected, and the cycle path is logged so an administrator can see what to break.

If any service in a cycle is Critical, peinit downgrades to Safe mode (§2.6) rather than rebooting. The cycle is a configuration error, and rebooting would find it again.

7.2.2 Missing targets #

RelationshipA missing target means
RequiresThe dependent is failed with DependencyFailure.
BindsToThe same — treated as a missing Requires.
WantsSilently dropped.
ConflictsSilently dropped.

This holds in every mode. Safe mode drops a hard dependency on a service it excluded — that is its own rule (§2.6), and without it excluding a service would fail everything downstream and Safe mode could start almost nothing. Only that case drops. A target missing from the registry or disabled by an administrator is a configuration error rather than a Safe mode exclusion, and blocks the dependent in Safe mode exactly as in Full.

7.2.3 Validation errors #

A service that hits one is failed with cause ValidationError and never started:

  • The flap constraint, HealthCheckRetries × HealthCheckInterval < RestartWindow (§5.6).
  • An invalid timer calendar expression (§9.1). This one is checked across every definition, not only those in the graph.
  • Two boot-triggered services that conflict with each other. Both are failed. Safe mode applies if either is Critical.

7.2.4 Validation warnings #

Logged, and do not prevent boot:

  • A service using Readiness=Alive that something else depends on hard. Alive readiness means the process exists, which is no guarantee it is functional, so anything waiting on it is waiting on the wrong thing.
  • Services that need a role no service fills — in practice authn, needed by every service whose Identity is not SYSTEM. Warned about rather than failed, because a missing hard dependency rejects the whole reload transaction, and that would prevent reloading the very definition that installs the missing provider. See §7.6.

7.2.5 Multiple findings #

A service can attract more than one finding in one pass — being both in a cycle and missing a Requires target, say. The runtime state records a single primary cause, chosen by precedence:

  1. CycleDetected
  2. ValidationError
  3. DependencyFailure

The precedence affects only which cause is stored. Every other finding for the service is retained beside it, in discovery order, and the operation's failure message enumerates all of them:

CycleDetected: dependency cycle a -> b -> a (also: ValidationError:
health check interval exceeds the restart window) [2 findings]

The primary comes first and unqualified, so a service with one finding reads exactly as it always did. A higher-precedence finding arriving later demotes the previous primary rather than deleting it — breaking the cycle should not be what it takes to discover the second fault.

Every finding is also emitted as its own peinit.graph.validation.failed KMES event, carrying graph.phase boot and the finding as outcome.reason. The console lines are for whoever is watching the boot; the events are the account that survives it.

HardDependencyBlocked — blocked because a dependency is blocked — gets its own reason, hard-dependency-blocked, rather than being reported as a missing dependency, which would claim the target does not exist when it does. It has no reload-path equivalent, because reload rejects wholesale instead of propagating a block.

The reload path behaves differently, because its consequence is different. Validation there accumulates every finding, encodes each as its own event under graph.phase reload-config, and then rejects the entire reload — the previous generation stays live and the findings return to the caller (§10.4). Boot marks individual services and continues; reload reports everything and changes nothing.

Both use the same event type deliberately: one consumer filter catches validation problems in either regime, and phase says which.

7.2.6 On-demand starts #

Before dispatching an administrator-requested on-demand start, peinit runs the shared definition validator over the services the start plan would activate. An unschedulable health check, an invalid health-check restart window or an invalid timer schedule rejects the plan with INVALID_STATE; no service from that plan is launched. This includes definitions reached through dependencies.

Definitions outside the plan, and dependencies whose running state already satisfies the request, do not make it fail. Missing or disabled hard dependencies retain the ordinary blocked-plan handling; dependency cycles retain the existing cycle refusal. Conflicting boot triggers are not themselves a reason to reject an explicit start.

This check also applies to an administrator restart when the service has no running process and the request builds an on-demand start plan. A queued administrator operation is checked when promoted into such a plan; rejection fails that operation with an invalid_state reason and leaves other queued operations eligible for promotion. It does not terminate the supervisor loop.

Internal starts from restart policy, timers, BindsTo recovery, OnFailure and TTY release retain their existing closure and cycle checks, without this additional definition-validation refusal, including when a queued internal operation is promoted. In particular, an invalid nonboot OnFailure target must not roll back the source service's transition to Failed. The stop/start leg of a restart of a running service and a deferred Backoff restart also retain their existing internal start behavior. Boot and reload-config validation remain as described above.

7.3 Graph Execution

Peios / Peios technical reference / peinit / Dependencies

Executing a graph means starting the services whose dependencies are all satisfied, and doing it again each time something becomes satisfying, until nothing is left.

execute_graph(graph, max_parallel):
    ready = services with no unsatisfied dependencies
    in_flight = 0

    while ready is not empty or in_flight > 0:
        while ready is not empty and in_flight < max_parallel:
            begin_start(ready.dequeue())
            in_flight += 1

        match wait_for_event():
            ServiceSatisfied(s):
                in_flight -= 1
                for each dependent of s:
                    if all its dependencies are satisfied:
                        ready.enqueue(dependent)

            ServiceFailed(s):
                in_flight -= 1
                propagate_failure(s)

MaxParallelStarts bounds the concurrency, counted per context as the members currently running.

The events are not only member completions. A dependency naming a readiness level is settled against the live service table rather than the graph's own bookkeeping, and the arrival of a LEVEL= — or the publisher leaving a dependent-satisfying state — re-runs the release for every context holding an edge on that service.

7.3.1 Execution contexts #

A graph execution is a retained object, not a transient loop. peinit holds a context carrying its members, their dependencies and their status, and there are two kinds: one boot context built from the boot plan, and an on-demand context per explicit start, built from that service's validated transitive closure.

Both use the same scheduler, the same satisfaction rules, the same failure propagation and the same parallelism, but they are distinct runtime objects — which matters because they can overlap.

An operation is associated with every context that created or adopted it. One operation can belong to more than one active on-demand context: two administrators starting different services that share a dependency both end up merged into the same already-starting operation for it, and both contexts need to hear how it turns out.

So when a pre-start outcome is terminal for an operation, peinit dispatches the corresponding graph event once per associated context. An operation with no associated context completes or fails normally, and its waiters are notified, but no graph event is dispatched.

A member whose dependent resolves without ever needing it is pruned — a dormant sub-tree is cancelled rather than started, so an on-demand start that turns out not to need half its closure does not start that half.

A context is retired once every member has reached a terminal status — satisfied, failed or pruned — which is exactly when no further graph event can be dispatched through it. Its operation associations go with it, except where an operation is also associated with a context that is still live.

Retirement happens on the operation-maintenance sweep rather than at the moment the last member goes terminal. The callers of a terminal outcome walk the graph events it produced and release the contexts those events name, so dropping a context inline would pull it out from under the rest of the same turn's work.

Without this a context and its associations persisted for the lifetime of the process. The memory was the smaller half: dispatching a terminal outcome walks every context associated with the operation, so on a long-lived machine where services are started regularly the terminal path grew steadily slower, in PID 1's single thread.

7.3.2 Failure propagation #

When a service enters Failed during graph execution:

  1. Everything that Requires or BindsTo it transitions to Failed with cause DependencyFailure.
  2. Everything that Wants it is unaffected and starts normally.
  3. Propagation is transitive: if A requires B and B requires C, and C fails, then B fails and then A fails.

7.3.3 On-demand start #

Starting a service explicitly resolves its dependencies first:

  1. Collect the transitive Requires and BindsTo closure.
  2. Collect the transitive Wants closure, best-effort.
  3. Validate the sub-graph — cycles, missing targets.
  4. Resolve conflicts, stopping whatever has to stop.
  5. Start the sub-graph with the same parallel algorithm. Dependencies started this way carry cause DependencyStart and operation source DependencyPropagation.

A service already in a satisfying state — Active, Completed or Skipped — is not restarted. Its dependency is already met.

A disabled hard-dependency target blocks the dependent, on both paths. Disabled suppresses automatic activation and the service may still be started explicitly (§3.2) — but nobody started this one explicitly. Something that requires it did, and its administrator took it out of service deliberately, so starting it to satisfy someone else's dependency would defeat the flag by a route its description does not consider.

Starting the disabled service itself still works. That is the escape hatch, and it is unchanged.

A disabled Wants target is skipped rather than blocking anything: a soft dependency is advisory, so there is nothing to fail.

7.3.4 Shutdown ordering #

Shutdown reverses the graph: services with no dependents stop first, services that everything depends on stop last, derived by reverse topological sort from the same edges.

Only hard dependencies — Requires and BindsTo — contribute to the ordering. A Wants dependent may therefore be stopped after its target.

The ordering is entirely emergent from what the definitions declare. There is no floor and no pinning, so where the TCB services end up in the wave order depends on their declared dependencies being right.

7.4 The Boot and On-Demand Paths

Peios / Peios technical reference / peinit / Dependencies

The two ways a graph gets built differ in more than scope, and the differences are worth having in one place.

BootOn-demand
MembersEvery boot-triggered root plus its closureOne requested service plus its closure
A missing hard-dependency targetBlocks the dependentBlocks the dependent
A disabled hard-dependency targetBlocks the dependentBlocks the dependent
A validation findingThe service is failed, others continueThe start fails
Multiple findings on one serviceOnly the highest-precedence one is reported—
ConflictsTwo boot-triggered conflicting services fail bothThe conflicting service is evicted
ContextOne boot contextOne context per explicit start
Failure of the whole buildRecovery modeAn error to the caller

7.4.1 Where a definition error lands #

The three places a bad definition can be caught behave differently, and which one catches it depends on what kind of wrong it is:

  • Decoding. A definition that does not parse — an invalid name, a malformed trigger, an unclosed quote, a registry: check naming an uncacheable key, a duplicate field — is caught when the registry is read. It fails that service with ValidationError and the rest continue, at boot and on reload alike (§3.2).
  • Graph validation. A definition that parses but does not fit — a cycle, a missing target, a flap-constraint violation, an invalid calendar expression — is caught here, and fails that service at boot or the whole reload on a reload-config (§7.2).
  • Start. A definition that parses and fits but whose preconditions do not hold — a failed assert, an unresolvable identity, a missing binary — is caught when the service actually starts, and fails that activation (§5.2, §5.3).

The dividing line between the first two is whether the problem is visible in one definition on its own. Decoding sees one key at a time; validation is the first place that can see two definitions together.

7.5 Readiness Levels

Peios / Peios technical reference / peinit / Dependencies

network-online.target on systemd is famously meaningless, because "online" has no single definition. A service needing a default route and a service needing only a loopback address want different things, and one target cannot mean both.

peinit does not have an online target. A service says what it is waiting for, in the publisher's own vocabulary:

Requires = ["netd:routed"]
Wants    = ["timed:synchronised"]

netd:routed waits for netd to be active and to have published the level routed. netd on its own is unchanged — the ordinary "that service must be active" dependency.

7.5.1 The syntax #

<service>:<level>, split on the first colon. It rides on the existing Requires, Wants and BindsTo fields rather than a field of its own, because a level dependency is a service dependency with a stricter predicate: netd:routed subsumes netd. Each relationship keeps exactly the semantics it already has, applied to the stricter test.

It cannot collide with a service name. A service name may contain only letters, digits, ., _ and -, so a colon never appears in one, and no existing definition can accidentally become a level dependency.

A trailing colon — "netd:" — reads as a plain dependency on netd. It is a typo, and treating it as a request for the empty level would produce a condition nothing could ever satisfy.

7.5.2 Through a role #

The service side of the syntax may be a role instead of a name:

Requires = ["network:routed"]

network is filled by whichever service declares Provides = ["network"] — netd, on a shipped image — and peinit rewrites the entry to netd:routed before anything reads it, exactly as §7.6 resolves a role. The level rides across unchanged. Everything downstream — validation, the graph, svctl status — sees a level dependency on a service, and never learns a role was involved.

This is the form a definition should use for the network. The role is "the executor of Peios Network Policy's interface layer", and the levels are not the executor's own words but the specification's: PNP defines readiness as link, addressed and routed, and whichever service fills the role publishes those. A definition that says network:routed survives netd being replaced; one that says netd:routed does not.

Several services may fill a role; the entry then resolves to one level dependency per provider, and a Requires waits for all of them. A role no service fills is left as written and fails validation as the missing hard dependency it is, naming the role the definition wrote.

7.5.3 How a level gets there #

The service publishes it on the notification channel it already has, as LEVEL= beside READY=1 and STATUS=:

LEVEL=link
LEVEL=addressed
LEVEL=routed

All LEVEL lines in one datagram form one set: peinit atomically replaces the sender's previous set with the distinct nonempty values, before releasing any dependents, including when the same datagram carries READY=1. A datagram with no LEVEL lines leaves the set unchanged.

LEVEL= on its own clears the set. Empty values mixed with nonempty ones add no member; they do not clear other lines in the same datagram. peinit records the set against the sending service, and holds any dependent whose requested level is absent.

There is no subscription. peinit does not connect to netd's socket or timed's, does not need to find them before they exist, and carries no knowledge of their wire formats — which matters, because PID 1 is the one process on the machine that cannot afford to fail to start.

7.5.4 Levels match set membership #

Requires = ["netd:addressed"] is satisfied when the active publisher's set contains addressed. Every member is an opaque, case-sensitive name; commas do not split a name into several levels.

peinit has no ordering over another daemon's vocabulary. A publisher that sends only LEVEL=routed does not satisfy an addressed dependent. netd knows which conditions imply others, so when routed it publishes link, addressed and routed together. A service should depend on the condition it needs, and the publisher reports every condition that holds.

7.5.5 A level is never stale #

peinit clears a service's entire level set whenever that service leaves a state that satisfies dependents. A level is a claim about a process that is currently making it, and one that outlived its process would hold a dependent open on a promise nobody was keeping — the exact failure the mechanism exists to prevent.

Because peinit is the process that notices a service exiting, this needs no timeout and no liveness check.

7.5.6 How the gate holds #

A level is settled at dispatch, not at planning. The plan decides what to start; whether a level is met is a live question — it can arrive after the target's own start completed (netd is active long before DHCP finishes) and be retracted while a dependent is still waiting — so the execution graph re-checks it against the service table every time it considers releasing the dependent, and holds the start until the answer is yes.

That check runs even when there is nothing to start. An already-active target is not part of the start plan, but the condition on it still is: the graph keeps a level edge to a service it is not starting, which is what makes Requires = ["netd:routed"] hold when netd is up and merely not routed yet.

Two events re-open the question. A level-set update arriving from the publisher releases every dependent whose requested level is now present — this is the only thing that can open a Requires or BindsTo gate, which take the exact level and nothing else. And the publisher leaving a state that satisfies dependents releases Wants waiters: a soft dependency waits only while someone is running who could still publish the level, and proceeds once nobody is — which keeps Wants failure-tolerant, the property that defines it.

A held start does not time out, and its StartTimeout runs from the moment the level arrives, not from when the start was asked for. Requires = ["netd:no-such-level"] holds the dependent indefinitely: the service stays inactive and the start operation stays pending, visible in svctl status as an operation that has not completed. That is the declared semantics, not a hang — the condition was never met, so the start never happened.

7.5.7 What happens on a drop #

Nothing, in this version. If a level falls away after a dependent has already started, the dependent keeps running and is not told.

That is a deliberate limit, not an oversight. The notification channel is one-way by specification (PSPU §4.16), so peinit has no way to tell a running service anything. A service that needs to react to a level changing while it runs should subscribe to the publisher directly — which is what resolvd does with netd today, and what any consumer that cares that much will want anyway, since it will need the detail rather than just the fact.

peinit's contribution is the start gate: the moment when the service does not exist yet and so cannot subscribe to anything at all.

7.5.8 Published levels #

PublisherLevels
netd, as the role networklink → addressed → routed, defined by Peios Network Policy
timedunsynchronised, settling, synchronised, spike

Any service may publish a level; nothing about the mechanism is specific to these two. The vocabulary is the publisher's own — or, where the publisher fills a role a specification defines, the specification's — and needs no registration.

7.6 Derived Dependencies

Peios / Peios technical reference / peinit / Dependencies

Most dependencies are declared. One is not.

A service whose Identity is anything but SYSTEM cannot start until the authority is listening, because peinit has to ask it for a token before there is a process at all (§4.3). That is a hard dependency in every sense Requires means one.

It used to be nobody's. peinit hardcodes the socket the authority listens on, so the requirement was real on every boot, but the ordering existed only if a definition happened to declare it — and all three LocalService definitions in the tree did not. Three of three is the number that makes it a defect in the mechanism rather than in three definitions: the requirement is created by Identity, so Identity is what should produce the edge.

7.6.1 Provides #

A service names the roles it fills:

Provides = ["authn"]

A role is a virtual name — a capability, not a service. Virtual and real names share one namespace, exactly as they do for packages (PSPU §5.4): a dependency on authn is satisfied by a service literally called authn, or by any service providing it.

The indirection is the point. peinit knows it needs an authority and knows which socket to speak to, but the authority's name is registry data and none of peinit's business — an image may ship a different one. So the derived edge names the role, and peinit resolves it to whoever declares it.

Provides entries use the service-name grammar and never carry a readiness level. netd:routed names a condition on a service, and a role is not a service, so there would be nothing for the level to qualify; a level on a Provides entry is rejected rather than ignored.

The other side of a dependency may name a role, with or without a level. Requires = ["network:routed"] is rewritten to one entry per service providing network, the level carried across, before the definition set becomes a service table — the same pass, at boot and on every reload, that derives the edges below. Requires, Wants and BindsTo are rewritten; a real service name is never mistaken for a role, a provider never gains a dependency on its own role, and a role no service fills is left as written for validation to report. See §7.5 for why the network is declared this way.

7.6.2 What peinit derives #

Before a definition set becomes a service table — at boot and on every reload, through the same code, because an edge that held until the first reload would be worse than no edge — peinit adds a Requires on each provider of authn to every service that needs the authority to launch.

A service needs it when its Identity is not SYSTEM, or when it has hooks and its HookIdentity is not SYSTEM. The second half matters: a SYSTEM service whose hooks run as LocalService materialises a token for them, and a definition that needs the authority for half its launch needs the ordering for all of it.

Three rules keep the derivation from doing damage:

  • A provider never gains a dependency on its own role. An authority declaring a non-SYSTEM identity would otherwise require itself, and a service that requires itself never starts.
  • An edge already declared is not added again, whether it was declared as Requires or BindsTo, and including one carrying a level. Requires = ["authd:ready"] already orders against the authority, and more strictly; adding a plain edge beside it would be a second edge to the same service saying less. BindsTo requires its target as well as binding to it, so the same holds for it.
  • Nothing is derived when no service fills the role. See below.

Because the result is an ordinary Requires, it needs no new kind of edge and behaves exactly like a declared one everywhere else — in graph validation, in graph execution, at shutdown, and in svctl status, where it appears as the dependency it is rather than as a hidden ordering.

Several services MAY fill one role; each becomes its own Requires, so the dependent is ordered after all of them. For authn this cannot arise — PGSS Logon §2.1 requires at most one authority on a running system — and where a future role allows several, ordering after all of them is the conservative reading and needs nothing new. peinit deliberately does not offer "wait for any one of them": that would change the release rule from every edge satisfied to every group has one satisfied, and would contradict Requires's failure rule, under which a target entering Failed fails the dependent.

7.6.3 When nothing fills the role #

An image whose services need a token and which ships no authority is broken. A reload of it produces a validation warning naming every service that cannot start, and succeeds anyway.

It deliberately does not invent an edge to a name no service answers to. That would be a missing hard dependency, and a missing hard dependency rejects an entire reload transaction — so an operator could not reload the definition that installs the authority. The image would be unrecoverable by the one mechanism meant to recover it.

The individual launches still fail, loudly, where the fault actually is: at the service that asked for a token and could not have one.

7.6.4 A role is not an online target #

§7.5 opens by rejecting network-online.target, and the objection there does not reach this mechanism. It is worth being explicit about why, because the two look alike from a distance.

The complaint about network-online.target is that "online" has no single definition: a service needing a default route and a service needing a loopback address want different things, and one name cannot mean both. The abstraction is over a condition, and the condition is ambiguous.

authn abstracts over no condition at all. It means "whoever answers ServiceAttest on the logon socket" — a protocol role, specified by PGSS Logon §2.19, which §2.1 says MUST have at most one occupant. There is exactly one thing it can mean, and what a dependent gets is the same thing it would get by naming the service directly.

network:routed passes the same test from the other direction. The role network is a position too — the executor of Peios Network Policy's interface layer — and routed is not peinit's word for "online" but the specification's word for one exact condition, a default route. The ambiguity that sinks network-online.target is absent because the condition is named, not the target.

The rule the two share: peinit does not invent vocabulary for conditions inside another service. A readiness level is the publisher's own word for its own state, or its specification's; a role is a name for a position in a protocol peinit already speaks. Neither is a target.

8.1 Jobs

Peios / Peios technical reference / peinit / Jobs and Operations

A job is one supervised process execution. Every fork peinit performs is a job: a service's main binary, a pre-exec hook, a post-exec hook, a reload command, a health check invocation, a submitted job (§8.5).

Jobs are the observable unit of what actually ran. Services are definitions carrying identity, policy and configuration; jobs are instances. A restart creates a new job.

8.1.1 Lifecycle #

Created --> Running --> Completed
   |           |
   |           +------> Failed
   |           |
   |           +------> Abandoned
   +------------------> Failed
StateMeaning
CreatedThe job object exists but exec has not succeeded. The process may not have been forked, or it may be in pending post-fork setup with the error pipe unresolved.
RunningExec succeeded and the process is alive.
CompletedThe process exited successfully — code 0, or one in SuccessExitCodes.
FailedThe process failed, or peinit classified the job failed before the fork because parent setup failed.
AbandonedThe process survived SIGKILL.

Job states are simpler than service states because they describe a process rather than a policy. A service has Starting, Reloading and Backoff because those are decisions; a job is running, or it finished, or it is stuck.

8.1.2 Fields #

Job {
    id:                 GUID       // UUIDv7
    service:            string?    // null for a submitted job
    job_type:           enum       // ServiceMain, PreExecHook, PostExecHook,
                                   // ReloadHook, HealthCheck, Submitted
    state:              enum
    pid:                u32?
    pidfd:              fd?
    resolved_identity:  string     // the resolved service, hook or submitter identity
    token_summary:      object     // the resulting SID, groups, privileges
    image_path:         string
    arguments:          string[]
    created_at_ns:      u64
    started_at_ns:      u64?
    ended_at_ns:        u64?
    exit_code:          i32?
    exit_signal:        i32?
    failure_cause:      string?
    cgroup_id:          string
    cgroup_generation:  u32
    activation_generation: u32
    operation_id:       GUID?      // null for a submitted job
    hook_index:         u32?
}

The rules that govern when the nullable fields are populated are what make a job record trustworthy:

  • id is assigned before the fork, so a job that never forks still has an identity.
  • pid and pidfd land on the record only once exec success is confirmed by EOF on the error pipe. Until then they are held in pending setup state and the job is Created.
  • An exit observed during that window is held, not applied. peinit resolves a reaped child to a job by PID, and inside this window no job carries the PID yet — a short-lived process can be gone before the error pipe is read. The exit is kept against its PID and replayed once the job has been started and can receive it. Discarding it would be unrecoverable: the job would be started against a PID that is already dead and reaped, and no second SIGCHLD is coming, so it would stay Running for ever. A reaped PID that matches no job and no pending setup is genuinely not peinit's — PID 1 reaps orphans it never started — and is ignored.
  • A setup failure takes the job straight from Created to Failed. ended_at_ns records the classification time, failure_cause records what went wrong, and pid, pidfd, started_at_ns, exit_code and exit_signal all stay null. There was no process to have a PID or an exit status.
  • exit_code and exit_signal are populated only when peinit observed an exit — never both, since a process either exits or is killed.
  • For an Abandoned job, ended_at_ns records when peinit stopped supervising, and the exit fields stay null. Nothing exited.

resolved_identity is the identity string — SYSTEM, LocalService, a SID — that was resolved for the execution. For a submitted job it is the job identity's user SID, since no name was ever involved (§8.5). token_summary is what the resulting token actually contains. They are separate because they can differ, and the identity field exposed in status views and job events is the former.

8.1.3 Retention #

peinit tracks active jobs in memory. When a job reaches a terminal state it emits a structured event carrying the full record and then drops the job. There is no job history in peinit, and no structure that could hold one.

eventd is the historian. It consumes those events from the KMES kernel ring buffer, and a query for a service's past jobs is a query to eventd.

A submitted job is the one partial exception: its record is dropped like any other, but the jobs system keeps its own entry for a further 60 seconds so that a submitter polling for the outcome can still read it (§8.5). That entry is a retained answer, not a history.

8.1.4 The launch queues are hints #

A job awaiting launch is queued by id, in one of six queues — service main, pre-start hook, post-start hook, control, health check and submitted. The queues hold ids; the store holds the records, and the store is the only truth.

They can therefore disagree. Any path that finishes a job in Created drops the record, and a path that does not also clear the queue leaves an id behind pointing at nothing. Requiring every such path to remember was a poor trade: there are around a dozen of them, nothing enforced it, and the cost of one omission was that peinit's runtime loop ended — which ends PID 1.

So a drain that meets an id with no record discards it and moves on to the next, rather than treating it as an error. A live entry behind a stale one still launches, so one bad id cannot stall a queue. This is why the ordering rule is worth stating plainly: keeping the queue in step is a tidiness obligation, not a correctness one.

Discarded entries are counted and reported on the work pump's turn, so a bookkeeping fault shows up as a number rather than disappearing. A count that is not zero means some path finished a job without clearing its queue entry, and that path is a defect even though it is no longer fatal.

8.1.5 Ownership #

ConcernOwner
Restart policy, dependencies, health check schedule, ErrorControlService
Current state — Active, Failed, …Service
PID, pidfd, exit code, exit signalJob
Execution timestampsJob
Identity and tokenJob
cgroup assignmentJob
Log correlationJob
Submitter, job descriptor, progress, retentionSubmitted job entry (§8.5)

A service tracks its current main job's identifier, and a status query returns it.

8.2 Operations

Peios / Peios technical reference / peinit / Jobs and Operations

An operation is a requested state machine action on a service, as a first-class object. Control commands do not mutate state directly: every one creates an operation that is validated, queued, resolved against whatever else is in flight, and executed by the event loop.

Operations exist because peinit serves concurrent callers — administrative tools, automated triggers, other services. Without them, two commands arriving together collide with whatever behaviour falls out; with them, the resolution is explicit and observable.

8.2.1 Lifecycle #

Pending --> Running --> Completed
  |           |
  +-> Merged  +-------> Failed
  |           |
  +-> Cancelled +-----> Aborted
  |
  +-> Failed
StateMeaning
PendingValidated and queued, waiting on a precondition.
RunningExecuting.
CompletedThe goal was reached. Start: Active for Simple, Completed or Inactive for Oneshot. Stop: the service is no longer running. Reload: the reload resolved.
FailedThe goal was not reached — or the operation's maximum lifetime expired while it was still Pending (a start peinit is holding on a dependency has no lifetime, §7.5) — or peinit could not execute it (below).
MergedMerged into an existing identical operation, whose identifier is recorded.
CancelledTerminated while Pending. It never executed.
AbortedTerminated while Running.

An operation can also fail because peinit could not execute it: the matrix admitted it and then the boundary found nothing to act on — no current main job, a transition the state machine does not permit. That is peinit's fault rather than the service's, and it is contained to the one operation. The operation fails with the result internal_error: <cause>, the service keeps the state it had, a [FAILED] console line names the service, the operation and the cause (peinit: service <service>: <type> operation <id> failed before it began: <cause>), and the runtime loop carries on. A caller waiting on the operation is answered INTERNAL_ERROR (§10.1).

The same rule covers every path that is about one service — a job terminal, a setup status, a health probe, a lifecycle deadline, a notify datagram, an event the ring refuses. An internal error there fails that service under InternalError (§6.3): its job is retired, its operation fails with internal_error: <step>: <cause>, a [FAILED] line peinit: service <service>: internal error at <step>: <cause> and a peinit.internal-error.contained event (§8.4) announce it, and the runtime loop carries on. Only an error about supervision itself — the wait, event source registration, the listeners, the event ring refusing even the small peinit.event.dropped, shutdown finalisation — ends the loop and enters recovery (§2.8). A bug peinit can attribute to one service costs that service; it does not cost the machine its control socket.

A lifecycle deadline that raised is removed once its error is contained, so it is announced once, not again on every timer turn. One that peinit cannot remove is retried at most once a second, and a retry that raises the same error and changes nothing is not announced again.

Cancelled and Aborted are the same idea at different points: never ran versus was running. Why it happened is a property of the event, not of the state.

Two things reach Aborted. A conflicting operation superseding this one, and a Restart whose target's definition is withdrawn while its stop leg is draining — the stop still finishes, but there is nothing to start, so the operation is aborted with the reason definition_removed_during_restart_stop_leg (§3.8).

A start that is aborted or cancelled takes its readiness deadline with it: the start is no longer waiting for READY=1, and ending its main process is the superseding operation's job. A readiness deadline whose operation has ended by the time it comes due is dropped, not acted on.

8.2.2 Fields #

Operation {
    id:               GUID
    operation_type:   enum    // Start, Stop, Restart, Reload, Reset
    service:          string
    state:            enum
    created_at_ns:    u64
    started_at_ns:    u64?
    completed_at_ns:  u64?
    source:           enum
    caller:           token_summary?   // admin-initiated only
    result:           string?
    merged_into:      GUID?
}

8.2.3 Sources #

Why peinit created the operation:

SourceMeaning
AdminA control client asked for it.
BootThe Phase 2 boot plan.
ShutdownThe shutdown lifecycle.
DependencyPropagationA start operation created one for an unsatisfied dependency.
RestartPolicyA restart policy generated a start.
TimerA timer trigger fired.
BindsToRecoveryA bound target returned to Active.
BindsToPropagationA bound target stopped.
ConflictResolutionA conflict evicted the running service.
OnFailureA failed service's fallback handler.
TtyReleaseA service released a terminal another service was waiting for.

Graceful shutdown creates a Stop operation with source Shutdown for each participant in its stop waves (§12.2). All are requested when the plan is fixed; each starts when its wave becomes eligible. These records observe the shutdown planner. They do not enter the ordinary control queue or replace an existing administrator operation, whose retained stopping deadline still applies. A later-wave observation may fail its Pending lifetime without authorising an early signal or cancelling the service's eventual stop.

A participant that exits before its wave can complete its observation without ever starting it; a crash fails it. Once stopping, reaching Inactive completes it, while a stop timeout, global timeout or forced reboot fails any observation still live. A failed observation is not completed again when the process is subsequently reaped.

8.2.4 The types #

Start creates a job for the target. Unsatisfied dependencies produce their own start operations with source DependencyPropagation. It completes when the service reaches Active, Completed or Inactive as appropriate, or Skipped when pre-start conditions do not hold.

Stop sends SIGTERM, arms StopTimeout, escalates to SIGKILL. It completes when the service reaches Inactive, or Failed after a conflict eviction or bound-dependency propagation.

Restart is a stop then a start, tracked under one identifier across both phases, and the type stays Restart throughout for observability.

Reload issues the reload command or signal (§6.5) and completes when the reload resolves. Unlike the other lifecycle commands it defaults to not waiting — the caller gets the identifier immediately.

Reset clears Failed, Abandoned or Skipped, taking the service to Inactive. It is synchronous.

8.2.5 Timeouts #

A start, reload or reset inherits the target's StartTimeout as its maximum lifetime; a stop inherits StopTimeout. A restart has two legs, each enforced against its own timeout, with the sum as the overall lifetime. A start the graph is holding has no lifetime while every unsatisfied dependency it waits on is a fact with no clock of its own — a readiness level not yet published (§7.5), a target in Backoff (§6.1), or a target itself held only on such facts; it stays Pending for as long as those facts are undecided, and a caller waiting on it waits with it. A dependency that is itself starting is bounded by that service's StartTimeout, and a dependent waiting on one keeps its own lifetime, measured from creation as below. When a hold ends, the released start's lifetime runs from the release. Queue time behind another operation is not a hold and still counts.

This also applies to an on-demand start whose Requires or Wants target is already in Backoff. The request is accepted and held; it does not bypass the target's remaining restart delay. Repeated requests merge into the pending operations. The target's deferred start receives its lifetime when the restart deadline releases it. If the target is stopped instead, hard dependents fail and soft dependents may proceed.

For a transitive hold, the required release point can precede the dependent's own dispatch: when its dependency becomes clocked, the dependent must receive its full lifetime even if it is still Pending, waiting for that dependency to finish. Its later dispatch must not restart that lifetime again.

The clock starts at creation, including queue time — except across a hold (§7.5), during which it does not run. From the caller's point of view they have been waiting since they sent the command, not since peinit got round to it. A start that sits Pending behind a stop for longer than StartTimeout fails without ever running.

8.2.6 Retention #

Pending and Running operations are held in memory. A terminal operation is emitted as an event and dropped after a grace period of 60 seconds — long enough for a polling client to collect the result.

peinit keeps no operation history, for the same reason it keeps no job history. eventd is the historian.

8.3 Conflict Resolution

Peios / Peios technical reference / peinit / Jobs and Operations

When a new operation is requested and one for the same service is already Pending or Running, peinit resolves the two.

8.3.1 Merging #

An operation of the same type merges. The new caller receives the existing operation's identifier, and from their point of view their request is in progress — they neither know nor need to know that it merged.

ExistingNewResolution
StartStartMerge
StopStopMerge
ReloadReloadMerge
RestartStartMerge — a restart already includes a start

Restart is not mergeable with itself. A second restart while one is in progress is queued. The one place that does not apply is a restart deferred by a backoff (§10.3): it is Pending with nothing running for a second one to queue behind, so a second restart in Backoff merges into it.

8.3.2 Cross-type #

ExistingNewResolution
Start (Pending)StopCancel the start, create the stop
Start (Running)StopAbort the start, create the stop
Start (Pending)RestartCancel the start, queue the restart
Start (Running)RestartQueue the restart
Stop (either)StartQueue the start
Stop (either)RestartQueue the restart
Restart (Pending)StopCancel the restart, create the stop
Restart (Running)StopAbort the restart, create the stop
Restart (either)RestartQueue
Reload (Pending)StopCancel the reload, create the stop
Reload (Running)StopAbort the reload, create the stop
Reload (Pending)RestartCancel the reload, create the restart
Reload (Running)RestartAbort the reload, create the restart
Anything (either)ResetReject

Combinations outside this table are rejected: a new Reload while a Start, Stop or Restart is active. A new Start while a Reload is active is not rejected — a reloading service is already where a start would take it, so the command is answered with the service's status and creates no operation, before conflict resolution is reached at all.

8.3.3 The principles #

  1. Stop wins over start. An explicit stop always takes priority. The administrator said stop, so stop; a queued start can follow.
  2. Later supersedes earlier. Start then immediately stop means the stop wins and the start is cancelled, recorded as superseded.
  3. Merging is transparent. The merged caller gets the original identifier and blocks on the original operation's outcome.

Reset is rejected outright while anything is in flight, because reset means "clear a terminal state" and nothing in flight has one.

8.3.4 Dependency propagation #

When a start executes against a service with unsatisfied dependencies, peinit creates start operations for them:

  • Requires — source DependencyPropagation. If one fails, the parent operation fails with DependencyFailure.
  • Wants — source DependencyPropagation. If one fails, the parent continues.
  • BindsTo recovery — source BindsToRecovery, created when a bound target returns to Active, for dependents sitting in Failed with cause BindsToPropagation.

Dependency-created operations follow the same resolution rules as administrator-created ones. If a dependency is already starting because something else also depends on it, the operations merge — and both graph execution contexts are then associated with the one operation (§7.3).

BindsToRecovery restarts are not subject to the restart budget. They are created because a dependency returned, not because anything failed.

8.3.5 Restart policy and timers #

A restart-eligible failure creates a start operation with source RestartPolicy once the backoff delay elapses. It goes through the ordinary validation and resolution: if an administrator has already sent a stop, or the budget is exhausted, it is rejected.

A timer firing creates an operation based on the service's current state:

TypeStateAction
OneshotInactive, Completed, FailedCreate a start, source Timer.
OneshotActive, StartingSet the pending flag. One catch-up run, no operation.
SimpleInactive, FailedCreate a start, source Timer.
SimpleActive, StartingNo-op. The firing is recorded.

The Oneshot catch-up creates its start when the current run completes. Multiple missed firings collapse into one pending run.

8.3.6 Boot and shutdown are not operations #

Boot and shutdown are modes peinit enters, which then generate per-service operations. There is no "shutdown operation" to observe or cancel. Boot-generated starts use source Boot.

Shutdown-generated stops use source Shutdown, and they are the one exception to the tables above. They are created outside conflict resolution: a Shutdown Stop does not merge into, cancel or abort an administrator's Stop or Restart already in flight, but exists beside it, so that operation keeps its identity and its retained timing (§12.2).

8.4 Event Emission

Peios / Peios technical reference / peinit / Jobs and Operations

peinit emits a structured event at every job and operation lifecycle transition, and for its own records. All of them go into the KMES kernel ring buffer through kmes_emit, encoded as msgpack per the KMES event-record format (Peios Kernel TRM §2).

There is no event socket. Structured events are not sent to eventd over any connection. eventd consumes them from the ring buffer, which is why they survive eventd being down, being restarted, or not existing yet. The only thing peinit sends eventd over a socket is service output (§11.4), which is a different path with different guarantees.

8.4.1 Names and shape #

Every type peinit writes is under its platform root, peinit, and every payload is a nested map whose fields sit at their catalogue paths (PGSS §6.4): the job is object.job.guid, the service object.service.name, the client that asked subject.token.sid, the result outcome.success. A value peinit does not have is an absent key, never nil; SIDs are written in binary (bin.sid), job and operation identifiers as binary GUIDs (bin.guid) that read back as the text svctl shows, enumerated values in kebab-case (service-main, explicit-start), and times as nanoseconds since the Unix epoch. peinit's fragment, /usr/share/evman/peinit.evman, describes every type and field, and the Events reference generates its pages from it.

No event carries a principal's name, peinit's own log wording, or Rust debug output. What a person reads is on the console; what a consumer queries is in the fields.

8.4.2 The emission policy #

Before peinit builds an event that is not essential, it asks the emission policy, Machine\Generic\Events (PGSS §6.9): a type switched off is never built, let alone written. With nothing set, a standard type is on and a verbose type is off. peinit opens one view of the policy for the life of PID 1; it needs no registry to open, decides by tier until the registry is readable, and applies a committed change to the next decision.

Four types are essential and never ask: peinit.boot.downgraded, peinit.recovery.entered, peinit.critical-service.failed and peinit.event.dropped. Each is rare, and each is the only record of what it describes. The verbose types are peinit.job.created, peinit.job.status.reported, peinit.operation.started, peinit.operation.merged, peinit.graph.operation.ended, peinit.config.reload.deferred, peinit.notify.status.reported and peinit.notify.progress.reported; everything else is standard. Setting Enabled = 1 on Machine\Generic\Events\peinit turns all of them on.

8.4.3 Job events #

peinit.job.created — the job object exists, before its process does. Carries the job, its type and state, its service and the activation generation it serves, the operation it serves, the executable, and the token it will run as.

peinit.job.started — exec succeeded. Adds the process ID (object.process.pid) and the cgroup (object.cgroup.path).

peinit.job.ended — the process exited or was killed, or the job ended without starting. Carries the whole record: the terminal state, outcome.success (true exactly for completed) with the failure cause as outcome.detail, the exit code or signal, the executable and arguments, the times it was created and started, its duration, and its cgroup and cgroup generation.

The token a job runs as is on each once peinit holds it: its user and group SIDs, and its present and enabled privileges as flags. A service's token is minted as its process is set up, so its peinit.job.created carries at most the user SID of a well-known identity.

peinit.job.ended cuts object.job.arguments to 32 KiB of whole arguments; object.job.arguments-truncated and object.job.arguments-count say so. An event the ring refuses outright is dropped, counted, and replaced by a peinit.event.dropped naming its type, its length, its service and job, and the error number, and a [ WARN ] line reports it; only the ring refusing that small event is fatal (§8.2). The count of drops is the console's, and the trail's is the number of those records. peinit.internal-error.contained records an error peinit contained to one service (§8.2): the step, the service, the job and whether the service was failed. The error's text stays on the console and in the failed operation's result.

A submitted job's lifecycle rides the same three events with no service, no activation generation and no operation: the keys are absent. Two more are its own:

peinit.job.status.reported — the job reported STATUS or PROGRESS. Carries the job, the submitter's SID, and notify.status and notify.progress.* as retained after the datagram. Written at most once per job per second; a datagram inside the window updates the view and writes nothing (§8.5).

peinit.job.output.dropped — the submitter's output sink stopped draining and peinit began dropping its copy of the job's lines. Once per job, on the first drop; the record in eventd is unaffected (§11.1).

8.4.4 Operation events #

Every operation event carries the operation (object.operation.guid), its type, source and state after the transition, and its service. An operation a client's command created also carries the client as subject.token.sid; one peinit created itself has no subject.

EventAdds
peinit.operation.requested—
peinit.operation.started—
peinit.operation.endedoutcome.success, outcome.detail, object.operation.duration
peinit.operation.mergedobject.operation.merged-into.guid

peinit.operation.ended is one type for the four ways an operation can end other than merging — completed, failed, cancelled while pending, aborted while running — and object.operation.state says which. outcome.success is true exactly for completed. A merged operation has no outcome of its own: the one it merged into decides it.

object.operation.duration is measured from the request, not from the start of execution — the same reasoning as the operation timeout — and is absent on a cancelled operation, which never ran. What a caller waited is what matters, and queue time is part of it.

The operation's result, in the words the control interface gives a client (inactive, startup killed by shutdown), is outcome.detail, whichever way it ended. The control interface's operation view calls the same text result or error (PSPU §4).

8.4.5 Ordering #

When one runtime step produces several lifecycle events, peinit emits them in causal order before committing the retained state for that step. For a terminal pre-start graph dispatch, the terminal event for the operation whose outcome satisfied or failed the graph input precedes the events for the operations that dispatch releases, which preserve graph dispatch order.

Every operation in a graph context is requested when the context is built rather than when its turn comes, so what a release emits is peinit.operation.started.

8.4.6 Peinit's own records #

peinit's own records go through the same path:

  • peinit.on-failure.suppressed when the fallback chain guard trips;
  • peinit.graph.validation.failed and peinit.graph.validation.warned for validation findings, one per finding, and peinit.boot.downgraded for each finding that forced Safe mode;
  • peinit.config.reload.applied for an explicit reload-config and for the reload that ends the boot window, and peinit.config.reload.deferred for a reload during it (§3.7);
  • peinit.notify.rejected for a refused notification datagram, and peinit.fd-store.rejected for a refused descriptor;
  • peinit.notify.status.reported, .errno.reported, .exit-status.reported, .stopping.reported and .progress.reported for the event-emitting notification fields, the last at most once a second for each activation (§10.5);
  • peinit.cgroup.leaked the first time a sub-cgroup is found still populated after its post-kill deadline (§5.7);
  • peinit.graph.operation.ended for a graph member's terminal outcome;
  • peinit.recovery.entered for the reason peinit dropped to a recovery shell (§2.8);
  • peinit.critical-service.failed and peinit.service.abandoned from the shutdown path (§12);
  • peinit.service.reload.timed-out for a reload a service started and never confirmed (§6.5).

Records are events rather than logs, and that distinction is the point: the ring buffer persists from the moment PKM loads, so a recovery during Phase 1 is captured before the registry exists, let alone eventd.

8.4.7 Access decisions #

peinit writes no event of its own for a refused command. Every access check it makes goes through KACS AccessCheck, and KACS records the decision, as kacs.audit.access.checked, when the descriptor's SACL asks for it (PGSS §6.7). peinit's compiled-in descriptors — the default service descriptor, the control descriptor and a submitted job's default — each carry a SACL auditing every refusal, for everyone, so a denial is recorded unless a descriptor says otherwise (§4.6, §13.4). A refusal also leaves a debugging line on the console, written only at peios.quiet=0.

So that the record says what was decided on, each check passes KACS an audit context naming its object, which the kernel copies into the record beside fields.attestation.userspace:

CheckedContextThe record carries
A service{kind: "service", service: {name}}object.kind service, object.service.name
A job, on either socket{kind: "job", job: {guid}}, the GUID in binaryobject.kind job, object.job.guid
The control door{kind: "peinit-system"}object.kind peinit-system

The rights in access.requested and access.granted are decoded against object.kind: the SERVICE_*, JOB_* and SYSTEM_* tables (§4.6, §8.5, §10.2). job-list checks every job it might list against that job's own descriptor, so each job it leaves out is one more decision for the job's SACL.

peinit holds SeAuditPrivilege enabled for this: without it KMES refuses its events, and the access-check path writes no record (§13.2).

8.5 Submitted Jobs

Peios / Peios technical reference / peinit / Jobs and Operations

A submitted job is a supervised process whose definition arrived on the jobs socket rather than from a service definition. It has no name, no policy and no operation: something asked for it, peinit ran it under an identity the asker was entitled to confer, and it is managed by whoever the job's own Security Descriptor admits. The protocol is PSPU §7; this chapter is how peinit implements its side.

Two shapes drive the design, and one mechanism serves both. A task — a backup, a migration — is submitted by a broker on behalf of the principal it is serving, runs to completion and reports progress on the way. A session — a per-logon host process — is submitted with a freshly issued logon token and one end of a socketpair, and lives until something stops it. Nothing in peinit distinguishes them.

8.5.1 Two doors #

The control socket is an administrator's door: its commands change the state of the system's services, and its descriptor admits the principals entitled to do that. Submitting a job is a different permission held by a different population, so it has a different socket, /run/services/peinit/jobs.sock (§10.7), with a different descriptor. The filesystem says who may submit and who may administer, separately, and peinit carries no policy of its own about either: being able to connect to the jobs socket is the permission to submit, and peinit performs no access check before a submit.

What a caller may do to a job is a third question, answered by the job's own descriptor. A submitter manages its jobs on the jobs socket; an administrator observes and stops them from the control socket with job-list, job-status and job-stop (§10.2). Both doors check the same descriptor with the same rights.

8.5.2 Two identities #

Every submission involves two identities, and peinit keeps them apart.

The submitter is the connection identity: the peer token captured once, at accept, exactly as the control socket captures it (§10.1). It is recorded on the job, it is what the quota counts against, and it is the owner of the job's descriptor. A submitter that connects while impersonating is recorded as the principal it was impersonating.

The job identity is what the process runs as. It is established in exactly one of two ways, and the boundary that does it — LinuxJobIdentityProvider — has no third:

  • No token attached. peinit opens the primary token of the connecting process through the kernel's handle on it. The handle comes from SO_PEERPIDFD at accept; the token comes from peios_token_open_process on that pidfd with QUERY | DUPLICATE. The job then has the identity a child of the submitter would have had — its primary token, with no impersonation inherited — so a submitter that was impersonating when it connected produces a job running as itself while being recorded as a submitter under the impersonated identity. A submitter that wants the impersonated identity on the job attaches it.
  • A token attached. The kernel delivered a token with the submit message through KACS_SCM_TOKEN, having run the impersonation gates with the submitter as installer and recorded on the token the lower of the socket's level and the source's own (Peios Kernel TRM §3.5). peinit uses that token as it arrived, at QUERY | IMPERSONATE | DUPLICATE.

Either way the source is duplicated to a primary token at its own impersonation level, with TOKEN_ALL_ACCESS, and the duplicate is what the child installs. The level carries through: a job started from an Impersonation-level token cannot convey Delegation, and a submitter that needs it — for a session that will authenticate onward on its user's behalf — sets its own jobs connection to Delegation before attaching, because the attach is clamped to the socket's level.

peinit refuses, with BAD_TOKEN, a source whose impersonation level is below Impersonation — an Identification-level token cannot pass an access check and an Anonymous one is not an identity to run a process as — and a source it cannot duplicate. It refuses nothing because of who the token names. Whether the submitter may run a job as that identity was the kernel's decision, made when the token was conveyed, and a second decision inside peinit could only disagree with the first.

There is no identity field in the definition, and no route by which a plain descriptor passed with SCM_RIGHTS becomes a job identity. A descriptor passed that way is not gated by the kernel — whoever holds an fd can pass it — and it would be the one path worth attacking.

The prepared token is a raw descriptor held on the job's entry until the launch takes its own duplicate of it (materialize_prepared_token, §4.1). A submission refused after the token was prepared closes it on the way out.

8.5.3 The submission #

submit runs in this order, and a refusal at any step leaves nothing behind — no record, no event, no quota consumed, and every descriptor the message carried closed:

  1. Parse and validate the definition against the attachment count (below). A malformed definition is INVALID_ARGUMENTS before any identity work is done.
  2. Refuse during shutdown with INVALID_STATE. The other four commands keep working while the system goes down.
  3. Establish the job identity, as above.
  4. Check the quota. peinit counts the submitter's live jobs — those not yet terminal — by submitter SID against Machine\System\Init\MaxJobsPerSubmitter, default 64. SYSTEM (S-1-5-18) is exempt. At the bound the answer is QUOTA_EXCEEDED. A job stops counting the moment it is terminal, not when its retained record is dropped.
  5. Build the job's descriptor: the one the submission supplied in SDDL, used as given, or the default (below).
  6. Create the record, the entry and the launch queue entry, in one SupervisorWork transaction.

The job is two things in peinit's state. The job record is the same JobRecord every fork gets (§8.1), with job_type: Submitted, no service, no operation, the job identity's user SID as resolved_identity, and a cgroup id under /sys/fs/cgroup/peinit/jobs/ named by the job's GUID. The entry is what the jobs system holds beside it: the submitter, the job identity's user SID and logon session, the definition, the descriptor, the prepared token, the attached descriptors with their names, the output sink, readiness and the retained STATUS and PROGRESS, any stop in progress, and once the job is terminal its outcome and retention deadline. Records are dropped when terminal, as every job's is; entries outlive them for the grace period, which is how a terminal job still answers status.

The submit is answered when the job leaves created — on exec confirmation or on launch failure — and not before. The submitter's connection holds a pending wait until then, reads nothing further, and is not idle. A running job's answer carries a duplicate of the job's pidfd as SCM_RIGHTS; a failed launch is still "status": "ok", with a terminal view whose cause says why. A pidfd that cannot be duplicated is answered INTERNAL_ERROR rather than a view with the handle silently missing — the submitter was promised one, and status gives it another chance.

8.5.3.1 The definition #

FieldDefaultValidation
image_pathrequiredAbsolute, non-empty, no NUL. Not checked for existence: execve decides that, as the job's identity.
arguments[]Strings without NUL.
environment{}Keys non-empty, no =, no NUL; values no NUL.
working_directory/Absolute.
description""
timeout0, no limitNon-negative integer, seconds.
stop_timeout10Positive integer, seconds.
readinessnonenone or notify.
readiness_timeout30Non-negative integer, seconds.
success_exit_codes[]Integers 0–255.
descriptors[]One name per injected descriptor; non-empty, no :.
outputfalseRequires at least one attached descriptor.
security_descriptorthe defaultSDDL with an owner and a DACL.

descriptors has to match the attachments exactly: its length plus one if output is set equals the number of descriptors on the message. The combined size of arguments and environment is bounded at 2 MiB, below what execve accepts. Nothing here restarts, depends, probes or schedules; a program that needs any of that is a service.

8.5.3.2 The descriptor #

Unless the submission supplied one, the descriptor is built by PeiosSystemAccessChecker from the submitter SID: owner and group the submitter; a DACL granting JOB_ALL_ACCESS to the submitter, to SYSTEM and to Administrators; nothing else; and a SACL auditing every refusal, for everyone, so a denied command on the job is recorded (§8.4). The job identity is granted nothing. A process cannot, by virtue of running as U, see or stop a job that runs as U — a submitter that wants the principal to see its own session says so in the descriptor it supplies.

A supplied descriptor is used as given, with no default entries added — its SACL included, so one with none has its denials recorded by no one. A submitter that omits itself has locked itself out of its own job, and peinit does not prevent that. The descriptor is fixed at submission.

RightBit
JOB_QUERY0x0001
JOB_STOP0x0002
JOB_SIGNAL0x0004
JOB_ALL_ACCESS0x0007

The generic mapping is read → JOB_QUERY, write and execute → JOB_STOP | JOB_SIGNAL, all → JOB_ALL_ACCESS. Every command on either door checks against this descriptor, the submitter included, naming the job to KACS in the check's audit context; a denial is answered ACCESS_DENIED, and the descriptor's SACL decides whether KACS records it (§8.4).

8.5.4 Launch #

Queued submissions launch from the work pump, one per pump step, through the ordinary child path (§5.3, §5.4) with three differences. The token source is LaunchTokenSource::Prepared: the launch takes its own duplicate of the prepared token through materialize_prepared_token, and the entry's copy is closed once the launch has it, whatever the outcome. The attached descriptors ride as inherited descriptors, placed from 3 upward with close-on-exec cleared and LISTEN_FDS, LISTEN_FDNAMES and LISTEN_PID set, exactly as the fd store hands descriptors back to a service (§10.6). And the environment is built as a service's is — the compiled-in base, the global layer, then the submission's environment in the place a definition's own variables occupy, then the protocol variables last, so a submitter cannot override NOTIFY_SOCKET or the LISTEN_* set (§5.5).

The cgroup is /sys/fs/cgroup/peinit/jobs/<guid>, a tree of its own rather than a service's.

Exec confirmation and failure arrive through the setup pipe as for any job. A parent-side failure — no token, no cgroup, no fork — takes the record from Created straight to Failed with cause parent_setup_failure; a child-side failure between fork and exec, or a failed exec, is pre_exec_failure. Both close everything the entry still held and try to remove the cgroup. On success the record is Running, the runtime registers the job's pipes with origin jobs/<guid>, and adopts the output sink if there is one (§11.1).

A stop on a job still queued cancels it: the launch entry is removed, the record fails before start with the stop's cause, and the answer is the terminal view. A stop that lands while setup is pending kills the cgroup and lets setup completion find the stop already recorded.

8.5.5 While it runs #

A submitted job speaks the notification socket like a service, with one difference in how it is believed: peinit routes a datagram to a service or to a submitted job by a pure PID lookup first — does any service's current main job carry this PID — and only then verifies the sender against that job's pidfd. A submitted job has no activation generation and no replacement, so the generation step does not apply. The sender is verified exactly once whichever way it was routed (§10.5).

What the fields do to a submitted job:

FieldEffect
READY=1For a notify job not yet ready: ready becomes true and the readiness timeout is disarmed. Otherwise ignored.
STATUS=Retained as status_text.
PROGRESS=Parsed as §4.19's three forms; retained as progress. A value outside the forms, T of zero or N above T is dropped, never repaired; the rest of the datagram is applied.
PROGRESS_UNIT=bytes, items or percent, retained; anything else dropped.
STOPPING=1Recorded, so a later stop sends no termination signal.
Everything elseIgnored: a job has no reload, no watchdog, no fd store.

A datagram that changed status_text, progress or the unit is due a peinit.job.status.reported event carrying the retained values (a verbose type, written only when the emission policy turns it on) — but at most one per job per second, measured from the last event emitted for that job, so a sender updating a thousand times a second produces one event a second while the view is current on every query.

Deadlines are held on the entry and folded into the one lifecycle deadline timer the event loop arms, as SubmittedJob kinds:

DeadlineDueAction
Timeoutstarted_at + timeoutBegin a stop with cause timeout.
ReadinessTimeoutstarted_at + readiness_timeout, for a notify job not yet readyBegin a stop with cause readiness_timeout.
StopKillstop requested + stop_timeoutSIGKILL the job's cgroup.
PostKillkill + PostKillTimeoutAbandon (below), or re-arm if the cgroup is empty but the exit not yet reaped.
CgroupCleanupterminal + PostKillTimeoutRetry removing a cgroup that was busy when the job ended.

A job being stopped has only its stop deadline; a deadline that fires for a job that has already ended does nothing.

8.5.6 Stopping #

Every stop — a submitter's stop, an administrator's job-stop, a timeout, shutdown — goes through begin_submitted_stop: record the cause and the kill deadline, then SIGTERM the main process through its pidfd unless the job has already sent STOPPING=1. A second stop on a job already stopping changes nothing and does not restart the deadline; a stop on a terminal job is a no-op answered with the unchanged view.

At the kill deadline the cgroup is killed. At the post-kill deadline, a cgroup still populated means the process survived SIGKILL: the record is abandoned, the cause becomes process_unkillable, the cgroup is leaked and reported as peinit.cgroup.leaked, naming the job rather than a service, and supervision stops.

signal is the raw mechanism, deliberately: one signal, by number, to the main process through its pidfd, on a running job only. SIGKILL this way leaves whatever the job spawned and produces a failed job with exit_signal set and a null cause. A submitter that wants the job ended uses stop.

8.5.7 Ending #

On reap, exit code 0 or one in success_exit_codes completes the job; any other code, or a signal, fails it. Then whatever the job left in its cgroup is killed and the cgroup removed; a busy cgroup schedules one CgroupCleanup retry, and a cgroup still busy after that is reported leaked. The prepared token, any attached descriptor the launch never took, and any sink the runtime never adopted are closed. The sink the runtime did adopt closes when the job's last pipe closes.

cause records what peinit decided, and stays null when the process ended of its own accord. A stop whose process handled SIGTERM and exited 0 is completed with cause explicit_stop: peinit asked, the process agreed, and both facts are recorded. A stopped job that died to the signal is failed with exit_signal set and the same cause.

The record is dropped at once, as every terminal job's is (§8.1), and peinit.job.ended carries it. The entry is retained for 60 seconds so a submitter polling for the outcome finds it, and then purged by operation maintenance; status on a purged or never-existent identifier is UNKNOWN_JOB either way. A terminal job whose cgroup cleanup is still pending is not purged until the retry has run.

8.5.8 Shutdown #

When a shutdown begins, every live submitted job is stopped at once, with cause shutdown and no ordering between them or against the service waves — a job has no dependencies to order by. A job still queued for launch is cancelled with the same cause. The shutdown is not finished while a live job remains: a job's reap advances shutdown progress exactly as a service's does, and the global timeout kills every live job's cgroup along with the remaining services (§12.2). submit is refused throughout; the other four commands keep answering, because a submitter watching its jobs go down has a reason to keep looking.

8.5.9 Clients #

svctl job list|status|stop are the control socket's job commands, and svctl job submit|wait|signal speak the jobs socket, submitting as the caller's own primary token with --fd NAME=FD for descriptors and --output for a sink. libpeinit exposes the same two halves: peinit_job_status, peinit_job_list and peinit_job_stop on the control client, and <peinit/jobs.h> — a peinit_jobs_t with peinit_job_submit, peinit_jobs_status, peinit_jobs_wait, peinit_jobs_stop and peinit_jobs_signal — with peinit_response_take_pidfd to claim the handle a submit returned.

8.6 What a Caller Sees

Peios / Peios technical reference / peinit / Jobs and Operations

Operations are how a caller observes something taking effect. This article covers what the model looks like from the outside; the wire contract itself is PSPU §4.

8.6.1 Every lifecycle command returns an identifier #

A command that creates, merges into, queues, cancels, clears or executes an operation returns that operation's identifier. A caller can then poll it, or block on it.

Two cases return no identifier, because no operation exists: a command whose target is already in the state it asks for, and one that has no effect at all — a stop on an Inactive service. Both return the service's status instead of an acknowledgement, which is the honest answer, and which also says what else the caller may do to the service (§10.2).

Commands that never create an operation — status, list, operation-status — have their own shapes. So do the three job commands, which never create an operation either: a submitted job has no state machine to contend for, so job-stop acts on it directly and answers with the job view.

8.6.2 Waiting #

Lifecycle commands block by default until the operation is terminal. The exception is reload, which returns immediately unless asked otherwise, because a reload's outcome is often advisory and a caller usually wants the identifier rather than the wait.

A waiting connection is not idle and is never closed by the idle timeout, however long the operation runs. It is bounded by the operation's own lifetime instead.

8.6.3 Merging is invisible #

A caller whose command merged receives the surviving operation's identifier and blocks on that operation's outcome. Nothing tells them they merged, because there is nothing they could usefully do about it. The consequence worth knowing is that the identifier a caller gets back may be older than their request, and its created_at will be earlier than when they sent it — which is exactly right, because that is when the work they are waiting on actually began.

8.6.4 What an operation's result carries #

A completed stop or reset carries the resulting service state; a completed start carries the readiness that satisfied it — alive readiness: process started, notify readiness: READY=1, or the exit code for a Oneshot — rather than a state name. A failed one carries the failure reason; one that peinit could not execute (§8.2) carries internal_error: <cause>. A merged one carries the survivor's identifier. A cancelled or aborted one carries why.

For a reload, the result also determines the reload's mode — whether the service confirmed the reload with a READY=1, whether peinit is merely assuming it happened, or whether it outright failed.

8.6.5 The job view #

A submitted job is reported in one shape on both sockets, the job view of PSPU §7.7: identifier, type of submitted, state, cause, submitter and identity SIDs, logon session, description, image path, pid while there is a process, ready for a notify job, exit code or signal, the retained status_text and progress, and the three timestamps. Every inapplicable field is present and null, and pid is null once the job is terminal — there is no process to name. Abandoned is the exception, and the reason for it: an abandoned job's processes survived SIGKILL and are still running, so its pid is kept.

What a submitter sees of its job on the jobs socket is exactly what an administrator sees of it in job-status, but for one field the control socket adds: granted, the job rights that caller holds on it (§10.2). And job-list is a list of the same views, filtered by JOB_QUERY on each (§10.2). A job-stop with wait registers a wait on the connection like a lifecycle command's, answered with the terminal view — or UNKNOWN_JOB, if the record was purged before the answer could be sent.

8.7 Submission and Progress Examples

Peios / Peios technical reference / peinit / Jobs and Operations

This reference expands the submitted-job operator workflow. Job implementation is in §8.5; transport is in §10.7 and the submission protocol is PSPU §7.

8.7.1 What a job runs as #

There is no identity field. A submitter cannot name an identity for its job; it can only run the job as an identity it already holds:

  • Nothing attached — the job runs as the submitting process's own primary token, exactly as a child of the submitter would. This is the common case for a tool or a service running something as itself.
  • A token attached — the submitter attaches a token to the submit message, and the kernel (through KACS) gates the attach: it verifies the submitter could act as that identity, at that impersonation level, at that moment. peinit turns the attached token into the job's primary token. This is how backupd runs a backup as its client, or a logon service starts a session as the user who just authenticated — the same impersonation machinery, with the kernel as the only judge. A job whose session must convey Delegation needs the submitter to set its jobs connection to Delegation level first; a job started at Impersonation cannot be raised later.

A token below Impersonation level, or one peinit cannot duplicate, is refused with BAD_TOKEN. peinit never refuses a token because of who it names — that decision was the kernel's.

Two identities are recorded on every job, and the view shows both: the submitter (who asked — the identity the connection was made under) and the identity (who it runs as), plus the identity's logon session.

8.7.2 The definition #

A submission carries the whole job in one message: image_path (absolute; required), arguments, environment, working_directory (default /), description, timeout (seconds; 0, the default, is no limit), stop_timeout (default 10 s), readiness (none or notify, to wait for READY=1), readiness_timeout (default 30 s), success_exit_codes, a list of named descriptors to hand the job, an output flag, and optionally a security_descriptor for the job itself. There are deliberately no policy fields — no restart, dependencies, health check, or trigger. A program that needs those is a service.

peinit does not check that the program exists or is executable when it accepts the submission; that is decided at execve, by the job's own token, and a program that cannot be run becomes a failed job rather than a refused submission. The submit is answered only once the job has left created — the process confirmed its exec, or starting it failed. A readiness: notify job is answered when it is running, not when it is ready; a submitter that wants to block until readiness follows with a wait.

Descriptors a submitter attaches are handed to the job from descriptor 3 with LISTEN_FDS, LISTEN_FDNAMES and LISTEN_PID set — the same convention a service uses to adopt stored descriptors, so a program written for one adopts them from the other unchanged. This is what makes a session job possible: the submitter keeps one end of a socketpair and attaches the other.

8.7.3 The job view #

Whether you ask on the jobs socket or with job status, you get the same view: the job id, type (submitted), state and cause, submitter and identity SIDs, logon_session, description, image_path, pid (while running), ready (null for a readiness: none job, otherwise whether READY=1 has arrived), status_text, progress, the exit code or signal, and the created_at/started_at/ended_at timestamps.

status_text and progress come from the job itself, through the same sd_notify socket a service uses. A job sends STATUS=Backing up /data/media for a human-readable line and PROGRESS= in one of three forms:

FormMeaningShown as
PROGRESS=NCounting, with no end in sight.A rising count.
PROGRESS=N/Counting towards an end that exists but is not yet known.A rising count, awaiting a bound.
PROGRESS=N/TN of T.A bar.

PROGRESS_UNIT=bytes|items|percent says what the numbers are. peinit keeps the latest accepted values and emits a peinit.job.status.reported event when they change, rate-limited to at most one per job per second so a chatty job cannot flood the event stream — the view always has the latest value regardless.

9.1 Calendar Expressions

Peios / Peios technical reference / peinit / Timers

A timer schedule is a calendar expression in systemd's OnCalendar format. The grammar below is what peinit parses.

DayOfWeek Year-Month-Day Hour:Minute:Second Timezone

Every field is optional, and which fields are present is worked out positionally from their shape.

FieldFormDefault if omitted
DayOfWeekweekday namesany day
DateYear-Month-Day*-*-*
TimeHour:Minute:Second00:00:00
Secondthe :Second of Time:00
Timezonean IANA namesystem-local

A time-only expression such as 02:00:00 implies the date *-*-*. Hour:Minute alone defaults the seconds to 00.

Years range 0–9999, months 1–12, days 1–31.

9.1.1 Component syntax #

Every numeric component, and the weekday, accepts:

  • Wildcard — * matches anything.
  • List — comma-separated values: 1,15.
  • Range — two values around .., inclusive: Mon..Fri, 8..17. A reversed range is a parse error rather than a wrap-around.
  • Repetition — a value or range suffixed with / and a step. value/step matches the value and every multiple of the step above it, so 0/15 in the minute field is 0, 15, 30 and 45. start..end/step walks from start to end inclusive.

Weekdays are English names, case-insensitive, abbreviated or full, and accept lists and ranges. Mon and Monday are the same day, as are Tue, Tues and Tuesday.

9.1.2 Last day of the month #

A ~ in place of the - between Month and Day counts the day from the end of the month: ~01 is the last day, ~02 the second-to-last, and so on. *-*~01 is the last day of every month; *-02~03 is the third-to-last day of February.

Repetition combines with it, and steps in the day-of-month direction — which means it walks the offset downwards, towards the end of the month. Mon *-05~07/1 covers the last seven days of May, and combined with Mon resolves to exactly one day: the last Monday in May.

Wildcards, ranges and lists are also accepted after ~, so ~* matches every day and ~03..07 the third- to seventh-to-last.

9.1.3 Named shortcuts #

ShortcutEquivalent
minutely*-*-* *:*:00
hourly*-*-* *:00:00
daily*-*-* 00:00:00
weeklyMon *-*-* 00:00:00
monthly*-*-01 00:00:00
quarterly*-01,04,07,10-01 00:00:00
semiannually*-01,07-01 00:00:00
yearly, annually*-01-01 00:00:00

Shortcut names are case-insensitive and accept a trailing timezone, so daily UTC is valid.

9.1.4 Timezones #

Timezone specifiers are IANA database names — Europe/London, US/Eastern, UTC. An expression with no timezone is interpreted in system-local time.

A name is validated when the expression is parsed and resolved again at evaluation. An unrecognised zone is a hard parse error, not a silent fallback to UTC.

9.1.5 Daylight saving #

  • Spring forward, where the clock skips an hour: a scheduled time falling inside the skipped interval does not fire. peinit moves on to the next scheduled second, then the next date. A schedule of *-03-31 01:30 Europe/London skips 2024 entirely.
  • Fall back, where an hour repeats: a scheduled time inside the repeated interval fires exactly once, on the first occurrence. On re-arming, the same civil time resolves to the same instant, which is not later than the firing that just happened, so the timer advances to the next day rather than firing again.

9.1.6 Precision #

Second-level. Unlike systemd, peinit does not accept fractional seconds — service scheduling has no use for finer granularity, and dropping it keeps the parser simpler. A fraction anywhere in the time component is a parse error.

9.1.7 Examples #

ExpressionMeaning
*-*-* 02:00:00Every day at 2am, system-local.
Mon *-*-* 00:00:00Every Monday at midnight.
*-*-1,15 12:00:00The 1st and 15th, at noon.
*-*~01 00:00:00The last day of each month, at midnight.
Mon..Fri *-*-* 09:00:00Weekdays at 9am.
*-*-* *:00/15:00Every fifteen minutes.
*-*-* 02:00:00 Europe/LondonEvery day at 2am London time.

Note

An expression that parses but can never match — *-02-30, or a fixed year already past — is not rejected at parse time. Computing its next occurrence walks forward a day at a time for ten years and then gives up, reporting no future occurrence. The horizon is there because walking the full year range would turn an unsatisfiable schedule into a boot that hangs.

9.2 Evaluation and Arming

Peios / Peios technical reference / peinit / Timers

A timer is a trigger, not a service type. A service with a timer:<schedule> trigger is an ordinary Simple or Oneshot service that peinit starts on a schedule.

9.2.1 Arming #

At boot, once the service graph is loaded, and whenever timer configuration changes, peinit computes the next firing time for every active trigger and arms a timerfd for it. Each trigger gets its own descriptor and its own computation.

A disabled service gets neither a registration nor a firing.

A schedule that fails to parse, or whose next occurrence cannot be computed, fails that trigger. Every other timer arms normally, and what did not arm is reported to the console and in the service's status. This matches how graph validation already treats an invalid schedule (§7.4), so the outcome no longer depends on which of the two caught it.

The next-occurrence search looks ten years ahead and then gives up. A schedule can parse and still match nothing — *-02-30, or a fixed year already past — and the horizon turns that into a prompt error against the one service rather than a very long walk. Ten years clears the sparsest schedule that is genuinely meaningful: *-02-29 skips a century year not divisible by 400, so it can run eight years dry.

9.2.2 Firing #

handle_timer(service, trigger):
    // 1. Decide what the firing means, from the service's state.
    match (service.type, service.state):
        (Oneshot, Active | Starting):
            service.pending_timer = true      // at most one
        (Simple,  Active | Starting):
            record the firing; no action
        (Oneshot, Inactive | Completed | Failed):
            create_operation(Start, service, source = Timer)
        (Simple,  Inactive | Failed):
            create_operation(Start, service, source = Timer)

    // 2. Record when it fired.
    write the last-run timestamp to the registry   // asynchronously

    // 3. Re-arm.
    next = next_occurrence(trigger.schedule, now) + random(0, TimerJitter)
    arm an absolute CLOCK_REALTIME timerfd for next

Every other state — Backoff, Stopping, Reloading, Abandoned, Skipped — records the firing and does nothing.

A timer can outlive its service until the next reload. A service whose definition is deleted while it runs carries on, and is discarded when it stops, and nothing re-plans the timers then. A firing for a service no longer known does nothing, records no last run, and is not armed again; the next reload leaves it out. It is never an error: an error out of a firing takes PID 1 into recovery.

No last run is recorded for a service whose definition has been deleted, whether it is still running or already gone: there is no key to record it in, and writing one would make the key again.

The last-run write happens in a forked child so that the event loop never waits on the registry — which matters because the registry is served by registryd, a service peinit supervises, so a synchronous write would let a wedged registryd stall PID 1.

The parent returns immediately with the child's pid, and remembers it. When the child is reaped, peinit matches its exit status back to the write; a failure is reported:

peinit warning: recording the last run of timer <schedule> for service
<service> failed; it will run catch-up again after a reboot

The write stays best-effort — nothing is retried and nothing is failed over it — but a persistent timer whose timestamp never lands runs its catch-up on every boot, and that is otherwise a symptom with no thread to pull. The outstanding-write table is bounded, so a child that somehow escapes reaping cannot grow it.

9.2.3 Oneshot pending runs #

A Oneshot that fires while it is already running sets a flag rather than queueing an operation. When it next reaches Inactive or Completed, peinit immediately creates a start operation and clears the flag.

Multiple firings during one run collapse into a single pending run. There is no queue, and the flag is per service rather than per trigger — a service with three timers that all fire during one long run still gets exactly one catch-up.

9.2.4 Multiple triggers #

Triggers on one service are independent: each has its own timerfd, its own next-firing computation, and its own last-run history. Only the Oneshot pending flag is shared.

9.2.5 Reporting #

status reports every trigger of a service that is not disabled, as it stands: the occurrence armed, the time it will fire with its jitter, and when it last fired; or, for a trigger that did not arm, why not (PSPU §4.14). list carries the soonest firing of each service.

The figures are the armed ones, given to the supervisor whenever a trigger is armed: at boot, on a reload that adds or changes the timer (an unchanged timer keeps its armed deadline), after each firing and after a clock change. A client cannot compute them itself, because the jitter is drawn at random when the trigger is armed.

When a trigger last fired is held in memory as well as the registry. It is seeded at boot from the recorded timestamp of a persistent trigger, set by each firing, including a boot catch-up, and kept across a reload for the same service and schedule. A definition deleted and made again between two reloads is the same service and schedule, and keeps it too. A non-persistent trigger, which records nothing (§9.3), reports only its firings since peinit started.

9.3 Persistence

Peios / Peios technical reference / peinit / Timers

TimerPersistent, on by default, controls whether a run missed across a reboot is caught up.

9.3.1 Where history lives #

Last-run timestamps are REG_QWORD values in the registry, written after a firing.

A service with a single timer trigger stores its timestamp as LastTimerRun on the service's own key:

Machine\System\Services\<name>\LastTimerRun

A service with multiple triggers stores one per trigger under a subkey, named by the schedule string:

Machine\System\Services\<name>\TimerState\<encoded-schedule>

A schedule contains characters — spaces, :, * — that are not valid LCS value names, so the name is the schedule with every character outside [A-Za-z0-9._-] percent-encoded, with uppercase hex digits. This is the same encoding used for cgroup ids (§5.1). The schedule *-*-* 02:00:00 is stored as:

%2A-%2A-%2A%2002%3A00%3A00

Two identical schedule strings on one service encode to the same name and therefore share one timestamp.

Timer firings are infrequent, so the write cost is negligible.

9.3.2 Catching up #

On boot, for each persistent trigger:

  1. Read the last-run timestamp.
  2. Compute the next scheduled firing after it.
  3. If that time has already passed, at least one run was missed: fire once, immediately.
  4. Compute the next future occurrence normally.

Catch-up is always a single run however many were missed. A daily timer that missed five days fires once on the next boot, not five times.

A trigger with no history at all is treated the same way, so its first boot produces one catch-up firing.

TimerPersistent=0 ignores history entirely — peinit does not even read the registry for that trigger, and computes the next occurrence from now.

9.3.3 When the timestamp is written #

The timestamp is written after the timer fires and the start is initiated, not after the service finishes. A service that crashes mid-run is not re-triggered on the next boot: the run was attempted, not missed.

A configuration reload keeps an already-armed timer when its service name, schedule string, TimerPersistent, TimerJitter, and timestamp storage layout are unchanged. Its scheduled occurrence, jittered firing time, and last firing are retained, even if the occurrence has passed while its jitter is still pending. Unrelated configuration edits and a timer's own LastTimerRun writes therefore cannot postpone that firing.

New or changed timers are armed from the current time with no catch-up, whatever TimerPersistent says. Removed or disabled triggers are unregistered. History is consulted at boot only.

Note

Timestamps for a multi-trigger service are keyed by schedule string, so changing a schedule orphans its history and the new schedule produces one spurious catch-up run. A stable trigger identifier would fix it; schedule changes are rare and one extra run is the whole cost. A single-trigger service is unaffected, since its timestamp lives at a fixed name.

9.4 Jitter and Clocks

Peios / Peios technical reference / peinit / Timers

9.4.1 Jitter #

TimerJitter, zero by default, adds a random delay to each firing. peinit draws a uniformly random whole number of seconds from zero to TimerJitter inclusive, from the kernel's random source, and adds it to the computed occurrence.

The delay is recomputed on every firing, so a daily timer with TimerJitter=900 fires at a different moment between 00:00 and 00:15 each day. With TimerJitter=0 no randomness is consulted at all.

Jitter is applied after the calendar expression is evaluated and is only ever added, so a timer never fires early — only late.

The boot catch-up firing is not jittered. It fires immediately, and jitter applies from the next armed occurrence onward.

9.4.2 Which clock #

The split is the point of this section, and it is not cosmetic.

Calendar timers are wall-clock schedules, so they are armed as absolute CLOCK_REALTIME timers: timerfd_settime with TFD_TIMER_ABSTIME | TFD_TIMER_CANCEL_ON_SET. CANCEL_ON_SET makes the descriptor's read return ECANCELED whenever the realtime clock is discontinuously changed — an NTP step, a manual set. peinit recomputes the next occurrence against the new wall clock and re-arms, which is what keeps *-*-* 02:00:00 anchored to 02:00 across clock corrections.

Interval timers are genuine relative durations and use CLOCK_MONOTONIC: the watchdog, health check intervals and timeouts, restart backoff, and the Start, Stop and Reload phase timeouts. "Wait thirty seconds" means thirty elapsed seconds regardless of what happens to the wall clock. They are not armed with CANCEL_ON_SET, correctly — a monotonic timer has no reason to be cancelled by a realtime set.

These are not separate descriptors. Every interval deadline is aggregated onto one monotonic timerfd armed to the earliest of them.

Last-run timestamps are recorded on CLOCK_REALTIME, since they record when a timer actually fired in wall-clock terms. The same firing passes a monotonic timestamp into the operation machinery, because operation timing is elapsed time.

9.4.3 Clock events #

  • A realtime step at runtime. The armed timer is cancelled; peinit recomputes against the new wall clock and re-arms. A backward step pushes the next firing later; a forward step that crosses an occurrence fires it once. If the step lands inside a jitter window, the firing happens at the un-jittered scheduled time — later than the schedule, never earlier.
  • Suspend and resume. An absolute deadline that elapsed while suspended fires once on resume. The expiration count is ignored, so a long suspend produces one firing, not one per occurrence.
  • A missed occurrence within one uptime. Fire once, then compute the next future occurrence. peinit never replays every occurrence that elapsed during a gap — the same rule as the cross-reboot catch-up.
  • A backward jump across a boot. If the last-run timestamp is in the future relative to the current wall clock at boot, peinit treats the history as unknown and fires the catch-up immediately. This check is boot-time only; there is no runtime equivalent.
  • A wrong clock at boot. A system that boots with a badly wrong clock and has NTP correct it later may fire a persistent catch-up spuriously or not at all. The runtime half is covered by CANCEL_ON_SET — once NTP corrects the clock, armed calendar timers are cancelled and recomputed — but the boot-time catch-up decision has already been made by then. Short of NTP-aware rescheduling, this remains an edge.

Note

The calendar parser deserves heavy testing. Time parsing is a rich source of edge cases: month boundaries, leap years, last-day-of-month arithmetic, DST transitions and timezone database updates are all fertile ground for subtle errors, and every one of them is a bug that only appears on a particular day of a particular year.

10.1 The Control Socket

Peios / Peios technical reference / peinit / The Control Interface

peinit serves every runtime command on a Unix stream socket at /run/services/peinit/control.sock, created during Phase 1 infrastructure setup and existing for the lifetime of the system. Its wire protocol is specified in PSPU §4; this chapter is how peinit implements its side. A second socket beside it, the jobs socket, is the door for submitting jobs rather than administering services; it is §10.7.

10.1.1 Creation and protection #

The socket is created with SOCK_CLOEXEC | SOCK_NONBLOCK and a listen backlog of 32, and unlinked when peinit drops it. Accepted connections come from accept4 with both flags, so no connection descriptor is ever inherited by a service.

peinit sets no POSIX mode bits on the socket, on the notification socket, or on anything else it creates. Under KACS, mode bits are not what governs access — a Security Descriptor is — so setting them would be inert.

What governs access is the descriptor on the socket inode, which the kernel checks at connect() before any peer identity is established. peinit creates /run/services/peinit/ and stamps the control socket explicitly, after binding and before anything can connect :

O:SYG:SYD:(A;;GA;;;SY)(A;;GA;;;BA)(A;;FW;;;AU)

Every authenticated principal may connect. FW is the file-write generic right, which is what connect() on a pathname socket needs. Reaching the socket grants nothing on its own: every command is checked against the control descriptor or the target service's descriptor, with the token captured at accept (§10.2). Admitting only SYSTEM and Administrators would make that check moot for everyone else, since a descriptor that grants a principal something could never be reached by them.

The notification socket is stamped explicitly too, with a descriptor of its own that grants SYSTEM and the Service group rather than Administrators (§10.5). The jobs socket carries a broader descriptor again, because reaching it is a different permission (§10.7).

10.1.2 Connections #

peinit accepts a connection, obtains the peer's token, and only then admits it against the connection limits:

KeyDefaultMeaning
Machine\System\Init\MaxControlConnectionsPerUser16Concurrent connections one caller may hold, counted by the user SID of its token. SYSTEM is exempt.
Machine\System\Init\MaxControlConnections256Concurrent connections in all: a bound on peinit's descriptors, set well above what callers reach.
Machine\System\Init\MaxRequestSize65536Maximum request size, in bytes.
Machine\System\Init\ConnectionTimeout30Seconds before an idle connection is closed.

The per-user limit is what keeps one caller from taking the connections everyone shares: with every authenticated principal able to connect, a single overall limit would let anyone fill it and lock administrators out. SYSTEM is exempt, as it is from MaxJobsPerSubmitter, because what it does is the machine's.

A connection over either limit is closed at the socket level, before any request is read and without a response — there is no error code for it, because there is no protocol state in which to deliver one. A peer whose token cannot be obtained is closed the same way.

The jobs socket has its own three, under Machine\System\Init\: MaxJobsConnections (64), MaxJobMessageSize (32768) and JobsConnectionTimeout (30), plus a fourth bound that is the submitter's rather than the connection's, MaxJobsPerSubmitter (64). All four are read at boot and on reload-config, and are listed with the other operational keys in the registry key reference.

10.1.3 The peer token #

The token is read once, when the connection is accepted, through the kernel's peer-token socket option (getsockopt(SOL_KACS, KACS_SO_PEER_TOKEN); the Peios Kernel TRM §3.5). It is the identity the peer thread was acting under when it connected, so a peer that was impersonating is captured as the impersonated identity — which is what makes access decisions reflect the identity a client is actually operating under rather than its underlying service identity.

Because it is captured once, a peer that changes identity mid-connection is still evaluated against the identity it connected with.

10.1.4 Idle and waiting #

A connection is idle only when it has nothing in flight. One blocked on a wait=true operation, on a job-stop with wait, or with output still buffered, is never idle and is never closed by ConnectionTimeout — it stays open until the operation or job resolves, bounded by the operation's own timeout rather than the connection's. A job wait has no timeout of its own; it is bounded by the job.

A wait is answered when its operation reaches a terminal state, with the lifecycle acknowledgement carrying the service's state at that moment. Two cases are answered differently. An operation that failed because peinit could not execute it (§8.2) is answered with an INTERNAL_ERROR whose message carries the operation's result, not with an ok carrying an unchanged state. And an operation whose service has been discarded by the time it resolves — a stop that drained a definition-removed entry (§10.3) — is answered with the state the service last had, since there is no entry left to ask. Neither answer costs the connection or the listener.

peinit handles one frame per readiness turn, and processes no further frames from a connection while a wait is pending on it. Pipelined requests are therefore serialised behind a wait.

10.1.5 Timestamps #

Every timestamp peinit puts on the wire is derived by projecting a monotonic event stamp through the current offset between the realtime and monotonic clocks. Elapsed-time decisions stay monotonic; only the presentation is wall-clock.

10.2 Dispatch and Authorisation

Peios / Peios technical reference / peinit / The Control Interface

A parsed command runs a fixed sequence before it does anything.

  1. The shutdown gate. If peinit is shutting down, everything except status, list, operation-status, boot, job-status, job-list and job-stop is rejected. boot stays open because, like the service queries, it changes nothing. The three job commands stay open because a shutdown stops every submitted job anyway (§12.2), and an administrator watching that happen, or wanting a job gone sooner than the shutdown will manage, has a reason to ask. The gate runs before the access check, so during shutdown a caller who would have been denied is told the command is invalid for the current state rather than that they lack the right.
  2. Resolve the target. A command naming no definition, and no addressable definition-removed entry, returns UNKNOWN_SERVICE; one naming no submitted job peinit holds returns UNKNOWN_JOB. peinit does not synthesise a descriptor for something that does not exist.
  3. AccessCheck. The caller's token, the target's descriptor, the generic mapping, the right the command requires (§4.6, §4.7), and an audit context naming the target: the service, the job, or the control door itself (§8.4). For a job the descriptor is the job's own and the mapping is the job mapping (§8.5).
  4. On denial, return ACCESS_DENIED. The decision is recorded by KACS, as kacs.audit.access.checked naming the caller, the target and the access requested and granted, whenever the target's descriptor has a SACL that asks for it; every descriptor peinit builds itself audits every refusal. peinit writes no event of its own. Silent denial is not acceptable; a denial an administrator cannot see is indistinguishable from a bug, which is why the defaults audit, and why a descriptor written to the registry should keep a SACL that does (§4.6).
  5. On grant, classify the command against the service's state (§10.3) and act.

10.2.1 Rights #

CommandRight
startSERVICE_START
stopSERVICE_STOP
restartSERVICE_START and SERVICE_STOP, requested as one mask
reloadSERVICE_INTERROGATE
resetSERVICE_STOP
statusSERVICE_QUERY_STATUS
listFiltered per service by SERVICE_QUERY_STATUS
operation-statusSERVICE_QUERY_STATUS on the target service
job-statusJOB_QUERY on the job
job-listFiltered per job by JOB_QUERY
job-stopJOB_STOP on the job
shutdownSYSTEM_SHUTDOWN
reload-configSYSTEM_RELOAD_CONFIG
bootSYSTEM_QUERY_STATUS on the control descriptor, which by default every authenticated principal holds (§4.7)

operation-status resolves the operation before it checks the right, so an unknown identifier is reported as unknown regardless of who asked. If the operation's service has since been discarded (§3.8), the right is checked against the ServiceSecurity the service had when the operation was created, which peinit records on the operation; a retained operation therefore stays queryable for its retention window (a2) by exactly the callers who could have queried the service.

job-status and job-stop resolve the job first too, and for them that is the whole story: UNKNOWN_JOB for an identifier that names nothing, ACCESS_DENIED for one that names a job the caller may not touch. A job identifier is unguessable, so a caller that names one was told it by something that had it, and there is no existence to hide. job-stop creates no operation; it stops the job directly (§8.5) and, with wait, registers a job wait on the connection answered by the terminal view.

10.2.2 What the caller may do #

A status response carries granted, the service rights the caller holds on the service, so that a client can offer only the commands that will be allowed rather than learn the rest from ACCESS_DENIED (PSPU §4.14). peinit finds them with step 3's AccessCheck — the caller's token, the service's ServiceSecurity, the service mapping — asking for MAXIMUM_ALLOWED instead of one right, once the query itself has been allowed, and reports the service rights among what it grants. A lifecycle command that has nothing to do answers with the status (§10.3), and that carries granted for its caller too.

Each job view on the control socket carries granted the same way: the job rights the caller holds, from the AccessCheck against the job's own descriptor with the job mapping (§8.5). job-stop with wait finds them when it is taken and keeps them on the connection's wait, because the terminal view is written by the run loop with no caller's token to hand; the descriptor is fixed at submission, so they are the same as at the answer. The jobs socket's view has no granted.

The MAXIMUM_ALLOWED check is a question, not a command. A right it does not grant is left out of granted; nothing is refused, and peinit answers the command as it would have without it.

10.2.3 Filtering #

list checks every service and partitions the result. Services the caller can query are returned; services it cannot are omitted, and the denials are left to each service's SACL rather than shown to the caller. A caller with no query rights anywhere gets an empty list and a successful response, not a denial . The filtering keeps a service's state from whoever may not query it; it does not hide that the service exists, which step 2 above already answers for any command that names one (§4.6).

Definition-removed services are listed, and the list entry does not say so. A status query on one does.

job-list is filtered the same way. The four request filters — submitter, identity, logon_session, state — narrow the candidates first; each survivor is then checked for JOB_QUERY against its own descriptor, each denial is the job's SACL's to record, and the response does not say whether a filter or a right removed an entry. Terminal jobs still within their retention are listed; a caller that wants only live ones filters by state.

10.3 The Command × State Matrix

Peios / Peios technical reference / peinit / The Control Interface

A command sent to a service in an unexpected state gets an answer, not a silent no-op. What the answer is depends on the pair.

InactiveStartingActiveReloadingStoppingCompletedBackoffFailedAbandonedSkipped
start StartMERGEALREADYALREADYQUEUEStartDEFERStartERRORStart
stop NOOPCancel+StopStopStopMERGEClearClearNOOPERRORNOOP
restart StartQUEUERestartRestartQUEUEStartRestartStartERRORStart
reload ERRORERRORReloadMERGEERRORERRORERRORERRORERRORERROR
reset NOOPERRORERRORERRORERRORERRORERRORClearClearClear
status OKOKOKOKOKOKOKOKOKOK

ALREADY — the service is already where the command would take it and no operation of that type is in flight. peinit returns the current status rather than an error.

MERGE — an operation of that type is already running. The command merges into it; the caller receives that operation's identifier and, if waiting, blocks on its outcome.

DEFER — create a Pending start operation but do not execute it until the existing backoff deadline expires. A deferred start already present is merged into.

QUEUE — the operation is queued Pending and executes after the current one completes.

NOOP — the command has no effect. peinit returns the status.

ERROR — the command is invalid for the state.

Clear — reset to Inactive.

Cancel — abort the current operation, then proceed.

10.3.1 The Backoff column #

Backoff is the interesting one, because the service is down with an automatic restart already pending.

  • start creates or merges into a deferred start operation and honours the remaining delay. It does not short-circuit the backoff. If the automatic restart later becomes due, it merges into the administrator's operation, so the identifier the caller holds is the one that executes.
  • stop cancels both the pending restart and any deferred start, and the service goes Inactive. A subsequent automatic restart is refused, because the service is no longer in Backoff.
  • restart replaces the automatic restart with the administrator's. A Restart operation with source admin is queued Pending and honours the remaining delay exactly as a deferred start does; when the delay expires it is that operation — keeping its type, for observability — that executes, so the identifier the caller holds is the one that runs. A deferred start already pending is cancelled with reason superseded_by_restart, and a second restart merges into the pending one rather than queueing behind it. Nothing is sent to the control boundary, because there is no process for it to act on.
  • reload and reset are invalid: there is no process to reload, and no terminal state to clear.

10.3.2 The Skipped column #

start and restart clear Skipped before they run. A Skipped service is not in a state a start can proceed from — the state machine permits Skipped -> Inactive and nothing else — so the activation performs that transition first, then re-evaluates the conditions from scratch. Both outcomes are possible: the precondition that was missing at boot may now hold, in which case the service starts; or it may still not, in which case the service is skipped again, for whatever reason applies now.

The clear is reported like any other transition, so a console watching the service sees it leave Skipped rather than appearing to jump: peinit: service X left skipped: ExplicitStart, before the start's own lines.

reset also clears Skipped, and differs only in stopping there.

10.3.3 The Abandoned column #

Every lifecycle command is invalid on an Abandoned service except reset, which clears it (§6.2). Nothing else is meaningful while processes that ignored SIGKILL are still in the cgroup.

10.3.4 Definition-removed services #

Independently of state, a service whose definition has been removed (§3.8) rejects start, restart and reload with UNKNOWN_SERVICE, accepts stop, and reports its state on status.

The accepted stop drains the instance with the cached StopTimeout, and the exit that ends it also discards the entry. A caller waiting on that stop is answered after the discard, with the state the service last had — Inactive, cause ExplicitStop — rather than with a lookup of an entry that no longer exists. A status sent after that is UNKNOWN_SERVICE, as for any service peinit does not know.

10.4 reload-config

Peios / Peios technical reference / peinit / The Control Interface

reload-config takes a fresh snapshot of the configuration from the registry. It does not live-update anything.

It is also the path a registry change notification takes: any drained watch event triggers the same full reload, rather than a targeted re-read of whatever changed.

10.4.1 Atomicity #

peinit reads everything first. Every registry read happens before any mutation, so a read failure returns an error with nothing touched. It then builds and validates a complete new graph in memory, and only swaps it in if validation succeeds.

If validation fails, the previous generation stays live and the findings are returned to the caller. This is where the reload path differs sharply from boot: boot marks individual services Failed and carries on, because it has to produce a running system; reload rejects the whole thing, because it has a running system already and a half-applied configuration would be worse than the one in place.

An explicit reload-config writes one peinit.config.reload.applied event either way: outcome.success, and on success the counts of what it added, updated, restored, marked removed, discarded and found undecodable; on failure why, in words, and one peinit.graph.validation.failed per finding under phase reload-config (§8.4). A reload a registry watch starts writes its findings and warnings but no peinit.config.reload.applied.

Decoding does not split: reload decodes per key exactly as boot does. A key that will not decode fails that one service with ValidationError — a placeholder entry, marked definition-removed, that status reports and nothing can start — and every other definition in the batch loads; a dependent of the failed key fails through ordinary propagation when it is next started. A service that is running when its key stops decoding keeps running with its definition marked removed, as if the key had been deleted. The reload succeeds, lists the keys in summary.undecodable, and names each key, the offending field and the problem in undecodable; each is also a peinit.graph.validation.failed event with outcome.reason validation-error under phase reload-config. A key whose name is not a service name (§3.1) is undecodable in the same way, reported under its raw name with field: name. Repairing the key restores the service on the next reload.

10.4.2 What changes #

  • Every service definition is re-read.
  • A new dependency graph is built and validated.
  • Running services are unaffected and continue on their activation generation.
  • New definitions take effect at the next start, restart, or trigger.
  • New services become available for start immediately.
  • Timer triggers are re-evaluated. Unchanged calendar timers retain their armed occurrence and jittered deadline (§9.3); new or changed timers are armed from the current time with no catch-up.

A reload also refreshes things that are not service definitions: the control descriptor, the four control socket limits, the log configuration, the shutdown settings, the global environment layer, and the eventd log socket path. It also prunes the fd stores of services that no longer exist.

10.4.3 Removals and the compiled-in service #

A definition that has disappeared is handled by §3.8 — discarded if nothing is running, marked definition-removed if something is.

registryd is exempt. Its compiled-in definition survives a reload that does not mention it, and its provenance survives a registry entry that shadows it. peinit started registryd before the registry existed, and a reload finding no definition for it cannot conclude that it should stop being managed.

10.5 The Notification Socket

Peios / Peios technical reference / peinit / The Control Interface

peinit binds one Unix datagram socket for service notifications, by default at /run/services/peinit/notify.sock. Its path is an implementation detail: services receive it through NOTIFY_SOCKET and nothing hardcodes it. The kernel command line can override it with peios.notifysocket=. peinit creates the socket's directory only when it lies under /run; an overriding path anywhere else must have a parent that already exists. /run/services/peinit itself is created whichever path is in use.

There is one socket, not one per service, and the bind unlinks any stale path first.

10.5.1 The descriptor #

O:SYG:SYD:(A;;GA;;;SY)(A;;FW;;;S-1-5-6)

S-1-5-6 is the Service group, which every token minted for a service logon carries.

It is written out rather than as its SU alias, and that is deliberate rather than an oversight. The alias table lives in libpeios, which is a separately versioned package that nothing here declares a minimum version of — so an image pairing a peinit that writes SU with a libpeios that predates the alias parses this descriptor, fails, and takes PID 1 into recovery before a console exists to say why. That is not hypothetical: it is what happened the first time this descriptor was written. A literal SID has been understood by every version there has ever been. That is the right grantee because it is the population with something to say here: membership follows from having been started as a service rather than from which account a process runs under, so an ordinary user process cannot acquire it however it was launched.

FW is the file-write generic right, which is what a write to a pathname socket needs — the same reasoning as the jobs socket (§10.7). Administrators are absent, unlike the control socket: an administrator has no business asserting that a service is ready.

The descriptor is installed by path, immediately after bind — before the socket is made non-blocking, given SO_PASSCRED, or polled.

It is by path because the fd form does not work on a socket. KACS exposes no fd-targeted descriptor call of its own: fd_set_sd is the path call with the target as dirfd, an empty path and AT_EMPTY_PATH, and the kernel answers EOPNOTSUPP for a socket. Stamping by fd was tried here and took PID 1 into recovery.

That leaves a window between bind and the stamp in which the socket carries what it inherited. It is safe for the same reason it is on the control socket: the parent grants services traverse and nothing else and carries no inheritable ACE, so the inherited descriptor is narrower than the one being installed. The failure direction is refusal, not exposure.

The parent directory /run/services/peinit carries a descriptor of its own, shared with the control socket that lands in it later in boot: SYSTEM and Administrators in full, and S-1-5-6 traverse only. One constant serves both because ensure_directory re-stamps a directory that already exists — two call sites installing different descriptors on one path would silently leave whichever ran last, and since the notify socket is bound first, that would be the control socket's, quietly removing the traverse services need to reach notify.sock at all.

The protocol itself is PSPU §4. What follows is how peinit decides whether to believe a datagram.

10.5.2 Authenticating a sender #

SO_PASSCRED is enabled on the socket, so every datagram arrives with a kernel-attested SCM_CREDENTIALS control message. A datagram without one is rejected outright. Descriptors for the fd store arrive alongside, as SCM_RIGHTS.

Authentication then runs five steps, and each closes a hole the previous one leaves:

  1. Find the sender. Scan for the current service-main job whose PID equals the sender's. Only a main job is ever a candidate, which is what makes NotifyAccess=Main the only mode there is — a hook or a health check cannot notify on a service's behalf. If no service's main job carries the PID, the live submitted jobs (§8.5) are scanned instead. The routing question is asked purely by PID, before either door verifies anything, so that a sender is verified against its pidfd exactly once whichever way it is routed.
  2. The job is Running. A job still in pending setup has not exec'd.
  3. The job has a pidfd.
  4. The pidfd still refers to that PID. This is the step that matters: PID matching alone is racy, because a PID can be recycled between the sender writing and peinit reading. The pidfd was obtained atomically at fork, so verifying the PID against it is what makes the match sound rather than probable.
  5. The generation matches. A job whose activation generation is not the service's current one is a previous incarnation, and its notifications are rejected. This is invariant 5 of §6.1 in force: a READY=1 from the process that just crashed cannot mark its replacement ready. A submitted job has no generation and no replacement, so for it the check stops at step 4.

Anything that fails is dropped and recorded as a peinit.notify.rejected event carrying the sender's PID (subject.process.pid), the sender's service, job and activation when peinit could attribute it, and the reason as one of a closed set of words in outcome.reason — unauthenticated-sender, generation-mismatch, malformed-line and so on (§8.4).

The UID and GID in the credentials are parsed and never used. They are not policy inputs, and peinit does not consult them for anything — identity on Peios is a token, and the token here is established by which job the sender is, not by what UID it claims.

10.5.3 Applying a datagram #

A datagram may carry several newline-separated lines, and peinit applies every one. Parsing happens before application and is all-or-nothing: if any line is malformed, nothing from that datagram is applied, and any descriptors it carried are dropped and closed. Partial application of an ambiguous service-control message is structurally impossible rather than merely avoided.

A rejection is recorded after authentication, so the event can name the service where one could be established.

10.5.4 What the fields do #

Most are handled elsewhere: READY=1 and RELOADING=1 in §6.5, WATCHDOG=1 and WATCHDOG_USEC and EXTEND_TIMEOUT_USEC in §6.6, STOPPING=1 in §12.2, and the fd store fields in §10.6. What each does to a submitted job — and which are ignored for one, since a job has no reload, watchdog or store — is in §8.5.

Six are event-emitting. STATUS=, ERRNO= and EXIT_STATUS= are authenticated and then emitted as KMES events — peinit.notify.status.reported, peinit.notify.errno.reported, peinit.notify.exit-status.reported — whose payloads carry the sender as subject.service.name, subject.job.guid, subject.job.activation-generation and subject.operation.guid, alongside the value: notify.status; notify.errno, the number the service sent, negated as every errno field is; notify.exit-status. A value that is not a number is left out and the event still written. They take the same path as job and operation events, not a forward to eventd. peinit.notify.status.reported is a verbose type, written only when the emission policy turns it on (§8.4).

STOPPING=1 emits peinit.notify.stopping.reported, carrying the same attribution and no value. It is there because the field's only effect is the absence of an action — peinit suppresses the SIGTERM (§12.2) — and an absence cannot be inferred from what happened afterwards. Without the event, a service that was stopping and correctly received no SIGTERM looks identical to one that should have received it and did not.

PROGRESS= and PROGRESS_UNIT= emit peinit.notify.progress.reported (a verbose type), carrying the same attribution and the progress as retained after the datagram — notify.progress.current, .total, .bounded and .unit, the fields of a submitted job's peinit.job.status.reported. A datagram carrying either yields one event, not one per line, and at most one a second for each activation: a datagram inside the second updates what a status query reports and emits nothing, so a service updating a thousand times a second produces one event a second.

READY=1 and RELOADING=1 emit nothing, deliberately: both are observable through the state transitions they cause.

STATUS= is additionally stored on the service's runtime state and exposed as status_text in a status query. It is cleared to null at the start of every activation generation, in the same step that increments the generation, so a status string cannot survive a restart and describe a process that no longer exists.

PROGRESS= and PROGRESS_UNIT= are stored the same way and exposed together as progress in a status query, in the form a submitted job's view gives it: {current, total, bounded, unit}, or null until the service sends a PROGRESS=. Each is parsed as for a submitted job (§8.5): a value outside PROGRESS='s three forms, a T of zero, an N above T, or a unit other than bytes, items or percent is dropped, never repaired, and the rest of the datagram is applied. Each is replaced only by a datagram that carries it. Both are cleared with status_text, in the same step.

ERRNO= and EXIT_STATUS= are not stored. They are emitted and otherwise not retained.

10.5.5 Bounds #

A datagram is read into a fixed 64 KiB buffer, and the control message buffer is sized for 64 descriptors. Both MSG_TRUNC and MSG_CTRUNC are inspected on the way in, and a datagram that overran either buffer is refused as truncated rather than acted on in part: a larger datagram is not applied from its surviving prefix, and a send carrying more than sixty-four descriptors is not applied from the ones that fitted.

10.6 The Fd Store

Peios / Peios technical reference / peinit / The Control Interface

The fd store lets a service keep file descriptors across its own restart. It pushes them to peinit, peinit holds them, and the new process gets them back. That is what lets a stateful daemon — a web server holding a listening socket, say — restart without dropping connections it has already accepted.

FdStoreMax in the definition sets the maximum number of descriptors peinit will hold. It defaults to 0, which disables the store : most services do not need it, and holding descriptors on behalf of a service that will never ask for them back is pure cost.

10.6.1 Storing #

When an authenticated datagram carries FDSTORE=1 with descriptors attached:

  1. If FdStoreMax is 0, peinit logs the rejection and closes the descriptor.
  2. If the store already holds FdStoreMax entries, peinit logs the rejection and closes the descriptor. The existing store is not modified — a full store does not evict.
  3. FDNAME=<name> names it; an absent or empty name means stored.
  4. FDPOLL=0 marks the descriptor exempt from poll monitoring.

Either rejection emits a peinit.fd-store.rejected event carrying the service, the descriptor's name and the reason (disabled or full), so a service whose descriptors are being silently dropped can find out why.

Several descriptors may share a name. One FDSTORE=1 carrying N descriptors creates N entries under the one name, each independently subject to the limit — so the first few fit and the overflow is rejected and closed.

peinit does not monitor stored descriptors. The FDPOLL flag is recorded and nothing reads it, so no stored descriptor is evicted for becoming invalid.

10.6.2 Removing #

FDSTOREREMOVE=1 with FDNAME=<name> removes every descriptor of that name and closes them. A name matching nothing is a no-op rather than an error.

FDSTOREREMOVE=1 without a name aborts the whole fd-store step for that datagram — so a datagram carrying both an unnamed remove and an FDSTORE=1 performs neither, and the attached descriptors are dropped and closed.

10.6.3 Injecting #

When a service restarts, peinit injects the stored descriptors during the child's pre-exec path (§5.4):

  1. They are placed consecutively from SD_LISTEN_FDS_START — descriptor 3 — upward, with close-on-exec cleared: the only sanctioned exception to the close-on-exec discipline.
  2. LISTEN_FDS is set to the count.
  3. LISTEN_FDNAMES is set to a colon-separated list of names in the same order as the descriptor numbers.
  4. The store is cleared. peinit no longer holds them.

All three variables are omitted entirely when the store is empty. LISTEN_PID, which a conforming client checks against its own PID before trusting LISTEN_FDS, is appended by the child itself after the clone, since only then is the PID known ; a value for it from either configurable environment layer is discarded so that it cannot shadow the real one (§5.5).

A submitted job receives its attached descriptors by exactly this path (§8.5).

A listening socket that comes back this way still conveys the identity captured when the previous instance called listen() on it. peinit does not touch that : a service that wants connecting clients to see the instance actually accepting restamps the returned listeners itself (peios_socket_restamp, the Peios Kernel TRM §3.5) before it starts accepting.

Injection happens for the main process only. Hooks and health checks never receive stored descriptors.

The store is cleared on a successful injection, at the top of the started-launch handling. A launch that fails does not clear it, so the descriptors survive a failed attempt and are available to the next one.

10.6.4 Clearing #

The store is cleared, and its descriptors closed, when:

  • The service is stopped explicitly — by an administrator, or by shutdown. The distinction peinit draws is the operation's type and source together: an administrator's stop clears, and a restart-policy-sourced stop does not. The service is not coming back from an explicit stop, so the descriptors are no longer useful.
  • The definition is removed and its entry finally discarded (§3.8), immediately if nothing was running and on the instance's exit otherwise.

It survives an automatic restart — crash, restart policy, new start — which is the entire point. The descriptors persist through exactly the restart the service did not choose and cannot prepare for.

Note

The LISTEN_FDS and LISTEN_FDNAMES convention is systemd's, so software already written to receive descriptors that way works unmodified. peinit's own C client library exposes the notification helpers but no descriptor-passing helper, so reaching the store from that library means constructing the control message by hand.

10.7 The Jobs Socket

Peios / Peios technical reference / peinit / The Control Interface

peinit serves job submission on a second Unix socket at /run/services/peinit/jobs.sock, created in Phase 1 infrastructure setup beside the control socket (§2.3) and existing for the lifetime of the system. Its wire protocol is PSPU §7; this article is how peinit implements the transport. What a submission is — identities, the definition, the job's life — is §8.5.

10.7.1 Why a second socket, and why sequenced packets #

The control socket's descriptor admits the principals allowed to change the system's services. Submitting a job is a different permission held by a different population, and putting the two on one socket would force one descriptor to serve both. Two sockets let the filesystem say who may submit and who may administer, separately, with no policy inside peinit.

The socket is SOCK_SEQPACKET rather than a stream because a message on it carries more than bytes: the token the job is to run as, and the descriptors it is to be given, as ancillary data. The kernel ties ancillary data to the record it was sent with, and a sequenced-packet socket makes one send one message and one identity, so peinit never has to decide which request an attached token or descriptor belongs to. The cost is that a message has a maximum size and cannot be split; a definition is small.

10.7.2 Creation and protection #

The listener is created with SOCK_CLOEXEC | SOCK_NONBLOCK and a backlog of 32; a stale path is unlinked before the bind, and connections are accepted with accept4 under the same flags. After binding, and before anything can connect, peinit stamps the socket inode :

O:SYG:SYD:(A;;GA;;;SY)(A;;GA;;;BA)(A;;FW;;;AU)

FW is the file-write generic right, which is what a Unix connect() on a pathname socket needs, so every authenticated principal may connect and SYSTEM and Administrators may additionally change the descriptor. Being able to connect is the permission to submit. The kernel checks this descriptor at connect(), and peinit performs no access check of its own before a submit ; an administrator who wants a narrower or wider population changes the descriptor on the socket, not anything in peinit. What a submitter may then do to a job is decided by the job's own descriptor (§8.5), never by this one.

A failure to bind or stamp the socket sends peinit to recovery, as the control socket's does: a system whose jobs door is missing is administrable, but one whose door exists with the wrong descriptor is either unreachable or open to everything, and neither is a state to boot into.

10.7.3 Connections #

The listener is one event source of the runtime loop; each accepted connection is another, keyed by its descriptor. On accept peinit captures the peer's identity once — the peer token, as the control socket does (§10.1), and the peer's process handle through SO_PEERPIDFD — and only then admits the connection against the limit :

KeyDefaultMeaning
Machine\System\Init\MaxJobsConnections64Concurrent connections.
Machine\System\Init\MaxJobMessageSize32768Maximum message content, in bytes. Half of KMES MaxEventSize's default, so a job whose record fills a message still has its peinit.job.ended fit the ring (§8.4).
Machine\System\Init\JobsConnectionTimeout30Seconds before an idle connection is closed.
Machine\System\Init\MaxJobsPerSubmitter64Live jobs one submitter SID may hold; SYSTEM exempt. See §8.5.

A connection over the limit, or one whose peer cannot be identified, is closed at the socket level without a response. The pidfd is held for the life of the connection, because a submit with no token attached opens the job identity through it (§8.5).

A connection carries an identity and nothing else. Closing it does not affect any job submitted on it: a job belongs to its submitter's identity, and a submitter that reconnects finds its jobs where it left them.

10.7.4 Messages #

Every message is received with room for one attached token and for 64 descriptors, the output sink included. One token is all a message can carry: the kernel refuses a second KACS_SCM_TOKEN control message at send time, so the "more than one attached token" refusal in PSPU §7.5 is enforced before peinit ever sees the record. Two truncations are told apart. Content the kernel truncated (MSG_TRUNC) is REQUEST_TOO_LARGE, and that closes the connection, since the transport has lost a record. Ancillary data the kernel could not fit (MSG_CTRUNC) is INVALID_ARGUMENTS, with the connection kept: the content is intact, but a request whose attachments were partly lost does not describe the job the submitter meant, and peinit does not act on it. Every descriptor a message carried that is not handed to a job or adopted as a sink is closed, on every path.

A response is one compact JSON object, no terminator. A submit answered with a running job carries a duplicate of the job's pidfd as SCM_RIGHTS on the response record; nothing else carries ancillary data.

Only REQUEST_TOO_LARGE closes the connection after an error. Every other error is answered and the connection kept.

10.7.5 The turn #

peinit handles one message per readiness turn on a connection, and reads nothing from a connection while a wait is pending on it, so pipelined messages serialise behind a wait. A turn either answers at once, or records a pending wait on the connection state, or does both in the case of a refusal. Three waits exist:

WaitSet byAnswered when
SubmitsubmitThe job leaves created: exec confirmed, or the launch failed.
WaitwaitThe condition holds — terminal, or ready-or-terminal.
Stopstop with waitThe job is terminal.

Waits are flushed after the supervisor's work has been committed each turn, and again whenever a job's state moves — a launch, a reap, a cancelled stop. The flush answers every satisfied wait with the job view at that moment, and for a Submit whose job is running, the pidfd. An answer that cannot be built — a job whose record was purged before the flush, or a pidfd that cannot be duplicated — is that connection's error record, UNKNOWN_JOB or INTERNAL_ERROR; it never aborts the flush of every other wait.

A connection with a pending wait, or with a response still queued, is not idle and is never closed by JobsConnectionTimeout. A wait has no timeout of its own; it is bounded by the job, and a submitter that needs a bounded wait polls status or waits on the pidfd it was given.

10.7.6 Commands #

submit, status, wait, stop and signal, exactly as PSPU §7.8 defines them and §8.5 implements them. Every one but submit names a job and is checked against that job's descriptor with the connection's token; a denial is answered ACCESS_DENIED, and recorded by KACS as kacs.audit.access.checked naming the job when the job's descriptor audits it, as a default descriptor does (§8.4).

10.7.7 Idle and shutdown #

Idle connections are closed before and after each wait of the event loop, and the next idle deadline is folded into the loop's wait timeout. During shutdown the socket stays open: submit is refused with INVALID_STATE, and the other four commands keep answering (§12.2).

10.7.8 Timestamps #

As on the control socket (§10.1): monotonic stamps projected through the current realtime offset at the moment of answering.

10.8 The Boot Query

Peios / Peios technical reference / peinit / The Control Interface

boot tells a client how the machine booted, so that a settings app or an administrator at a shell can see that it is in Safe mode, why, and how many more failed boots stand between it and recovery. The wire shape is PSPU §4.15. This page is where peinit gets each answer.

It takes no arguments and changes nothing. It is checked against the control descriptor for SYSTEM_QUERY_STATUS, which the default grants every authenticated principal (§4.7), and it is answered during shutdown (§10.2).

10.8.1 Mode and reason #

mode is the mode Phase 2 booted in, after any downgrade: full or safe (§2.6). A Recovery boot never reaches the runtime — peinit runs the recovery shell instead (§2.8) — so there is no control socket to ask, and boot never answers recovery.

reason says why.

reasonWhen
normalA Full boot.
requestedA Safe boot asked for with peios.safemode=1.
safe_mode_downgradeA Full boot that Phase 2 downgraded to Safe: a Critical service in a dependency cycle, or in an unresolvable conflict, at boot (§2.6).

For a downgrade, downgrade carries every finding that forced it, in the words the console line and the peinit.boot.downgraded event use — all of them, not the first. peinit keeps them from the boot plan, the one place they survive the Safe-mode rebuild.

10.8.2 Attempts #

attempts is the boot attempt count (§2.7) as Phase 1 checked it against the recovery threshold: the value in /.peinit/boot-attempts before this boot's increment, or 0 if the increment could not be written, which is how Phase 1 treats a counter it cannot advance. It is the count at the start of this boot, so it does not drop to 0 when this boot is confirmed; confirmed says that.

max_attempts is the threshold: peios.bootattempts=N, or 3 when the command line does not set it. 0 means the check is off.

10.8.3 Confirmation #

A boot is confirmed when it has counted as a success: every Critical service has held a dependent-satisfying state for BootSuccessGrace seconds without a break, and peinit has reset the counter to 0 (§2.5). grace_seconds is that grace. The other fields say where the boot stands.

confirmedwaiting_onconfirms_atconfirm_errorMeaning
falsethe Critical services not holdingnullnullThe boot is waiting for them.
falseemptya timenullEvery Critical service holds; if they keep holding, the boot counts then.
trueemptynullnullThe boot has counted, and the counter is 0.
falseemptynullwhyThe grace ended but the counter could not be reset. The next boot will count this one as a failure.

confirmed is true only once the reset has been written. A grace that ends with the write failing — a full disk, say — is not a confirmed boot: the file still holds this boot's increment, and the next boot will count it, whatever its services did. The failed reset is not otherwise reported, so this is where an administrator finds it.

A confirmed boot stays confirmed. A Critical service that fails later is handled by the ordinary Critical failure path — restart budget, then reboot (§2.6) — not by un-counting the boot.

A boot with no Critical services holds from the moment Phase 2 runs, so confirms_at is known at once.

10.9 Control Client Examples

Peios / Peios technical reference / peinit / The Control Interface

These examples connect the svctl operator guide to the control interface. PSPU §4 owns the wire contract; the socket and dispatch mechanisms are in §10.1 and §10.2.

10.9.1 How the control interface works #

The socket speaks newline-delimited JSON: one request object per line, one response object per line. A request and its success response look like this:

{"command": "start", "service": "jellyfin", "wait": true}
{"status": "ok", "operation_id": "a1b2c3d4-...", "service": "jellyfin", "state": "active", "cause": "explicit_start", "warnings": []}

Two properties are worth knowing even if you only ever use svctl:

  • Every command is access-controlled. By default, anyone who is signed in may connect. When you connect, peinit captures your token from the kernel and runs AccessCheck against the target service's descriptor for every command. There is no "trust localhost," no override. Who may do what is the subject of Who can manage a service.
  • Lifecycle commands create operations. A start/stop/restart/reload/reset returns an operation_id — a GUID you can poll. Conflict resolution between concurrent commands happens at the operation layer, which is why two simultaneous starts merge instead of colliding.

10.9.2 Reading status #

status returns the full picture for one service:

{
    "status": "ok",
    "service": "jellyfin",
    "display_name": "Jellyfin",
    "description": "Media server for the living room.",
    "state": "active",
    "cause": "explicit_start",
    "status_text": "Listening on port 8096",
    "progress": null,
    "current_job": {"id": "a1b2...", "type": "service_main", "pid": 1234, "started_at": "...", "identity": "jellyfin-svc"},
    "current_operation": {"id": "e5f6...", "type": "start", "source": "admin"},
    "health": "healthy",
    "uptime_seconds": 86400,
    "definition_removed": false,
    "warnings": [],
    "timers": [],
    "granted": ["query_status", "start", "stop", "interrogate"]
}
  • display_name and description are the definition's, or null.
  • state and cause are the lifecycle pair — read them together.
  • status_text is the latest STATUS= string the service sent via sd_notify (null if never sent; cleared on each restart).
  • progress is how far the service last said it had got, from PROGRESS= and PROGRESS_UNIT=: {"current": 3, "total": 10, "bounded": true, "unit": "items"}, with total and unit null when it didn't say them. null if it never sent one; cleared on each restart.
  • current_job and current_operation are the job and operation GUIDs, or null.
  • health is healthy, unhealthy, unknown, or null (no health check).
  • definition_removed is true when the definition was deleted but an instance is still draining.
  • warnings lists leaked sub-cgroups and other operator-relevant notices.
  • timers has one entry for each timer trigger, described below.
  • granted is what you may do to this service, as peinit checks it: the rights the service's permissions give you, out of query_status, start, stop and interrogate (reload). A program shows a control only where its right is listed, rather than offering one that will be refused.

Each entry in timers describes one schedule as peinit has it armed:

{"schedule": "*-*-* 02:00:00",
 "scheduled_at": "2026-06-02T02:00:00.000000000Z",
 "fires_at": "2026-06-02T02:07:12.000000000Z",
 "last_fired_at": "2026-06-01T02:03:40.000000000Z",
 "not_armed": null}
  • scheduled_at is the schedule's next occurrence.
  • fires_at is when the timer will actually fire. It is later than scheduled_at by the random delay TimerJitter drew for this firing.
  • last_fired_at is when it last fired, or null if it has not fired since peinit started and has no recorded run.
  • not_armed is set only for a schedule peinit refused, such as one that never comes round. It gives the reason, and the times are then null.

Times are in UTC. svctl status prints them to the second:

$ svctl status logrotate
logrotate: inactive
cause: clean_exit
timers:
  *-*-* 02:00:00
    next: 2026-06-02T02:00:00Z, firing at 2026-06-02T02:07:12Z after jitter
    last fired: 2026-06-01T02:03:40Z

list returns a compact summary of every service you can query — services you lack SERVICE_QUERY_STATUS on are simply omitted, not denied:

{"status": "ok", "services": [
    {"service": "jellyfin", "state": "active", "cause": "explicit_start", "health": "healthy", "next_timer_at": null},
    {"service": "logrotate", "state": "inactive", "cause": "clean_exit", "health": null, "next_timer_at": "2026-06-02T02:07:12.000000000Z"}
]}

next_timer_at is the soonest fires_at of the service's timers. svctl list shows it in a NEXT TIMER column.

operation-status returns one operation by GUID; an unknown or expired GUID is the UNKNOWN_OPERATION error. (Operations are dropped after a short retention grace once terminal — long enough for a polling client to read the result, not forever.)

job status returns one submitted job's view under "job", and job list returns the views you can query under "jobs":

{"status": "ok", "job": {
    "id": "5f2a...", "type": "submitted", "state": "running", "cause": null,
    "submitter": "S-1-5-21-...-1001", "identity": "S-1-5-21-...-1001", "logon_session": 999,
    "description": "nightly backup", "image_path": "/usr/bin/backup", "pid": 4521, "ready": null,
    "exit_code": null, "exit_signal": null,
    "status_text": "Backing up /data/media", "progress": {"current": 3, "total": 5, "bounded": true, "unit": "items"},
    "created_at": "...", "started_at": "...", "ended_at": null
}}

The fields are explained in Jobs and operations. A job is retained for 60 seconds after it ends; after that its GUID is the UNKNOWN_JOB error. Like list, job list omits the jobs you lack JOB_QUERY on rather than denying them.

11.1 Wiring

Peios / Peios technical reference / peinit / Output Handling

peinit is not a logging system — eventd stores, indexes and queries logs. But peinit holds the pipes at birth. It decides where a service's output goes, and it has to cover the window before eventd exists.

11.1.1 The pipes #

Before exec, peinit creates a pipe for stdout and one for stderr with pipe2(O_CLOEXEC). The child's streams are redirected onto the write ends; peinit keeps the read ends and watches them with epoll.

The blocking discipline is asymmetric, deliberately :

  • The parent's read ends are non-blocking. PID 1 cannot afford to block on a read.
  • The child's write ends keep ordinary blocking semantics.

That second half is what makes backpressure work. When a service produces faster than peinit consumes, the kernel pipe buffer fills and the service's own write() blocks, so the service slows down. Making the write ends non-blocking would turn that into EAGAIN failures in the service, converting a flow-control mechanism into an error the service has to handle.

The child's stdin is redirected to /dev/null. peinit provides no interactive input channel; a service needing input obtains it explicitly — through a socket, or a stored descriptor — never through inherited stdin.

The epoll instance itself is close-on-exec and is never inherited.

11.1.2 Tagging #

peinit reads output line by line and tags each line with:

  • the origin,
  • the stream — stdout or stderr,
  • a CLOCK_REALTIME timestamp,
  • the job's identifier.

The origin is the service name for a main process. For a hook it is the service name, the hook kind and its index — jellyfin/ExecStartPre[0]. Reload commands are <service>/ExecReload and health checks <service>/HealthCheck. A submitted job, which has no service, is jobs/<guid>.

Health check output is captured, which is usually the only way to find out why a health check failed.

11.1.3 The output sink #

A submitted job's output is recorded like any other's; that is unconditional, and eventd is always the primary destination. A submitter may additionally ask for a copy by attaching a descriptor with output: true (§8.5). When the job's pipes are registered, the runtime adopts that descriptor as the job's sink: it is put in non-blocking mode, and every line read from either pipe is written to it as the line followed by a newline, untagged, in the order the lines were read. The two streams interleave; the sink gets what the job wrote and nothing else.

The copy is best-effort and the record is not. A write that would block drops the line for the sink only, counts it, and on the first drop for that job emits one peinit.job.output.dropped event — one per job, however many lines follow — so a submitter that sees a gap can learn it caused one. Any other write failure closes the sink at once. The sink is otherwise closed when the job's last pipe closes, after the last line has gone to it, and a sink attached to a job that never got pipes is closed without a write.

11.1.4 Terminal-attached services #

A service with a TTYPath has all three streams on its terminal, and both pipe pairs are closed in the child (§5.4). Its output is not captured at all — it goes to the terminal, which is what asking for one means.

peinit opens the terminal with read data, write data and read attributes. The last matters because the descriptor is inherited by every process the service starts, and a KACS handle answers fstat only if that right is on it: without it a session can read and write its terminal but not name it — ttyname() fails, tty reports "not a tty", and a program choosing TERM cannot tell a serial line from a virtual console. isatty() is an ioctl and works either way, which is why the gap is easy to miss.

11.2 The Pre-Eventd Buffer

Peios / Peios technical reference / peinit / Output Handling

Before eventd starts there is nowhere to send service output: eventd's log socket does not exist until eventd binds it. peinit buffers in memory until then. The buffer is bounded, and drops the oldest entries when it fills.

KeyDefaultMinimumMeaning
Machine\System\Init\PreEventdBuffer10485764096Bytes of output retained before eventd exists.

A value below the minimum keeps the default and logs a warning, rather than being honoured or failing the boot (§11.3). Zero is the case that matters: a zero-capacity buffer rejects every record, so honouring it would silently discard the whole pre-eventd window.

The value is read from the registry at boot and refreshed on reload, and the buffer adopts the new capacity each time. Lowering it takes effect immediately, dropping the oldest entries until the contents fit — the same end of the buffer a steady-state overrun drops.

Dropping the oldest rather than the newest is the right way round for this buffer: the point of the window is the boot that is happening now, and the most recent output is what explains where it got to.

Note

In practice the only services that run before eventd are registryd, lpsd, authd and eudev. The first three are Peios-owned with controlled output. eudev is the real overrun risk, being verbose about device enumeration. A crash loop's output is bounded by the restart budget.

11.2.1 Audit records are not logs #

peinit's own audit records — access denials, critical failures, recovery mode entry, graph errors, security-relevant transitions — are events rather than logs. They go into the KMES kernel ring buffer, which persists from the moment PKM loads: before eventd, before the registry, before Phase 2.

So there is no pre-eventd buffer for them, and no window in which they could be lost. eventd picks them up from the ring buffer when it attaches, wherever in the boot they were emitted.

That distinction is the reason the two paths exist at all. Logs are best-effort and lossy by design; audit events are neither.

11.3 Flood Protection

Peios / Peios technical reference / peinit / Output Handling

A noisy service cannot be allowed to starve the event loop. Three bounds apply.

KeyDefaultMinimumMeaning
Machine\System\Init\MaxLogLineLength8192256Bytes per line before truncation.
Machine\System\Init\MaxLogBufferPerService655364096Bytes buffered per service pipe before backpressure.
Machine\System\Init\LogReadBytesPerEvent16384512Bytes drained from one pipe per readable event.

A value below the minimum is not honoured and does not fail the boot: the compiled-in default is used and a warning is logged naming the key, the configured value and what was used instead. The same rule covers PreEventdBuffer (§11.2), and it is the rule peinit already applies to the equivalent kernel command-line knobs — a typo in a logging knob must not decide how the machine boots, and must not be silent either.

MaxLogBufferPerService's minimum is the one with an external cause: F_SETPIPE_SZ will not go below one page and rounds up regardless, so a smaller number in the registry would describe a pipe that does not exist. The other minimums are engineering judgement — the point below which the mechanism stops working rather than working differently.

A line exceeding MaxLogLineLength is truncated and marked [truncated], with the content trimmed so that content and marker together come to exactly the limit. Everything up to the next newline is then suppressed rather than emitted as a second line.

MaxLogBufferPerService is applied as the pipe's own capacity through F_SETPIPE_SZ , which is a literal reading of "bytes buffered per service pipe before backpressure" — the kernel does the buffering and the bound is where it belongs.

LogReadBytesPerEvent bounds one readable event rather than one loop iteration. A turn with several ready pipes reads up to the budget from each, bounded overall by how many events one epoll wait returns.

11.3.1 Where loss is allowed and where it is not #

At the pipe stage, peinit does not drop. It keeps reading within its budget and appends every complete line; only EAGAIN, end of file, or an error stops the read. Backpressure through the pipe is the flow-control mechanism between a service and peinit, and dropping there would replace it with silence.

Downstream of the pipe, delivery is loss-tolerant by design. The pre-eventd buffer drops its oldest entries when full, and eventd's datagram socket may drop records under load. The no-silent-drop guarantee covers reading the pipe, not delivery to eventd.

Audit events are exempt from all of it. They go through KMES.

11.3.2 Event loop fairness #

No source may starve the loop. Signals are handled at the highest priority — SIGCHLD reaping and shutdown handling take precedence over everything else in every iteration — with the shutdown deadline timer next and every other source below that, ties broken by arrival order.

The power button shares the top priority with signals , on the reasoning that someone physically pressing it is asking for the same class of attention.

11.4 The eventd Handoff

Peios / Peios technical reference / peinit / Output Handling

When eventd reaches Active, peinit switches from buffering to forwarding.

  1. Start sending to eventd's log datagram socket, at the path from Machine\System\eventd\LogSocketPath.
  2. Replay the pre-eventd buffer, oldest first, preserving each line's timestamp and metadata. The replay is best-effort: these are datagrams on a loss-tolerant socket, so some may be dropped, and peinit does not block waiting to deliver them.
  3. Switch to real-time forwarding — new output sent as it arrives.
  4. Clear the buffer.

From there peinit is a relay: read from the pipes, tag each line, forward. Audit events continue to flow as KMES events, entirely separately.

11.4.1 Broker authentication #

eventd's log socket denies FILE_WRITE_DATA to the Service logon group before allowing SYSTEM. peinit's bootstrap token is SYSTEM without that group; every phase-2 service token carries it, including a token whose user is SYSTEM. peinit is therefore the only service-output broker.

peinit does not attach a KACS token per log datagram. The socket descriptor authenticates the broker once at send admission, and peinit derives each origin from the pipe it is draining. Avoiding ancillary token fds also preserves mixed-origin batching and the hot-path reuse described below.

11.4.2 Datagram framing and the record #

peinit sends a msgpack array of one or more records in each datagram. It takes the largest ordered prefix that fits the smaller of the PSPU portable ceiling of 262144 encoded bytes and the log socket's send buffer. It deliberately does not read eventd's larger local ceiling: fixing the producer boundary avoids registry work and means an eventd configuration change cannot invalidate peinit's batches. peinit asks for a send buffer large enough for the portable ceiling when it connects, but the kernel caps what it grants, so the socket's own limit is the one that finally bounds a batch. A successful datagram advances the replay buffer by the whole array, while a failed datagram advances it by nothing. The one exception is a batch the socket refuses as too large: a single record that cannot fit one datagram is discarded rather than replayed forever, and peinit announces the discard on the console.

Each record is a msgpack map :

KeyTypeContent
originstringThe service name, or the hook identifier.
is_errorboolTrue for stderr.
messagestringThe line.
timestampuintNanoseconds, wall clock.
job_idbinThe job's 16-byte GUID in PCDS binary layout. Omitted when absent.

The map has four or five entries depending on whether a job identifier applies.

PSPU §3.7 requires PCDS GUID layout: Data1, Data2 and Data3 are little-endian; the final eight bytes (Data4) retain their byte order. The payload is still exactly 16 bytes.

Note

peinit 0.0.12 source sends RFC-order UUID bytes instead. The PCDS layout above requires the log job-ID encoding fix; check the installed peinit revision before relying on that encoding.

11.4.3 Lossy delivery #

eventd's log socket is a non-blocking Unix datagram socket. If eventd cannot drain it fast enough its SO_RCVBUF fills and the kernel drops further datagrams silently — log ingestion deliberately exerts no backpressure on senders.

peinit therefore keeps no unbounded outbound write buffer. It sends bounded arrays of records and accepts that some datagrams may be dropped. It never blocks on a send, and never lets pending records grow without bound.

peinit creates one non-blocking datagram socket when forwarding begins, connects it to eventd, and reuses both that socket and its encoding allocation. It does not create a socket or allocate a fresh output buffer per record. When eventd becomes inactive, the connection is discarded so the next Active transition connects to the newly bound socket.

A drop and a transport failure are handled differently, and the difference is what keeps the lossy design working.

A drop is EAGAIN, EWOULDBLOCK or ENOBUFS — the receive buffer is full and the datagram did not fit. That is the designed outcome under load, so the connection stands and forwarding continues. Live records in the dropped datagram are gone; peinit counts them and sends the next batch. Replaying the pre-eventd buffer is the one case where waiting beats dropping — those records are already held and the buffer is bounded, so the flush stops and the next turn tries again.

A transport failure is anything else: the socket path gone, the peer refusing. peinit clears the connection, keeps the unsent records in the pre-eventd buffer in order, and the end of the turn re-establishes forwarding by replaying them.

Treating a drop as a transport failure made peinit oscillate between forwarding and buffering under exactly the load the lossy socket exists to absorb, and replay records eventd may already have held — so the busier eventd got, the more work peinit made for it.

11.4.4 When eventd goes away #

peinit supervises eventd like any other service, so it sees the exit directly. It re-enables the pre-eventd buffer, and when eventd restarts and reaches Active the handoff repeats.

There is a log gap between eventd crashing and restarting, bounded by the buffer size. Events are unaffected: they land in the KMES ring buffer regardless of eventd's state, and eventd resumes consuming from the last persisted sequence when it comes back.

11.5 Console Output

Peios / Peios technical reference / peinit / Output Handling

peinit writes its own operational messages to /dev/console :

  • Phase 1 progress — mount results, registryd starting.
  • Phase 2 progress — services starting and failing, dependency errors.
  • Shutdown progress.
  • Recovery mode entry.
  • Critical service failures.

Service output is never echoed to the console. The console is for peinit's own messages; a service that wants a terminal asks for one with TTYPath.

11.5.1 The line format #

Every line carries a fixed-width outcome tag, then the component name, then the message:

[      ] peinit: phase1 starting
[  OK  ] peinit: service authd started
[ SKIP ] peinit: service resolvd skipped: DependencyFailure
[ WARN ] peinit warning: calendar timer not armed: Malformed
[FAILED] peinit: service netd failed to launch: token materialisation failed
[ CRIT ] peinit: entering recovery: BootAttempts(3)

The tag column is the point of the format. An operator scanning a boot reads down one column rather than reading sentences, and a failure stops looking exactly like the twenty lines around it.

TagMeans
[ ]Progress with no outcome yet. Holds the column while receding.
[ OK ]It worked.
[ SKIP ]Deliberately not done.
[ WARN ]Wrong, but the boot continues.
[FAILED]It did not work.
[ CRIT ]The machine is about to be lost.

An [ OK ] line reads "mounted", never "mounting": the tag already claims the outcome, so the verb has to agree with it.

11.5.1.1 Tag and severity are different axes #

The tag is presentation. Severity decides who is allowed to print. They correlate but neither determines the other — a Normal service failing and a Critical service failing both render [FAILED] while carrying different severities — and collapsing them would quietly change what peios.quiet=2 suppresses.

11.5.1.2 Colour #

Tags are coloured with ANSI SGR escapes: green for OK, yellow for SKIP and WARN, red for FAILED, and white-on-red for CRIT. Untagged progress is never coloured.

Colour is decided once, from the kernel command line, following the same rule systemd uses: on unless the command line says TERM=dumb. peinit is PID 1 and has no inherited TERM to consult, so the command line is the only place an operator can say a console cannot render escapes. Deliberately not a peios.* token — that set is kept small on purpose, and TERM=dumb is a spelling people already know.

Colour changes the bytes and never the layout, so a serial log and a virtual terminal agree about where the message starts.

11.5.1.3 The stage banner #

Before Phase 1 does any work, peinit prints a banner naming the stage and the boot mode:

  ══════════════════════════════════════════════════════════════
   peinit · real root · PID 1 · Full boot                 v0.0.1
  ══════════════════════════════════════════════════════════════

It is punctuation rather than decoration. prelude prints the matching one when the initramfs takes over, so the two mark the handover from kernel to initramfs to real root — the one thing a boot log otherwise never says out loud.

The mode is the one this boot starts in: Full boot, Safe mode, or RECOVERY MODE. A later downgrade to Safe is announced by its own message rather than by reprinting a banner, because a second banner would read as a second stage.

Naming the mode is why the kernel command line is read at the very top of run_init, before any Phase 1 work. That reverses an earlier ordering which assumed mounting the virtual filesystems is what makes /proc/cmdline readable. It is not: the mount step reads /proc/self/mountinfo first, and /proc is provided by the initramfs. One consequence — a machine with both an unreadable command line and another Phase 1 fault now reports the command line as its recovery reason.

The banner carries ordinary status severity, so peios.quiet=2 drops it along with every other kind of progress.

11.5.1.4 Who else writes to this console #

The format is shared by specification, not by shared code. peinit, prelude and the hook scripts each implement it separately, because prelude is a size-critical initramfs PID 1 with no dependencies and the hooks are shell. Changing a tag word or a width means changing it in all three.

Everything peinit runs that reaches the console is in this format, including output peinit did not write itself. Autorun scripts are captured, not left to inherit peinit's streams, and their lines are relayed under the script's own file name :

[      ] 10-apply-seeds.sh: applied 20 file(s), 92 key(s); 0 failed
[  OK  ] peinit: ran 1 autorun script(s)

Relayed lines always carry the blank tag. peinit does not know whether a given line of somebody else's output is good news — the producer knows and peinit does not, and guessing from prose is how a rename silently turns an error green. The producer's outcome is reported separately, from its exit code, which peinit does know.

Relayed lines are sanitised first. A script sharing the console could otherwise steer it with an escape sequence, and peinit's own output shares the device. Three rules apply:

  • Control characters become ?.
  • A line is cut at 512 characters.
  • A producer is capped at 200 lines, with the remainder counted.

The only lines on a normal boot that are not in this format come from outside peinit's reach entirely: the firmware's own messages before any kernel runs, and a service that owns the terminal through TTYPath writing to it directly.

11.5.2 Severity and quiet #

Each message carries a severity, and peios.quiet (§2.6) decides what that means:

  • At 0, everything is written.
  • At 1, the default, peinit stays out of a terminal held as the controlling terminal of a running service, except to announce loss of the system. Terminals are matched by device rather than by path, since /dev/console and /dev/ttyS<n> can be the same device; where the device cannot be determined peinit assumes the terminal is held.
  • At 2, ordinary progress is dropped everywhere while errors still get through.

Suppressed messages are discarded rather than buffered for later.

Shutdown progress carries ordinary status severity, so peios.quiet=2 suppresses it along with every other kind of progress.

The autorun step in Phase 1 (§2.3) bypasses the policy entirely, on the grounds that a script running that early and going wrong is worth interrupting anything for. Its lines are still tagged.

11.5.3 Phase 1 registryd failures #

One deliberate exception to "service output is never echoed to the console". peinit gives every service capture pipes and drains them in the runtime loop — but Phase 1 has no loop, so a registryd that printed exactly why it could not serve was reported to the operator as nothing but registryd readiness timeout expired before READY=1, with the reason sitting unread in a pipe.

When Phase 1 registryd fails to become ready, or says READY=1 and then cannot serve, peinit now drains those pipes and relays what it found :

[FAILED] peinit: registryd failed; what it said follows
[      ] registryd(stderr): open hive Machine: permission denied

Failure path only. On a normal boot the descriptors are retained for the runtime, which drains them into the pre-eventd buffer and on to eventd like any other service's output — so registryd's startup lines are queryable with evctl and are not on the console.

The drain is safe to run from PID 1 because the read ends are non-blocking: a registryd that is alive and silent yields EAGAIN rather than hanging the boot.

Note

peios.quiet governs peinit only. What the kernel prints is governed separately by loglevel, which shipped images set to 4. See Boot and boot modes.

11.6 Terminal Ownership

Peios / Peios technical reference / peinit / Output Handling

A TTYPath (§3.2) names a device that has exactly one owner at a time. Two services writing to the same terminal do not fail; they interleave. On a console that produces a login prompt with a progress line through the middle of it, and a password echoed into whatever redrew last. So peinit hands the device to one service and makes the others wait.

11.6.1 Who is holding it #

A service holds its terminal in every state where it may still have a process on the device:

HoldingReleased
Starting, Active, Reloading, Stopping, BackoffInactive, Completed, Failed, Skipped, Abandoned

The two emphasised entries are the ones worth stating outright.

Backoff holds. A service between restart attempts is coming back, and handing its console to somebody else during the gap produces two owners a second later. This is deliberately not the "settled" rule used by boot:settled (§2.5), which also treats Backoff as still moving, but reaches it from the opposite direction.

Abandoned releases , and that one is a compromise. The process survived SIGKILL (§5.7) and may well still be writing. But peinit has no remaining way to reclaim the device from it, so treating it as held would mean the terminal is lost for the rest of the boot — and a console nobody can log in on is worse than a console with a stuck process on it.

Nothing is stored. The holder is derived from the service table, so there is no claim to leak, to double-release, or to fall out of step with the state machine.

11.6.2 Losing the terminal #

Every start path — boot, deferred, on-demand, restart, and the completion leg after a filesystem check helper (§5.2) — asks whether the terminal is free before it asks anything else. If another service holds it, the start ends there: the service goes to Skipped with cause TtyUnavailable (§6.3), its operation completes, and its dependents are satisfied exactly as any other skip satisfies them.

Skipped rather than Failed, because nothing is wrong. Nothing is written to the console about it either — it is the mechanism working, and the message would land on the very terminal whose new owner is at that moment drawing on it.

The check runs ahead of the service's own conditions and asserts, which is why a service queued on a busy terminal never forks a check helper.

The graph paths — the Phase 2 boot plan, and any start that brings dependencies with it — ask twice. Their pre-start check runs for every service released together before any of them has moved, so two boot-plan services naming one terminal both pass it. The question is then asked again at the moment each one would enter Starting, and by then the first holds the device: the second is skipped there, with the same cause and the same completed operation, rather than launched into a terminal that already has a session.

A relaunch from Backoff asks too, and a service whose terminal was taken while it waited goes from Backoff to Skipped (§6.2).

11.6.3 Winning the terminal #

TTYPrecedence decides who wins when several services want the same device at the same moment. Higher wins; equal precedence breaks on service name , so an operator who states no preference still gets the same machine on every boot rather than whichever definition the registry happened to enumerate first. The default is 0.

Precedence is consulted among simultaneous candidates only. A service that already holds a terminal is never preempted by a higher-precedence latecomer : taking a live terminal away from a process that is using it would lose whatever the operator was in the middle of typing.

Two places have simultaneous candidates. Deferred starts (§2.5) are dispatched highest-precedence first, so the winner takes the device and the rest are skipped. Handovers, below, pick one winner outright.

Within the Phase 2 boot plan the order is the dependency graph's, not precedence's — a boot-plan terminal service is being written over by peinit's own progress output anyway, which is what boot:settled exists to avoid.

11.6.4 Handing it on #

A service that names a terminal and carries the tty:released trigger (§3.4) is started when that terminal comes free. peinit watches every transition out of a holding state, so it does not matter whether the holder exited cleanly, crashed, was stopped by an administrator or was evicted by a conflict — all four free the device , and the waiter is wanted after a crash more than after a clean exit, not less.

Which terminal was released is recorded on the transition, not looked up afterwards, and that is load-bearing rather than tidy. A service whose definition has been removed loses its table entry the moment it reaches a state that does not retain one (§3.8) — the same moment it releases its terminal. Asking afterwards which TTYPath it had gets nothing.

That is not a corner case. It is precisely what a service that retires itself does: remove its own definition so it never runs again, then exit. First-boot setup is exactly that, and before the release rode on the transition it ended by telling the operator they could log in, on a console it had just vacated and nobody had been given.

One winner per release, chosen by precedence. Starting the whole queue would have every loser immediately skipped again by the rule above: the same outcome, reached noisily, with a skipped-looking service for each.

Two services are never offered a terminal:

  • The one whose exit freed it. It has the strongest claim on its own console, so without this exclusion the highest-precedence holder restarts itself on every exit — a restart policy with no budget, and one no waiter behind it ever gets past. Relaunching a service that stopped is RestartPolicy's job (§6.4).
  • One whose definition has been removed (§3.8), since it cannot be started at all.

A waiter is usually sitting in Skipped when its turn comes, from the moment it lost the terminal. The handover starts it as an explicit start would, which is the transition that takes a service out of Skipped (§6.2) and re-evaluates its conditions.

11.6.5 Why not Conflicts, or a dependency #

Conflicts (§7.1) fails both services when they meet in one boot. The services that want a terminal are the interactive ones — a login prompt, an installer, a first-boot setup flow — so that answer is a machine with no way into it.

A dependency says the wrong thing twice. A login prompt does not need first-boot setup to have run; it needs the console to be free, and a setup flow that failed frees it just as well as one that succeeded. And naming the holder would mean rewriting every waiter each time the set of things that might take the console changed.

What is left is a queue on a device, which is what this is.

11.6.6 Fixed system-console endpoints #

When the LCS boundary reads service definitions, /dev/console in TTYPath is resolved from the last entry in /sys/class/tty/console/active. A tty0 endpoint is pinned to /dev/tty1; an explicit /dev/tty0 is also pinned to /dev/tty1. The selected system-console endpoint is cached for the boot so registry reloads and foreground VT changes do not move existing sessions. Resolution failure makes the affected definition undecodable, rather than silently opening a moving alias. Arbitration and process launch use the same resolved path.

On terminal main-process exit the runtime kills the exiting job's cgroup before releasing the terminal or scheduling its successor. Descendants belong to the session that ended, not the next login. Other terminal services and ordinary daemon lifecycle semantics are unaffected. An error killing the cgroup fails terminal-event processing; a new prompt must not be scheduled through that error.

11.7 Service Output Flow

Peios / Peios technical reference / peinit / Output Handling

These notes collect the output-flow explanation behind finding service logs. The preceding sections define the precise transport and limits. Service-output delivery is best-effort; KMES events use a separate finite in-memory ring, and eventd supplies persistence.

11.7.1 Capturing output #

When peinit forks a service it creates pipes for stdout and stderr, redirects the child's streams to the write ends, and keeps the read ends — monitoring them in its event loop. (The child's stdin is /dev/null; see The execution environment.) It reads output line by line and tags each line with:

  • the service name,
  • the stream (stdout or stderr),
  • a timestamp,
  • the job GUID.

The job GUID is the important one: it is what lets a later query return exactly one execution's output, never interleaved with the run before or after it. Hook output is captured too, labelled with the hook (e.g. jellyfin/ExecStartPre[0]), and so is health-check output — invaluable for diagnosing why a check failed. A submitted job's output is captured the same way, labelled jobs/<id>; its submitter cannot opt out of that, but can ask for a copy by attaching an output sink, to which peinit writes each line as it reads it. The copy is best-effort — lines the sink cannot take are dropped for the sink only, while forwarding to eventd continues independently, and reported once per job as an output.dropped event — and the sink never slows the job or peinit.

11.7.2 The pre-eventd buffer #

Before eventd is running there is nowhere to send logs — its socket does not exist yet. peinit therefore buffers captured output in a fixed-size in-memory pre-eventd buffer (default 1 MB); when it fills, the oldest entries are dropped.

In practice the only services that run before eventd are registryd, lpsd, authd, and eudev. The first three are Peios-owned with controlled output; eudev is the real overrun risk (verbose device enumeration), and the restart budget naturally bounds output from a crash loop.

11.7.3 Flood protection #

A noisy service must never starve peinit's event loop. Several limits enforce that:

Key (Machine\System\Init\)DefaultLimits
MaxLogLineLength8192Bytes per line; longer lines are truncated with a [truncated] marker.
MaxLogBufferPerService65536Bytes buffered per service pipe before back-pressure.

Two mechanisms back these up:

  • A per-readable-event budget. LogReadBytesPerEvent bounds the bytes drained from one pipe per readable event. A loop turn with several ready pipes may read up to that budget from each; see §11.3.
  • Back-pressure, not dropping. If a service writes faster than peinit reads, the kernel pipe buffer fills and the service's own write() calls block. This is deliberate: the service slows down. While reading the pipe, peinit does not drop output — the no-silent-drop guarantee covers reading the pipe. It is only downstream (the pre-eventd buffer, and eventd's datagram socket) that delivery becomes lossy.

Note

Signals are never starved. In every loop iteration, child reaping (SIGCHLD) and shutdown handling take priority over all other event sources, including log reading. A flood of service output can never delay peinit noticing that a service died or that the system is shutting down.

11.7.4 The eventd handoff #

When eventd reaches Active, peinit switches from buffering to forwarding:

  1. It begins sending to eventd's log datagram socket (path from Machine\System\eventd\LogSocketPath).
  2. It replays the pre-eventd buffer, oldest first, preserving each line's timestamp and metadata. This replay is best-effort — the records are datagrams on a loss-tolerant socket and some may be dropped under load; peinit never blocks waiting to deliver them.
  3. It switches to real-time forwarding — new output goes out as it arrives.
  4. It clears the buffer.

From then on peinit is a pipe relay: read lines, tag them, and forward the largest ordered batch that fits the smaller of the portable 262144-byte ceiling and the log socket’s send buffer. peinit does not read eventd’s larger local ceiling; see §11.4. It keeps one connected socket and reuses its encoding buffer, so normal forwarding does not create a socket or output allocation for every line. The log socket is non-blocking and loss-tolerant — if eventd cannot drain it fast enough the kernel drops further datagrams silently, because log ingestion must never exert back-pressure on the whole system through PID 1. peinit keeps no unbounded outbound buffer and never blocks on a send to eventd.

If eventd itself crashes after starting, peinit (which supervises it like any service) notices, re-enables the pre-eventd buffer, and repeats the handoff when eventd comes back. There can be a log gap across the restart, bounded by the output buffer. Structured events use the independent KMES ring; eventd can resume reading the events that remain there. The ring can overwrite old events and does not survive reboot, so eventd supplies persistence rather than KMES. See KMES failure modes.

12.1 Triggers

Peios / Peios technical reference / peinit / Shutdown

Four paths initiate a shutdown.

12.1.1 The control socket #

A shutdown command naming a type, gated on SYSTEM_SHUTDOWN against peinit's control descriptor (§4.7):

TypeEffect
poweroffStop everything, unmount, power off.
rebootStop everything, unmount, reboot.
haltStop everything, unmount, halt — the CPU stops, the system stays powered.

12.1.2 Signals #

SignalMeaning
SIGINTReboot. The kernel sends it on Ctrl+Alt+Del.
SIGTERMPoweroff. PID 1 cannot be killed by it but may choose to act on it.
SIGPWRPoweroff. The compatibility path for environments that surface power failure or a power-button policy as a signal.

Three SIGINTs within five seconds force an immediate shutdown: no graceful stop, no ordering, SIGKILL every service cgroup, sync, reboot. The window is a sliding five seconds and the press is recorded before the already-shutting-down check, so three presses still force even after a graceful reboot has begun. That is the point — someone pressing it three times has decided the graceful path is not working.

12.1.3 The power button #

An EV_KEY / KEY_POWER press from a readable /dev/input/event* device is a graceful poweroff. Only a press — value 1 — initiates. Releases, key repeats, other keys and other event types are ignored.

The path is fail-soft throughout: a missing /dev/input, a device that cannot be opened or registered, and a registered descriptor that later fails to read are all survivable, and a failing descriptor is removed from the event loop so repeated failures cannot spin PID 1. Losing it degrades only direct power-button handling; the socket and signal paths remain.

It is deliberately minimal. It is not a power-management policy engine and does not replace a future daemon that would translate richer policy into control socket commands.

12.1.4 Critical service failure #

A Critical service entering Failed with its restart budget exhausted means peinit syncs the filesystems and reboots immediately. This is not a graceful shutdown: there is no stop ordering, no seed save, and no unmount. The system is in an undefined state and the fastest path to a defined one is a reboot.

12.2 The Graceful Sequence

Peios / Peios technical reference / peinit / Shutdown

12.2.1 Step 1: Enter the shutdown state #

peinit sets an internal flag. While it is set, no new service starts. Control commands other than status, list, operation-status, job-status, job-list and job-stop are rejected as invalid for the current state.

Every live submitted job (§8.5) is stopped at this point too: SIGTERM to each — unless it has already sent STOPPING=1 — with cause shutdown, its own stop_timeout to the kill, and no ordering between jobs or against the service waves, because a job has no dependencies to order by. A job still queued for launch is cancelled with the same cause. submit is refused for the rest of the shutdown; status, wait, stop and signal keep answering, as do the control socket's job-status, job-list and job-stop.

Timer triggers are disarmed. The timerfds stay armed and keep firing — peinit gates the handler rather than the fd, matching how every other event source is handled — but a firing during the shutdown window is classified as a no-op and nothing is started or queued.

The gate matters more than the rule reads. The states a timer firing would start from are Inactive, Completed and Failed, precisely the states step 3 classifies as not participating, so a firing would create a service after the plan was fixed: in no wave, not waited for, and not reached by the global-timeout sweep, still holding files open when peinit reaches the unmount step.

12.2.2 Step 2: Suspend Critical failure semantics #

A Critical service failing during shutdown is recorded but does not trigger a reboot. The system is already going down, and rebooting from here would loop.

12.2.3 Step 3: Classify #

Completed services — Oneshots with RemainAfterExit — have no process and are transitioned to Inactive, releasing the dependency relationships they were holding so their dependents can be stopped cleanly.

The rest are classified for stop eligibility:

  • Active and Reloading are graceful-stop eligible and enter the waves.
  • Stopping services are already on a stop path. They join the waves for ordering and timeout purposes, but do not receive another SIGTERM.
  • Starting services are not eligible. peinit cancels the startup, SIGKILLs the service cgroup if one exists, and transitions them to Failed with cause ShutdownWave while post-kill verification is pending. A cgroup still populated after the post-kill timeout takes the service to Abandoned with cause ProcessUnkillable and the cgroup is leaked. A Starting service whose job never forked skips the check and goes straight to Failed.
  • Inactive, Failed, Skipped, Backoff and Abandoned do not participate. Abandoned cgroups stay leaked and shutdown continues.

12.2.4 Step 4: Stop in reverse dependency order #

peinit builds the reverse dependency graph over hard dependencies and stops in waves:

  1. Each eligible service receives SIGTERM. Already-stopping ones do not.
  2. Each has StopTimeout to exit.
  3. On expiry, SIGKILL to the service's entire cgroup.
  4. No service is stopped until everything depending on it has stopped.

The compiled-in Phase 1 service, registryd, stops in a wave of its own after every other participant, though no definition declares a dependency on it. Every service defined in the registry rests on it, as it started before any of them; stopped by the graph alone it would land in the first wave and go while the services still running needed it. The rule follows the definition's compiled-in provenance, not its name.

A participant the service table no longer has counts as stopped. A service whose definition is withdrawn while it runs is kept until it stops and then discarded (first-boot setup withdraws its own service this way), and the plan fixed at step 3 still names it. Waiting for it would hold its wave open for good.

A service that sent STOPPING=1 does not receive a SIGTERM at all: it has already said it is shutting down, and peinit goes straight to the stop deadline. The notification is acknowledged as a peinit.notify.stopping.reported event (§10.5), which is what makes the suppressed SIGTERM legible afterwards rather than looking like one peinit failed to send.

12.2.4.1 Observing the waves #

Every stop-wave participant receives its own Shutdown-sourced Stop record when the shutdown plan is fixed. Its requested event records the intent; its started event records when the participant's wave becomes eligible, including when an existing stop is adopted without another SIGTERM. The records and their identifiers are visible through the ordinary operation queries and peinit.operation.* events.

A service that exits cleanly before its wave completes its Pending observation without a started event. A crash before the wave fails it. For a service already stopping, reaching Inactive completes the observation; a stop timeout, global timeout, or forced reboot fails it. Post-kill abandonment does not produce a second terminal result for an observation already failed at the kill. Starting services retain their existing startup-failure records, and Completed services cleared without a process do not receive stop observations.

An administrator Stop or Restart already in flight keeps its identity and its original timing evidence. The shutdown observation is separate; it neither supersedes that record nor queues a second control command.

12.2.4.2 Timing an already-stopping service #

peinit does not reset an already-Stopping service's clock, either when shutdown begins or when its wave becomes eligible. It uses the timing evidence retained from the stop path that put the service in Stopping:

  • If a Stop operation, or a Restart executing its stop leg, is in flight, that operation's retained timing governs.
  • Otherwise peinit uses service-level evidence, which carries the cause that initiated the transition — ExplicitStop, ShutdownWave, ConflictEviction or BindsToPropagation.

peinit requires the evidence to be present, to belong to a service actually in Stopping, to carry a cause matching the service's current cause, to name one of those four causes, and not to describe a deadline earlier than its own start.

Evidence that is missing, stale or ambiguous fails closed rather than being guessed at. A stop that began before the shutdown has already spent part of its own clock, and inventing a fresh budget here would hand the service a second full StopTimeout.

Failing closed costs that one participant, not the shutdown. A service whose evidence cannot substantiate a deadline is given no graceful period at all: its deadline is already due, so the timeout scan escalates it to SIGKILL on its wave, and the console says which way the evidence was unusable:

peinit: shutdown cannot substantiate a stop timeout for <service>
(<reason>); killing it without a graceful period

Every other participant keeps its full StopTimeout, and the shutdown runs to completion.

An operation whose lifetime expires while it is still Pending — a later-wave stop waiting for its dependencies — fails that operation and its waiters. It does not authorise signalling the service before its wave is eligible. Shutdown owns the signalling; the operation object is an observation of it.

12.2.5 Step 5: Global timeout #

KeyDefaultMeaning
Machine\System\Boot\ShutdownTimeout90Seconds for the whole sequence.
Machine\System\Boot\PostKillTimeout5Seconds for a cgroup to drain after SIGKILL.

On expiry, every remaining participant's cgroup is killed, anything that does not drain within the post-kill timeout is marked Abandoned with its cgroup leaked, and shutdown continues regardless. Live submitted jobs are killed in the same sweep, whatever phase their stop had reached, and abandoned on the same terms.

The global timeout fires once. What its sweep killed is then governed by the post-kill timeout alone: sweeping again would kill the survivors again and push their post-kill checks back each time, so nothing would ever be abandoned and the sequence would never reach step 6.

The console says what the sequence was waiting for when the timeout fired, and, after any later timeout turn that leaves it still waiting, what remains: participants with their state and wave, pending stop and post-kill checks, and live jobs.

peinit: shutdown global timeout expired waiting for lpsd (Active, wave 1), job <id>
peinit: shutdown still waiting for post-kill check of lpsd

Submitted jobs are named on the console as they are stopped, killed and abandoned: peinit: shutdown stopping job <id>, killing job <id> and abandoned job <id>.

The sequence does not move to step 6 while a submitted job is live. A job's reap advances shutdown progress exactly as a service's does, so a session that exits promptly on SIGTERM costs the shutdown nothing, and one that does not costs it stop_timeout and the post-kill grace.

12.2.6 Step 6: Save the random seed #

After every service has stopped and before any unmount, peinit writes a fresh seed to /var/state/peinit/random-seed for the next boot: 512 bytes from the kernel CSPRNG on this machine, never copied from an image, protected so that only SYSTEM-equivalent authority can read or replace it.

The write is crash-conscious: a temporary file on the same filesystem, written, flushed, atomically renamed over the old seed, and the containing directory flushed. A failure is recorded and shutdown continues.

The forced and Critical-reboot paths skip it, along with the unmount step, and go directly to sync and the final action.

12.2.7 Step 7: Unmount #

  1. Snapshot the mount table first, from /proc/self/mountinfo.
  2. Attempt to unmount every remaining non-root mount in the namespace — not only what peinit mounted, so the Phase 1 set is covered by construction.
  3. Process in descending path depth, so children go before parents.
  4. A mount point already gone is a successful no-op.
  5. On failure, attempt a read-only remount. If that fails too, record it and continue.
  6. The root is never unmounted, but is remounted read-only at the end. A failure there is recorded and does not stop step 8.

12.2.8 Step 8: Sync and the final action #

This step is irreversible. Cleanup failures retained from steps 6 and 7 affect diagnostics only and never block it.

  1. sync(). Called on all three paths — graceful, forced and Critical reboot.
  2. reboot(2) with RB_POWER_OFF, RB_AUTOBOOT or RB_HALT_SYSTEM.
  3. If reboot(2) returns, the final action failed. peinit enters a minimal failed-shutdown state, keeps PID 1 alive, records the failure, and retries the same action no more than once a second. It does not restart services and does not enter recovery mode — finalisation has begun and there is nothing to go back to.

12.2.9 Shutdown during boot #

A shutdown requested while Phase 2 is still running takes effect immediately, through the same classification: Starting services are SIGKILLed, services that reached Active are stopped gracefully, and the boot is abandoned.

12.3 Signals

Peios / Peios technical reference / peinit / Shutdown

PID 1 handles every signal through a signalfd. All signals are blocked and read from the event loop. So peinit installs no signal handlers of its own, and nothing peinit writes has to be async-signal-safe.

/proc/1/status does report two caught signals, SIGSEGV and SIGBUS. They are the Rust runtime's stack-overflow guard, which runs on an alternate stack and touches nothing peinit owns, so the reasoning above is unaffected.

12.3.1 Setup #

peinit builds a mask containing every blockable signal in the supported range — SIGKILL and SIGSTOP are not blockable and are never delivered through a signalfd — and installs it with rt_sigprocmask(SIG_BLOCK, ...) before entering the main event loop. It then creates the descriptor with signalfd4(-1, mask, ...), using the same mask, with SFD_CLOEXEC | SFD_NONBLOCK.

If any part of that fails — blocking, creating the descriptor, retaining it, registering it with the event loop — peinit fails closed. There is no fallback to asynchronous handlers, because a PID 1 with handlers installed where it expected a signalfd is a PID 1 whose assumptions about what can interrupt it are wrong.

The mask is inherited across fork, which is why every child resets it before exec (§5.4).

12.3.2 The signals #

SignalBehaviour
SIGCHLDReap children with waitpid. Match them to tracked jobs; also reap orphans belonging to nothing.
SIGINTReboot. Three within five seconds forces one.
SIGTERMPoweroff.
SIGPWRPoweroff.
SIGHUPIgnored. PID 1 has no controlling terminal.
SIGPIPEIgnored. A broken pipe on the control socket cannot be allowed to kill PID 1.

Everything else is ignored. The kernel protects PID 1 from fatal signals, so no signal can kill it.

12.3.3 Reaping #

peinit reaps with waitpid(-1, ..., WNOHANG) in a drain loop, and normalises the wait status before any service or job policy sees it:

  • An exited child carries its exact exit code, 0–255.
  • A signalled child carries the terminating signal number and whether the core-dump bit was set.
  • A stopped or continued status is invalid on this path, because peinit never asks for them. Observing one fails closed rather than being interpreted.

That last rule matters more than it looks. A stopped child reported as an exit would be read as a service that had terminated, and peinit would act on a process that is merely paused.

As PID 1, peinit also reaps processes nobody is tracking — orphans reparented to it — and reports them as untracked rather than trying to attribute them to a service.

12.4 Finalisation

Peios / Peios technical reference / peinit / Shutdown

Three shutdown paths reach the kernel, and they do different amounts of work on the way.

PathStop wavesSeed saveUnmountSyncFinal action
GracefulYesYesYesYesYes
Forced — three SIGINTsNo, SIGKILL everythingNoNoYesYes
Critical service failureNoNoNoYesYes

The two abrupt paths are required to reach sync() and the final kernel action with minimal additional work, and skipping the seed and the unmounts is what "minimal" means. A machine that is rebooting because its audit daemon died has nothing to gain from a tidy unmount and something to lose from the time it takes.

12.4.1 What survives a failure #

Steps 6 and 7 — the seed and the unmounts — retain their failures as evidence rather than acting on them. Every one is recorded, none of them blocks step 8, and none of them enters recovery mode. By this point there is nothing to recover to: services have stopped and the filesystems are on their way down.

12.4.2 If the final action returns #

reboot(2) does not return on success. If it does, the final action failed, and peinit enters a minimal failed-shutdown state:

  • PID 1 stays alive. It has to — PID 1 exiting panics the kernel.
  • The failure is recorded.
  • The same action is retried, no more than once a second.
  • Services are not restarted, and recovery mode is not entered.

There is no way back from here. Finalisation has begun, the services are gone and the filesystems are read-only; the only correct behaviour is to keep trying the one thing that would end it.

RB_HALT_SYSTEM does not return either, so this state is only reachable for a genuine failure rather than for the halt case.

12.4.3 A note on the mount table #

The unmount step re-reads /proc/self/mountinfo when checking whether a mount point that returned ENOENT or EINVAL is really gone. Depth ordering puts the depth-one mounts last, alphabetically — /dev, /proc, /run, /sys — so /proc is unmounted before /run and /sys are attempted. Neither of those answers ENOENT or EINVAL in practice — /sys unmounts, and /run either unmounts or is busy — so the check never runs for them. It is a latent hazard rather than a live one: if either ever did answer that way, the check could not read the file it needs, and the shutdown would record a cleanup failure and attempt a pointless read-only remount.

13.1 Trust Boundaries

Peios / Peios technical reference / peinit / Security

peinit sits at two.

13.1.1 Kernel to peinit #

peinit is the first userspace process, and the kernel gives it a SYSTEM token — S-1-5-18, every privilege. This is the root of trust for all userspace identity on the system.

peinit does not drop that token and does not authenticate to anything. Its identity is axiomatic: there is no authority above it in userspace that could vouch for it, and the kernel handing it the boot token is the vouching.

13.1.2 peinit to services #

peinit creates service processes with specific identities and reduced privileges. The trust runs one way. peinit trusts the kernel because it has no alternative; services trust peinit because peinit gave them their identity. Services do not trust each other — KACS mediates every access between them, and nothing peinit does creates a relationship between two services beyond the ordering their definitions asked for.

13.1.3 The TCB #

peinit is part of the Trusted Computing Base, alongside the kernel, KACS, LCS, KMES, registryd, authd, lpsd and eventd. A compromise of any of them compromises the system.

That list is not decoration. It is why registryd is exempt from the global environment layer (§5.5) — a component in the TCB cannot be configurable by a mechanism it is itself the enforcement point for — and it is why eventd is Critical, since a TCB whose audit trail can be silently stopped is not one.

13.1.4 Filesystem enforcement #

KACS enforces Security Descriptors on filesystem access. peinit's Phase 1 descriptor seeding (§2.3) exists precisely because of it: a freshly mounted tmpfs carries no descriptor, and under DENY_MISSING every inode on it would be unreachable to everything — including peinit — until something stamps a descriptor it can inherit from.

That enforcement has no bypass. There is no owner exemption, no privilege that overrides a missing descriptor, and no root escape , which is what makes the seeding step fatal on failure rather than advisory.

FACS extends the model further, and until it lands the filesystem layer still relies partly on conventional trust — correct packaging, controlled binary paths — for the objects nothing has stamped.

13.2 peinit's Privileges

Peios / Peios technical reference / peinit / Security

peinit runs as SYSTEM with every privilege for the lifetime of the system. What it actually exercises is narrower.

Privilege or capabilityUsed for
SeCreateTokenPrivilegeMinting SYSTEM tokens for platform services during bootstrap, before authd exists (§4.2).
SeTcbPrivilegeRequesting tokens from authd on a service's behalf, and installing a primary token on a child whose identity differs from peinit's own — a service's, or a submitted job's.
SeAuditPrivilegeWriting events: KMES refuses an emit from a token without it enabled. And recording access decisions: an AccessCheck peinit runs writes the kacs.audit.access.checked record its descriptor's SACL asks for only while the caller holds it enabled (§8.4).
Token duplicationTurning a job identity into the primary token a submitted job runs as (§8.5): opening the submitter's own primary through its pidfd, or taking the token the kernel attached, and duplicating either to a primary. SYSTEM holds full access on every token's default descriptor, which is what makes the duplicate permitted.
Process creationFork and exec, inherent to PID 1.
cgroup managementCreating and destroying trees under /sys/fs/cgroup/peinit/.
Signal deliverySIGTERM and SIGKILL to managed processes.
Mount operationsThe Phase 1 virtual filesystems.

peinit verifies the three privileges at startup, before Phase 1 does anything that needs them, and fails to recovery naming which is missing :

peinit: required privileges are not held: Token("peinit is missing
required privilege(s): SeCreateTokenPrivilege")

Present and enabled — a privilege the token carries but has not enabled is not usable, and would fail identically to being absent. SeAuditPrivilege is the exception: held but disabled, peinit enables it before checking, rather than run with no events and no record of who was refused what. The boot SYSTEM token PID 1 runs as holds every privilege enabled, so on a token the kernel made the check passes and changes nothing.

The check earns its place by what it replaces. A missing SeCreateTokenPrivilege used to surface as an EPERM from the first token mint, which is the first service start — registryd, in Phase 1 step 6. So a peinit that could not mint tokens entered recovery reporting what read as a registryd problem, and nothing anywhere named the privilege.

SeImpersonatePrivilege is not required and not checked, even though it appears in the requirement list peinit was specified against. It is not used. peinit passes the peer's token descriptor to AccessCheck directly rather than impersonating the caller and evaluating as them, so the privilege that would be needed to impersonate is not needed at all. The same holds on the jobs socket: peinit never impersonates a submitter, and the identity a job runs as is one the kernel already verified the submitter could convey.

For non-platform services peinit creates no tokens. It installs the ones authd minted. It mints only for the SYSTEM platform services it starts during bootstrap, before authd is available.

Note

Minting is interim. The intended model derives a service's token from peinit's own handle through a KACS duplicate-with-additions operation — kernel-attested as a descendant of peinit's real token, and needing no minting privilege. That would let peinit drop SeCreateTokenPrivilege entirely, which is the point: the privilege to mint arbitrary tokens is the strongest thing peinit holds and the one it has the least use for.

13.3 The Attack Surface

Peios / Peios technical reference / peinit / Security

SurfaceReachable byControlsProtected by
Control socketEvery authenticated principal, by defaultService lifecycle, shutdown, observing and stopping jobsThe peer token and AccessCheck against the target's descriptor for every command; a per-user connection limit
Jobs socketEvery authenticated principal, by defaultRunning a program under supervision as an identity the submitter holds; managing the jobs it submittedThe socket inode's descriptor, which is the whole of the submission permission; the kernel's gating of the attached token; the job's own descriptor for every later command; a per-submitter quota
Notification socketAnything that can connectService readiness, watchdog, stored descriptorsThe socket inode's descriptor, then PID matching verified through a pidfd, plus the start generation
Registry keysAnything with registry accessDefinitions, triggers, configurationRegistry key descriptors, enforced by LCS
Machine\System\Init\EnvVars\Anything with registry accessThe environment of every serviceThat key's descriptor, and nothing else — variable names are not filtered
Service cgroupspeinitProcess tracking and clean killOwnership of the hierarchy
Phase 1 mountspeinitVirtual filesystem availabilityHardcoded, no external input
/lcl/policy/autorun.dWhatever can write itArbitrary code as SYSTEM in early bootThe directory's descriptor
Boot attempt counterWhatever can write /.peinit/Recovery mode entryThat file's descriptor

13.3.1 What the socket descriptors mean in practice #

The control socket admits every authenticated principal (§10.1), and what each may then do is decided per command by the control descriptor and each service's own. The ACCESS_DENIED path and the KACS audit record it produces are therefore reachable by anyone who can log on: a caller can make as many kacs.audit.access.checked records as it has refused commands to send, because the default SACLs ask for one per refusal; a descriptor whose SACL audits less makes fewer. So is peinit's request parser, which bounds every request by MaxRequestSize before parsing it. What one caller can hold open is bounded by MaxControlConnectionsPerUser, so no one caller can fill the connections everyone shares.

The notification socket is stamped for SYSTEM and for the Service group S-1-5-6 (§10.5) — every process started as a service, and nothing else. Administrators are deliberately absent from it: an administrator has no business asserting that a service is ready, and the two sockets have different populations however alike their paths look.

A connection to the notification socket from any other principal is refused at connect(), by the filesystem, before peinit ever obtains a peer token — which means no audit event records it.

The jobs socket is deliberately wider: FILE_WRITE_DATA for Authenticated Users, full control for SYSTEM and Administrators (§10.7). Connecting is the submission permission, and peinit adds no check of its own behind it. What bounds the population that can reach it is that descriptor, and what bounds what any one submitter can do is the quota and the rule that a job runs only as an identity the kernel verified the submitter held. The ACCESS_DENIED path is reachable here — it is how a submitter learns it may not touch another submitter's job — and every such denial is the job's SACL's to record: under a job's default descriptor, KACS records each as kacs.audit.access.checked naming the job (§8.4).

These moved together with §4.3, and had to. A service resolved to a non-SYSTEM identity could not have reached the notification socket to report that it had started, so making identity real while the socket stayed SYSTEM-only would have broken every non-SYSTEM service's readiness protocol at once. What makes the pair work is that the tokens peinit mints for itself now carry S-1-5-6 as well, so a SYSTEM service and a LocalService one are on the same footing here.

13.3.2 The autorun directory #

The Phase 1 autorun step (§2.3) executes every file in /lcl/policy/autorun.d as SYSTEM, before path provisioning and before any service. It is fail-open by design, so nothing about a script going wrong stops the boot.

Its only protection is the descriptor on that directory. Anything that can write there executes as SYSTEM at the earliest point in userspace that exists.

13.4 Security Invariants

Peios / Peios technical reference / peinit / Security

Properties peinit does not violate.

1. peinit does not grant privileges it was not asked to grant. RequiredPrivileges is subtractive. peinit removes privileges and never adds one — there is no code path that constructs anything but a removal (§4.5).

2. peinit does not bypass AccessCheck for control operations. Every control command reaches a check against the appropriate descriptor. There is no backdoor, no override flag, and no "trust localhost". Every command on a submitted job, on either socket, is checked against the job's own descriptor — the submitter included, and the job identity excluded unless the descriptor says otherwise (§8.5).

3. peinit does not expose one service's state to another without access control. list returns only what the caller may query, and status is checked per service; job-list and job-status are checked per job (§10.2).

4. No access denial is silent by default. Every check names its object to KACS in an audit context, and every descriptor peinit builds itself — the default service descriptor, the control descriptor, a submitted job's default — carries a SACL auditing every refusal, so a failed AccessCheck is recorded by KACS as kacs.audit.access.checked with the caller, the object and the access requested and granted (§8.4). A descriptor written to the registry or supplied with a job is honoured as it is, SACL included; whoever may write one is trusted with that object's audit policy (§4.6). The MAXIMUM_ALLOWED check that fills a response's granted refuses nothing (§10.2).

5. The control descriptor, the ServiceSecurity descriptors and the per-job descriptors are the only policy inputs for runtime access control. peinit consults no configuration file, no environment variable and no hardcoded principal list. The only inputs to AccessCheck are those descriptors — the first two sourced from the registry with a compiled-in default, the third fixed at submission from the submitter's SID or the SDDL it supplied. Who may submit is not a policy input at all: it is the jobs socket's file descriptor, decided by the filesystem before peinit sees the connection.

6. peinit does not share its SYSTEM token. It opens its own token query-only, as a template, and never installs it on a child. Even an Identity=SYSTEM service gets a separately minted token.

7. peinit does not drop its SYSTEM identity. PID 1 runs as SYSTEM for the lifetime of the system. Only the forked child installs a token; peinit never installs one on itself.

8. Identity is deterministic, and the dangerous case is never implicit. Every service runs with a known identity. SYSTEM has to be declared explicitly; an absent or empty Identity means LocalService.

The declaration logic upholds the eighth: an empty value resolves to LocalService, and SYSTEM is reached only by naming it. What a service actually receives depends on materialisation, and the two can no longer diverge: the authd client that returned a minted SYSTEM token for whatever it was asked for is gone, and a token whose user SID is not the one the declared identity predicts fails the launch rather than being reported under the name that was asked for (§4.3).

9. A submitted job runs only as an identity the kernel verified its submitter could convey. There is no identity field, no route from a plain descriptor to a job identity, and no token opened through a PID or descriptor the submitter named. The job identity is the connecting process's own primary — reached through the kernel's handle on that process — or the token the kernel attached to the message after running the impersonation gates with the submitter as installer. peinit never re-derives that decision and never refuses a token for who it names (§8.5).

14.1 Losing the Registry

Peios / Peios technical reference / peinit / Failure Modes

peinit depends on the registry once, hard, and then never again in the same way. The difference between those two situations is most of what this section is about.

14.1.1 During Phase 1 #

registryd failing to start, failing readiness, or failing the schema-version probe sends peinit to recovery mode immediately. There is no Phase 2 without a registry and nothing useful to degrade to. The counter is incremented, so a persistently broken registryd burns boot attempts even though every one of them fails the same way.

The recovery shell reached from this failure does not have registryd running, and the offline tools (§2.8) are what an administrator has. A Phase 1 failure earlier than the registryd step is different: recovery starts a registryd of its own there (§2.8).

14.1.2 During Phase 2 #

A registry read that fails or times out during the definition read sends peinit to recovery, for the same reason: there is no graph to boot.

14.1.3 At runtime #

This is where the design pays off. peinit holds a complete in-memory model and does not read the registry during normal supervision, so registryd going away does not stop peinit supervising anything. Services keep running, restarts keep working, the control socket keeps answering, and timers keep firing.

What stops working is anything that needs new configuration: reload-config fails. Change notifications stop arriving, and a timer's last-run timestamp cannot be written — so a persistent timer may produce a spurious catch-up on the next boot.

registryd itself is a Critical service , so its failure takes the ordinary Critical path: restart budget, then sync and reboot. peinit does not have to handle a permanently absent registryd at runtime, because the system reboots first.

14.1.4 A definition that will not decode #

One definition that fails to decode fails that definition (§3.2). At boot the key is marked Failed with cause ValidationError and every other service starts normally; on a reload the key is failed the same way and every other definition loads. One malformed key no longer stops unrelated definitions in the same batch from taking effect, and a boot has no previous generation to fall back to anyway, so refusing everything would mean not booting at all over a single malformed key — which is what it used to do.

14.2 Losing a Dependency

Peios / Peios technical reference / peinit / Failure Modes

14.2.1 authd #

Without authd, no non-SYSTEM service can obtain a token, so every such start fails with ParentSetupFailure. Platform services are unaffected, since they never take that path.

authd is Critical , so its own failure eventually reboots the system rather than leaving it in a state where no user-facing service can start.

14.2.2 eventd #

peinit supervises eventd like anything else, and eventd is Critical. While it is down, peinit re-enables the pre-eventd buffer (§11.2) and keeps collecting output; when eventd returns the handoff repeats.

There is a log gap bounded by the buffer size. There is no event gap — audit events land in the KMES ring buffer regardless of eventd's state, and eventd resumes from the last persisted sequence. A submitter's output sink (§11.1) is fed regardless, since it is written from the pipe read rather than from the forwarder.

14.2.3 A bound dependency #

A service that BindsTo something is stopped when that something stops, with cause BindsToPropagation, and restarted when it returns, with cause BindsToRecovery and no charge against the restart budget (§7.1). This is the one dependency relationship that recovers by itself.

A Requires dependency crashing does not affect a running dependent at all. The dependent was ordered after it, not coupled to it.

14.2.4 A dependency that never becomes satisfying #

A dependent blocked on a hard dependency waits until its own operation lifetime expires, and then fails. The timeout is measured from when the operation was created rather than from when it started running, so a service queued behind a slow dependency can exhaust its StartTimeout without ever having attempted to start — which is the honest answer, in that the caller really has been waiting that long.

14.3 Unkillable Processes

Peios / Peios technical reference / peinit / Failure Modes

A process in uninterruptible kernel sleep does not respond to SIGKILL. Nothing peinit can do will make it exit, and the design consequence is that peinit stops trying rather than blocking on it.

Detection is uniform: after sending the kill, arm a post-kill deadline (5 seconds by default) and check whether the cgroup still reports as populated when it fires.

For a service stop, the post-kill deadline belongs to that stop operation, including the stop leg of a Restart. When the main exits and the stop ends, the deadline is cancelled. A retained obsolete deadline is ignored before probing or removing the cgroup: the same pathname may already belong to the next instance. A later stop that escalates to SIGKILL gets its own full PostKillTimeout; it cannot inherit the earlier instance's remaining grace. Re-recording a deadline for the same stop does not extend it.

What follows depends on which cgroup it was:

CgroupConsequence
main/The service goes to Abandoned with cause ProcessUnkillable. Supervision stops; the cgroup is leaked.
health/ or hooks/The sub-cgroup is orphaned and recorded as a leak. The service carries on normally.
checks/The sub-cgroup is recorded as a leak of type helper, as any other leaked sub-cgroup is (§5.7).

The distinction is about what the stuck process is holding. A main process holds the service's ports, locks and connections, so a service whose main process cannot be killed cannot be restarted into a working state. A health check or a hook holds nothing, so a stuck one is a nuisance rather than a blocker.

14.3.1 What Abandoned means #

peinit has given up. The service is not restarted, does not satisfy dependents, and every lifecycle command against it is invalid except reset (§10.3).

A reset re-checks main/. If it has finally emptied — the I/O that was hung completed, or the device came back — peinit cleans up the whole tree and the service returns to Inactive. If it is still populated, the service returns to Inactive anyway, the cgroup stays leaked, and both the acknowledgement and the console carry a warning saying so.

14.3.2 When the process finally dies #

Abandoning a service does not close its main job. The process is still there, so there is still something to reap, and peinit keeps the job open against the possibility that it eventually exits — which it often does, once the I/O it was blocked on completes.

That exit arrives for a service already in Abandoned, a state that has stopped expecting a process. peinit records it and does nothing else: no transition, no restart, no operation update. The service stays Abandoned, the stop stays failed , and the console reports the exit:

peinit: service <service> main process exited in state Abandoned; no action taken

The exit is news about the process, not a retroactive success for the stop that gave up on it. Recovering the service still takes a reset, which is also what cleans up the cgroup the process was holding.

The same handling covers a late exit in any state that no longer expects a process — see §6.2.

14.3.3 The generational escape #

A leaked cgroup cannot be removed, so the next start would collide with it. Recording a leak increments the service's cgroup generation, and the next start builds a fresh tree at %gen<N> (§5.1). Old trees persist until reboot.

That is what lets a service recover from a leak at all: peinit cannot clean up the old tree, so it stops trying to and uses a new one.

14.3.4 What it actually means #

Every path here has the same underlying cause. Something below the service — a hung mount, a failing controller, a device that stopped answering — is not responding to the kernel, and no amount of restarting the service will change that. The leak record exists to say so, because the alternative is a service that mysteriously will not restart.

14.4 Resource Exhaustion

Peios / Peios technical reference / peinit / Failure Modes

peinit is single-threaded PID 1, so most resource pressure reaches it as a failure at a syscall rather than as slowness.

14.4.1 Descriptors #

peinit holds a descriptor per supervised process (a pidfd), two per supervised process for output pipes, one per armed timer, one per control connection, two per jobs connection (the socket and the peer's pidfd), one per submitted job's output sink, plus the sockets, the epoll instance and the signalfd. Control connections are bounded in all by MaxControlConnections (256), which is set for descriptors rather than for fairness: what keeps one caller from taking them is MaxControlConnectionsPerUser (§10.1). A submission also holds its prepared token and attached descriptors from acceptance until the launch takes them, which is bounded by MaxJobsPerSubmitter times the descriptor cap per message.

EMFILE or ENFILE from pipe2 or clone3 fails the start with ParentSetupFailure — a restart-eligible cause, so a service that failed because the system was momentarily out of descriptors gets another go.

The pre-start check helper's result descriptor and pidfd are both unregistered from the event loop and closed, so a service using filesystem conditions costs no descriptors beyond the start itself.

14.4.2 Processes #

EAGAIN from clone3 — the PID limit — is also ParentSetupFailure and restart-eligible.

The one path that forks outside the launch machinery is the timer last-run write, one child per firing of a persistent timer (§9.2). The children _exit as soon as the write returns and peinit matches each one's exit status back to its write, so a failure is reported rather than discarded — but the fork itself is real and happens on every firing.

It is a fork of PID 1, so the child briefly inherits everything peinit holds: every pidfd, the epoll instance, both sockets, every service's pipe read end. It uses none of them and exits immediately. Driving the write through an asynchronous LCS path instead would remove the fork entirely; there is no such path today.

14.4.3 Memory #

ENOMEM from clone3 behaves like the others.

peinit's own memory is bounded by design in the places that could otherwise grow without limit: the pre-eventd buffer has a fixed size and drops its oldest, there is no outbound queue for log delivery, terminal jobs and operations are dropped rather than retained, and neither has a history structure.

Nor do the two structures that once did. A graph execution context and its operation associations are retired on the maintenance turn after every member reaches a terminal state (§7.3), so a boot or an on-demand start costs nothing lasting. And an OnFailure chain entry is cleared once its handler has held for a RestartWindow (§6.3), so a resident handler does not permanently occupy a slot of that failure's depth budget.

14.4.4 Disk #

A full root filesystem shows up in three places. The boot attempt counter cannot be written, which peinit treats as a counter of zero and continues — a failure to record an attempt is not itself worth escalating. The random seed cannot be saved at shutdown, which is recorded and does not block the shutdown. And registryd cannot write, which is registryd's problem and reaches peinit as a Critical service failing.

14.4.5 The OOM killer #

An ErrorControl=Critical service is marked OOM-immune, with oom_score_adj at -1000; everything else is left at the default (§5.4). A Critical service is one whose loss reboots the machine, so letting the OOM killer pick it would turn memory pressure into a reboot.

peinit itself is PID 1 and the kernel will not choose it.

14.5 Power Loss and Corruption

Peios / Peios technical reference / peinit / Failure Modes

14.5.1 What peinit writes #

Three things, and all three are written to survive an unexpected loss of power:

WhatWhereHow
Boot attempt counter/.peinit/boot-attemptsA plain integer, rewritten each boot.
Local machine ID/lcl/etc/machine-idTemporary file, flush, rename.
Random seed/var/state/peinit/random-seedTemporary file on the same filesystem, flush, atomic rename, directory flush.

The seed and the machine ID are atomic in the sense that matters: a reader sees either the old value or the new one, never a partial write.

14.5.2 Reading a damaged file #

The three behave differently on finding something they cannot use, and the differences track how bad each situation is.

The counter is the strictest. Absent means zero. But unreadable, empty, non-decimal, carrying trailing data, or overflowing all send peinit to recovery mode. A counter that cannot be trusted cannot escalate, and the whole point of it is escalation — so failing to read it is treated as though it had already escalated.

The machine ID is regenerated. Absent, empty, all zeroes, the wrong length, not hexadecimal, or missing its trailing newline all produce a fresh identifier, recorded as a warning. It is an opaque install identifier, not a security principal, and a new one is a smaller problem than no boot. An unreadable file or an unwritable path is fail-soft too — the boot continues on an ephemeral identifier, recorded as a warning. Only a failure to generate one sends peinit to recovery, because at that point there is no identifier to continue with (§2.3).

The seed is entirely fail-soft. Absent, empty, oversized, or unrestorable all continue the boot. An empty or oversized seed is recorded as an error on the way past (§2.3); none of them stops the boot. A system with no entropy cache still boots; it starts with less entropy, which is the image builder's problem to solve with a hardware or virtio RNG.

14.5.3 The registry #

peinit does not write service state to the registry, apart from timer last-run timestamps. Everything else about a service's runtime state lives in memory and is rebuilt from the definitions on the next boot, which means a power loss cannot leave peinit's own state inconsistent — there is no state on disk to be inconsistent with.

Registry consistency across power loss is loregd's concern, and its recovery paths are what the recovery shell offers (§2.8).

14.5.4 Timers across a loss #

A persistent timer's last-run timestamp is written after the firing and after the start is initiated, not after the service completes (§9.3). A power loss mid-run therefore does not re-trigger on the next boot — the run was attempted, not missed.

A power loss between the firing and the write does re-trigger, which is the right way round: one extra run is better than a silently skipped one.

14.5.5 Shutdown that never finishes #

If power is lost during the graceful sequence, the effect depends on how far it got. Before step 7, the filesystems are still mounted read-write and the next boot behaves like any unclean shutdown. After step 7, the root has been remounted read-only and everything else unmounted, so there is nothing outstanding to lose.

The counter was incremented at the start of the boot that is now ending, and a successful boot resets it during that boot, once the success grace period has been held. So a power loss during the shutdown of a boot that succeeded leaves the counter at zero; one during the shutdown of a boot that never reached success leaves it advanced. Enough of those in a row reach the recovery threshold, which is the intended behaviour: a machine that keeps failing to reach a successful boot is a machine an administrator should be looking at.

Appendix A Registry Key Reference

Peios / Peios technical reference / peinit

Every registry key peinit reads or writes. Semantics are in the sections referenced.

A.1 Service definitions #

KeyPurposeDefined in
Machine\System\Services\Parent key. Each child key is one service.§3.2
Machine\System\Services\SchemaVersionSchema version guard. REG_DWORD, currently 1. Created by peinit if absent.§2.3, §3.2
Machine\System\Services\ServiceSecurityThe descriptor inherited by definitions that carry none. REG_BINARY.§4.6
Machine\System\Services\<name>One service definition.§3.2
Machine\System\Services\<name>\ProvidesRoles this service fills, which dependencies may name in place of the service. REG_MULTI_SZ. Read at boot and on reload.§7.6
Machine\System\Services\<name>\LastTimerRunLast-run timestamp for a single-trigger persistent timer. REG_QWORD, written by peinit.§9.3
Machine\System\Services\<name>\TimerState\Per-trigger timestamps for a multi-trigger service. Each value is named by the percent-encoded schedule and holds a REG_QWORD.§9.3

A.2 Boot configuration #

KeyTypeDefaultPurposeDefined in
Machine\System\Boot\MaxParallelStartsdword, > 010Services starting concurrently during boot. Zero, a type mismatch, or a malformed payload is invalid and enters recovery.§2.5
Machine\System\Boot\BootSuccessGracedword30Seconds every Critical service has to hold a dependent-satisfying state before the boot counts as successful.§2.5
Machine\System\Boot\SettleTimeoutdword5Seconds to wait for the boot set to settle before starting boot:settled services regardless. Zero is legal.§2.5
Machine\System\Boot\ShutdownTimeoutdword90Seconds for the entire graceful shutdown.§12.2
Machine\System\Boot\PostKillTimeoutdword5Seconds for a cgroup to drain after SIGKILL before it is treated as stuck. Bounds one service's final stop, where ShutdownTimeout bounds the sequence.§12.2

A.3 Operational parameters #

KeyTypeDefaultPurposeDefined in
Machine\System\Init\ControlSecuritybinarySYSTEM and Administrators, every right; Authenticated Users, SYSTEM_QUERY_STATUSThe descriptor for system-level control operations and the boot query.§4.7
Machine\System\Init\MaxControlConnectionsPerUserdword16Concurrent control socket connections one caller's user SID may hold. SYSTEM is exempt.§10.1
Machine\System\Init\MaxControlConnectionsdword256Concurrent control socket connections in all.§10.1
Machine\System\Init\MaxRequestSizedword65536Maximum control request size, in bytes.§10.1
Machine\System\Init\ConnectionTimeoutdword30Seconds before an idle control connection is closed.§10.1
Machine\System\Init\MaxJobsConnectionsdword64Concurrent jobs socket connections.§10.7
Machine\System\Init\MaxJobMessageSizedword32768Maximum jobs socket message content, in bytes.§10.7
Machine\System\Init\JobsConnectionTimeoutdword30Seconds before an idle jobs connection is closed.§10.7
Machine\System\Init\MaxJobsPerSubmitterdword64Live submitted jobs one submitter SID may hold. SYSTEM is exempt.§8.5
Machine\System\Init\MaxLogLineLengthdword8192Bytes per output line before truncation. Minimum 256; below that the default is used and a warning logged.§11.3
Machine\System\Init\MaxLogBufferPerServicedword65536Pipe capacity per service, applied with F_SETPIPE_SZ. Minimum 4096 (one page, the kernel's own floor).§11.3
Machine\System\Init\LogReadBytesPerEventdword16384Bytes drained from one output pipe per readable event. Minimum 512.§11.3
Machine\System\Init\PreEventdBufferdword1048576Bytes of output retained before eventd is available. Applied at boot and on reload. Minimum 4096.§11.2
Machine\System\Init\EnvVars\parent keyemptyVariables injected into every service but registryd. Value name is the variable name; REG_SZ data is the value. Its descriptor is security-critical.§5.5
Machine\System\Init\ProvisionedPaths\parent keyemptyBoot-time path provisioning entries.§2.4
Machine\System\Init\ProvisionedPaths\<name>\Kindstring—directory or file. Required.§2.4
Machine\System\Init\ProvisionedPaths\<name>\Pathstring—The absolute path. Required.§2.4
Machine\System\Init\ProvisionedPaths\<name>\Securitybinarybuilt-inThe descriptor to apply.§2.4
Machine\System\Init\ProvisionedPaths\<name>\Requireddword0If 1, a failure enters recovery before Phase 2.§2.4

A.4 Other subsystems #

KeyTypePurposeDefined in
Machine\System\eventd\LogSocketPathstringWhere peinit forwards service output.§11.4

A.5 Watches #

peinit subscribes to Machine\System\Services\ and Machine\System\Init\ at boot. Any drained event triggers a full reload-config, which also covers the OVERFLOW case (§3.7).

Appendix B Constants and Paths

Peios / Peios technical reference / peinit

Values compiled into peinit, which no registry key changes.

B.1 Filesystem paths #

PathPurpose
/usr/bin/peinit2Where peinit is installed in package storage.
/bin/peinit2The runtime path the kernel init= names.
/sbin/registrydThe compiled-in registryd image path.
/var/state/loregd/Machine.hiveThe machine hive, passed to registryd.
/var/state/loregd/Users.hiveThe users hive, passed to registryd.
/.peinit/peinit's own state directory on the root filesystem.
/.peinit/boot-attemptsThe boot attempt counter.
/lcl/etc/machine-idThe local machine identifier.
/var/state/peinit/random-seedThe persisted entropy seed.
/lcl/policy/autorun.dPhase 1 autorun scripts.
/run/services/peinit/control.sockThe control socket.
/run/services/peinit/notify.sockThe notification socket, by default.
/run/services/peinit/jobs.sockThe jobs socket.
/sys/fs/cgroup/peinit/The root of every service cgroup tree.
/sys/fs/cgroup/peinit/jobs/<guid>A submitted job's cgroup.
/dev/rtc, /dev/rtc0The hardware clock, in that order of preference.
/dev/consoleWhere peinit writes its own messages.
/bin/recsh, /bin/shThe recovery shell, in that order of preference.

B.2 Timeouts and limits #

ConstantValueMeaning
registryd setup timeout30 sProcess setup, driven synchronously in Phase 1.
registryd readiness timeout30 sWaiting for READY=1 in Phase 1.
Reload detection window2 sWaiting for RELOADING=1 after a reload signal. Bounded above by the operation deadline.
Restart delay cap60 sThe ceiling on exponential backoff.
Timeout extension cap×4The multiple of a phase's base timeout an extension may reach.
Operation retention60 sHow long a terminal operation is kept before being dropped.
Submitted job retention60 sHow long a terminal submitted job's entry answers status before being dropped.
peinit.job.status.reported interval1 sThe least spacing between two peinit.job.status.reported events for one job.
Submitted job stop_timeout10 sThe default, per submission.
Submitted job readiness_timeout30 sThe default, per submission.
Submitted job argv and environment2 MiBThe bound on a definition's arguments and environment together.
Boot attempt threshold3The default, overridden by peios.bootattempts=.
OnFailure chain depth16The maximum handler chain from one originating failure.
Pre-eventd buffer1 MiBThe compiled-in buffer capacity.
Notification datagram64 KiBThe receive buffer size.
Descriptors per datagram64The control message buffer capacity.
Random seed512 bytesWritten at shutdown. Restoring accepts 1–4096 bytes.
Control listen backlog32
Jobs listen backlog32
Descriptors per jobs message64Including an output sink.
Tokens per jobs message1
First injected descriptor3Where stored descriptors are placed.
Final action retry1 sThe minimum interval between reboot(2) retries.

B.3 Environment #

VariableValue
PATH/sbin:/bin — the compiled-in base for every service.

The recovery shell additionally receives TERM=linux and HOME=/.

B.4 Kernel command line #

ParameterEffect
peios.safemode=1Force Safe mode.
peios.recovery=1Force recovery mode.
peios.bootattempts=NThe recovery threshold; 0 disables the check.
peios.quiet=NConsole verbosity: 0, 1 or 2.
peios.notifysocket=PATHOverride the notification socket path.

B.5 Descriptors peinit applies #

WhereSDDL
/dev/shm, /run, /sys/fs/cgroup after mountingO:SYG:SYD:(A;OICI;GA;;;SY)(A;OICI;GA;;;BA)(A;CI;GRGX;;;WD)(A;OICIIO;GA;;;S-1-3-0)
/dev/null, /dev/zero, /dev/full, /dev/random, /dev/urandom, /dev/tty, /dev/ptmx (DACL only)D:(A;;GA;;;SY)(A;;GA;;;BA)(A;;FRFW;;;WD)
A provisioned path with no SecurityO:SYG:SYD:(A;;GA;;;SY)(A;;GA;;;BA)(A;;FR;;;BU)
A service's /run/<name> runtime directoryO:SYG:SYD:(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;<service SID>)
The random seed fileO:SYG:SYD:(A;;GA;;;SY)
The default ServiceSecurityO:SYG:BAD:(A;;0x000F;;;SY)(A;;0x000F;;;BA)(A;;0x0001;;;AU)
The default ControlSecurityO:SYG:BAD:(A;;0x0007;;;SY)(A;;0x0007;;;BA)(A;;0x0004;;;AU)
The control socket inodeO:SYG:SYD:(A;;GA;;;SY)(A;;GA;;;BA)(A;;FW;;;AU)
The jobs socket inodeO:SYG:SYD:(A;;GA;;;SY)(A;;GA;;;BA)(A;;FW;;;AU)
A submitted job with no security_descriptorOwner and group the submitter; (A;;0x0007;;;<submitter>)(A;;0x0007;;;SY)(A;;0x0007;;;BA)

B.6 Access rights #

RightBit
SERVICE_QUERY_STATUS0x0001
SERVICE_START0x0002
SERVICE_STOP0x0004
SERVICE_INTERROGATE0x0008
SERVICE_ALL_ACCESS0x000F
SYSTEM_SHUTDOWN0x0001
SYSTEM_RELOAD_CONFIG0x0002
SYSTEM_QUERY_STATUS0x0004
JOB_QUERY0x0001
JOB_STOP0x0002
JOB_SIGNAL0x0004
JOB_ALL_ACCESS0x0007

B.7 Identifiers #

Every GUID peinit generates — jobs, operations — is UUIDv7, so identifiers sort by creation time.

1.1 Overview

Peios / Peios technical reference / loregd / Introduction

loregd — the Local Registry Daemon — is the primary registry source for Peios. It holds one or more registry hives in SQLite databases and serves them to the kernel's registry subsystem over the Registry Source Interface (RSI).

loregd is not architecturally special. Any process that implements the RSI contract can serve as a registry source, and the kernel is source-agnostic: it does not know or care that the process answering for a hive keeps its data in SQLite.

What makes loregd special is operational. It is the source that provides the Machine\ and Users\ hives at boot, which puts it on the critical path to a running system — if loregd does not come up, very little else does.

The dev.peios.loregd package installs the daemon, its registryd role provider, and the protected empty state directory. Debugging support is split into dev.peios.loregd-debuginfo and dev.peios.loregd-debugsource; the matching build inputs are published as dev.peios.loregd-source.

peinit owns the compiled-in Phase-1 service definition, so Loregd does not ship a second service seed that could compete with it.

1.1.1 Where loregd sits #

The registry is split across a trust boundary. The kernel side owns the namespace, the layer model, access checks, watches, and transactions; it holds no storage of its own. The source side owns bytes on disk and answers questions about them. loregd is a source, and everything in this manual describes the lower half of that split.

Three consequences run through the whole design:

  • loregd stores; it does not decide. It never resolves layers, never filters results by visibility, and never applies a security descriptor. It returns every layer entry it holds and lets the kernel work out which one wins. The security descriptors in its tables are opaque payload to it.
  • GUIDs come from the kernel. loregd does not mint key identities. It records the GUID it is given, and uses it as the primary key.
  • Sequence numbers come from the kernel. loregd stores them and reports the maximum it holds at registration, but never allocates one.

1.1.2 What this manual covers #

The command line and startup sequence; the SQLite schema backing a hive and the tmpfs-backed store backing volatile keys; the connection model, write serialisation, and how requests are dispatched; and the handling of each RSI operation, including transactions and enumeration ordering.

The kernel half of the registry — the namespace, layers, watches, access control — is chapter 5 of the Peios Kernel TRM, and the RSI wire protocol itself is specified in PSPK. Key schemas belong to the subsystems that own them, and loregd's supervision as a service belongs to the init system's manual.

1.2 Terminology

Peios / Peios technical reference / loregd / Introduction

The registry's own vocabulary — hive, key, value, layer, source, watch, security descriptor, sequence number — is defined by the kernel-side registry documentation and is used here unchanged.

The terms below are specific to loregd.

Hive database. The SQLite database file backing one hive. Each hive registered by loregd has its own file, whose path is given on the command line (§2.1). Referred to in SQL as the main schema.

Volatile database. The tmpfs-backed SQLite WAL database for one hive's volatile keys, attached as volatile. It mirrors the persistent tables, starts empty, and is never reused across daemon instances (§3.3).

Folded name. The case-folded form of a key name, value name, or child name, stored in a _folded column beside the canonical case-preserving name and used for all case-insensitive comparison (§3.4).

Write connection. The single SQLite connection per hive through which every mutation passes. Its uniqueness is what serialises writes (§4.1).

Read pool. The fixed set of connections serving reads that are not part of a transaction, selected round-robin (§4.1).

Snapshot connection. A dedicated connection opened for one read-only transaction, pinning a point-in-time view of the hive database for that transaction's lifetime (§4.3).

Orphan. A key record that no path entry in any layer points at. Orphans are cleaned up at startup (§2.2) and are reported by RSI_DELETE_LAYER as the keys its deletion left unreachable (§5.6).

2.1 Command Line

Peios / Peios technical reference / loregd / Startup

loregd is configured entirely by its argument vector. It takes one or more hive declarations, each naming a hive and the SQLite database file that backs it:

loregd <HiveName>=<DatabasePath> [<HiveName>=<DatabasePath> ...]

For example:

loregd Machine=/var/state/registry/machine.regdb Users=/var/state/registry/users.regdb
loregd Machine=/var/state/registry/machine.regdb Users=/var/state/registry/users.regdb Roles=/var/state/registry/roles.regdb

Each argument is split at its first =, so a database path may itself contain =.

Every declared hive is registered with the kernel at startup (§2.2).

2.1.1 Argument validation #

loregd rejects the invocation and exits with a non-zero status if any of the following hold:

ConditionReason
No hive arguments at allAt least one hive is required.
An argument with no =Not a hive declaration.
An empty hive name or empty pathNeither is meaningful.
A relative database pathPaths are required to be absolute.
A hive name containing \, /, or NULThese are path separators and terminators in the registry namespace.
The hive name CurrentUser, in any caseReserved by the kernel as a per-token alias; no source may claim it.
Two declarations of the same hive nameDuplicates are detected on the folded name, so Machine and MACHINE collide.

Hive-name comparison is case-insensitive throughout — for duplicate detection here, and for routing requests later — but the case as written on the command line is preserved and is what loregd presents to the kernel when it registers.

2.1.2 Unsupported recovery switches #

In the source-checked implementation at 3cb9768 (1 October 2026), --inspector, --recover-from-backup and --dangerously-clear-database are not implemented switches. The argument parser accepts hive declarations only; each of these spellings lacks = and is rejected as a malformed declaration. They are not offline inspection, restoration or clearing commands.

See startup backup and repair limits before treating startup recovery as a way to repair a database. Identify the installed provider and revision before choosing any recovery procedure.

2.1.3 Configuration #

loregd has no configuration file and reads no configuration from the registry. This is deliberate: loregd is the configuration store, and a store that had to read its own configuration in order to start could not start.

Everything about how it behaves comes from three places: the command-line arguments above, the contents of the SQLite databases they name, and compiled-in constants (§4.1).

NOTIFY_SOCKET is the one environment variable loregd consults, and it carries no behavioural setting. When it is set, loregd treats it as a service-manager readiness socket: once the hives are registered and the request loop is about to begin, it connects and sends READY=1. When it is unset, the step is skipped.

loregd writes ordinary progress to standard output and faults to standard error. peinit captures both streams during Phase 1: if loregd fails before readiness, peinit relays the captured diagnostic output to the console; after the log service starts, the pre-eventd buffer carries both streams into eventd. This keeps early-boot failures visible without bypassing the system log.

2.2 Startup Sequence

Peios / Peios technical reference / loregd / Startup

Startup runs to completion before loregd accepts a single request. Any failure in it is fatal: loregd logs the error and exits non-zero rather than serving a hive it could not fully prepare.

2.2.1 1. Parse and validate arguments #

Extract the hive name to database path mapping and apply the validation in §2.1.

2.2.2 2. Open each hive database #

For each declared hive, create the database file's parent directory if it is absent (mode 0700), then open — or create — the SQLite database at that path. Registry hives can contain credentials and security policy, so the fallback directory is private to the service. A packaged system normally receives the same protected directory from the dev.peios.loregd package, but a first boot onto an empty /var/state still works without anything having prepared it.

Four pieces of connection state are established immediately:

  • PRAGMA journal_mode=wal. The result is read back and checked; if the database does not report wal, the open fails. WAL mode is what allows concurrent readers alongside a writer (§4.1), so silently running without it is not acceptable.
  • PRAGMA foreign_keys=ON. Neither schema declares a foreign key, so this constrains nothing.
  • PRAGMA busy_timeout=0 — SQL lock conflicts return busy immediately (§4.4).
  • The volatile database is attached (§3.3).

Each database/sql handle is limited to a single underlying connection.

2.2.3 3. Attach the volatile store #

Each hive gets a fresh tmpfs-backed SQLite database, attached as volatile and put in WAL mode with private caches. Before opening hives, loregd protects and locks /run/loregd, verifies tmpfs, and removes old volatile stores left by a crash (§3.3). It never reuses a prior process's volatile data.

2.2.4 4. Establish the schema #

If the database has no schema_version table, it is new: loregd creates the persistent tables, creates the volatile tables, and stamps the schema version, in one transaction.

If the version is present, it is compared against the version loregd supports. A newer version aborts startup, and so does an older one (§3.1).

2.2.5 5. First-boot root key #

For each hive, look for a key with no parent. If none exists, this is the hive's first boot: loregd generates a random 16-byte GUID for the root key, builds the default hive-root security descriptor, and inserts the root key record.

The default root descriptor grants SYSTEM and Administrators full access to the key, grants Authenticated Users read access, marks all three as container-inheritable, and sets both owner and group to SYSTEM.

2.2.6 6. Crash recovery #

SQLite's own WAL recovery handles any transaction that was uncommitted when the process died; it happens when the database is opened and needs nothing from loregd.

What loregd does on top of that is clean up orphaned keys — key records that no path entry in any layer points at. These can survive a crash between a key's creation and the creation of the path entry naming it. In one transaction, loregd deletes the values belonging to orphaned keys, then their blanket tombstones, then the key records themselves.

The hive root is exempt: it legitimately has no parent and no path entry pointing at it, so orphan detection skips keys whose parent_guid is null.

2.2.7 7. Compute the maximum sequence number #

For each hive, take the maximum sequence across the path entries, values, and blanket tombstones — in both the persistent and the volatile tables — then take the maximum across all hives. That single global figure is what loregd reports at registration, so the kernel can resume allocating sequence numbers above everything already stored.

In practice the volatile tables are empty at this point in startup and contribute nothing.

2.2.8 8. Open the registry device #

Open /dev/pkm_registry. The kernel requires SeTcbPrivilege in the calling thread's token to permit this; loregd performs no check of its own and relies on the kernel to refuse.

Before registration, negotiate RSI lifetime extension version 1 with REG_SRC_NEGOTIATE. Retain the returned source-session identifier for request contexts and lease renewal. A kernel without this extension is incompatible; startup fails before registering hives instead of silently changing lifetime semantics.

2.2.9 9. Register the hives #

Issue REG_SRC_REGISTER with every hive name, its root key GUID, and the global maximum sequence number from step 7. The registration flags are zero: loregd registers global hives only and never private ones.

2.2.10 10. Signal readiness and serve #

Send readiness to NOTIFY_SOCKET if it is set (§2.1), install the termination signal handler (§2.3), and enter the request loop (§4.2).

2.2.11 Backup and repair limits #

The source-checked implementation at 3cb9768 (1 October 2026) does not create an automatic backup at startup. The startup caller opens each hive; hive initialization prepares its database, schema, root, orphan cleanup and reader pool. The orphan cleanup is a transaction deleting unreferenced persistent records, not backup restoration or a database-corruption repair facility.

WAL recovery and RSI_FLUSH checkpointing do not create a backup copy. Separately, loregd supports read-only RSI snapshot transactions used by higher-level backup operations. A transaction snapshot is not an automatically saved startup backup. See the LCS backup contract for that separate operation; this startup behavior does not establish an end-to-end offline repair or restore procedure.

Verify the existence and coverage of a recovery copy rather than assuming startup made one. The command-line limits also rule out the previously described loregd repair switches for this implementation.

2.3 Exit and Shutdown

Peios / Peios technical reference / loregd / Startup

2.3.1 What ends the process #

loregd exits when any of the following happens:

  • The kernel closes the registry device. Reading from /dev/pkm_registry returns end-of-file, the request loop returns, and loregd shuts down cleanly with status 0. This is the normal path when the registry subsystem goes away.
  • A termination signal arrives. SIGTERM and SIGINT are trapped. The handler closes the device, which unblocks the read loop and produces the same clean shutdown as above. This is how the service manager stops loregd.
  • A request cannot be framed. If a message read from the device cannot be parsed as an RSI request, loregd treats it as unrecoverable and exits non-zero.
  • Startup fails. Any error in §2.2 is fatal.

On shutdown, in-flight requests are drained before the process exits (§4.2), then outstanding transactions are rolled back, every connection is closed, and the per-hive volatile directories are removed before the runtime lock is released.

2.3.2 What does not end the process #

A storage failure during request handling does not terminate loregd. Errors from SQLite while serving a request — including I/O errors — are converted into an RSI_STORAGE_ERROR response and the daemon carries on serving. There is no corruption detector and no disk-full detector that takes the process down; a database that has become unreadable will produce a stream of storage errors rather than an exit.

Note

This is worth knowing when diagnosing a system whose registry has started failing: the symptom is failing operations, not a dead daemon, and loregd will still be running and still registered.

2.3.3 What happens to the data #

Persistent data is durable at the point each transaction commits; SQLite finalises any outstanding WAL state as the connections close.

Volatile data is never recovered after process exit. Clean shutdown removes the tmpfs stores; startup removes crash residue and creates new stores before registering the hives (§3.3).

When the kernel observes the source disconnect, it marks every hive loregd served as unavailable.

3.1 Schema Version

Peios / Peios technical reference / loregd / Storage

Every hive database carries a schema version in a single-row table :

CREATE TABLE schema_version (
    version INTEGER NOT NULL
);

The current version is 1 — the only version that has existed.

loregd checks the version as it opens each database (§2.2, step 4) and takes one of three paths:

StateBehaviour
No schema_version tableThe database is new. loregd creates the persistent tables, the volatile tables, and inserts version 1, all in one transaction.
Version equals 1Normal startup.
Version greater than 1Startup fails. The database was written by a newer loregd, and proceeding risks corrupting it by writing through an older understanding of its layout.
Version less than 1Startup fails.

There are no migrations. loregd carries no migration table, no migration step list, and no upgrade path; an older database is reported as requiring migration and startup stops there. Since 1 is the only version ever assigned, this does not arise in practice — but a second version cannot be stamped without building the migration machinery first.

Two details of the check are worth knowing. The schema_version table has no primary key, uniqueness constraint, or check constraint, so a second row is not detected: the first row read wins.

And because every table is created with IF NOT EXISTS, a database holding the data tables but no schema_version table is stamped as version 1 without any validation that its contents match that layout.

3.2 Persistent Tables

Peios / Peios technical reference / loregd / Storage

Each hive is one SQLite database, and every hive database has the same four data tables. loregd creates them on first boot (§2.2, step 4).

The tables hold what the kernel gives loregd and nothing derived from it. Security descriptors are stored as opaque blobs, GUIDs and sequence numbers are assigned by the kernel, and no table records a resolved or filtered view of anything.

3.2.1 keys #

CREATE TABLE keys (
    guid           BLOB NOT NULL PRIMARY KEY,
    name           TEXT NOT NULL,
    name_folded    TEXT NOT NULL,
    parent_guid    BLOB,
    sd             BLOB NOT NULL,
    volatile       INTEGER NOT NULL DEFAULT 0,
    symlink        INTEGER NOT NULL DEFAULT 0,
    last_write_time INTEGER NOT NULL
);
ColumnMeaning
guidThe 16-byte key GUID assigned by the kernel. Primary key.
nameThe key's own name component, with case preserved as written.
name_foldedThe folded form of name (§3.4), used for case-insensitive lookup.
parent_guidThe parent key's GUID; null for the hive root, which is how the root is identified.
sdThe security descriptor, in binary self-relative form. Opaque to loregd.
volatile1 for a volatile key, 0 for a persistent one. In this table it is always 0 — volatile keys live in the volatile database (§3.3).
symlink1 if the key is a symbolic link.
last_write_timeUnix nanoseconds.

3.2.2 path_entries #

CREATE TABLE path_entries (
    parent_guid       BLOB NOT NULL,
    child_name        TEXT NOT NULL,
    child_name_folded TEXT NOT NULL,
    layer             TEXT NOT NULL,
    target_type       INTEGER NOT NULL,
    target_guid       BLOB,
    sequence          INTEGER NOT NULL,
    PRIMARY KEY (parent_guid, child_name_folded, layer)
);

CREATE INDEX idx_path_entries_target
    ON path_entries (target_guid)
    WHERE target_type = 0;

A path entry is one layer's opinion about one child name under one parent. Several layers may hold entries for the same name; resolving between them is the kernel's job, not loregd's.

ColumnMeaning
parent_guidThe parent key's GUID.
child_nameThe child name with case preserved.
child_name_foldedThe folded form, which is what the primary key uses — so a name collides case-insensitively within a layer.
layerThe layer name. Compared as binary, so layer names are case-sensitive, unlike key names.
target_type0 for a GUID entry (the key exists in this layer), 1 for HIDDEN (a tombstone masking lower layers).
target_guidThe target key's GUID when target_type is 0; null for HIDDEN.
sequenceThe kernel-assigned sequence number.

The partial index on target_guid covers only non-HIDDEN rows. It is what makes the reverse lookup — which path entries point at this key — cheap, and that reverse lookup is what orphan detection (§2.2, step 6) and RSI_DROP_KEY need.

3.2.3 values #

CREATE TABLE [values] (
    key_guid       BLOB NOT NULL,
    name           TEXT NOT NULL,
    name_folded    TEXT NOT NULL,
    layer          TEXT NOT NULL,
    type           INTEGER NOT NULL,
    data           BLOB,
    sequence       INTEGER NOT NULL,
    PRIMARY KEY (key_guid, name_folded, layer)
);
ColumnMeaning
key_guidThe key this value belongs to.
nameThe value name, case preserved. The empty string is the key's default value.
name_foldedThe folded form; also the empty string for the default value.
layerThe layer this value entry belongs to.
typeThe registry value type — REG_SZ is 1, REG_DWORD is 4, and so on. REG_TOMBSTONE (0xFFFF) marks a per-value tombstone.
dataThe value payload; null for a tombstone.
sequenceThe kernel-assigned sequence number.

values is a reserved word in SQL, so every reference to this table is quoted — [values], or main.[values] and volatile.[values] when the schema is named explicitly. Unquoted, it is a syntax error.

3.2.4 blanket_tombstones #

CREATE TABLE blanket_tombstones (
    key_guid       BLOB NOT NULL,
    layer          TEXT NOT NULL,
    sequence       INTEGER NOT NULL,
    PRIMARY KEY (key_guid, layer)
);

A blanket tombstone hides every value a key holds in the layers beneath it, rather than naming one value the way a REG_TOMBSTONE entry does. One row per key per layer.

3.3 The Volatile Store

Peios / Peios technical reference / loregd / Storage

Volatile keys never reach the persistent hive file. Each hive attaches a separate SQLite database as volatile, using WAL and private connection caches. Its files live on tmpfs beneath /run/loregd/hive-<random>/.

The runtime directory has a protected, inheritable SYSTEM-only KACS DACL. Startup verifies that it is on tmpfs and refuses symlinks. A stable .lock file prevents two daemon instances from cleaning up each other's stores.

3.3.1 A mirror of the persistent schema #

The volatile database carries the same four tables as the persistent one — keys, path_entries, [values], and blanket_tombstones — with identical columns, identical primary keys, and the same partial idx_path_entries_target index on non-HIDDEN path entries. Column meanings are exactly those in §3.2.

There is one deliberate difference. In volatile.keys the volatile column defaults to 1 rather than 0, and loregd writes 1 into it. Every record in this database is volatile by definition, and the column carries that fact back out in responses without a second lookup.

Because the two schemas are structurally identical, a query that needs both stores is a UNION ALL across them rather than two queries merged in application code:

SELECT 1 FROM main.keys WHERE guid = ?
UNION ALL
SELECT 1 FROM volatile.keys WHERE guid = ?
LIMIT 1

3.3.2 Why this shape matters #

Making the volatile store a SQLite database attached to the same connection buys transactional behaviour for free. A SQLite transaction spans every attached schema, so a transaction that mutates both persistent and volatile data commits or rolls back as one unit, with no separate mechanism to keep the two halves consistent.

Volatile writes inside a transaction are invisible to other readers until commit and disappear on rollback for the same reason persistent ones do — see §4.3.

3.3.3 Lifetime #

Every open hive gets a fresh random directory. Read, write and snapshot connections attach the same database file for that hive. Different hive instances never share a store merely because their names match.

Clean shutdown closes the connections and removes the hive directories. After a crash, files may remain in tmpfs until the next start or reboot. The next daemon acquires the runtime lock and removes that residue before creating fresh stores; it never reads or recovers old volatile data. The .lock file remains; its kernel lock ends when the process exits. Every volatile table is empty on startup, including a restart within the same boot.

3.3.4 Which store an operation uses #

For an operation naming a single key, the key's own storage decides: loregd looks the GUID up in main.keys and volatile.keys, and whichever holds it determines where the reads and writes go.

RSI_CREATE_KEY is the exception, because the key does not exist yet — the volatile flag in the request decides which database receives it.

Two operations are not scoped to one store at all. RSI_LOOKUP and RSI_ENUM_CHILDREN consult both and combine the results, because a persistent parent may legitimately have volatile children. The reverse does not arise: a persistent child beneath a volatile parent is forbidden by the kernel's data model, so a volatile key's whole subtree is volatile.

3.4 Case Folding

Peios / Peios technical reference / loregd / Storage

Key names and value names are case-insensitive but case-preserving. loregd implements this by storing both forms: the name as written, and a folded form alongside it in a _folded column.

The folded form is computed once, when the name is written, and it is the folded column that appears in every WHERE clause and every primary key. Lookups are therefore plain binary comparisons :

SELECT * FROM path_entries
WHERE parent_guid = ? AND child_name_folded = ?

This is why no custom SQLite collation is registered anywhere in loregd — the case-insensitivity has already happened by the time SQLite sees the query.

The canonical name is what comes back in responses, so callers see the case they originally supplied while storage compares the folded form.

Hive names are folded too, though not stored : routing a request to a hive and rejecting duplicate hive declarations (§2.1) both compare folded names.

Layer names are not folded. They are compared as binary, so layer names are case-sensitive.

3.4.1 How the folded form is computed #

The folded form is Unicode Simple Case Folding: each codepoint is replaced by its status C or S mapping from CaseFolding.txt, Unicode 16.0.0, or left alone when it has none. This is the same rule the kernel applies when it compares layer names and every other name in LCS, and loregd must produce byte-identical results, because a name the two fold differently is a key that exists for one side and not the other.

Identity is guaranteed by construction rather than by care. loregd does not derive the fold from the Go standard library, whose case tables implement lowercasing (a different operation) at a different Unicode version. Its table is generated by the kernel's own generator, pkm/tools/lcs/generate_casefold_table.py, from the same CaseFolding.txt that produces the kernel's table; the generated Go file records the source version and checksum, and the lookup mirrors the kernel's binary search over the same ranges and pairs. A Unicode version bump regenerates both outputs together.

Note

Lowercasing is not case folding, and the difference is not cosmetic. Folding maps 1,484 codepoints; against Go's ToLower the two disagree on 222 of them. The Cherokee blocks fold towards uppercase, so lowercasing sends all 172 of them the wrong way; Greek final sigma ς and the symbol variants ϐ ϑ ϕ ϖ ϰ ϱ ϵ fold to their base letters but lowercase to themselves; the Cyrillic historic letters at U+1C80–U+1C89 and the Garay block, new in 16.0, have no lowercase mapping at all. With lowercasing, a key stored as Σ is not found by a lookup for ς. loregd used lowercasing plus three corrections until September 2026; folded columns written by that implementation differ from the current rule for names containing any of those 222 codepoints.

4.1 Connections

Peios / Peios technical reference / loregd / Concurrency

Each hive holds a small, fixed set of SQLite connections, and which one an operation runs on decides both its isolation and what it can block on.

ConnectionCountUsed for
WriteOne per hiveEvery mutating operation, and every read inside a bound read-write transaction.
Read poolmin(NumCPU, 16), at least 1Reads outside a transaction. Selected round-robin.
SnapshotOne per active read-only transactionReads inside a read-only transaction. Created on demand, closed when the transaction ends.

The pool size is compiled in and cannot be configured — loregd reads no configuration (§2.1).

4.1.1 One connection per handle #

Each Go database/sql handle has one physical connection. The write handle is reserved by a bound read-write transaction. Other mutations return busy rather than waiting for that reservation; per-hive gates coordinate admission and publication (§4.4).

It also means the snapshot connections are genuinely separate handles rather than borrowed pool entries, which is what keeps a long-running read-only transaction from consuming a pool slot.

4.1.2 What every connection carries #

Connection state is established on every physical connection, including one that replaces a connection discarded after cancellation:

  • journal_mode=wal on both databases, verified after being set.
  • foreign_keys=ON.
  • busy_timeout=0 — SQL lock conflicts return busy immediately (§4.4).
  • The hive's tmpfs database attached as volatile (§3.3).

Both stores have private-cache WAL readers. The volatile tables are created on the write connection; other connections attach the same file. A read-only transaction pins a snapshot of both stores before a commit can publish.

4.1.3 Which connection an operation uses #

Nine mutating operations are routed to the write connection through the transaction-aware write path: RSI_CREATE_KEY, RSI_WRITE_KEY, RSI_DROP_KEY, RSI_CREATE_ENTRY, RSI_HIDE_ENTRY, RSI_DELETE_ENTRY, RSI_SET_VALUE, RSI_DELETE_VALUE_ENTRY, and RSI_SET_BLANKET_TOMBSTONE.

Four read operations are routed to the read pool, or to the transaction's connection when one is bound: RSI_LOOKUP, RSI_ENUM_CHILDREN, RSI_READ_KEY, and RSI_QUERY_VALUES.

RSI_DELETE_LAYER and RSI_FLUSH take the hive's write connection directly rather than through the transaction-aware path, and open and commit work of their own. Neither can join a caller's transaction: RSI_FLUSH checkpoints a hive, and RSI_DELETE_LAYER spans every registered hive while a transaction binds to exactly one.

They do still consult the request's transaction id, for the two answers that do not require joining. A read-only transaction is declined with RSI_INVALID, as it is on every other mutating path. And because each hive allows a single write connection, RSI_DELETE_LAYER declines with RSI_TXN_BUSY while any transaction holds a pinned write connection rather than blocking on it forever — the same answer RSI_FLUSH gives for its own hive.

4.2 Request Dispatch

Peios / Peios technical reference / loregd / Concurrency

RSI requests arrive multiplexed on the /dev/pkm_registry file descriptor. Each carries a request id and a transaction id in its header.

loregd reads messages into a single 16 MiB buffer, copies each one out, and hands it to a new goroutine — one per request, with no cap on how many may be in flight.

Responses are serialised by a mutex, because one write to the device must correspond to exactly one response.

On shutdown the in-flight goroutines are drained before the process exits.

4.2.1 Identifying the target hive #

Most operations name a key GUID (or, for lookups and enumerations, a parent GUID), and loregd must decide which hive owns it.

A guidCache maps GUID to hive. It is seeded at startup with every hive's root GUID, and maintained as keys come and go:

  • RSI_CREATE_KEY stores the new GUID immediately, before the transaction that created it commits, and registers an abort hook to evict it if that transaction rolls back. The immediate store is necessary because the cache-miss probe reads through the pool, which cannot see uncommitted rows.
  • RSI_DROP_KEY evicts immediately outside a transaction, or through a commit hook inside one.
  • RSI_DELETE_LAYER evicts the GUIDs its deletion orphaned.

On a miss, loregd probes each hive in turn:

SELECT 1 FROM main.keys WHERE guid = ?
UNION ALL
SELECT 1 FROM volatile.keys WHERE guid = ?
LIMIT 1

Only hits are cached, so a GUID that exists nowhere is re-probed against every hive on every request that names it. The cache has no size bound; it shrinks only through the three eviction paths above.

A GUID that resolves to no hive produces RSI_NOT_FOUND for most operations. RSI_DROP_KEY and the entry deletions treat it differently — see §5.3 and §5.4.

4.2.2 Operations that resolve differently #

RSI_FLUSH carries a hive name rather than a GUID and resolves it by folded name (§3.4), so the match is case-insensitive.

RSI_DELETE_LAYER does not resolve a hive at all: it applies to every registered hive and concatenates the orphan sets (§5.6).

4.3 Transactions

Peios / Peios technical reference / loregd / Concurrency

Transaction identifiers are allocated by the kernel and carried on every request. loregd binds them to connections lazily — RSI_BEGIN_TRANSACTION does no SQLite work at all.

4.3.1 Beginning #

RSI_BEGIN_TRANSACTION carries the transaction id and a mode: RSI_TXN_READ_WRITE (0) or RSI_TXN_READ_ONLY (1). loregd records the id as pending in the requested mode and returns RSI_OK immediately. No connection is taken and no SQLite transaction is opened.

loregd supports both modes — its SQLite backing provides atomic read-write commits and stable read-only snapshots — so it never returns RSI_TXN_NOT_SUPPORTED.

Re-using a transaction id that is already active returns RSI_INVALID. A missing or truncated mode field, or a mode other than 0 or 1, returns RSI_INVALID without recording a transaction. The id remains available for a subsequent well-formed request. Callers must send the complete u64 id and u32 mode that PSPK §4.4 requires; a request carrying only the id is rejected, not treated as read-write.

4.3.2 Read-write transactions #

The transaction binds to a hive on its first mutating operation: loregd identifies the hive from the operation's GUID, acquires that hive's write connection, issues BEGIN IMMEDIATE, and records the binding. If SQLite reports SQLITE_BUSY at that point, the operation returns RSI_TXN_BUSY.

Once bound, every subsequent operation with that transaction id — reads included — runs on the same connection. That is what provides read-your-own-writes: uncommitted rows are visible to the transaction because it is the connection that wrote them. Reads issued before the transaction binds go to the read pool instead, since there is nothing uncommitted to see.

One SQLite transaction spans both databases. A per-hive publication gate excludes readers while a commit publishes, so readers cannot observe one store before the commit and the other after it. Non-transactional mutations use the same gate for their whole operation. The gate is released between transaction requests, allowing ordinary readers to see committed data while a write transaction remains open.

SQLite WAL does not provide crash-atomic commit across attached database files. After a crash, the persistent hive is recovered by SQLite and the volatile store is discarded, never replayed (§3.3).

An operation whose GUID belongs to a different hive than the transaction is bound to is rejected with RSI_STORAGE_ERROR. The kernel enforces hive-scoping before requests reach loregd, so this is a backstop.

4.3.3 Read-only transactions #

A read-only transaction binds on its first read. loregd identifies the hive, opens a dedicated connection — deliberately not one from the read pool, so a long-lived snapshot cannot starve ordinary reads — and issues BEGIN DEFERRED. WAL fixes the snapshot at that first read, and every later read with the same transaction id reuses the connection and observes the same point in time.

Before the first request proceeds, loregd reads both schemas while holding the hive gate, pinning both WAL snapshots at the same published state. Later reads, including the first access to a volatile key, retain that state. The snapshot does not block volatile writes.

A mutating operation carrying a read-only transaction id is rejected with RSI_INVALID before any state changes. The nine operations that route through the write path get this from the routing itself; RSI_DELETE_LAYER and RSI_FLUSH cannot route that way and ask directly, for the same answer (§4.1).

4.3.4 Committing and aborting #

RSI_COMMIT_TRANSACTION issues COMMIT on the bound connection and returns RSI_OK. If the commit fails, the transaction is left open so the caller may retry or abort: a busy or locked failure returns RSI_TXN_BUSY, anything else RSI_STORAGE_ERROR. Committing an unknown transaction id returns RSI_STORAGE_ERROR.

Committing a read-only transaction releases the snapshot and returns RSI_OK.

RSI_ABORT_TRANSACTION issues ROLLBACK, closes the connection, releases any snapshot, runs the transaction's abort hooks, and always returns RSI_OK — including for a transaction id it has never seen. A connection with an uncertain rollback is discarded rather than returned to the pool. This is how the kernel releases a read-only snapshot after a REG_IOC_BACKUP finishes or fails.

LCS sends aborts when a client transaction expires or its last handle closes. loregd also rolls back every remaining transaction during shutdown. A renewable cleanup lease also reclaims state when an abort is lost. Its authority comes from the live kernel owner, for both read-write transactions and read-only snapshots; it is not an inactivity timeout (§4.4).

If a request interrupts a bound SQLite transaction, loregd marks that ID failed. Reads, writes and commits on it return a storage error until LCS aborts it; it is never silently rebound. This handles SQLite's possible automatic rollback after interruption.

4.4 Waiting and Contention

Peios / Peios technical reference / loregd / Concurrency

4.4.1 Request deadlines #

Each RSI operation uses the absolute CLOCK_MONOTONIC deadline supplied by LCS through REG_SRC_REQUEST_CONTEXT. The budget includes kernel admission, queue time, connection-pool waits, publication-gate waits and SQL statements; receiving the request does not restart it. A contended wait that expires returns RSI_TXN_BUSY; successful operations retain their success status. SQLite's busy timeout is zero because its native busy wait can outlast a canceled Go context. Busy classification uses SQLite and Go error codes.

The lifetime extension is negotiated before hive registration. loregd requires version 1 and fails startup on a kernel without it. The 25-second local budget is only a fallback for direct, unnegotiated test clients.

4.4.2 A transaction holding the writer #

A competing mutation returns RSI_TXN_BUSY immediately while a read-write transaction owns the hive's write connection. This includes non-transactional key drops, conditional value writes, flushes and layer deletion. Admission and binding are coordinated under the hive gate, so the check cannot race a new binding. A read-only snapshot is not counted as a writer.

Both stores use WAL with private caches. Ordinary reads see committed data while a writer remains open; backup snapshots do not hold volatile table locks that block writers. A short per-hive gate excludes reads from commit publication and non-transactional mutations, not from an entire transaction's lifetime. Different hives have separate gates.

4.4.3 Cancellation and cleanup #

Operations on the same transaction are serialized, including commit and abort. Rollback uses a fresh cleanup context, and an uncertain connection is discarded rather than returned to the pool with a live transaction. Every replacement physical connection receives its WAL, foreign-key and volatile-attachment setup again.

SQLite may automatically roll back an interrupted write. Such a bound transaction is marked failed and subsequent use returns a storage error; LCS must abort it. It is never silently rebound or allowed to autocommit.

LCS owns transaction lifetime. A live transaction file object (including all duplicated handles) or an in-kernel operation owns the shared kernel transaction record. A source can renew its five-second cleanup lease only while that owner and source session remain valid. SQL activity neither creates ownership nor extends a transaction's lifetime deadline. An internal operation may have no separate lifetime limit; its live operation scope is still required.

A separate control loop renews leases with three seconds or less remaining, checks expiration every 100 milliseconds, and reclaims expired transactions. Control ioctls consume no ordinary RSI request slots. Final owner release, abort, timeout or a lost source session prevents renewal even if an abort message cannot be delivered. Expired transactions reject new operations; late renewals cannot revive them. Cleanup serializes on the transaction's operation gate and waits for an already-admitted commit to resolve. A request timeout is not evidence that a commit failed. The request deadline gates commit admission; once admitted, SQL COMMIT runs without caller cancellation and its actual result is reported, including through normal late-response recovery. Shutdown drains requests and cleanup before closing databases.

5.1 Store Routing

Peios / Peios technical reference / loregd / Request Handling

Persistent data lives in the hive database's main schema; volatile data lives in the attached volatile schema (§3.3). Most operations act on one of the two, and loregd has to decide which before it can run any SQL.

5.1.1 Operations naming one key #

For a write that names an existing key, the schema containing the key row selects the store. loregd first probes main.keys by GUID, then probes volatile.keys only if the first query found no row. A database error is returned as a storage error, not treated as absence. If an externally modified database contains the GUID in both stores, the existing main-first lookup precedence applies; normal key creation rejects such duplicates.

A GUID present in neither produces RSI_NOT_FOUND for most operations.

Routing follows the source table, including when the stored volatile column contradicts it. A row in main.keys carrying volatile = 1 still routes writes to main; a row in volatile.keys carrying 0 still routes writes to volatile. An inconsistent flag must not redirect writes to the other store. Rows loregd creates carry the consistent flag for their store. This routing rule does not repair external metadata changes: read-key and lookup metadata continue to report the stored column value.

RSI_CREATE_KEY cannot consult a key that does not exist yet, so it routes on the volatile flag carried in the request instead.

RSI_CREATE_ENTRY routes to the store containing the child key the entry points at. When that child GUID is present in neither store, the entry is written to the persistent table.

RSI_HIDE_ENTRY routes on the parent key, since a HIDDEN entry belongs to the parent's child list and a volatile parent's whole subtree is volatile.

5.1.2 Operations spanning both stores #

RSI_LOOKUP, RSI_ENUM_CHILDREN, RSI_READ_KEY and RSI_QUERY_VALUES are not scoped to one store: a persistent parent may have volatile children, and a persistent key may have volatile-store rows beneath it. Each issues a single UNION ALL statement over the two schemas rather than querying them separately, so the merge happens inside SQLite.

Nothing de-duplicates across the two stores. The primary keys that make (parent_guid, child_name_folded, layer) unique apply per schema, so if the same triple exists in both, both rows appear in the response. The same holds for value entries keyed on (key_guid, name_folded, layer).

The deletions — RSI_DELETE_ENTRY, RSI_DELETE_VALUE_ENTRY and RSI_DROP_KEY — do not route at all. They delete from both schemas unconditionally.

5.2 Response Ordering

Peios / Peios technical reference / loregd / Request Handling

The queries backing enumerations and lookups carry no ORDER BY, and a UNION ALL across the two stores yields rows in whatever order SQLite produces them. That order is not stable across calls.

The kernel walks enumeration results by dense index across repeated calls, so an unstable order would make that walk duplicate or drop entries. loregd therefore sorts every affected array into a canonical order before encoding a response:

ResponseSorted by
RSI_LOOKUP path entrieslayer, then sequence
RSI_ENUM_CHILDREN childrenfolded child name
RSI_ENUM_CHILDREN per-child entrieslayer, then sequence
RSI_QUERY_VALUES value entriesfolded value name, then layer, then sequence
RSI_QUERY_VALUES blanket tombstonesfolded layer name, then sequence
RSI_DELETE_LAYER orphan GUIDsascending GUID, compared bytewise
Key-metadata blocks, in any responseascending GUID, compared bytewise

This ordering is a wire-stability guarantee only. It has no bearing on layer resolution, which is order-independent — the kernel selects a maximum, not a first match.

5.2.1 Arrays assembled outside one query #

Two of those arrays are not a single query's result set. They are assembled first and sorted afterwards:

  • The blanket-tombstone array in an RSI_QUERY_VALUES response comes from its own UNION ALL, separate from the value entries it travels beside, and takes the same order they do.
  • The orphan-GUID array in an RSI_DELETE_LAYER response is the concatenation of every registered hive's orphan set. That walk ranges a Go map, whose iteration order is randomised, so the array is sorted bytewise before it is encoded.

5.2.2 Child display names #

RSI_ENUM_CHILDREN groups rows by child_name_folded and emits one child block per folded name, carrying a display name taken from the child_name column.

Where two rows share a folded name but differ in stored case — Foo in one store and FOO in the other, say — the display name emitted is the lower of the two bytewise, which is a property of the set rather than of the order the union produced. Both the order of children and the case of the name reported for each are therefore stable across calls.

5.3 Key Operations

Peios / Peios technical reference / loregd / Request Handling

5.3.1 RSI_CREATE_KEY #

The request's volatile flag selects the target table (§5.1). For a persistent key:

INSERT INTO keys
    (guid, name, name_folded, parent_guid, sd, volatile, symlink,
     last_write_time)
VALUES (?, ?, fold(?), ?, ?, 0, ?, ?)

last_write_time is not carried in the request. loregd sets it to the current wall-clock time in Unix nanoseconds at insertion.

Uniqueness comes from the target table's primary key on guid, surfaced as RSI_ALREADY_EXISTS. That key binds one schema, so loregd also asks the other store whether it holds the GUID before inserting, and answers RSI_ALREADY_EXISTS if it does. Without that check a GUID could come to exist in both stores, where metadata reads would resolve it to the main row — the reading query takes the first row of a UNION ALL that puts main first — leaving the volatile row unreachable while it still occupied its GUID.

The new GUID is added to the hive cache immediately, before the enclosing transaction commits, with an abort hook to remove it if that transaction rolls back (§4.2).

An unresolvable parent GUID returns RSI_NOT_FOUND, after a fallback check of the registered hives' root GUIDs.

5.3.2 RSI_READ_KEY #

SELECT name, parent_guid, sd, volatile, symlink, last_write_time
FROM main.keys WHERE guid = ?
UNION ALL
SELECT name, parent_guid, sd, volatile, symlink, last_write_time
FROM volatile.keys WHERE guid = ?
LIMIT 1

RSI_NOT_FOUND if the GUID is in neither store, and likewise if it resolves to no hive. The volatile field in the response is the stored column value.

5.3.3 RSI_WRITE_KEY #

Updates the two mutable fields of a key, selected by a field mask:

BitValueField
00x01sd
10x02last_write_time

Valid masks are therefore 0x00, 0x01, 0x02 and 0x03. Any other bit set returns RSI_INVALID — it indicates an attempt to modify an immutable field.

loregd builds one UPDATE from the mask, setting only the named fields:

-- mask 0x03
UPDATE keys SET sd = ?, last_write_time = ? WHERE guid = ?

A mask of 0x00 names no fields and acts as an existence check, returning RSI_OK or RSI_NOT_FOUND. An update that matches no row also returns RSI_NOT_FOUND.

Outside a transaction the update is a single auto-committed statement. Inside one it runs on the transaction's connection.

5.3.4 RSI_DROP_KEY #

Purges every trace of a GUID from both stores — four tables in each schema:

DELETE FROM keys               WHERE guid = ?;
DELETE FROM path_entries       WHERE target_guid = ?;
DELETE FROM [values]           WHERE key_guid = ?;
DELETE FROM blanket_tombstones WHERE key_guid = ?;

Outside a transaction, the eight statements are wrapped in a BEGIN IMMEDIATE transaction of their own so the purge is atomic. Inside one, they run on the transaction's connection.

Dropping a GUID that does not exist returns RSI_OK; so does one that resolves to no hive. The operation is idempotent. The GUID is evicted from the hive cache (§4.2).

5.4 Path Entry Operations

Peios / Peios technical reference / loregd / Request Handling

5.4.1 RSI_LOOKUP #

Returns every layer's entry for one child name under one parent, together with metadata for the keys those entries point at.

SELECT layer, target_type, target_guid, sequence
FROM main.path_entries
WHERE parent_guid = ? AND child_name_folded = ?
UNION ALL
SELECT layer, target_type, target_guid, sequence
FROM volatile.path_entries
WHERE parent_guid = ? AND child_name_folded = ?

HIDDEN entries are returned as entries but contribute no metadata GUID. For each distinct non-HIDDEN target_guid, loregd fetches the key's metadata — one query per GUID — and emits the blocks in ascending GUID order (§5.2).

loregd does no layer filtering and no resolution: every entry it holds is returned, and choosing between them is the kernel's job.

An unresolvable parent GUID returns RSI_NOT_FOUND.

If a path entry names a target_guid for which no key record exists, loregd drops that entry from the response and returns RSI_OK, so the child reads as absent. This is reachable in ordinary operation: the kernel issues RSI_CREATE_ENTRY before RSI_CREATE_KEY, so a lookup landing between the two sees an entry whose key has not yet been written, and failing the request would make a routine race fatal to the caller.

5.4.2 RSI_CREATE_ENTRY #

INSERT INTO path_entries
    (parent_guid, child_name, child_name_folded, layer,
     target_type, target_guid, sequence)
VALUES (?, ?, fold(?), ?, 0, ?, ?)

The target table follows the store containing the child key (§5.1). A child GUID in neither store lands in the persistent table.

RSI_ALREADY_EXISTS comes from the target table's primary key on (parent_guid, child_name_folded, layer). That key binds one schema, so loregd also asks the other store for the same triple before inserting, and answers RSI_ALREADY_EXISTS if it finds one. Nothing de-duplicates on read, so a triple in both stores would put two entries claiming the same name in the same layer inside one child block (§5.1).

An unresolvable parent GUID returns RSI_NOT_FOUND.

5.4.3 RSI_HIDE_ENTRY #

Writes a tombstone that masks the same name in lower layers:

INSERT OR REPLACE INTO path_entries
    (parent_guid, child_name, child_name_folded, layer,
     target_type, target_guid, sequence)
VALUES (?, ?, fold(?), ?, 1, NULL, ?)

target_type is 1 and target_guid is null. The target table follows the store containing the parent key, because a volatile parent's entire subtree is volatile. A parent GUID in neither store returns RSI_NOT_FOUND.

5.4.4 RSI_DELETE_ENTRY #

Removes one layer's entry for one name, from both stores:

DELETE FROM path_entries
WHERE parent_guid = ? AND child_name_folded = ? AND layer = ?

No rows-affected check is made, so deleting an entry that is not there succeeds. A parent GUID that resolves to no hive returns RSI_NOT_FOUND rather than succeeding.

5.4.5 RSI_ENUM_CHILDREN #

Returns every layer's entry for every child under a parent:

SELECT child_name, child_name_folded, layer, target_type,
       target_guid, sequence
FROM main.path_entries WHERE parent_guid = ?
UNION ALL
SELECT child_name, child_name_folded, layer, target_type,
       target_guid, sequence
FROM volatile.path_entries WHERE parent_guid = ?

Rows are grouped by folded child name into one block per child, each carrying that child's per-layer entries. Metadata for the distinct non-HIDDEN target GUIDs is fetched and emitted exactly as for RSI_LOOKUP, including its treatment of an entry whose key record does not yet exist: the entry is dropped, and a child left holding no entries at all is dropped with it, so the child reads as absent rather than failing the enumeration. The metadata block for a dropped entry is omitted with it — the kernel rejects a GUID-typed entry carrying no metadata, and equally a metadata block no entry references.

Ordering, and the treatment of two rows whose folded names match but whose stored case differs, are covered in §5.2.

5.5 Value Operations

Peios / Peios technical reference / loregd / Request Handling

5.5.1 RSI_QUERY_VALUES #

Returns every layer's entry for one value, or for all of a key's values when the request sets the query-all flag:

-- single value
SELECT name, layer, type, data, sequence
FROM main.[values] WHERE key_guid = ? AND name_folded = ?
UNION ALL
SELECT name, layer, type, data, sequence
FROM volatile.[values] WHERE key_guid = ? AND name_folded = ?

-- query all
SELECT name, layer, type, data, sequence
FROM main.[values] WHERE key_guid = ?
UNION ALL
SELECT name, layer, type, data, sequence
FROM volatile.[values] WHERE key_guid = ?

The response also carries the key's blanket-tombstone state:

SELECT layer, sequence
FROM main.blanket_tombstones WHERE key_guid = ?
UNION ALL
SELECT layer, sequence
FROM volatile.blanket_tombstones WHERE key_guid = ?

Value entries and blanket tombstones are both sorted (§5.2).

An unresolvable GUID returns RSI_NOT_FOUND. An existing key with no values returns RSI_OK with empty arrays.

5.5.2 RSI_SET_VALUE #

The store containing the key selects the target table (§5.1). A key GUID in neither store returns RSI_NOT_FOUND.

INSERT OR REPLACE INTO [values]
    (key_guid, name, name_folded, layer, type, data, sequence)
VALUES (?, ?, fold(?), ?, ?, ?, ?)

5.5.2.1 Conditional writes #

When the request carries a non-zero expected_sequence, the write is a compare-and-swap. loregd reads the current entry's sequence and writes only if it matches:

SELECT sequence FROM [values]
WHERE key_guid = ? AND name_folded = ? AND layer = ?

If the row is absent, or its sequence differs, the operation returns RSI_CAS_FAILED and writes nothing.

Outside a transaction, the check and the write are wrapped in their own BEGIN IMMEDIATE transaction so no other writer can interleave. If that transaction cannot begin because the database is busy, the operation returns RSI_TXN_BUSY. Inside a transaction, the caller's transaction already provides the isolation.

5.5.3 RSI_DELETE_VALUE_ENTRY #

Removes one layer's entry for one value, from both stores:

DELETE FROM [values]
WHERE key_guid = ? AND name_folded = ? AND layer = ?

No rows-affected check, so deleting an absent entry succeeds. A GUID that resolves to no hive returns RSI_NOT_FOUND.

5.5.4 RSI_SET_BLANKET_TOMBSTONE #

Sets or clears the tombstone that masks every value a key holds in lower layers. The store containing the key selects the target table (§5.1). A GUID in neither returns RSI_NOT_FOUND.

-- set
INSERT OR REPLACE INTO blanket_tombstones (key_guid, layer, sequence)
VALUES (?, ?, ?)

-- clear
DELETE FROM blanket_tombstones WHERE key_guid = ? AND layer = ?

5.6 Layer and Maintenance Operations

Peios / Peios technical reference / loregd / Request Handling

Both operations in this section consult the request's transaction id before doing anything — a read-only transaction may not mutate, and an unknown id is refused (§4.3) — but neither can join one. Both take the hive's write connection directly and commit work of their own, which is why a bound transaction makes them decline rather than wait (§4.1).

5.6.1 RSI_DELETE_LAYER #

Removes every entry belonging to one layer and reports the keys that the removal left unreferenced.

The operation is applied to every registered hive, not to one identified from the request, and the per-hive orphan sets are concatenated into a single response array.

For each hive, inside one BEGIN IMMEDIATE transaction, loregd first computes the orphan set and then deletes:

-- GUIDs referenced by the layer being removed
SELECT DISTINCT target_guid FROM main.path_entries
WHERE layer = ? AND target_type = 0
UNION
SELECT DISTINCT target_guid FROM volatile.path_entries
WHERE layer = ? AND target_type = 0

minus the GUIDs referenced by any other layer, gathered the same way with layer != ?. What remains is reachable only through the layer being deleted, and is therefore orphaned by it.

DELETE FROM path_entries       WHERE layer = ?;
DELETE FROM [values]           WHERE layer = ?;
DELETE FROM blanket_tombstones WHERE layer = ?;

Both schemas are covered, and all six deletions run inside the same transaction as the orphan computation, so nothing can be inserted between the two steps. The orphaned GUIDs are evicted from the hive cache (§4.2) and returned to the caller.

The response array is sorted into ascending byte order (§5.2).

A contended write is reported as RSI_TXN_BUSY, the same distinction every other write path draws: the operation declines if any transaction already holds a hive's write connection, and again if a hive's own BEGIN IMMEDIATE finds the database busy. Other failures are RSI_STORAGE_ERROR.

5.6.2 RSI_FLUSH #

Forces the hive's write-ahead log to be checkpointed so that all persistent data is durable on disk:

PRAGMA wal_checkpoint(TRUNCATE)

The request carries a hive name rather than a GUID. It is matched case-insensitively against the registered hives by folded name (§3.4). A name matching none returns RSI_INVALID.

Two conditions return RSI_TXN_BUSY instead of checkpointing:

  • Any transaction is currently bound to the hive. A checkpoint on a connection already held by a transaction would deadlock, so loregd declines immediately rather than waiting (§4.4).
  • The checkpoint itself reports that it could not complete because the database was busy.

The volatile store has no durability and is unaffected: nothing is flushed, and nothing needs to be.

5.7 Status Codes

Peios / Peios technical reference / loregd / Request Handling

loregd returns seven of the RSI status codes:

StatusValueReturned when
RSI_OK0The operation succeeded. Also returned by the idempotent deletions when their target was already absent.
RSI_NOT_FOUND1A named key GUID exists in neither store, or resolves to no registered hive. Also an update matching no row.
RSI_ALREADY_EXISTS2A duplicate key GUID or path entry: the target table's primary key, or the matching check against the other store (§5.3, §5.4). No value operation produces it — every value write is an INSERT OR REPLACE.
RSI_STORAGE_ERROR3A SQLite failure while serving the request. Also an operation whose GUID belongs to a hive other than the one its transaction is bound to, a mutating operation carrying an unknown transaction id, and a failed commit that was not busy.
RSI_TXN_BUSY6BEGIN IMMEDIATE found the database busy, a conditional write could not begin its transaction, or RSI_FLUSH or RSI_DELETE_LAYER found a transaction already bound to a hive's write connection.
RSI_INVALID7An unknown opcode, a request whose payload cannot be decoded, an out-of-range RSI_WRITE_KEY field mask, a mutating operation carrying a read-only transaction id, an RSI_FLUSH naming an unregistered hive, or an RSI_BEGIN_TRANSACTION re-using an active id or naming a mode other than RSI_TXN_READ_WRITE or RSI_TXN_READ_ONLY.
RSI_CAS_FAILED8A conditional RSI_SET_VALUE whose target was absent or whose sequence did not match.

Three codes are defined by the interface and never produced by loregd: RSI_NOT_EMPTY (4), RSI_TOO_LARGE (5), and RSI_TXN_NOT_SUPPORTED (9).

RSI_TXN_NOT_SUPPORTED is never needed because loregd supports both transaction modes (§4.3).

RSI_TOO_LARGE is never returned because an oversized or malformed frame is not answered at all. Framing is validated before an opcode is known, and a frame that fails validation ends the connection instead of producing a response (§2.3).

Appendix A Prior Art

Peios / Peios technical reference / loregd

A.1 SQLite #

loregd's storage engine is SQLite, and it is specified against SQLite rather than against an abstract store. The schema, the concurrency model, and the operational behaviour all name SQLite features directly: WAL mode for concurrent readers alongside a serialised writer, savepoints and transactions for atomicity, PRAGMA wal_checkpoint for durability on demand, and its crash recovery for restart after an unclean shutdown.

Both halves of a hive are SQLite. Persistent data lives in the database file named on the command line; volatile data lives in a second, tmpfs-backed WAL database attached to the same connections (§3.3). Using one engine for both is what makes a transaction spanning persistent and volatile data atomic without any additional machinery.

A different storage engine would have to supply equivalent transactional and concurrency semantics. Nothing in the design forbids that, but nothing accommodates it either.

A.2 The Windows registry hive format #

The Windows registry stores hives as binary REGF files managed directly by the kernel's Configuration Manager. loregd departs from that model completely: storage is a SQLite database managed by an unprivileged userspace daemon, not a kernel-managed binary file, and the kernel holds no storage of its own at all.

What the two share is the data model — keys, values, security descriptors — which Peios inherits through the registry's kernel-side specification rather than from the file format. The on-disk format has no relationship to REGF whatsoever, and no REGF file can be read by loregd or written by it.

A.3 The Registry Source Interface #

loregd implements the RSI, which is specified in PSPK rather than here. That specification defines the operations, the message format, the error model, and the obligations binding on any source; loregd's request handling (chapter 5) is a mapping of those operations onto SQL against the two stores.

Where this manual and the RSI specification disagree about wire behaviour, the specification is correct and this manual has a bug — the RSI is a contract with the kernel, and loregd is one implementation of one side of it.

1.1 Overview

Peios / Peios technical reference / eventd / Introduction

eventd is the observability daemon: the single persistent sink for everything a Peios system records about itself. Events, logs and metrics all end in eventd, and every query for any of them is answered by it.

It is one of the platform daemons the service manager starts at boot, signed at TCB level, and it is Critical — a system that loses it loses its audit trail.

The three data types are genuinely different and eventd treats them differently at every layer.

Events are structured, typed records carrying identity stamps the kernel applied and an emitter could not influence. They arrive through KMES, in per-CPU shared-memory ring buffers, and eventd is their primary consumer. They are the audit and security telemetry, and losing one is a real failure — so the event path is the one with sequence numbers, gap detection, per-transaction durability, and an eventd.events.lost record written whenever anything is lost.

Logs are text: a line a program wrote, with light metadata attached. They arrive on a datagram socket, mostly from the service manager forwarding what it read from a service's standard output and standard error. Losing one is an inconvenience, and the ingestion path is designed around that tolerance rather than against it.

Metrics are numeric measurements over time — dense series rather than discrete occurrences. They arrive on a second datagram socket, pushed by whatever is doing the measuring. eventd is a sink, not a collector: it scrapes nothing and polls nothing.

1.1.1 The shape of the daemon #

Three ingestion paths, three storage engines, one query surface.

The event path runs one drain thread per CPU, each attached to one ring buffer, handing events over a bounded channel to a writer thread that batches them into a SQLite shard. Shards are independent — separate files, separate write-ahead logs, separate writer threads, no shared write-path state — so write throughput scales with the shard count (§2.3).

The log and metric paths each run a single thread that reads datagrams and writes them, to one database each. Neither contends with the event path.

Underneath all three sits a decision that shapes the event pipeline entirely: the KMES ring buffers are the only buffer. eventd holds no large intermediate queue. Events move from the ring buffer through a small bounded handoff straight into a transaction, and when the writer falls behind, backpressure propagates backwards until the ring buffer absorbs it — and when the ring buffer cannot, the loss is detected and recorded rather than hidden (§2.5).

Adaptive indexing watches which fields event queries filter on and maintains indexes for them, shedding those indexes under write pressure because ingestion throughput outranks query latency (§3.4). Repeated wide metric-window queries can seed bounded adaptive rollups, but eventd proves each cached result current against raw samples and never maintains it on the ingestion path (§5.6).

Everything eventd holds is readable only through access checks that KACS performs, per event type, per log origin, per metric name, and per field within a record (§7).

1.1.2 What eventd is not #

It is not a log framework. eventd stores lines; it does not parse them, does not understand severity beyond a single error flag, and does not care whether the text happens to be JSON.

It is not a metric collector. Nothing in eventd reads /proc, scrapes an endpoint, or polls a service. Something else measures and pushes.

It is not a tracing system. Distributed tracing is out of scope entirely.

It is not the low-latency path to events. A consumer that needs events in microseconds attaches to the KMES ring buffers directly, as revstrm does. eventd sits above that transport, adds persistence and access control, and costs a batch commit interval in latency.

It is not the only KMES consumer, and holds no privileged position among them.

1.2 What This Manual Is

Peios / Peios technical reference / eventd / Introduction

This is a Technical Reference Manual Proposal. eventd has not been written.

Everything in this manual is therefore a description of a design rather than of an artifact: the schemas, the thread structure, the algorithms, the constants and the failure behaviour are what eventd is specified to do, not observations of what it does. Nothing here has been checked against an implementation, because there is none to check against.

It is written in the indicative mood, exactly as a reference manual for existing software would be, and it makes no conformance demands. When the code lands, this document becomes eventd's Technical Reference Manual — corrected wherever the implementation and the design turn out to disagree, and with no change in kind. That is the whole reason for the proposal form: a design document that will be read as a manual is better written as one from the start.

The distinction a reader needs to keep is that a TRM's authority comes from the software and this one has none of that yet. Where a statement here is surprising, it is a design decision that has not survived contact with an implementation.

1.2.1 What is a contract and what is not #

eventd's three external interfaces are specified separately, in PSPU §3: the log ingestion channel, the metric ingestion channel, and the query channel with its query language. Those are contracts, binding on anything that speaks them, and they are normative where this manual is not.

This manual covers the other side of that boundary — how eventd fulfils them:

  • how it consumes KMES and what it does when it falls behind (§2)
  • what it stores, in what schema, and how it accelerates and expires it (§3, §4, §5)
  • how a query in the language of PSPU §3 becomes an answer (§6)
  • how access decisions are reached (§7)
  • how it starts, stops, and behaves when something breaks (§8, §9)

Where a chapter touches the contract, it references PSPU §3 rather than restating it, and describes only what eventd adds.

The access control mechanism is here rather than in PSPU because it is behind the abstraction: a client sees only which records and fields it received (PSPU §3.28). The field GUID derivation in §7.3 is the one part of it that a third party may need to reproduce — an administrator writing a Security Descriptor computes the same GUIDs — and it is a candidate for promotion into a specification if that turns out to be a common thing to do.

1.2.2 Versions and constants #

Constants, configuration keys and catalogues are collected in the appendices at the end of the manual, so that a chapter's reference material does not interrupt the prose that explains it. Where an appendix defines a value, the body references it rather than repeating it.

1.3 Terminology

Peios / Peios technical reference / eventd / Introduction

Terms defined elsewhere are used with the same meaning and are not redefined here: event, header, payload, stamp, ring buffer, consumer, origin class and sequence number from the KMES chapters of the Peios Kernel TRM and PSPK; token, GUID, SID, Security Descriptor, ACL, ACE and privilege from the Peios Kernel TRM and PCDS; registry, hive, key, value and layer from the Peios Kernel TRM; producer, client, log record, metric sample, time series and concrete identifier from PSPU §3.2.

Syscall numbers and signatures for the kernel interfaces eventd calls — kmes_attach, the SOL_KACS peer-token option, kacs_access_check and kacs_access_check_list — are in the Peios Kernel TRM's generated ABI appendices, §2.A and §3.A. This manual names them and does not repeat their numbers.

The following are specific to eventd.

Drain thread: one of the threads that reads from a per-CPU KMES ring buffer. There is exactly one per CPU (§2.2).

Writer thread: the sole writer to one event shard. There is exactly one per shard, and no other thread writes to that database (§2.3).

Shard: one of the independent SQLite databases the event store is split across, each with its own file, write-ahead log and writer thread. A shard is a write-path construct only; the query path treats the whole directory as one store (§2.3).

Active shard: a shard in the current configuration's numbering. Historical shard: a shard database left behind by a previous configuration. It takes no new events and is still queried, through read-only connections; its one writer is the retention coordinator (§3.3, §3.6).

Handoff channel: the bounded queue between drain threads and a writer thread. It is a small startup-fixed scheduling handoff, bounded by slots and bytes independently of transaction batch size (§2.3).

Synthetic event: a record eventd generates itself and writes directly to a shard, bypassing KMES. Synthetic events carry no identity stamps and no sequence numbers, and are distinguished by their type, one of exactly five eventd reserves: eventd.daemon.started, eventd.daemon.stopped, eventd.events.lost, eventd.store.quarantined and eventd.config.changed (§2.6).

Gap record: the eventd.events.lost synthetic event recording that events were lost on one CPU, and which sequence numbers went missing (§2.5).

Event store directory: the directory holding every shard database and the metadata database. Metadata database: eventd-meta.db, the one database that is not a shard and survives shard reconfiguration (§3.5).

Desired index set: the global, priority-ordered list of fields eventd aims to have indexed across all shards.

Material indexes: the indexes a given shard actually has, which converge toward the desired set when the shard is quiet and diverge from it under pressure (§3.4).

Shedding: dropping secondary indexes to protect write throughput (§3.4).

Rollup: a disposable, pre-computed metric-window result whose freshness is proved against authoritative raw samples before reuse (§5.6).

Series cache: the bounded in-memory map from series identity to series row, which keeps metric ingestion off SQLite in the common case (§5.3).

Logical live size: (page_count - freelist_count) × page_size for a SQLite database — the space actually holding data, excluding pages freed by deletion and available for reuse. Retention is enforced against this rather than against the file size (§3.6).

Quarantine: renaming a database SQLite has reported corrupt, aside from the path eventd uses, and creating an empty one in its place (§3.3).

1.4 Prior Art

Peios / Peios technical reference / eventd / Introduction

eventd is not a port of anything. It occupies a position several existing systems also occupy, and differs from each of them in ways worth being explicit about. PSPU §3.C compares the wire contracts; this compares the systems.

1.4.1 Windows Event Log and ETW #

The closest structural match. Windows splits the job in two: Event Tracing for Windows delivers events from kernel and application providers, and the Event Log service persists and serves them. Peios splits it the same way, with KMES in the ETW position and eventd in the Event Log service's.

Held in common: kernel-mediated delivery with metadata the emitter cannot forge, a userspace service owning persistence, and access control by Security Descriptor on a named channel — which in eventd becomes a descriptor per event type pattern (§7.2).

Different: eventd unifies three data types where Windows separates them across the Event Log, ETL trace files and Performance Counters. eventd stores in SQLite rather than a proprietary binary format. And ETW's buffering is a kernel-managed trace session, where KMES exposes shared-memory ring buffers with a lock-free consumer protocol that eventd drains directly (§2.2).

1.4.2 journald #

journald is the systemd system journal: it captures service output and structured messages and stores them in an indexed binary journal.

Held in common: a single daemon for system-wide log ingestion, capturing standard output and standard error as records with metadata attached, stored in a binary format with indexes.

Different: journald is log-only, and a systemd system needs a separate stack for metrics. journald's access control is Unix file permissions and polkit, where eventd's is KACS Security Descriptors evaluated per record and per field (§7). And journald reads kernel messages from /dev/kmsg, a text interface with no identity, where eventd receives kernel events through KMES with the kernel's own identity stamps intact.

The similarity worth noting is that journald's storage is also its index, and its query surface is a matcher over fields rather than a language. eventd goes further in that direction — an actual query language, over all three data types — for the reason PSPU §3.C gives: computing on the collector's side is what lets access control constrain the computation.

1.4.3 Prometheus and OpenTelemetry #

Prometheus is a pull-based metrics system with a local time-series database; OpenTelemetry is a vendor-neutral collection framework spanning traces, metrics and logs.

eventd's metric store fills roughly the role of a local Prometheus TSDB, and takes the Prometheus data model — a name plus labels identifying a series, cumulative-bucket histograms — more or less wholesale (§5.2).

Different: eventd is pushed to rather than scraping. It implements no distributed tracing at all. And where OpenTelemetry's answer to the three-signal problem is a common collection framework in front of three backends, eventd's is one backend with one access control model and one query surface — which is the whole design bet.

1.4.4 Deliberately elsewhere #

ConcernWhere it lives
Event emission, buffering and deliveryKMES, in the Peios Kernel TRM; the consumer protocol in PSPK
Event type vocabulary and payload schemasthe emitting subsystem's own documentation
Access control primitivesKACS, in the Peios Kernel TRM
Configuration storageLCS and loregd
Daemon lifecycle, and forwarding service outputpeinit
The three interfaces eventd exposesPSPU §3

2.1 The Pipeline

Peios / Peios technical reference / eventd / Event Ingestion

eventd is the primary consumer of the KMES ring buffers. Events travel from shared memory to a committed database row in four stages.

  1. Drain. One thread per CPU reads events from that CPU's ring buffer, following the lock-free read protocol PSPK specifies (§2.2).
  2. Detect. The drain thread compares each event's sequence number against the last it saw for that CPU. A jump means events were lost, and the loss becomes a gap record (§2.5).
  3. Hand off. The drain thread copies the event out of the mapped region and passes it to the writer thread that owns the shard it routes to (§2.3).
  4. Write. The writer thread accumulates events into a transaction and commits, sizing the batch to whatever throughput allows (§2.4).

Two principles govern the whole pipeline, and most of its behaviour follows from them rather than from anything specific to a stage.

2.1.1 The ring buffers are the only buffer #

eventd holds no large intermediate queue between KMES and SQLite. The handoff channel has startup-fixed slot and byte bounds chosen by performance testing, independently of the writer's transaction batch size, and nothing else accumulates (§2.3).

When the writer falls behind, the channel fills; when the channel is full, the drain thread stops reading; when the drain thread stops reading, events accumulate in the ring buffer, which is exactly what a ring buffer is for. Backpressure propagates all the way back to the kernel, and the absorption capacity is the ring buffer's, which an administrator already sizes.

If the ring buffer also fills, KMES overwrites its oldest events, the drain thread notices the sequence jump when it resumes, and the loss is recorded (§2.5). That is the designed worst case: eventd loses events visibly rather than buffering without bound and dying.

The alternative — a large in-process queue — would move the same capacity into a place where losing it is invisible, where it competes with the page cache for memory, and where an out-of-memory kill takes the whole queue with no record that it existed.

2.1.2 Sharding scales writes linearly #

Each shard is a self-contained SQLite database with its own file, its own write-ahead log and its own writer thread. Shards share no write-path state, so the write path has no cross-shard lock, no shared counter and no coordination point (§2.3).

The consequence for the query path is that a shard means nothing to it. A shard database holds whatever CPUs happened to route to it in whatever eventd lifetime wrote it, so a query filtering by CPU reads every shard, and the query path never assumes a relationship between a shard and a CPU (§6.4).

2.2 KMES Consumption

Peios / Peios technical reference / eventd / Event Ingestion

2.2.1 Attachment #

At startup eventd calls kmes_attach(KMES_ATTACH_QUERY_SLOTS, …) to obtain the logical CPU slot count. It then walks every slot from zero to one below that count. A successful call returns one descriptor for that logical CPU's ring buffer; EINVAL means the slot is a hole and eventd continues rather than treating it as the end of enumeration. Each successful descriptor is mapped at the size derived from the returned capacity, exactly as PSPK §2.3 defines.

The attached logical CPU IDs may therefore be sparse. eventd assigns the successful attachments dense internal ordinals in ascending logical CPU-ID order for shard routing (§2.3), while preserving the logical cpu_id everywhere externally visible: event rows, receipt ranges, gap records and diagnostics.

Attachment requires SeSecurityPrivilege in the effective token, which is the privilege that grants an unfiltered view of every event on the system. eventd holds it because it is the party that then applies per-event access control on everything it stores (§7).

Discovering zero CPUs is a startup failure (§8.2). There is no configuration for the CPU count and no way to attach to a subset.

2.2.2 Drain threads #

There is one drain thread per CPU, and each reads exactly one ring buffer. A drain thread never reads another CPU's buffer, which is what makes per-CPU sequence tracking a thread-local variable rather than shared state.

Each thread follows the read protocol PSPK specifies:

  • read_pos starts at tail_pos on first attachment, so eventd begins at the oldest surviving event rather than at the newest.
  • The drain loop loads write_pos with acquire ordering, checks tail_pos for lapping, validates the event's structural integrity, and advances read_pos by event_size.
  • After reading an event it re-reads tail_pos — the torn-read check — to detect that KMES overwrote the event while it was being copied.
  • With nothing to read it uses the notification protocol, setting need_wake and waiting on the futex, rather than spinning.

2.2.3 Copying #

A drain thread reserves both one slot and the event's byte length in its shard handoff before copying the event — header and payload — into process-local memory and advancing read_pos. If either reservation is unavailable, it leaves the event in KMES and waits; it does not copy or advance first (§2.3).

Nothing derived from the mapped region ever reaches a writer thread. The region is producer-owned and KMES may overwrite any part of it the moment read_pos moves past, so a pointer handed across the channel would be a pointer into memory that another CPU is entitled to rewrite before the writer gets to it.

The copy is bounded by the event's own event_size, and the drain thread never reads beyond it.

2.2.4 Generation changes #

An administrator changing the ring buffer capacity causes KMES to replace the buffers, which it signals by changing the generation field. A drain thread checks it after each drain cycle, and on a change:

  1. Records the sequence number of the last event it has successfully handed off.
  2. Finishes draining the old buffer to its now-frozen write_pos, updating that sequence number as it goes.
  3. Calls kmes_attach again for the same logical CPU, obtains the replacement descriptor, and maps it while the old mapping remains valid.
  4. Scans the new buffer from tail_pos for the first event whose sequence is greater than the final sequence drained from the old buffer, and sets the new read_pos there.
  5. Closes and unmaps the old buffer only after that scan succeeds.
  6. Resumes draining from the new buffer.

Each drain thread handles this independently. There is no barrier, no coordination and no shared state, because each attaches only to its own CPU's buffer — so a resize is a per-CPU event that happens to occur on every CPU at roughly the same time.

Draining the frozen old buffer before switching prevents loss at the back of the old generation. The sequence scan prevents duplicates from the events KMES copied into the replacement. A shrink may still discard the oldest survivors; that loss appears as an ordinary sequence gap.

2.2.5 Sequence tracking #

During an uninterrupted run, each drain thread holds the last sequence number it saw for its logical CPU. It serves gap detection (§2.5) and a generation change.

After a restart, there is deliberately no single resume number. Different shard transactions may have committed out of sequence, so taking MAX(sequence) could skip an earlier stripe that never committed. The authority is the union of committed receipt_ranges from every readable active and historical shard (§3.1).

Startup merges those ranges per (boot_id, cpu_id), scans the surviving ring records, skips survivors already covered by a receipt, and re-ingests uncovered survivors. It emits a gap only for a sequence that is covered by neither a committed receipt nor a surviving ring record (§8.5). With no receipt for a CPU, coverage begins before sequence 1, so an overwritten prefix is detected correctly.

The metadata database's sequence checkpoints and the shutdown event are diagnostic only (§3.5, §8.4). Neither participates in recovery.

eventd depends on KMES sequence numbering remaining continuous while the kernel boot ID is unchanged, and so on KMES being initialised once for the whole boot and never unloaded or reloaded. A sequence regression under the same boot ID, other than duplicates deliberately encountered during replacement scanning, is a fatal incompatibility: eventd stops rather than confuse two KMES stream lifetimes under one receipt key.

2.3 Sharding

Peios / Peios technical reference / eventd / Event Ingestion

2.3.1 The shard count #

Event writes are distributed across independent SQLite databases. An explicit StorageShards count (§A) is between one and 256. Zero means "as many shards as there are successfully attached KMES buffers", and is the default; that automatic count is not capped at 256.

Every shard costs file descriptors. An active shard's writer connection holds three for eventd's life — the database, its write-ahead log and its shared-memory index — as does the retention coordinator's connection to each historical shard (§3.6), and every event query that reads every shard holds about two more per shard while it runs (§6.4). eventd neither raises its own descriptor limit nor caps the shard count to fit it. The packaged eventd-service.reg sets LimitNOFILE to 262144. peinit applies it as both the soft and hard RLIMIT_NOFILE before launch; eventd does not raise the limit itself. Under an inherited 1024-descriptor soft limit, 256 shards do not start, and counts well below that leave little room for queries. Operators can override LimitNOFILE (DWORD) at Machine\System\Services\eventd and restart the service to apply it (peinit TRM §3.2).

The packaged value uses this conservative planning allowance:

4 × Q × max(E, 1) + 4 × Q + 3 × (E + 3) + 2 × C + 256

Here Q is the concurrent query count, E the total active and historical event databases visible to queries, and C the attached KMES buffer count. The terms allow four descriptors per query database (database, WAL, shared-memory index and temporary-file headroom), four per query for its socket, peer token and transient handles, three per persistent database connection (including log, metric and metadata stores), two per KMES buffer during replacement, and 256 for fixed handles and maintenance. Historical shards have persistent retention connections as well as query readers. Streaming queries are already included in Q; their separate limit is not added again.

At the default MaxConcurrentQueries=128, with at most 256 query-visible event databases and 256 attached KMES buffers, this gives 133129; 262144 rounds up with additional headroom. This is a sizing allowance, not a strict SQLite descriptor bound or a guarantee that the workload fits memory and thread limits. The limit does not preallocate handles.

Neither historical shard discovery nor the automatic shard count is globally capped by the explicit StorageShards range. More historical shards, more attached buffers or higher query concurrency require operator sizing and, where needed, a larger service limit. The host's fs.nr_open ceiling must also permit the chosen limit; setting the service value does not raise that system ceiling.

Two properties make a count perform well, and neither is enforced. A power of two lets routing use a bitwise AND rather than a modulo. A multiple of the attached-buffer count distributes shards evenly across drain threads. The default satisfies the second by construction.

2.3.2 Assignment #

Shard-to-CPU assignment is computed once at startup and is fixed for the process lifetime.

The successfully attached logical CPUs are assigned dense ordinals c = 0..attached_count-1 in logical-ID order (§2.2). For each ordinal c, eventd assigns every shard j where j % attached_count == c. If that produces nothing — which happens when there are fewer shards than attached buffers — ordinal c is instead assigned c % shard_count. Every drain thread ends with at least one write path. Logical cpu_id, not the ordinal, is stored.

The three cases behave differently:

RelationResult
shards == CPUsone shard per CPU, the 1:1 case
shards < CPUsseveral CPUs share a shard
shards > CPUsa CPU owns several shards

A drain thread owning several shards distributes contiguous sequence stripes, not individual events round-robin. It sends a fixed positive number of successive events to one shard, then moves to the next shard it owns. The stripe length is selected once at startup and is not persisted; committed receipt ranges, rather than recomputing the routing choice, are the recovery authority (§2.2, §3.1).

Keeping a contiguous run together makes one receipt describe the run compactly. It preserves parallelism across CPUs and across successive stripes without requiring any cross-shard commit coordinator.

When the counts do not divide evenly, some CPUs carry one shard more than others, or some shards receive from one CPU more than others. The resulting imbalance is one shard's worth of throughput, which is negligible against the whole.

2.3.3 Shards are not a query-path concept #

Assignment is not persisted. A shard database accumulates events from whatever CPUs routed to it during whatever eventd lifetimes wrote it, so a single shard file may hold events from a different set of CPUs in different regions of its history.

The query path therefore assumes nothing: it reads every database in the directory, and a query filtering on event.cpu scans all of them (§6.4). Sharding is a write-path optimisation that the read path pays a fan-out for.

2.3.4 Writer threads #

Each shard has exactly one writer thread, and that thread is the only writer to that database. No other thread and no other connection writes to it, which is what makes the single-writer assumptions in §3.3 and §3.4 safe.

Drain threads never write to SQLite. When several drain threads share a shard they hand off concurrently, so the handoff channel is multi-producer and single-consumer.

2.3.5 The handoff channel #

Each writer thread has one bounded channel through which drain threads submit events. Its capacity is fixed at startup and independent of MaxBatchSize: the channel is bounded both by occupied slots and by the total bytes held by those slots. Both are internal implementation limits selected by performance testing, not live registry settings.

When either bound would be exceeded the drain thread stops reading from the ring buffer and waits. It reserves capacity before copying or advancing read_pos (§2.2), does not drop events to relieve pressure, and does not grow the channel. Events accumulate in the ring buffer instead, which is the designed path (§2.1):

writer slow → channel fills → drain pauses → ring buffer absorbs
            → KMES overwrites oldest if full → gap detected on resume

When the writer consumes slots and the channel has room, the drain thread resumes immediately.

Publication wakes a writer waiting on an empty channel; releasing capacity wakes producers waiting for room. The change itself supplies the wakeup, even when it races with a thread going to sleep. Neither side needs a later event or a timeout to notice the available work or capacity.

The channel is a small scheduling handoff, not storage for a complete transaction and not a second backlog buffer. A writer can consume and insert one slot while producers refill it, so a transaction may contain many more events than the channel can hold simultaneously. Changing MaxBatchSize changes only the writer's next commit threshold and never resizes or replaces a live channel (§8.3).

2.3.6 Lifecycle and reconfiguration #

Shard databases are created in the event store directory on first use. eventd never deletes or overwrites one left by a previous configuration: starting with fewer shards than exist leaves the excess in place, and the query path continues to read them (§3.3).

Changing StorageShards takes effect at the next restart. The configuration watch notices the change and eventd defers it rather than reassigning CPUs or creating shards while running (§8.3).

Shard count changes are expected to be rare — set once from the hardware profile, one for a small board and a multiple of the CPU count for a server, and then left alone. Live migration would mean rebalancing writer threads and channels while events are in flight, for a configuration change that happens once in a machine's life.

2.4 The Batch Writer

Peios / Peios technical reference / eventd / Event Ingestion

2.4.1 Transactions #

Each writer thread writes to its shard with explicit transactions: a BEGIN, one INSERT per event, a COMMIT. The commit is the durability boundary.

For every maximal contiguous sequence run represented by the event rows and eventd.events.lost rows in that transaction, the writer also inserts a receipt_ranges row for (boot_id, cpu_id, first_sequence, last_sequence) (§3.1). Events, gaps and their receipts commit together: there is no second durability operation and no global receipt writer.

The database runs in WAL mode with synchronous = FULL, so every commit fsyncs the write-ahead log. This is the strictest of the three stores' settings, and the only one where per-transaction durability is bought at per-transaction cost — because an event may be an audit record and losing the last second of them to a power cut is a real loss.

2.4.2 Adaptive batch sizing #

The writer sizes each batch to balance throughput against how much sits uncommitted at any moment.

Throughput is always the priority. If eventd falls behind the emission rate, ring buffers fill and events are overwritten, which is irrecoverable; a shorter power-loss window is not worth that trade. The algorithm maximises resilience within the constraint that throughput is maintained, never against it.

  1. When the first event is available, the writer opens a transaction and records the start time.
  2. It reads available events from its drain threads and inserts them.
  3. After each group of inserts it commits if any of these holds:
    • no assigned drain thread currently has an event available
    • the batch holds MaxBatchSize events
    • MaxBatchLatencyMs has elapsed since the first event entered it
  4. Otherwise it keeps reading and inserting.
  5. With nothing available and an empty batch, it sleeps until a producer wakes it.

The first condition is what makes the algorithm adaptive. Under light load the input drains immediately, so a batch of three events commits at once and the exposure window is microseconds. Under sustained load batches grow until they hit the size cap or the latency cap, whichever comes first, and the per-commit fsync is amortised across thousands of rows.

The writer chooses its own insert-group size, subject to a group never letting a batch exceed the size cap or stay open past the latency cap.

Both bounds are configuration (§A). The defaults are 10000 events and 100 milliseconds — the tightest latency of the three stores, for the same reason the durability setting is the strictest.

2.4.3 WAL checkpointing #

WAL mode accumulates log data until a checkpoint copies it back into the main database file. Under sustained writes the log grows.

Each writer triggers a checkpoint when its write-ahead log reaches WalCheckpointPages (§A), in SQLITE_CHECKPOINT_PASSIVE mode — checkpointing as much as it can without blocking readers. If a passive checkpoint cannot make progress because readers hold pages, the writer does not block: it keeps writing and retries after a later commit.

Checkpointing runs on the writer thread and briefly serialises with insert work, which is inherent to SQLite rather than a choice — a database cannot be checkpointed and written concurrently. Passive mode is the lightest option available, yielding immediately when readers hold pages, and the per-checkpoint cost is bounded by the threshold.

2.4.4 Prepared statements #

Each writer prepares its INSERT once at startup and reuses it for every row, which keeps SQL parsing and planning off the hot path entirely.

The receipt insert and new-event-type catalogue insert are prepared and reused too. A known event type causes no catalogue SQL operation; the writer's in-memory intern table identifies only genuinely new names, which are inserted in the same transaction as their first event (§3.1).

2.4.5 Maintenance commands #

The writer owns the shard's only read-write connection. Retention, receipt compaction and index policy code may inspect through read-only connections, but they submit bounded maintenance commands to this writer rather than opening another writer or taking a shard mutex.

The writer considers low-priority maintenance only at transaction boundaries and rechecks ingestion pressure after one bounded action. A size-urgent retention delete may be included in an existing ingestion transaction so it adds no second commit. Index creation remains cancellable through SQLite's progress handler (§3.4). Under sustained pressure maintenance may lag indefinitely; accepting incoming events is the higher priority.

2.5 Gap Detection

Peios / Peios technical reference / eventd / Event Ingestion

Every event carries a per-CPU, per-boot sequence number, and a drain thread knows what it last saw. That is the whole mechanism: an event whose sequence number exceeds the expected next one means the numbers in between belonged to events eventd never received.

During restart reconciliation, "expected" is the union of committed receipt coverage and surviving ring records rather than one last-seen number. A sequence already covered by either source is not a gap (§2.2).

2.5.1 Causes #

  • Ring buffer overrun. KMES overwrote events before eventd read them. The most serious case: it means audit events were lost irrecoverably.
  • Structural drops. KMES declined to write an event that exceeded its size limits.
  • Downtime. Events emitted while eventd was not running.

All three appear identically at the consumer, which is why the record says what was lost rather than why.

2.5.2 Gap records #

On detecting a jump, the drain thread generates a gap record, an eventd.events.lost event, carrying:

  • the CPU identifier
  • the first missing sequence number, the last seen plus one
  • the last missing sequence number, the revealing event's minus one
  • the count of missing events
  • the timestamp of the last event successfully processed on this CPU, where one is known

"Known" means the drain thread saw that event in this run. A gap found during restart reconciliation whose preceding events are accounted only by a committed receipt range carries no preceding timestamp, even though those events are stored: a receipt proves the sequence was accounted for, and eventd does not read the stored event back to recover its time.

Its own timestamp, event.time, is the timestamp of the event that revealed the gap, not the moment eventd noticed. The lost events were therefore written between the last processed timestamp and the record's own, and the record sorts beside the event that revealed it.

The record is written into the shard database through the normal write path — handed to the same writer thread, batched with ordinary events, committed in the same transaction. That transaction's receipt range covers the missing sequence interval as well as any real event rows, so a restart never emits the same gap twice (§3.1). It is not emitted through KMES, which would be circular: the mechanism for recording that the event transport lost something cannot depend on that transport.

The gap details are stored as a MessagePack map in the payload column (§3.2), under the catalogue's buffer.cpu and loss.* fields, and gap records are queryable exactly like any other event.

2.5.3 The CPU column #

A gap record populates cpu_id and leaves the other KMES header columns — sequence, origin_class, the identity GUIDs — null (§3.1).

cpu_id is populated deliberately, and it is the one place a synthetic event carries a header field. Without it, EVENTS WHERE event.cpu == 3 would return every event from CPU 3 except the record saying that events from CPU 3 went missing — which is the one record such a query most needs to return.

sequence stays null because a gap record has no place in the sequence: it describes numbers that were skipped, and giving it one of them would make it indistinguishable from the event that was lost. It is also what keeps gap records distinct from real events; receipt_ranges records the accounted sequence interval separately (§3.1).

2.5.4 Lapping #

If read_pos falls behind tail_pos, the consumer has been lapped and the PSPK read protocol advances it to tail_pos.

Lapping needs no special handling here. The next event read is the oldest survivor, its sequence number is far beyond what the thread expected, and ordinary gap detection records the difference. The lapping case and the restart case produce the same record by the same path.

2.6 Synthetic Events

Peios / Peios technical reference / eventd / Event Ingestion

Synthetic events are records eventd generates about itself. They are written straight to a shard database and never touch KMES.

They carry no KMES header: no identity stamps, no sequence number, no origin class. What they have is a timestamp and one of exactly five event types, all under eventd's own platform root eventd (PGSS §6.A), and the type is what distinguishes them in the events table — no separate record-type column exists (§3.1). The timestamp is eventd's clock when it generated the record, except for a gap record, whose timestamp is that of the event that revealed the gap (§2.5).

2.6.1 When they are generated #

ConditionType
Lost events detected on a CPUeventd.events.lost (§2.5)
eventd started and attached to KMESeventd.daemon.started
Graceful shutdown, once everything read is committedeventd.daemon.stopped
A store was found corrupt and quarantined, at startup or at write time (§9.2)eventd.store.quarantined
A configuration value changed at runtimeeventd.config.changed

Payload schemas for all five are in §3.2.

All five are tier essential (PGSS §6.8). eventd is their emitter as well as their store, and the emission policy never switches an essential type off (PGSS §6.9), so eventd writes each whenever its condition occurs and consults no policy for them. Their fields are those of eventd's catalogue fragment, installed as /usr/share/evman/eventd.evman (PGSS §6.10).

Note what is absent: there is no synthetic event for malformed ingestion input. Authentication does not make admitted input trustworthy, and emitting a durable record per bad datagram would hand a producer an amplification primitive (PSPU §3.4). These five are conditions eventd observed about itself, not reactions to what it was sent.

2.6.2 Which shard #

CPU-specific synthetic events — gap records — go to the shard assigned to the CPU that generated them, handed to that writer thread alongside that CPU's ordinary events. A gap record travels with the events it describes.

Daemon-wide ones — start, stop, configuration changes, quarantines — go to shard 0 when shard 0 is writable, and otherwise to the lowest-numbered writable active shard. If no shard is writable at all the event is skipped, with the failure logged to standard error.

A quarantine is the case that needs the fallback. It describes a failure on one particular shard but is itself a daemon-wide notification, so it is not written to the failing shard unless that shard has since been replaced and is writable again (§9.2) — writing the record of a shard's failure into that shard would lose it exactly when it matters.

These events are infrequent enough that concentrating them on shard 0 costs nothing measurable in balance.

2.6.3 Storage and ordering #

Synthetic events live in the same shard databases as KMES events and participate in the same batching, the same retention and the same queries. Access control treats their types like any other (§7.2), so a descriptor at Machine\System\eventd\Security\Events\eventd governs all five, and one at …\Events\eventd.daemon only the start and stop records.

They are ordered by their timestamp and take no part in per-CPU sequence numbering.

For the four daemon-wide types that timestamp is when eventd noticed, not when the condition occurred. A gap record is different: its timestamp is the revealing event's (§2.5), so it sorts beside that event, and with loss.preceding-time it bounds when the lost events were written. eventd may detect a gap long after the events were overwritten — after a restart, the first thing it writes in a boot can be the record of events lost before — and the record's time says when they were lost, not when eventd found out.

2.6.4 The reserved types #

A KMES event whose type is one of the five is not stored: eventd counts it and drops it, and still receipts its sequence (§3.1). Exactly those five names are reserved, not the eventd root or any prefix of it, so eventd remains free to emit other eventd.* types through KMES.

3.1 The Event Shard Schema

Peios / Peios technical reference / eventd / Event Storage

Every shard database holds one events table.

ColumnTypeContents
idINTEGER PRIMARY KEYSQLite rowid, monotonic within the shard.
boot_idBLOB NOT NULL16-byte boot ID GUID in PCDS binary layout.
timestampINTEGER NOT NULLNanoseconds since the Unix epoch. From the KMES header for a real event; eventd's clock at generation for a daemon-wide synthetic one; the revealing event's timestamp for a gap record (§2.5).
cpu_idINTEGERFrom the KMES header. Null for daemon-wide synthetic events; populated for gap records (§2.5).
sequenceINTEGERPer-CPU, per-boot sequence from the KMES header. Null for every synthetic event.
origin_classINTEGER0 userspace, 1 KMES, 2 KACS, 3 LCS, 4 NTFE. From the header, as written. Null for synthetic events.
event_typeTEXT NOT NULLFrom the header; or one of the five types eventd writes itself (§2.6).
effective_token_guidBLOB16-byte GUID for the effective token at emission. Null for synthetic events; the null GUID when identity was unavailable at emission.
true_token_guidBLOB16-byte GUID for the process's primary token. Null for synthetic events.
process_guidBLOB16-byte GUID for the emitting process. Null for synthetic events.
payloadBLOBMessagePack. For a KMES event, the raw payload bytes exactly as received. For a synthetic event, a map (§3.2). Null when the event carries none.

3.1.1 Header fields are columns #

Every KMES header field is extracted into its own column rather than left inside the payload blob. That is what lets a predicate on emitter.process.guid or event.type become a SQL comparison rather than a decode of every candidate row, and it is what makes those fields indexable by ordinary column indexes (§3.4).

The column names are internal to the store. A query names a header field by its path from PSPU §3.22, and eventd reads it from its column:

FieldColumn
event.timetimestamp
event.sequencesequence
event.cpucpu_id
event.typeevent_type
event.boot.guidboot_id
emitter.classorigin_class
emitter.token.guideffective_token_guid
emitter.true-token.guidtrue_token_guid
emitter.process.guidprocess_guid

A column's name is not a field: cpu_id in a query names a payload field, as any other name outside these nine does.

event_type is the sole discriminator between real and synthetic records. No record-type column exists, because the five types eventd writes itself already partition the type namespace and a second column would be a second thing to keep consistent.

The partition holds because eventd reserves those five names, and only those (§2.6). A KMES event whose type is one of them is not stored: eventd counts it and receipts its sequence, so it is neither a row nor a gap. Any other type, including any other under eventd., is stored as usual. The count appears in the diagnostic dump (§8.5).

3.1.2 The payload is not touched #

For a KMES event the payload column holds the bytes KMES delivered, unmodified. eventd does not decode them on the write path, does not re-encode them, and does not validate them beyond what the ring-buffer protocol already checked.

The payload is a MessagePack value whose schema belongs to the emitting subsystem, and eventd has no catalogue of those schemas. It decodes on the read path when a query needs a payload field (§6.1), which is also the only point at which the flattening rules of PSPU §3.22 apply.

Storing the bytes verbatim is also what keeps a payload value at a header field's path recoverable: the value, or the map and everything beneath it, is suppressed from the query surface but remains in the blob.

3.1.3 The event-type catalogue #

Every shard also holds event_types:

ColumnTypeContents
event_typeTEXT PRIMARY KEYA concrete event type that has been committed to this shard.

The writer loads this small set into an in-memory intern table at startup. A known type adds no catalogue statement to an event insert. A genuinely new type is inserted once, in the same transaction as its first event, and enters the in-memory set only after that transaction commits. A transaction-local pending set prevents later events of that type in the same batch from repeating the catalogue statement; rollback discards the pending set.

Query planning unions these catalogues across relevant active and historical shards to discover concrete identifiers for access checks (§6.1). It never needs a DISTINCT event_type scan of the events table, so idx_events_event_type remains an adaptive index that may be shed without breaking planning (§3.4).

Retention records the distinct types touched by each delete batch and offers orphan checks as low-priority maintenance. The writer rechecks NOT EXISTS in the deletion transaction, and removes the type from its in-memory set only after that deletion commits. A stale catalogue row is safe — it causes an unnecessary access check but cannot expose data — so an unindexed or pressure-interrupted check is simply skipped and cleanup never delays ingestion. Catalogue pages count toward the store's logical live size (§3.6), preventing unique-name abuse from escaping the store-wide size accounting.

3.1.4 Committed receipt ranges #

Every shard also holds receipt_ranges:

ColumnTypeContents
boot_idBLOB NOT NULL16-byte kernel boot ID.
cpu_idINTEGER NOT NULLLogical KMES CPU identifier.
first_sequenceINTEGER NOT NULLFirst accounted sequence, inclusive.
last_sequenceINTEGER NOT NULLLast accounted sequence, inclusive.

The primary key is (boot_id, cpu_id, first_sequence, last_sequence), and every row satisfies first_sequence > 0 and last_sequence >= first_sequence.

A receipt says that every sequence in its range was accounted for by the same transaction: either the real event row was stored or an eventd.events.lost row durably records why it was absent. The receipt is in that transaction, so its presence proves commit without a global checkpoint or another fsync. Receipt rows survive event retention.

Startup unions and merges overlapping or adjacent ranges across every readable shard (§2.2, §8.5). A read-only background pass may prepare a low-priority compaction plan while the store is quiet. Writer-owned mutations first insert a merged range and only later delete ranges it subsumes, including ranges in other shards. Insert-before-delete makes every intermediate state conservative and crash-safe.

Compaction mutations piggyback transactions a writer would commit anyway; they never cause a standalone commit or fsync, acquire no hot-path coordination primitive, and may lag indefinitely. Startup merge correctness never depends on physical compaction.

3.1.5 Identity may be absent two ways #

effective_token_guid, which a query reads as emitter.token.guid, distinguishes two cases that would otherwise look alike. Null means the record is synthetic and never had an identity. The null GUID — sixteen zero bytes — means the record is a real KMES event whose identity was not available at emission time, because it was emitted before or outside a context that had one.

The distinction matters for audit: "eventd wrote this" and "the kernel emitted this and could not attribute it" are different facts.

3.1.6 Write-time indexes #

One index is created with the table:

  • idx_events_timestamp on events(timestamp)

Time-range filtering is the foundational access pattern — nearly every query carries a SINCE — and it is the one index eventd never sheds, whatever the write pressure (§3.4). Every other index is the adaptive system's business.

3.1.7 Schema version #

Each shard holds events, event_types, receipt_ranges, and a metadata table:

ColumnTypeContents
keyTEXT PRIMARY KEYMetadata key.
valueTEXT NOT NULLMetadata value.

with two required entries: schema_version, and created_at as a UTC timestamp formatted YYYY-MM-DDTHH:MM:SSZ. The current version is in §B.

eventd checks the version at startup and applies the lifecycle rules of §3.3. It does not migrate: an unrecognised version is a startup failure for an active shard and an exclusion for a historical one. Migration is an administrative operation, deliberately not an automatic one — a daemon that silently rewrote an audit store's schema on first start after an upgrade would be doing the one thing an audit store must not do unattended.

3.2 Synthetic Event Payloads

Peios / Peios technical reference / eventd / Event Storage

Each of the five synthetic event types (§2.6) carries a MessagePack map in payload, with the schema below. The fields are catalogue fields, defined in eventd's fragment /usr/share/evman/eventd.evman or in the kernel's (PGSS §6.10), and each is stored as nested maps, one per segment of its path: store.shard-count is the value at {store: {shard-count: …}}. A query names a field by its dotted path, and reads an array field whole.

No field is ever nil. A field with no value is left out of the map (PGSS §6.5), and the table below says when each one can be.

None of them is at a header field's path, so none is suppressed. The boot and the CPU are read from the columns as event.boot.guid and event.cpu (§3.1), and no payload repeats the boot.

3.2.1 eventd.daemon.started #

FieldTypeContents
store.restartedboolTrue when committed rows or receipts for this boot already existed at startup; false on the boot's first successful eventd start.
store.shard-countunsigned integerActive shard count after resolving StorageShards.
store.resume.cpusarray of unsigned integerOne entry per logical CPU, in ascending order.
store.resume.sequencesarray of unsigned integerParallel to store.resume.cpus: for the CPU at the same index, the highest sequence contiguously accounted for after receipt/ring reconciliation, or 0.

There is no boot ID in the payload: the record's own event.boot.guid is the boot it describes.

store.restarted is the boot-boundary decision of §3.7 recorded as data, which makes "did eventd crash during this boot, and how often" answerable by query rather than by inference from gaps.

3.2.2 eventd.daemon.stopped #

FieldTypeContents
store.committed.cpusarray of unsigned integerOne entry per logical CPU, in ascending order.
store.committed.sequencesarray of unsigned integerParallel to store.committed.cpus: the highest sequence contiguously covered by committed receipts for that CPU this boot, or 0 if none was.

When eventd cannot read its committed receipts at shutdown, both arrays are left out and the payload is an empty map. It never writes zeroes in their place, so a 0 is always a real value.

Diagnostic only. Startup derives recovery coverage from committed receipt ranges, never from this payload (§2.2).

3.2.3 eventd.events.lost #

FieldTypeContents
buffer.cpuunsigned integerThe CPU whose ring the gap was found on.
loss.sequenceunsigned integerFirst missing sequence number.
loss.sequence-lastunsigned integerLast missing sequence number.
loss.countunsigned integerHow many are missing.
loss.preceding-timetimestampThe last event successfully processed before the gap. Absent when none is known.

The time of the event that revealed the gap is not a payload field: it is the record's own event.time (§2.5).

The CPU appears both here, as buffer.cpu, and in the cpu_id column (§2.5). The column is what a WHERE event.cpu == N predicate matches and what a query presents as event.cpu; the payload field is what WHERE buffer.cpu == N matches, and what a reader of the stored payload sees without joining anything.

3.2.4 eventd.config.changed #

FieldTypeContents
config.key.pathstringAlways Machine\System\eventd.
config.namestringThe value's name under config.key.path.
config.type-previousunsigned integerThe registry type the value had before, as its REG_* number: 4 for REG_DWORD, 11 for REG_QWORD. Absent when no value was stored.
config.value-previousunsigned integerThe value before. Present exactly when config.type-previous is.
config.typeunsigned integerThe registry type the value has now, numbered the same way. Absent when the change removed the value.
config.valueunsigned integerThe value now. Present exactly when config.type is.

Every value eventd reloads at runtime is a REG_DWORD or a REG_QWORD (§A), so both values are integers, and a query can compare them as numbers.

A side is absent when no value is stored, even though eventd's compiled default then applies. A stored value of the wrong type is reported as the type eventd expects, holding the integer it actually uses.

3.2.5 eventd.store.quarantined #

FieldTypeContents
store.kindstringevent, log or metric.
store.shardunsigned integerThe shard, for the event store. Absent for the log and metric stores, which are not sharded.
outcome.detailstringHuman-readable description of what eventd found.

outcome.detail is diagnostic text and its wording is not stable. store.kind and store.shard are the fields worth alerting on. The metadata database is never reported here: a damaged one is rebuilt (§3.5) and writes no record.

3.3 Database Lifecycle

Peios / Peios technical reference / eventd / Event Storage

3.3.1 The event store directory #

Every shard database and the metadata database live in one directory, named by EventStorePath (§A). There is no compiled-in default: a missing or invalid value is a startup failure, and eventd writes event databases nowhere else.

The standard path is /var/state/eventd/events/. The eventd package ships /var/state/eventd/ and its events/, logs/, and metrics/ children. The three store directories are also declared as required peinit provisioned directories, so peinit establishes their descriptor before eventd starts; /var/state/eventd/ itself is not a provisioned directory. Each of the four carries an explicit protected, inheritable descriptor granting full control only to SYSTEM, Administrators, and the service SID of eventd's own virtual Service identity, which is what the daemon runs as:

O:SYG:SYD:P(A;OICI;GA;;;SY)(A;OICI;GA;;;BA)(A;OICI;GA;;;S-1-5-80-1963885778-1835409261-1671587836-2279113866-1994761124)

A deployment choosing another configured path provisions it with this descriptor before eventd starts.

eventd does not create store directories. It opens every path component without following symbolic links, retains the resulting directory descriptor, and opens, creates, renames and quarantines database, WAL and shared-memory files relative to that descriptor. A missing path, a non-directory component, a symbolic-link component, or a store directory whose owner, group and DACL are not exactly the descriptor above — which is what keeps an untrusted principal from replacing children — is a startup failure. SQLite's database, -wal, and -shm files inherit the directory's protection.

3.3.2 Naming #

Active shards are shard-NNNN.db, with the shard index zero-padded to four digits — the index assigned at startup, which is not a CPU number (§2.3).

Starting with more shards than exist creates the new ones. Starting with fewer leaves the excess in place: they become historical shards, are never deleted, and remain available to the query path.

3.3.3 Creation #

A shard database that does not exist, or that holds no schema at all (what a power cut leaves when it takes the uncheckpointed creating transaction), is created, in one transaction, with:

  1. WAL mode, PRAGMA journal_mode=WAL
  2. PRAGMA synchronous=FULL
  3. the events, event_types, receipt_ranges, and metadata tables (§3.1)
  4. the idx_events_timestamp index
  5. the schema_version and created_at entries

3.3.4 Opening an active shard #

  1. Open in WAL mode.
  2. Set synchronous to FULL.
  3. Read and verify schema_version. Missing or unrecognised is a startup failure. No migration is attempted.
  4. Verify structural integrity — the required tables and write-time indexes exist. Failing this, with SQLite reporting no corruption, is a startup failure.
  5. If SQLite reports corruption while opening or verifying, quarantine and replace (below). A database holding schema objects but no metadata table with entries is not a shard eventd could have written; it is quarantined and replaced like a corrupt one.

Steps 3 and 4 fail rather than repair because an active shard is required: eventd has no degraded mode that runs without one (§8.2).

3.3.5 Quarantine #

When SQLite reports corruption in a required store, eventd renames the database aside and starts a fresh one at the original path. The main database file and any matching -wal and -shm files are renamed with the suffix .corrupt.<timestamp_ns>, all three using the same suffix from one operation, and a new empty shard-NNNN.db is created.

If a target name is taken, eventd appends .N with the lowest positive integer that makes it unique — which happens when two quarantines land in the same nanosecond, and when a previous quarantine already used the name.

Quarantining rather than deleting is the point: the corrupt file is the only copy of whatever it held, recovering data from it is an administrative operation, and eventd attempts no automatic repair.

The corruption is logged and an eventd.store.quarantined event is emitted once a shard is available to write it to (§9.2).

3.3.6 Historical shards #

A historical shard is never required for startup. If one has a missing or unrecognised schema version, fails structural verification, or cannot be opened read-only, eventd logs the error and excludes it from the query path for this run — it does not fail startup and does not quarantine it.

The asymmetry is deliberate. An active shard that will not open means eventd cannot do its job; a historical one that will not open means some old data is unreadable, which is a smaller problem than refusing to boot the audit daemon over it.

3.3.7 Query path discovery #

The query path opens every file in the directory matching shard-NNNN.db that has a recognised schema and passes structural verification — active and historical alike. It assumes no particular number of them.

It explicitly does not treat every .db file in the directory as a shard: eventd-meta.db is excluded by the naming pattern, along with anything else that happens to be there.

Each is opened with a read-only connection. Read-only connections in WAL mode do not contend with the writer's connection.

3.3.8 Concurrency #

Each shard has exactly one read-write connection, owned by its writer thread — for a historical shard, the retention coordinator (§3.6) — and any number of read-only connections owned by query handlers. WAL mode permits concurrent readers alongside one writer without blocking either.

Writer threads never share connections. Each creates and owns its own for the process lifetime, which is what makes the prepared statement in §2.4 a per-thread object with no locking around it.

3.4 Adaptive Indexing

Peios / Peios technical reference / eventd / Event Storage

Secondary indexes make queries fast and writes slow. Which indexes are worth that trade depends on what a particular deployment actually queries, which varies between systems and over time, and which nobody wants to tune by hand.

eventd observes the queries and maintains the indexes they imply — subject throughout to the rule that throughput outranks query latency.

3.4.1 Three decoupled parts #

Query frequency counters. Query handlers increment a per-field counter when a field appears in a WHERE predicate. This is the write-heavy path — once per query, per predicate. Counters live in memory and are flushed periodically to the metadata database (§3.5), never to a shard, because they are global state that must survive shard reconfiguration.

Index policy. A periodic process reads the counters, applies the creation and removal thresholds, and computes the desired index set — an ordered list of fields, highest priority first. It runs every AdaptiveIndexPolicyIntervalMinutes (§A), and also at once on every applied configuration change (§8.3) and every INDEX command, and once more at shutdown; it is the only writer to the desired set.

Each run reads the current thresholds and WAL checkpoint setting after its wait ends, including the final run on shutdown. An applied threshold change is therefore judged using the new configuration; it does not require a second change to take effect. Pending wakes may coalesce, so this is not a promise of one run per registry update.

Shard convergence. Writer threads read the desired set and move their material indexes toward it. They never read the counters and never write the desired set.

The separation exists so that the high-frequency counter updates never contend with the writer threads. The policy is the bridge between them and runs on the order of once an hour, so it is never a contention point.

The desired set is global — one list applying to every shard. Individual shards do not make independent decisions; they differ only in how far they have got.

3.4.2 Convergence #

A shard converges when it is quiet: its writer thread has no pending events, and no batch within the last SheddingWindowSeconds exceeded 75% of MaxBatchSize, the same measure that sheds indexes (below). When it is quiet and its material indexes do not match the desired set, it takes one convergence action — creating the highest-priority missing index, or dropping the lowest-priority material index no longer wanted — then rechecks write pressure before considering another. It does so without waiting for the next policy run.

Creation uses CREATE INDEX IF NOT EXISTS; removal uses DROP INDEX IF EXISTS. Both run on the shard's writer thread, which is the only thread permitted to write that database (§2.3).

Index creation is cancellable. If drain threads detect rising write pressure during a build, they signal the writer to abort; the writer cancels the CREATE INDEX, SQLite rolls back the partial index cleanly, and the writer returns to event batches immediately. The abandoned build is retried at the next quiet period.

Cancellation responsiveness matters more than it looks. sqlite3_interrupt sets a flag checked at SQL VM opcode boundaries, and during B-tree construction for a large index the gap between checks can be tens of milliseconds — long enough to overrun a ring buffer at a high event rate. sqlite3_progress_handler(), registering a callback invoked every thousand opcodes that checks a cancellation flag and returns non-zero to abort, gives cancellation that tracks the pressure signal rather than lagging it.

Shards converge at their own pace. One under sustained pressure may lag the desired set indefinitely, and that is the correct outcome: it is prioritising throughput.

3.4.3 Shedding #

Under sustained pressure a shard drops indexes to cut per-insert cost.

Graduated shedding. If more than SheddingBatchPercent of a shard's batches within a SheddingWindowSeconds sliding window exceeded 75% of MaxBatchSize, the shard drops its lowest-priority secondary index — the one whose column has the lowest query frequency in the desired set. If pressure persists, the next-lowest goes, and so on. The check runs once per batch commit.

Emergency shedding. If a shard is at maximum batch size and its drain thread signals rising ring buffer pressure, it drops all secondary indexes at once. DROP INDEX is a metadata operation measured in milliseconds, so it is safe to do under pressure in a way that creation is not.

The pressure signal comes from the drain thread watching the gap between write_pos and its own read_pos. Exceeding EmergencySheddingBufferPercent of ring buffer capacity raises it. It is a distinct signal from the index-build cancellation one: this triggers shedding whether or not a build is in progress.

idx_events_timestamp is exempt. It is never shed at any pressure, because time-range queries are the access pattern everything else is built on and the store is unusable without it.

When pressure subsides, shedding reverses: the shard rebuilds toward the desired set under the same quiet-period scheduling and the same cancellability, highest priority first.

3.4.4 Candidates #

Any field that can appear in a WHERE predicate is a candidate.

Header columns. The columns of event.type, emitter.class, event.cpu, emitter.token.guid, emitter.true-token.guid, emitter.process.guid and event.boot.guid (§3.1). The counters and the desired set name each by its field path. timestamp, the column of event.time, is always indexed and is not adaptively managed; a predicate on event.time is not counted.

Payload fields. Any queryable flattened path that appears in a predicate is a candidate for an expression index. A path suppressed by the flattening rules of PSPU §3.22 — a header field's path or one beneath it, a key that is not a valid segment, a duplicate path — never receives one, because it is not a query-language field at all. A path beside a header field's, such as emitter.process.pid beside emitter.process.guid, is an ordinary candidate.

Counters and a desired set written by an earlier eventd, which named header fields by their columns (event_type, process_guid), are read back at startup under the fields' paths. Such a name could only have meant the header field then, because a payload key spelled like a column was reserved, so the renaming loses no index and builds no spurious one.

The raw payload column never receives a plain column index. Indexing an opaque blob accelerates nothing.

3.4.5 Payload indexes are an optimisation, not an authority #

An expression index extracts a field from the payload on every insert and indexes a deterministic private key for it. The exact key bytes are internal to eventd and are not part of the storage contract.

eventd may implement these as SQLite expression indexes with deterministic extraction functions, as generated columns, or by any equivalent SQLite-backed means. What every mechanism has in common is that it implements the same field resolution and flattening as PSPU §3.22 — and that where the index cannot reproduce the query language's comparison semantics exactly, it is used only to narrow candidate rows, with the real predicate applied after the row is loaded (§6.3).

SQLite's native dynamic-type equality and ordering never substitute for the query language's string case folding, numeric comparison, binary comparison, array comparison, or null and missing-field handling. Getting a smaller answer faster is worthless if it is a different answer.

Rows where the field is absent, unqueryable or suppressed index as null, or are otherwise excluded in a way that preserves those semantics.

Payload indexes are otherwise ordinary members of the desired set, with the same priority ordering, shedding and convergence.

3.4.6 Naming #

Header column indexes are idx_events_<column>, named for the column rather than the field — idx_events_event_type for event.type, idx_events_process_guid for emitter.process.guid.

Payload expression indexes are named from the field GUID (§7.3), to avoid both collisions and characters SQLite will not accept in an identifier:

idx_events_payload_<field_guid_hex>

where field_guid_hex is the UUID v5 field GUID for the flattened path, as 32 lowercase hexadecimal digits with braces and hyphens stripped. The path source.name yields idx_events_payload_ followed by the 32 hex digits of uuid_v5(EVENTD_FIELD_NAMESPACE, "source.name").

Deriving the name from the GUID rather than from the path means the same path always produces the same index name, and no path — however it is spelled — can produce a name that collides with another's or that SQLite rejects.

3.5 The Metadata Database

Peios / Peios technical reference / eventd / Event Storage

One database in the event store directory is not a shard: eventd-meta.db. It holds the state that is global to eventd rather than to any shard — adaptive index state and diagnostic sequence checkpoints — and it is the one database that survives shard reconfiguration untouched.

It is created on first startup if absent or holding no schema, opened in WAL mode with synchronous=NORMAL. It is written at each run of the index policy (§3.4) — every policy interval, on every applied configuration change and every INDEX command, and once more at shutdown — and read at startup, so per-transaction durability buys nothing: losing the counters since the last run costs some adaptation, not any data.

The query path excludes it explicitly, since it is in the same directory as the shards (§3.3).

3.5.1 Tables #

index_counters — query frequency per field (§3.4).

ColumnTypeContents
field_pathTEXT PRIMARY KEYHeader field or payload path: event.type, access.granted, source.name. A header field's column name written by an earlier eventd is read back as its path (§3.4).
query_countINTEGER NOT NULLQueries filtering on it within the current window.
window_startINTEGER NOT NULLWhen the window started, nanoseconds since the epoch.

desired_indexes — the computed desired index set.

ColumnTypeContents
field_pathTEXT PRIMARY KEYHeader field or payload path, read back like a counter's.
priorityINTEGER NOT NULLRank; lower is higher priority.
is_expressionINTEGER NOT NULL1 for a payload expression index, 0 for a column index.

sequence_checkpoints — diagnostic only.

ColumnTypeContents
boot_idBLOB NOT NULLThe boot the checkpoint applies to.
cpu_idINTEGER NOT NULLCPU identifier.
sequenceINTEGER NOT NULLLast committed sequence for that pair when written.
updated_atINTEGER NOT NULLWhen it was written.

Primary key (boot_id, cpu_id). Startup recovery uses committed receipt ranges and never this table (§2.2). The table exists so that an operator can see what eventd believed at shutdown and compare it with receipt coverage — the two disagreeing is itself diagnostic.

meta — key-value.

ColumnTypeContents
keyTEXT PRIMARY KEYMetadata key.
valueBLOB NOT NULLStrings as UTF-8 bytes, binary as raw bytes.

with two required entries: schema_version (§B) and created_at as a UTC YYYY-MM-DDTHH:MM:SSZ string. The administrative descriptor lives with every other eventd descriptor at Machine\System\eventd\Security\Admin (§7.2); no access-control state is stored in this reconstructible database.

3.5.2 Concurrency #

One writer connection, owned by the index policy thread. No other thread opens the database read-write.

Query handlers write only to the in-memory counters; the policy thread flushes them at each interval. Writer threads and query handlers read the desired sets from memory, never from the database.

Graceful shutdown writes sequence_checkpoints through the same connection, after policy activity has stopped (§8.4).

With a single writer and no other database-level access, SQLite's WAL mode is the whole of the concurrency control needed.

The policy thread checkpoints the write-ahead log at WalCheckpointPages in passive mode, and does not block when readers hold pages — the same rule as every other store (§2.4).

3.5.3 Recovery is cheap #

If SQLite cannot read the file, or the schema version is missing or unrecognised, or any required table or meta entry is missing or malformed, eventd logs an error and recreates the database from defaults.

This is the opposite of the rule for a shard, which fails startup (§3.3), and the difference is what is at stake. A shard holds the only copy of audit data. This database holds an optimisation policy and some diagnostics — all of it reconstructible, none of it irreplaceable. Losing it costs the adaptation eventd had accumulated, and the counters begin refilling immediately. Security policy is unaffected because it lives in the registry.

3.5.4 Startup #

  1. Open eventd-meta.db, creating it if absent.
  2. Verify the schema version and required meta entries; recreate from defaults on failure.
  3. Load index_counters into memory.
  4. Load desired_indexes into memory.
  5. Load sequence_checkpoints, for diagnostics only.
  6. Discover the material indexes in each shard from its schema and compare against the desired set.

eventd resumes convergence from wherever each shard happens to be. It neither drops nor rebuilds indexes at startup: a shard's material set is a fact to be observed, not a state to be restored.

3.6 Retention

Peios / Peios technical reference / eventd / Event Storage

Retention bounds disk growth. eventd deletes on two axes, age and size, and enforces both — an event goes when it exceeds either threshold.

The retention model is deliberately minimal, and a later one is expected to support rules resembling queries: retain KACS events for ninety days, synthetic events for seven, userspace-origin events for fourteen; and to prune during ingestion rather than only in arrears. What is here is the least that prevents unbounded growth.

3.6.1 Age #

Rows are deleted from events where timestamp is older than EventRetentionDays (§A) from the current wall clock, until none remain. Each shard is processed independently, and the rule covers KMES events, synthetic events and gap records alike.

3.6.2 Size #

Size is measured as logical live size, not file size:

logical_live_bytes = (page_count - freelist_count) * page_size

from PRAGMA page_count, PRAGMA freelist_count and PRAGMA page_size, taken after attempting a passive WAL checkpoint. The event store's total is the sum across every shard.

Pages freed by retention do not count, because they are reusable by future inserts. Counting them would make retention chase its own tail: each deletion would free pages that still counted against the limit, prompting more deletion.

When EventRetentionMaxBytes is non-zero and the total exceeds it:

  1. Identify every non-current boot ID present in the shards.
  2. Order them by their newest event timestamp, oldest boot first.
  3. Delete each of those boots entirely, across all shards, one boot at a time, until the total is within the limit or no non-current boots remain.
  4. If still over, delete the oldest events of the current boot by timestamp, across all shards, until within the limit.

Size pressure prefers boot boundaries. Deleting a whole old boot removes a self-contained unit — its sequence numbers, its gap records and its startup event go together — and it preserves recent events across the boundary, which is what an operator investigating a reboot needs. Only when whole boots are exhausted does eventd start on the current one.

3.6.3 Running it #

A background retention coordinator processes the event store first, then the log store (§4.4), then the metric store (§5.5). Each store is processed whether or not the one before it failed, and the pass reports every store that did. The interval is RetentionCheckIntervalMinutes (§A); an applied configuration change (§8.3) and a write refused for want of space (§9.2) each request a pass at once. After a failed pass, requests are held for one second, doubling to a minute while passes keep failing, so a full disk does not drive retention into a tight retry loop. It uses read-only connections to measure and plan. The only read-write connections it owns are to historical shards, which have no ingestion writer: it opens each one read-write for the life of the process and deletes from it and checkpoints it directly, as that shard's one writer.

Every other database has exactly one read-write connection, owned by its ingestion writer. The coordinator submits low-priority maintenance commands to that owner. A command runs only at a transaction boundary, deletes at most RetentionDeleteBatchRows, and yields to ingestion before another command runs. Under urgent size pressure the owner may append one bounded deletion to an ingestion transaction it already has open, avoiding an additional commit and fsync.

The passive checkpoint attempted before a size measurement is also a writer-owned command. The coordinator requests it, then reads the page and freelist counts through its read-only connection. It never tries to checkpoint through that connection or temporarily promotes it.

There is no retention writer mutex and no second SQLite writer. A single unbatched DELETE over a month of events could otherwise hold a write transaction for as long as it took, blocking the writer and, behind it, the drain thread and KMES ring.

3.6.4 Reclamation #

Deleting rows does not shrink a SQLite file. Freed pages are reused by later inserts, and reclaiming filesystem space needs VACUUM, which rewrites the whole database.

eventd never runs VACUUM automatically. Reclamation is an explicit administrative operation.

In steady state it is not needed. Where ingestion and retention run at comparable rates, the file settles at roughly the high-water mark of retained data and the freed pages are recycled without further growth — which is also why logical live size, rather than file size, is the right thing to measure.

3.7 Boot Partitioning

Peios / Peios technical reference / eventd / Event Storage

Every record eventd stores — every event, every log line, every raw metric sample — carries a boot_id: a 16-byte GUID identifying the boot that produced it. Linux assigns it at boot; eventd reads /proc/sys/kernel/random/boot_id at startup, validates the UUID text, and stores the corresponding PCDS-layout GUID.

3.7.1 What it is for #

Disambiguation. KMES per-CPU sequence numbers restart at zero each boot. Without a boot ID, sequence 42 from one boot is indistinguishable from sequence 42 from the next, and every gap calculation across a reboot would be wrong.

Lifecycle. Retention can delete a whole boot as a unit rather than scanning by timestamp, and a boot is the natural unit to delete: its events, its gaps and its startup record go together (§3.6).

3.7.2 Where it is stored #

StoreColumn
Eventevents.boot_id, every row
Loglogs.boot_id, every row
Metricsamples.boot_id, every row

The log store records it because log output can mean different things across boots — a service configured differently at boot time says different things.

The metric store records it per sample but does not make it part of series identity (§5.2). A time series stays continuous across a reboot, which is what a chart of CPU usage across a restart should show, and a query that wants one boot's worth filters for it explicitly (PSPU §3.25).

3.7.3 Uniqueness #

Within one boot an event is uniquely identified by (cpu_id, sequence). Across boots, boot_id supplies the disambiguating dimension, and the triple (boot_id, cpu_id, sequence) is globally unique.

3.7.4 Detecting the boundary #

At startup eventd reads the kernel boot ID, then searches every readable event shard database — historical shards included — for committed rows or receipt ranges carrying it.

No committed rows or receipts for this boot. This is the boot's first successful eventd start. Coverage for every CPU begins before sequence 1, the new boot ID is used for subsequent writes to all three stores, and eventd.daemon.started carries store.restarted false.

Committed rows or receipts exist. eventd was previously ready in the same boot. It merges receipt coverage per (boot_id, cpu_id), reconciles that coverage with surviving ring records (§2.2), continues under the same boot ID, and emits eventd.daemon.started with store.restarted true.

The two cases are distinguished by the data itself rather than by any flag eventd persisted. That is the point: a flag would have to be written at a moment eventd might not reach, and a crash is precisely the case where it did not.

Committed receipt ranges are the sequence authority. Committed event rows still establish that eventd previously ran when no receipt was yet needed. The metadata database's sequence checkpoints and the previous eventd.daemon.stopped payload are diagnostic — neither is consulted for recovery (§2.2, §3.5).

The receipt key is sound only while KMES sequence numbers do not restart under one kernel boot ID, so eventd depends on KMES remaining initialised for the whole boot. A future KMES ABI may add a stream generation GUID to make module reloads distinguishable without relying on that lifecycle invariant.

4.1 The Log Writer

Peios / Peios technical reference / eventd / Log Storage

One thread reads datagrams from the log socket and writes log records to the log store. It is independent of the event drain and writer threads, so log ingestion never contends with event ingestion.

The wire contract — socket type, datagram ceiling, record format, and exactly which malformations cost what — is PSPU §3.6 to §3.8. What follows is what eventd does with a record once it has one.

4.1.1 One thread does both jobs #

The log thread performs both the socket reads and the SQLite writes.

The consequence is direct: during a batch commit the socket is not being drained, and datagrams arriving in that window occupy the receive queue until it fills. A Unix datagram socket's receive queue is bounded in datagrams, by the kernel's net.unix.max_dgram_qlen, and it is the whole cushion. Once it is full the kernel refuses further sends rather than accepting and discarding them: a non-blocking sender receives EAGAIN, and a blocking one waits until the queue drains. eventd sets SO_RCVBUF to four times the datagram ceiling, but for this socket type the kernel charges a queued datagram to its sender's send buffer, and the receive buffer bounds nothing.

Note

Linux's default net.unix.max_dgram_qlen is ten, and the kernel refuses a send only once more than that many are queued, so the queue holds eleven datagrams. A batch commit takes one to ten milliseconds, and those eleven are the only thing absorbing arrivals in the window. A service dumping a stack trace through non-blocking sends will have datagrams refused, which is the intended degradation for a loss-tolerant path (PSPU §3.4): whatever is lost is lost in the sender, and eventd neither buffers beyond the queue nor learns of the refusal.

Splitting into a reader and a writer with a bounded handoff — the shape the event path uses (§2.3) — would decouple them. It is not done, and the reasoning is that log loss is tolerable by design (PSPU §3.4), log volume is normally well below event volume, and the single-thread model avoids a handoff channel and its backpressure semantics entirely.

Where log throughput does become the constraint, sharding the log store the way the event store is sharded is the larger lever; splitting the thread only moves the stall.

4.1.2 Batching #

The writer batches on the same adaptive principle as the event writer (§2.4), with the socket receive queue as its input. A transaction opens when the first valid record is available and commits when any of these holds:

  • no further datagram is immediately available in the receive queue
  • the batch holds LogMaxBatchSize records
  • LogMaxBatchLatencyMs has elapsed since the first record entered it

If a datagram yields more valid records than fit in the remaining space, the writer commits, then continues with the same datagram in a new transaction. A transaction never exceeds the size cap and never stays open past the latency cap — a batched datagram cannot smuggle a larger transaction past either.

The defaults (§A) are 5000 records and 500 milliseconds. The latency is five times the event writer's, because log loss on power failure is acceptable where event loss is not, and larger, less frequent transactions are more efficient at the moderate volumes logs normally run at.

The writer keeps the known origin names in memory. Before inserting the first log row for an origin not in that set, it executes INSERT OR IGNORE into log_origins in the same transaction (§4.2).

A transaction-local pending set ensures that insert runs only once per new origin in a batch. The main in-memory set is updated only after commit and the pending set is discarded on rollback. Thus discovery never needs to scan the hot logs table, and a rolled-back first row cannot make the cache claim that its catalogue entry exists.

4.1.3 Durability #

The log store runs in WAL mode with synchronous=NORMAL, not FULL.

NORMAL syncs at checkpoint time rather than at every commit. It is durable against process crashes — the write-ahead log survives — but not against power loss, where commits since the last checkpoint may be gone.

This is a deliberate divergence from the event store, and it is the single clearest expression of the hierarchy the whole daemon is organised around: events are sacred, logs are not. Paying an fsync per transaction to protect data whose loss is defined as acceptable would be paying for nothing.

4.1.4 Adding to the record #

eventd supplies the boot_id and, where the producer omitted timestamp, its own clock at receipt. Everything else is stored as given — message byte for byte (PSPU §3.8).

4.1.5 Rejected origins are counted #

A record whose origin is outside the origin grammar (§4.2) is discarded where it is parsed, before it can join a batch. eventd counts every such discard and keeps the most recent origin, escaped and truncated to 64 characters; both appear in the diagnostic dump (§8.5) as log_ingress and last_rejected_log_origin.

It also writes one line to standard error on the first discard and at most one a minute after that. peinit captures eventd's own standard error, so that line is stored as an ordinary log record under the origin eventd and is queryable with everything else — and it still reaches the console directly when the log store is the broken thing.

The rate limit is what makes the report usable. A producer whose vocabulary has drifted from this one usually uses the same origin for every line it sends, so an unlimited report would turn a silent total loss into a flood; the count is the quantity that matters and the text is there to name the vocabulary that drifted.

4.2 The Logs Table

Peios / Peios technical reference / eventd / Log Storage

The log store is a single SQLite database — not a directory of shards. There is no sharding here: one ingestion thread produces the writes, so splitting the target would give a single writer several files to switch between rather than several writers working in parallel.

It holds the logs, log_origins and metadata tables.

ColumnTypeContents
idINTEGER PRIMARY KEYSQLite rowid, monotonic.
boot_idBLOB NOT NULL16-byte boot ID GUID in PCDS binary layout.
timestampINTEGER NOT NULLNanoseconds since the Unix epoch — the producer's value if it supplied one, otherwise eventd's clock at receipt.
originTEXT NOT NULLThe producer peinit associated with the output pipe — a service, or a hook, health check, reload command or job within one.
is_errorINTEGER NOT NULL1 for standard error or an explicitly marked error, 0 otherwise.
messageTEXT NOT NULLThe log text.
job_idBLOB16-byte correlation GUID when the producer supplied one; null otherwise.

The schema is deliberately narrow. A log record is text with light metadata: what produced it, whether it was an error, when, and optionally which execution it belongs to. There is no payload blob and no origin class. origin is broker-attested rather than a service's self-assertion: the log socket admits peinit and excludes service-logon tokens (§7.6). The optional correlation key is not an identity.

4.2.1 What an origin looks like #

An origin is one or two components (PSPU §3.7):

origin    := component | component "/" producer
producer  := component | component "[" [0-9]+ "]"
component := [A-Za-z0-9_][A-Za-z0-9_.-]*

One component is a service — loregd, jellyfin — and is what a main process's output carries. Two name a producer within a service: peinit tags a pre-start hook jellyfin/ExecStartPre[0], a reload command jellyfin/ExecReload, a health check jellyfin/HealthCheck, and a submitted job jobs/<guid> (peinit TRM §11.1). The bracketed index appears only after a slash, and there is never more than one slash.

Ingestion discards a record whose origin is anything else, before the record reaches a batch (§4.1). Nothing wider is accepted: * would impersonate the access-control wildcard, and a backslash, a quote or whitespace would reach into the registry path an origin's descriptor lives at (§7.2).

A program needing more structure than this emits events.

4.2.2 The origin catalogue #

log_origins has one column, origin TEXT PRIMARY KEY. The log writer inserts a previously unseen origin in the same transaction as its first log row (§4.1), so every committed row is discoverable immediately.

Query planning enumerates origins from this compact catalogue rather than running DISTINCT origin over the retained log rows (§6.1).

Catalogue entries deliberately survive ordinary retention. An origin may therefore remain discoverable after its final row expires; this safe superset avoids write work on the ingestion path.

Low-priority maintenance may remove an entry after proving through a read-only scan that no row refers to it, with the deletion submitted to the log writer. The writer rechecks NOT EXISTS in that transaction and removes the origin from its in-memory set only after commit, so a log arriving between planning and execution cannot become undiscoverable.

Catalogue pages count toward logical live size.

4.2.3 is_error is an integer here and a boolean there #

The column stores 0 or 1; the query language exposes a boolean, and accepts either WHERE is_error == true or WHERE is_error == 1 (PSPU §3.22). ERROR ONLY is sugar for the first.

4.2.4 Write-time indexes #

Three indexes are created with the table:

  • idx_logs_timestamp on logs(timestamp) — time-range filtering, as everywhere.
  • idx_logs_origin on logs(origin) — "show me logs from X", which is the dominant log query.
  • idx_logs_job_id on logs(job_id) WHERE job_id IS NOT NULL — a partial index for "show me logs for job X". Partial because ordinary service output may carry no correlation key, so only correlated lines are worth indexing.

The origin index costs write amplification beyond the timestamp index, and the cost is modest in practice: origin has low cardinality, tens of distinct names on a normal system, so its index pages stay in SQLite's page cache and insertion stays cheap. The trade is accepted deliberately — the two dominant log queries must not become full table scans.

4.2.5 No adaptive indexing #

The log store does not participate in adaptive indexing (§3.4). Its field set is closed and small, and the three write-time indexes already cover the access patterns; there is no space of candidate fields for a policy to discover.

The same follows for query frequency counters: log queries do not increment them (§6.5).

4.2.6 Schema version #

The log store holds a metadata table with the same two-column structure as a shard's (§3.1). Schema version 1 comprises logs, log_origins and metadata; its version number is in §B.

eventd checks it at startup and applies the lifecycle rules of §4.3, and does not migrate.

4.3 Database Lifecycle

Peios / Peios technical reference / eventd / Log Storage

4.3.1 Path #

LogStorePath (§A) names a provisioned directory, conventionally /var/state/eventd/logs. The database itself has the fixed name logs.db inside it. A missing, invalid or unsafe directory is a startup failure.

The directory has the provisioning, descriptor and path-resolution requirements defined for the event store (§3.3). eventd does not create it or any parent directory, never follows a symbolic-link component, and creates or opens the database relative to an already validated directory handle.

4.3.2 Creation #

A log store that does not exist, or whose database holds no schema at all (what a power cut leaves when it takes the uncheckpointed creating transaction), is created, in one transaction, with:

  1. WAL mode, PRAGMA journal_mode=WAL
  2. PRAGMA synchronous=NORMAL
  3. the logs, log_origins and metadata tables (§4.2)
  4. the idx_logs_timestamp, idx_logs_origin and idx_logs_job_id indexes
  5. the schema_version and created_at entries

4.3.3 Opening #

  1. Open in WAL mode.
  2. Set synchronous to NORMAL.
  3. Verify the schema version. Missing or unrecognised is a startup failure; no migration is attempted.
  4. Verify structural integrity — required tables and indexes present. Failing this, with SQLite reporting no corruption, is a startup failure.
  5. On SQLite reporting corruption, quarantine and replace. A database holding schema objects but no metadata table with entries is not a store eventd could have written; it is quarantined and replaced like a corrupt one.

Quarantine works exactly as for a shard (§3.3): the database, -wal and -shm files are renamed with a shared .corrupt.<timestamp_ns> suffix, .N appended with the lowest positive integer if the name is taken, and a fresh empty log store is created at the configured path.

The log store is a required store. There is no degraded mode in which eventd runs without one (§8.2), which is why steps 3 and 4 fail startup rather than proceeding without logs.

4.3.4 Concurrency #

Exactly one read-write connection, owned by the log writer thread, and any number of read-only query and maintenance connections. WAL mode lets readers run concurrently. Retention and catalogue cleanup submit bounded commands to the writer and never open another read-write connection (§3.6).

4.3.5 Checkpointing #

The log writer checkpoints when its write-ahead log reaches WalCheckpointPages (§A), in passive mode, and does not block if readers hold pages — it keeps writing and retries after a later commit.

Checkpointing matters more here than in the event store, because synchronous=NORMAL makes the checkpoint the durability boundary rather than merely a space-reclamation event: data committed since the last checkpoint is what a power cut takes (§9.5).

4.4 Retention

Peios / Peios technical reference / eventd / Log Storage

Log retention works exactly as event retention does (§3.6), on the same background thread, running after the event store and before the metric store. As there, the model is an early simplification and both limits are enforced with the more aggressive one winning.

4.4.1 Age and size #

Rows older than LogRetentionDays (§A) are deleted from logs until none remain.

If LogRetentionMaxBytes is non-zero and the store's logical live size exceeds it, the oldest entries by timestamp are deleted until it is within the limit. Logical live size is the same measure as §3.6 — (page_count - freelist_count) * page_size after attempting a passive checkpoint — and freed pages do not count.

There is no boot-boundary preference here. Event size retention prefers to drop whole old boots because a boot is a self-contained unit of sequence-numbered records; a log line has no such structure, so oldest first is the whole rule.

4.4.2 The default is shorter than events' #

Fourteen days against the event store's thirty (§A).

Historical log data is worth less than historical audit data, and it is usually bulkier per unit of value. The metric store's default is longer than either at ninety days, for the opposite reason: a metric sample is tiny and trend data is worth more the further back it goes (§5.5).

4.4.3 Batching #

The retention coordinator plans with a read-only connection and submits low-priority commands to the log writer. Each writer-owned transaction deletes at most RetentionDeleteBatchRows, and ingestion is rechecked before the next command. Under urgent size pressure the writer may append one bounded delete to a transaction already open. Retention never takes a writer mutex or opens a second read-write connection (§3.6).

The stall this avoids is the log ingestion thread's, and that thread is also the one draining the socket (§4.1) — so a retention pass holding a long write transaction would not merely delay writes, it would stop the socket being read and lose the datagrams that arrived meanwhile.

4.4.4 Reclamation #

VACUUM is never run automatically, as everywhere. Freed pages are recycled by later inserts and are excluded from the size measure.

5.1 The Metric Writer

Peios / Peios technical reference / eventd / Metric Storage

One thread reads datagrams from the metric socket and writes samples to the metric store, independent of both the event and log paths. It has the same single-thread shape as the log writer, with the same consequence during a commit (§4.1).

The wire contract is PSPU §3.9 to §3.13. The same thread also writes eventd's own health samples, which skip step 1 below (§5.7).

5.1.1 Processing a record #

For each valid record:

  1. Authorize the name. The datagram's KACS token must hold EVENTD_PUBLISH on the resolved metric-name descriptor (§7.6).
  2. Resolve the series from name and labels — and, for a histogram, bucket boundaries — through the in-memory series cache (§5.3). A series that does not exist is inserted into series with the record's type, and the cache is updated.
  3. Check the type. If the record's type differs from the resolved series' type, the record is dropped without replying to the producer. The type is set at creation and is immutable; a series never changes type.
  4. Insert the sample into samples with the resolved series_id, the timestamp and the value. SQLite assigns samples.id, which is the deterministic tiebreaker among samples sharing a timestamp (§5.2). A histogram's data is encoded as the canonical MessagePack sample map and stored in histogram_data.

The transaction result counts step-3 failures and carries the most recent conflict's metric name and expected and received types. The metric thread adds the count to an in-memory diagnostic total, retains that conflict, and emits a coalesced standard-error warning at most once per minute. SIGQUIT includes both the total and latest conflict (§8.5).

There is still no response to the producer and no event per failure. Authentication identifies the producer; it does not make reactions proportional to its bad input safe. The one-minute global bound is what keeps the warning from becoming the amplification vector PSPU §3.4 forbids. A valid transaction takes only the existing zero-mismatch branch and performs no diagnostic allocation or synchronization.

5.1.2 Out-of-order samples #

The writer stores a valid sample whose timestamp precedes samples already held for that series.

Producers batch, clocks step, and sweeps get retried. Refusing late samples would convert any of those into silent loss, so eventd accepts them and defines every ordering it performs over (timestamp, id) rather than over insertion order. That makes raw RATE and DELTA evaluation deterministic regardless of arrival (§6.2).

5.1.3 Batching #

The same adaptive algorithm as the event and log writers (§2.4), with the socket receive queue as input. A transaction opens at the first valid sample and commits when any of these holds:

  • no further datagram is immediately available
  • the batch holds MetricMaxBatchSize samples
  • MetricMaxBatchLatencyMs has elapsed since the first sample entered it

A datagram yielding more samples than fit is split across transactions, the writer committing before continuing with the same datagram. Neither cap is ever exceeded.

The defaults (§A) are 5000 samples and 1000 milliseconds — the longest latency of the three writers. Metrics are typically sampled every fifteen seconds, so a one-second commit window accumulates a whole sweep's worth without any latency that a dashboard could notice. Under a burst, where a collection agent submits every core, disk and interface at once, the size cap is what forces a timely commit.

5.1.4 Durability #

WAL mode with synchronous=NORMAL, as the log store (§4.1). Metric loss on power failure is acceptable, so per-transaction fsync buys nothing.

5.2 Series and Samples

Peios / Peios technical reference / eventd / Metric Storage

The metric store is a single SQLite database, and unlike the event and log stores it is organised around series rather than records. Individual samples are appended to a series that already has an identity.

5.2.1 The series table #

ColumnTypeContents
idINTEGER PRIMARY KEYSeries identifier; the foreign key samples uses.
nameTEXT NOT NULLThe metric name.
labelsTEXT NOT NULLCanonical label representation. Empty string for no labels.
typeINTEGER NOT NULL0 counter, 1 gauge, 2 histogram.
label_hashINTEGER NOT NULLHash of the canonical label string.
boundaries_hashINTEGERHash of the canonical boundary blob. Null for counters and gauges.
boundariesBLOBCanonical boundary blob. Null for counters and gauges.

5.2.1.1 The canonical label string #

Labels are sorted by key in unsigned UTF-8 byte order, each pair written key=value, and the pairs joined with commas: core=0,host=server1. The empty label set is the empty string.

No escaping is performed and none is needed, because ingestion rejects = and , inside a key or a value (PSPU §3.10). That prohibition exists precisely to make this encoding unambiguous, and it is the reason the constraint binds the producer rather than being handled internally.

5.2.1.2 The boundaries blob #

A fixed binary encoding, not MessagePack:

  1. boundary_count, u32 little-endian
  2. that many IEEE-754 f64 values, each little-endian, in the validated order the producer sent

It exists only to identify histogram series and to resolve hash collisions, and is never returned in a query result.

5.2.1.3 Hashes narrow, they do not decide #

label_hash and boundaries_hash are 64-bit FNV-1a over the exact bytes of the canonical string or blob, with offset basis 0xcbf29ce484222325 and prime 0x100000001b3. The high bit is cleared before storage, hash & 0x7fff_ffff_ffff_ffff, so the value always fits SQLite's signed INTEGER.

A lookup always verifies the full labels string, and for a histogram the full boundaries blob, after narrowing by hash. A hash is an index key, never an identity: two label sets that collide are still two series.

5.2.1.4 Uniqueness #

The table carries UNIQUE(name, labels, boundaries), on the full boundary blob. boundaries_hash is a lookup key with its own index, and two histograms whose boundaries collide on it are two series.

For counters and gauges boundaries is null, and SQLite treats nulls as distinct in a unique constraint — so the constraint does not enforce uniqueness for them. What does is the single-writer resolution logic (§5.3), which checks before inserting. The constraint is a defensive backstop against a future change that introduces a second write path, not the primary mechanism.

type is not part of the identity. A record resolving to an existing series with a different type resolves successfully and is then dropped for the mismatch (§5.1).

5.2.2 The samples table #

ColumnTypeContents
idINTEGER PRIMARY KEYInternal row identifier; the tiebreaker for samples sharing a series and timestamp.
series_idINTEGER NOT NULLReferences series(id).
boot_idBLOB NOT NULL16-byte boot ID GUID.
timestampINTEGER NOT NULLNanoseconds since the Unix epoch.
valueREAL NOT NULLThe raw value for counters and gauges. Stores 0 for histograms.
histogram_dataBLOBCanonical MessagePack histogram sample map. Null for counters and gauges.

For a histogram, histogram_data is a canonical MessagePack map (PSPU §3.5) with exactly four keys: boundaries, an array of float64 in the producer's order; counts, an array of unsigned integers; total_count; and sum, a finite float64.

value is a placeholder for histogram rows and is never returned as a metric query value. Storing 0 rather than null keeps the column NOT NULL and keeps the row layout uniform.

Canonical encoding is required here because a stored sample map must be byte-stable: two equal histograms encode identically, which is what makes them comparable without decoding.

boot_id is per sample and is not part of the series identity, so a series stays continuous across a reboot (§3.7).

5.2.3 Ordering #

Query execution order within a series is always (timestamp, id) ascending, never insertion order alone. Duplicate timestamps are permitted and id gives them a stable order.

id is internal. It is never exposed as a query field, a result field, an access-control field, or a reserved label key (PSPU §3.28).

5.2.4 Write-time indexes #

  • idx_samples_series_timestamp on samples(series_id, timestamp, id) — the dominant pattern is "samples for series X over range Y in deterministic order", and this one composite index serves the series lookup, the range filter and the (timestamp, id) ordering in a single scan.
  • idx_series_name on series(name) — name lookups.
  • idx_series_label_hash on series(label_hash) — series resolution on the ingestion path.
  • idx_series_boundaries_hash on series(boundaries_hash) — narrowing a histogram's lookup by its boundary hash.

5.2.5 Schema version #

A metadata table with the same structure as the other stores' (§3.1). Version 1 comprises series, samples and metadata. Version 2 adds the disposable rollups query cache (§5.6); it does not change raw series or sample storage. Version 3 changes only the series table's uniqueness, from the boundary hash to the full boundary blob, and adds idx_series_boundaries_hash. The current value is in §B. eventd checks it at startup and applies the lifecycle and migration rules of §5.4.

5.3 Series Resolution

Peios / Peios technical reference / eventd / Metric Storage

Every arriving sample must be turned into a series_id before it can be inserted. This happens once per sample on the single metric ingestion thread, so it is the hottest lookup in the daemon and the reason a cache exists at all.

5.3.1 Resolving #

  1. Compute the canonical label string: sort by key in unsigned UTF-8 byte order, encode each pair key=value, join with commas. The empty label set encodes as the empty string. No escaping — ingestion has already rejected the delimiters (§5.2).
  2. Hash it.
  3. For a histogram, compute the canonical boundary blob and its hash from the validated, producer-supplied order. eventd never sorts boundaries. For counters and gauges both are null.
  4. Look up series by name, label_hash, and for histograms boundaries_hash.
  5. On a match, verify the full labels string, and for histograms the full boundary blob. If the record's type differs from the existing series' type, drop the record (§5.1). Otherwise use the existing series_id.
  6. On no match, insert a new series row and use the new identifier.

A histogram whose boundaries changed takes step 6: it is a new series (PSPU §3.13). The old one keeps its historical samples and the new one starts accumulating.

5.3.2 The cache #

Resolution runs through a bounded in-memory cache mapping (name, canonical labels, boundaries hash, boundaries blob) to series_id. For counters and gauges the boundary components are absent.

A hit is a hash table lookup with no SQLite involvement. A miss costs one SELECT on name and label_hash, after which the result is inserted, evicting the least recently used entry if the cache is full.

The bound is MetricSeriesCacheSize (§A), default 50000, with LRU eviction. It bounds memory, not the number of series: the series table is uncapped and a new series is always created in the database. A system with a million series and a 50000-entry cache uses memory proportional to the cache, and at roughly 200 to 300 bytes an entry the default costs 10 to 15 MB.

The cache starts empty after a restart and is warmed on demand — within one collection cycle, typically fifteen seconds, every active series is cached. There is no pre-warming pass, because reading a million-row series table at startup to populate a 50000-entry cache would be work spent to discard most of its result.

5.3.3 Sizing it #

The cache is sized for the set of actively reporting series, and behaves badly below it.

Below that, LRU does not help, because every series is equally hot: each collection cycle evicts the overflow and reloads it, producing a fixed number of SELECTs every cycle, permanently. A system with 55000 active series and a 50000-entry cache incurs about 5000 cache misses every fifteen seconds, indefinitely.

This interacts badly with label cardinality (PSPU §3.10). Labels with unbounded values — request identifiers, user-supplied strings — grow the series table without limit, and once the active set exceeds the cache every cycle pays the eviction cost on the one thread that also drains the metric socket. The failure presents as metric loss, because the thread stops reading while it queries.

eventd does not cap series creation: at the interface, a producer creating a genuinely new series is indistinguishable from one creating garbage, and rejecting either would break a correct producer to inconvenience an incorrect one (PSPU §3.13). The default 1 GiB metric retention ceiling bounds the resulting disk growth (§5.5), but it does not prevent a hot working set from exceeding this cache and thrashing the ingestion thread before retention runs.

5.4 Database Lifecycle

Peios / Peios technical reference / eventd / Metric Storage

5.4.1 Path #

MetricStorePath (§A) names a provisioned directory, conventionally /var/state/eventd/metrics. The database itself has the fixed name metrics.db inside it. A missing, invalid or unsafe directory is a startup failure.

The directory has the provisioning, descriptor and path-resolution requirements defined for the event store (§3.3). eventd does not create it or any parent directory, never follows a symbolic-link component, and creates or opens the database relative to an already validated directory handle.

5.4.2 Creation #

A metric store that does not exist, or whose database holds no schema at all (what a power cut leaves when it takes the uncheckpointed creating transaction), is created, in one transaction, with:

  1. WAL mode.
  2. PRAGMA synchronous=NORMAL — the log store's reasoning, for the same reason: metric loss on power failure is acceptable (§4.1).
  3. The series, samples, rollups and metadata tables (§5.2, §5.6).
  4. Every write-time index.
  5. The schema_version and created_at entries.

5.4.3 Opening #

  1. Open in WAL mode with synchronous NORMAL.
  2. Verify the schema version. Version 1 is migrated transactionally to version 2 by adding the adaptive-rollup cache. Version 2 is migrated transactionally to version 3 by rebuilding the series table under its new uniqueness constraint, foreign keys off for the rebuild and every identifier kept; a version 2 store missing a required table or index fails startup instead. Missing or unrecognised versions are a startup failure.
  3. Verify structural integrity — required tables and indexes present. Failing this, with SQLite reporting no corruption, is a startup failure.
  4. On SQLite reporting corruption, quarantine and replace, exactly as for a shard (§3.3): matching -wal and -shm files renamed with the same .corrupt.<timestamp_ns> suffix, .N appended if the name is taken, and a fresh empty store created at the configured path. A database holding schema objects but no metadata table with entries is not a store eventd could have written; it is quarantined and replaced like a corrupt one.

The metric store is a required store; there is no degraded mode without one (§8.2).

After opening or creation the series cache is empty and fills on demand (§5.3).

5.4.4 Concurrency #

Exactly one read-write connection, owned by the metric writer thread, and any number of read-only query and maintenance connections. WAL mode permits readers alongside the writer. Retention and adaptive rollups plan or compute with read-only connections and submit bounded commands to the writer; they never open another read-write connection (§3.6, §5.6).

The single-writer property is load-bearing here in a way it is not for the other stores: series resolution checks for an existing row and then inserts, without a transaction spanning both, and only one writer makes that safe (§5.3).

5.4.5 Checkpointing #

The metric writer checkpoints at WalCheckpointPages (§A) in passive mode, and does not block when readers hold pages.

As with the log store, the checkpoint is the durability boundary under synchronous=NORMAL, not merely space reclamation (§9.5).

5.5 Retention

Peios / Peios technical reference / eventd / Metric Storage

Metric retention runs on the same background thread as the other two, after the log store (§3.6, §4.4). Both limits are enforced and the more aggressive wins.

5.5.1 The pass #

  1. Delete rows from samples older than MetricRetentionDays (§A) until none remain.
  2. If MetricRetentionMaxBytes is non-zero and the store's logical live size exceeds it, delete the oldest samples by timestamp until it is within the limit. The mainline default is 1073741824 bytes (1 GiB); zero is an explicit opt-out rather than the default.
  3. Track every series_id whose samples were deleted by either step.
  4. Before deleting a sample batch, delete all adaptive rollups belonging to any affected series (§5.6).
  5. Delete every series row with no remaining samples.

Logical live size is the same measure as §3.6.

Step 5 removes the definitions of series nobody produces any more, which is the only mechanism that ever removes a series row. A series that stopped receiving samples persists until its last sample is removed. In the absence of size pressure that is ninety days by default; the byte ceiling can remove it earlier.

5.5.2 The longest default #

Ninety days, against fourteen for logs and thirty for events (§A).

A metric sample is small and its value grows with age: a year of CPU utilisation is a capacity-planning input in a way that a year of log lines is not. A thousand series sampled every fifteen seconds produce about 5.7 million samples a day, which is a few hundred megabytes in SQLite.

The long age limit therefore does not make storage unbounded. The 1 GiB size default is the safety boundary for a publisher that continually creates new label combinations: eventd still accepts new series, as PSPU requires, but retires the oldest metric data rather than allowing one metric namespace to consume the shared eventd volume. Installations with a dedicated, externally bounded metric volume may set zero deliberately.

The size decision is made by the retention coordinator, never while a datagram is parsed or a sample is committed. Normal metric persistence therefore pays no size-check cost.

5.5.3 Rollups are not downsampling #

Retention deletes raw samples; it does not replace them with lower-resolution data. Adaptive rollups accelerate repeated queries while their raw sources remain present and valid. They are invalidated before retention removes any source from their series, so they cannot extend the apparent lifetime of data or answer a query after its authoritative samples have expired (§5.6).

A future retention engine may aggregate high-resolution data as it ages — per-second samples becoming five-minute aggregates after a week, hourly aggregates after a month — but that would require a separate versioned storage and query contract.

5.5.4 Batching and reclamation #

The retention coordinator plans with a read-only connection and submits low-priority delete commands to the metric writer. Each writer-owned transaction deletes at most RetentionDeleteBatchRows, and ingestion is rechecked before the next command. Under urgent size pressure the writer may append one bounded retention delete to a transaction it already has open; retention never takes a writer mutex or opens another read-write connection (§3.6).

VACUUM is never run automatically. Freed pages are recycled and are excluded from the size measure.

5.6 Adaptive Rollups

Peios / Peios technical reference / eventd / Metric Storage

Raw rows in samples are always authoritative. The rollups table is a disposable query cache: deleting every rollup can make a query slower, but cannot change its answer. A miss or a stale row is computed directly from raw samples and returned immediately; query execution never waits for the cache to be repaired.

The design protects eventd's primary job. Metric ingestion does not inspect, update or coordinate with rollups. A query computes a candidate while doing work it already needed for its own answer, and submits that candidate through a bounded non-blocking channel. The sole metric writer considers at most one such command only after the receive queue is idle and the current sample batch is committed. A full command channel, writer pressure or any ordinary cache-write failure drops the candidate rather than delaying ingestion.

5.6.1 Eligible queries #

A query can read or seed rollups only when all of these are true:

  • it has SINCE and an explicit AVG_OVER, MIN_OVER, MAX_OVER or SUM_OVER function;
  • the selected output is per series — the selector is bracketed, or it resolves to exactly one series;
  • it has no WHERE predicate or cross-type filter; and
  • AdaptiveRollupMaxRows is non-zero.

The label selector has already selected concrete series and is therefore safe. An unbracketed multi-series query is not eligible because its window result combines series; reusing independently cached averages or extrema would not in general reproduce that result. Predicate and cross-filter shapes remain on the raw path because their result depends on more than the cached series window.

Only complete windows aligned to Unix-epoch multiples of the requested width are cached. Any partial window at either end of the requested range is always read from raw samples. A query uses the cache only when every complete interior window for the series has a valid row, including explicit empty-window rows; otherwise it evaluates that series from raw samples. This all-or-raw rule keeps the fallback simple and prevents a cache miss from changing transform pairing.

Streaming continuations do not use or seed rollups. They preserve their normal incremental raw-query semantics.

5.6.2 Schema and identity #

Metric schema version 2 adds:

CREATE TABLE rollups (
    series_id INTEGER NOT NULL REFERENCES series(id) ON DELETE CASCADE,
    window_start INTEGER NOT NULL,
    window_width INTEGER NOT NULL CHECK (window_width > 0),
    transform INTEGER NOT NULL CHECK (transform IN (0, 1, 2, 50, 95, 99)),
    function INTEGER NOT NULL CHECK (function BETWEEN 0 AND 3),
    value REAL,
    overflow INTEGER NOT NULL CHECK (overflow IN (0, 1)),
    source_max_sample_id INTEGER NOT NULL CHECK (source_max_sample_id >= 0),
    source_baseline_sample_id INTEGER CHECK (source_baseline_sample_id > 0),
    CHECK (overflow = 0 OR value IS NULL),
    PRIMARY KEY
        (series_id, window_start, window_width, transform, function)
) WITHOUT ROWID;

transform distinguishes no transform, RATE, DELTA, P50, P95 and P99. function distinguishes AVG_OVER, MIN_OVER, MAX_OVER and SUM_OVER. value is null only for an empty window or a percentile overflow; overflow distinguishes those cases. Empty rows are necessary to prove that a complete cached range really has no missing window.

Opening a version-1 metric store creates the table and its pruning index in one immediate transaction, then records version 2, and continues to version 3 (§5.4). Unknown versions still fail startup (§5.4, §B).

5.6.3 Validity proof #

Each row records the greatest raw sample id observed inside its window as source_max_sample_id. Before serving it, the query engine proves that the same series and window has no sample with a greater id. A later sample therefore makes the row stale even when its timestamp was backfilled into an old window. An empty window records zero, so its first raw sample also invalidates it.

RATE and DELTA additionally depend on the sample immediately preceding the window. Their rows record that sample's id in source_baseline_sample_id, with null meaning no preceding sample existed. The query engine compares this with the current immediately preceding sample. A new, changed or deleted baseline makes the row stale.

Retention invalidates all rollups for every series from which it deletes a raw sample before it deletes the sample (§5.5). This is deliberately conservative. The query-time proof remains mandatory: maintenance ordering alone is not a freshness guarantee.

Non-finite cached values are rejected. As everywhere in metric evaluation, a numeric result must be finite; percentile overflow is represented by the explicit null-and-overflow state instead (§6, PSPU §3.25).

5.6.4 Seeding and bounds #

An eligible raw evaluation may enqueue candidates only after it has scanned at least AdaptiveRollupMinSamples raw inputs. It enqueues no more than AdaptiveRollupBatchRows missing windows, and submission uses try_send on a fixed-capacity channel. Repeating a wide query gradually fills its cache in bounded pieces without starting a background scanner.

The writer upserts a candidate under the complete cache identity and prunes the oldest window starts until no more than AdaptiveRollupMaxRows remain. The cap is global to the metric store. It limits this optional acceleration state; it does not delete series or raw samples.

All three controls are live (§A). Setting AdaptiveRollupMaxRows to zero immediately disables cache reads and writes without changing query results.

Adaptive rollups are not downsampling. Raw samples retain their ordinary age and size policy, and every supported query remains answerable from them (§5.5).

5.7 Health Metrics

Peios / Peios technical reference / eventd / Metric Storage

eventd records its own health as metrics named eventd.*. They answer the questions an operator asks of a running collector — is it keeping up, is it losing anything, how big are the stores, are queries being turned away — through the ordinary query channel, so they can be charted and compared over time like any other metric.

5.7.1 How they are written #

Every HealthMetricIntervalSeconds (§A), the metric thread takes one sample of every series below and commits it to the metric store. The samples do not travel through the metric socket: there is no datagram, no KACS token and no EVENTD_PUBLISH check (§7.6), in the same way synthetic events never touch KMES (§2.6). An interval of 0 turns them off.

Health samples obey MetricMaxBatchSize (§5.1). They fill any pending batch up to the size cap captured when it opened; further transactions use the current configured cap (§8.3). The final partial batch is committed immediately. Splitting an interval's samples does not drop or reorder them.

Each sample carries eventd's clock and the current boot ID, like a metric record that omits its timestamp (PSPU §3.11).

The counters count from eventd's start, and so restart from zero whenever eventd does. A reader asks for their RATE or DELTA, which read a decrease as a restart (PSPU §3.10, §3.25).

5.7.2 The series #

NameTypeLabelsWhat it is
eventd.events.storedcountershardEvents committed to each active shard.
eventd.events.lostcountercpuSequences recorded as lost on each CPU, whether KMES overwrote them or a full store refused their batch (§2.5, §9.1). The sum of the ranges in the eventd.events.lost records eventd committed; the metric and the event type share the name.
eventd.kmes.ring.fill.percentgaugecpuHow full each CPU's KMES ring buffer is, as its drain last saw it.
eventd.events.index.shedscounterreasonEvent indexes dropped under write pressure: pressure one at a time, emergency all at once (§3.4).
eventd.logs.storedcounterLog records committed.
eventd.metrics.storedcounterMetric samples committed, these included.
eventd.metrics.series.cachedgaugeEntries in the series cache (§5.3).
eventd.store.bytesgaugestoreBytes each store occupies on disk, its write-ahead logs included: events (every active and historical shard), logs, metrics and metadata.
eventd.store.write.errorscounterstoreFailed writes to each store, the ones the diagnostic dump names the latest of (§8.5).
eventd.retention.deletedcounterstoreRows retention removed from each store (§3.6, §4.4, §5.5).
eventd.queries.activegaugeQueries running, streaming or not.
eventd.queries.streaminggaugeStreaming queries running.
eventd.queries.refusedcounterreasonQueries turned away by MaxConcurrentQueries (machine), MaxStreamingQueries (streaming) or MaxQueriesPerUser (user) (§6.5).
eventd.queries.failedcounterreasonQueries ended by QueryTimeoutMs (timeout) or MaxQueryHeldBytes (held_bytes) (§6.5).

eventd.store.bytes is the space on disk, which is what fills a volume. Size retention compares a store's logical live size instead, which leaves out free pages and the write-ahead log (§3.6), so the two can differ.

5.7.3 Who can read and publish them #

They are read like any metric, through the Metrics descriptors (§7.2). eventd creates Metrics\eventd on first boot, granting EVENTD_READ to SYSTEM, Administrators and Authenticated Users and EVENTD_PUBLISH to nobody. Without it the wildcard's grant would let an administrator's process send samples under eventd.* through the socket and pass them off as eventd's. An administrator who wants them narrower edits that descriptor.

5.7.4 What is left out #

Rejected ingestion input is not counted here: log records with an invalid origin, and metric datagrams without a token, truncated, denied by name policy or carrying the wrong type. Counting that input where a query client can see it is exactly what PSPU §3.4 and §3.12 forbid. Those counts stay in memory and reach only the diagnostic dump (§8.5).

Neither are datagrams the kernel refuses at a full socket queue. eventd is never told about them (§9.1), so it has nothing to count.

6.1 Parsing and Planning

Peios / Peios technical reference / eventd / Query Execution

A query arrives as one string (PSPU §3.15). Turning it into an answer has four phases before any data is read: parse, plan, authorize, execute.

6.1.1 Parsing #

The string is parsed into a syntax tree. The parser:

  1. Identifies the mode from the first token — EVENTS, LOGS or METRIC.
  2. Extracts the primary selector: a type pattern, a FROM list, or a metric name with an optional label selector.
  3. Collects every clause, in whatever order they appear.
  4. Validates that the clauses suit the mode — CONTAINING only in log mode, RATE only in metric mode, SELECT only where a result schema is not fixed.

Parse errors are returned immediately, before anything is opened, read or authorized. A malformed query costs a decode and a parse.

6.1.2 What can only fail later #

Some failures need data. The parser cannot know whether a metric name resolves to a counter or a histogram, how many series a selector matches, or whether the effective range exceeds the cross-type lookback limit — those depend on the store, so they surface at planning or execution time (PSPU §3.B).

The practical consequence is that the same query string can parse everywhere and fail on one machine: a metric selector matching one series on a two-core box matches two on a four-core one.

6.1.3 Planning #

Planning resolves what the query will actually touch:

  • which concrete identifiers the data could carry — event types, log origins, metric names — because access control resolves per identifier and a broad selector authorizes nothing by itself (§7.4)
  • which series a metric selector matches, and whether they are type-homogeneous
  • which fields the query references, for both authorization and frequency accounting (§6.5)
  • which stores are involved, including any cross-type source

Identifier discovery reads compact catalogues: the union of every shard's event_types, the log store's log_origins, and the metric store's series rows (§3.1, §4.2, §5.2). The primary selector filters that set before access checks. Discovery therefore never runs a DISTINCT scan over a hot records table and does not depend on an adaptive event index being present.

Catalogues may conservatively contain a name whose final retained row was deleted. That safe superset can cause an extra access check but cannot expose a row or omit a concrete identifier that committed successfully. Low-priority cleanup is optional and is never query-critical.

6.1.4 Header literal types #

Literal conversion uses the namespace of the record being compared. emitter.class aliases apply only to event headers. GUID forms apply to the event GUID headers, log boot_id and job_id, and metric boot_id. The same spelling in an ordinary payload field or metric label remains a string; it does not borrow another namespace's header type (PSPU §3.22).

The rule holds for comparisons, membership and compound predicates, metric bracket selectors, and metric selectors inside historical or streaming cross-type conditions. A metric condition uses the metric namespace even when the query's primary source is events or logs.

6.1.5 When payloads are decoded #

Event payloads are stored as opaque MessagePack and are never decoded on the write path (§3.1). Decoding happens here, on the read path, and only where a query needs it: to evaluate a payload predicate, to build a flat result record, or to compute a payload expression index's key on insert.

At high result counts this dominates the query path. Constructing flat maps from thousands of events means decoding thousands of payloads and applying the flattening rules of PSPU §3.22 to each. Partial extraction is the lever: with a SELECT present, only the named paths need decoding, and without one a streaming decoder that emits flattened pairs avoids materialising the payload at all (§C).

6.1.6 Read connections #

Execution uses read-only SQLite connections, which in WAL mode do not block writer threads. The one exception is SQLite's own. A reader that catches the wal-index header while a writer is updating it takes the write lock for the moment it needs to read the header again. A writer therefore waits up to 25 milliseconds for the write lock rather than failing a commit at once, and fails only if the lock is held longer.

An event query opens one connection per shard database in the directory (§6.4). A log or metric query opens one. eventd supports concurrent queries up to its admission limit (§6.5), subject to the operating system actually having the descriptors and memory; where it cannot allocate what an admitted query needs, it fails that query rather than blocking a writer or exceeding the limit.

6.2 Ordering and Tiebreakers

Peios / Peios technical reference / eventd / Query Execution

PSPU §3.21 requires every result order to be total and deterministic for a fixed set of stored records, so that SKIP and TAKE page reliably. This is how eventd achieves it.

6.2.1 The tiebreakers #

Where the query's explicit SORT keys — or the mode's default ordering — do not uniquely order two records, eventd appends internal keys until the order is total:

ModeAppended, in order
Eventstimestamp, the column of event.time, descending, shard index ascending, events.id descending
Logstimestamp descending, logs.id descending
Metricstimestamp ascending, metric name ascending, canonical labels ascending, and samples.id ascending where the row corresponds to a raw sample or a derived sample pair

These are not query-language fields. They never appear in a result record, cannot be named in a SORT or a SELECT, and have no access-control identity (PSPU §3.28).

A metric's canonical-label key is captured before field filtering. Hiding a label leaves this internal key intact, including on per-series transformed and aggregate rows, so it does not change their order or pagination. The denied label remains absent from the result. Explicit query references to that label still require field access (§7.4).

The shard index is the numeric identifier from the shard-NNNN.db filename. It appears in the event tiebreaker because rowids are per-database: two events in different shards can share a rowid, and without the shard index the pair would be genuinely unordered.

The metric tiebreaker includes name and labels because an unbracketed query merges several series into one output stream, where the timestamp alone does not separate rows from different series.

6.2.2 Why insertion order is never the answer #

samples.id and events.id break ties within one database, but neither is a substitute for the timestamp ordering they follow.

Metric samples may arrive out of timestamp order (§5.1), so insertion order and time order genuinely differ. Every metric computation — RATE's consecutive pairs, aggregate window membership, cross-type interval construction — is defined over (timestamp, id) ascending precisely so that a late-arriving sample lands where its timestamp says it belongs rather than where it happened to be written.

Events are less prone to it, since a drain thread reads one ring buffer in order, but a shard receiving from several CPUs interleaves them arbitrarily and a clock step can invert two events from the same CPU.

6.2.3 Value ordering is not SQLite's #

Sorting, grouping and equality all use the query language's semantics, never the storage engine's dynamic-type rules (PSPU §3.20, §3.21).

The divergences are not edge cases. SQLite compares an integer and a real by converting; the query language compares them mathematically and exactly, including beyond binary64's exact integer range. SQLite orders text by byte; the query language folds ASCII case and then falls back to the original bytes only to break a fold-equal tie. SQLite has its own type-affinity ordering across storage classes; the query language fixes its own type order, nulls first, arrays last.

Where a SQL construct cannot reproduce those semantics, eventd uses it only to narrow candidates and applies the real comparison after loading the row (§6.3).

6.2.4 Canonical representatives #

A group whose members are equal under the language's rules but not byte-identical emits the smallest member rather than the first (PSPU §3.21).

Smallest rather than first is what makes the representative a property of the set. An event query merges results from every shard in an order that depends on which shard answered first, so "first" would be nondeterministic across runs of the identical query — which is exactly the property this chapter exists to prevent.

6.3 SQL Translation

Peios / Peios technical reference / eventd / Query Execution

Events and logs are translated to SQL. Metrics are translated to SQL against series and samples. Clients never see any of it — the translation is entirely internal and carries no guarantees.

6.3.1 What translates directly #

Event header fields are columns, so a predicate on one can become a SQL WHERE comparison over an indexable column (§3.1). Today one predicate does per shard: the first of event.type == a string, which becomes a comparison of the event_type column, or a comparison of event.cpu or emitter.class with an integer, which compares cpu_id or origin_class, falling back to an indexed payload field (below). Other header predicates, emitter.process.guid among them, are applied after loading. A column's own name in a predicate is a payload field, not the column.

Timestamps bound every read already, through SINCE and UNTIL. A top-level comparison of the record's time with an integer — event.time for events, timestamp for logs — narrows that range further, so a page of older records — WHERE event.time <= T — starts reading at T rather than at the newest record. One inside an OR does not narrow, since it need not hold.

Log fields are all columns; log mode has no payload and its field set is closed (§4.2).

Metric selection resolves names and labels through series — from the in-memory cache where possible — and reads samples for the range, ordered by the composite index that already provides (timestamp, id) (§5.2).

6.3.2 What does not #

Event payload predicates have no column. They become eventd-internal payload extraction predicates, and may use an adaptive payload expression index to narrow candidates (§3.4).

HAS, array containment, narrows nothing. The payload expression index stores one key per field, and a field holding an array of group SIDs has no single key to store, so a HAS predicate is answered by decoding each candidate row and testing its array in full. The operator is correct on every row and costs a scan of whatever the rest of the query left; a query using it wants a time range or another predicate beside it.

The rule governing every such translation is that SQL narrows, the query language decides. Where a SQL construct cannot reproduce a predicate's comparison semantics exactly, eventd uses it only to reduce the candidate set and then applies the real predicate after loading the row.

SQLite's native dynamic-type equality and ordering never substitute for the query language's ASCII case folding, exact numeric comparison, binary comparison, array comparison, or null and missing-field handling (§6.2). An index that returns a smaller set faster is useful; one that returns a different set is a wrong answer arriving quickly.

6.3.3 Where access control sits #

Access filtering is part of the logical execution, not a filter over the output (PSPU §3.18, §3.28). eventd may push authorization predicates down into SQL when the concrete identifier set is known at planning time, or read candidate rows and discard them before aggregating.

Which it does is a performance decision. What is fixed is that the externally visible result is identical to the one filtering-first would produce — aggregates, ordering and pagination included, since all three would otherwise leak the existence of rows the caller cannot read.

6.3.4 Aggregation #

Aggregation is never pushed into SQL. SQL reads the candidate rows, and eventd folds them into groups as they are read (§6.4), under the language's own equality (§6.2) and after access filtering has discarded what the caller cannot read — grouping over columns included, not only grouping over payload paths whose comparison semantics SQL cannot reproduce.

For an event query this means every shard yields its matching rows, so the work is proportional to the row count, while what the aggregation holds is proportional to the group cardinality (§6.4).

6.4 Cross-Shard Fan-Out

Peios / Peios technical reference / eventd / Query Execution

Event queries execute against every database in the event store directory — active shards and historical ones alike (§3.3). Log and metric queries touch one database each and need none of this.

Shards carry no meaning for the query path (§2.3). A shard holds whatever CPUs routed to it during whatever lifetimes wrote it, so there is no shard a query can skip on the basis of its contents, and a predicate on event.cpu scans all of them.

6.4.1 Merging #

How results combine depends on the query.

Queries in the default order. A non-aggregating query with no SORT reads every shard at once, each newest first through idx_events_timestamp, and the coordinator takes whichever shard's next row comes first under the tiebreakers of §6.2: an N-way merge of the sorted streams. Each row that passes access control and every predicate is sent as soon as it is taken. Once SKIP + TAKE rows have passed, the merge stops. What the query holds is one row per shard, the merge frontier, whatever the size of its result.

Sorted queries. A SORT on any other field reads the shards in turn and gathers the visible rows. With TAKE it keeps only the best SKIP + TAKE of them, trimming to that whenever it holds twice as many or a thousand, whichever is more. Without TAKE it holds every visible row. Either way it holds them within MaxQueryHeldBytes (§6.5).

Aggregating queries. Rows from every shard fold into one set of groups as they are read, and no row is kept. Each group keeps:

QueryEach group keeps
COUNT BY, TOP N BY, GROUP … COUNTa count; at the end the groups are sorted by count descending and TAKE applies
GROUP … SUMthe exact integer sum while every value is an integer and it fits, and the binary64 sum
GROUP … AVGthe binary64 sum and count, divided once at the end
GROUP … MIN / MAXthe extreme so far
DISTINCTthe value

GROUP … SUM, AVG, MIN and MAX groups are sorted by their keys ascending, in the order the fields are named, before SKIP and TAKE apply. An explicit SORT overrides that order, with group-key order breaking its ties (PSPU §3.21).

AVG is the one that cannot be composed from its own output: averaging partial averages weights each part equally, regardless of how many rows it held, so the sum and the count are kept and divided once.

A group is found by hashing a key that every pair of language-equal values shares — numbers by their nearest binary64, text with ASCII case folded — and then by the language's own equality against each group's representative in the order the groups appeared (§6.2). The hash only narrows the search; equality decides.

What an aggregation holds is bounded by the number of its groups rather than by its rows, within MaxQueryHeldBytes (§6.5).

6.4.2 The unbounded case #

A non-aggregating query without TAKE has no implicit row limit. EVENTS SINCE 7d ago may match millions of rows, all of which pass through the merge.

In the default order that costs time and no memory, so the query timeout is the only backstop (§6.5). The timeout covers sending as well as reading, so a client that reads slowly can run out of time on a result the merge would have finished.

6.4.3 Descriptors #

An event query opens a read-only connection per database, and each SQLite connection holds one or two descriptors for the database and its write-ahead log. With many historical shards this adds up quickly across concurrent queries.

Active shard writer connections stay open for the process lifetime and are not negotiable. Historical shard read connections are the pool worth bounding — opened when a query touches them, closed after a period of inactivity (§C).

6.5 Accounting and Limits

Peios / Peios technical reference / eventd / Query Execution

6.5.1 Recording what was asked #

Every event query is recorded by the adaptive indexing system (§3.4). For each WHERE predicate:

  • a header field reference increments the frequency counter of that field's column, kept under the field's path (event.type, emitter.process.guid); event.time, whose column is always indexed, is not counted
  • a payload field reference increments that path's counter

Cross-type WHERE predicates are counted like any other, since they narrow the same data by the same fields.

This applies to event queries only. The log and metric stores have fixed write-time indexes and no candidate space for a policy to explore (§4.2, §5.2), so their queries increment nothing.

Counters are in-memory and are flushed to the metadata database at each policy interval (§3.5). Query handlers never write to that database directly, which is what keeps the once-per-query update off any lock a writer thread contends for.

6.5.2 Concurrency #

eventd bounds concurrent queries — streaming and non-streaming together — at MaxConcurrentQueries (§A). Beyond it a query is rejected with an error rather than queued.

The per-query cost that limit is protecting is real: read-only SQLite connections, one per shard for an event query (§6.4), memory for the merge, and CPU for execution and payload decoding.

MaxStreamingQueries is enforced separately and is lower. A streaming query holds its resources for as long as its client stays connected, where an ordinary one holds them for at most a timeout, so the two populations need different bounds.

Both are global. Every authenticated caller may connect (§7.1), so eventd also bounds the queries one caller may have running at once, at MaxQueriesPerUser. The caller is the user SID of the token read at connect. The slot is taken as soon as the token is read, before the request, so connections held open without a query count too. SYSTEM's queries are not counted: SYSTEM is the machine, not one caller among others. Over the limit, the query is rejected with an error, as over the global ones.

The per-user limit counts users, not processes or sessions: two windows of the same person share one budget, and two people never share one.

Queries and ingestion are separate channels, so exhausting the query side cannot exhaust ingestion (PSPU §3.3).

6.5.3 Memory #

eventd may not be killed: it runs with oom_score_adj −1000, so if its memory alone fills the machine, the kernel has nothing left to reclaim and panics. What bounds a query's memory therefore has to hold however many queries run at once.

A query in the default order holds one row per shard and nothing more (§6.4), and is never refused for the size of its result (PSPU §3.16).

Everything else a query must hold — a sorted query's rows, an aggregation's groups, a watch batch — counts against MaxQueryHeldBytes (§A), one budget for every running query together. A query takes from it in 64 KiB granules as it grows, gives back what it no longer needs when a sorted query trims, and gives back all of it when it ends. A query that would take more than is left fails with an error, rather than being truncated or answered from part of the data.

What a row or group costs is estimated from its fields and values, not measured from the allocator, so the budget is approximate. It is still a bound: with the default of 256 MiB, no number of concurrent queries holds much more than that.

A metric query reads each series' samples in order and holds none of them: a transform keeps only the sample before, and an aggregation folds each sample into its window, or its series, as it is read. What it holds is what its result needs, and that counts against the same budget: the matched series, one fold per window or per series, the latest point of each series, and the result rows. A query whose result is the samples themselves — a range without an aggregation — holds them all, and is refused when they pass the budget like a sorted query. A window aggregation over the same range holds one fold per window, however many samples fall in it.

6.5.4 Request and response sizes #

eventd rejects an inbound frame above MaxQueryRequestBytes before allocating or reading its payload. Outbound result records are grouped at record boundaries toward QueryResponseTargetBytes (§A). The target is not a limit: a complete record that exceeds it is sent alone, never split, truncated, skipped or failed merely for exceeding the target (PSPU §3.15–§3.16). Only the protocol's u32 frame length is a hard outbound bound.

6.5.5 Timeouts #

Every query has a maximum execution time, QueryTimeoutMs (§A).

The clock starts once the request has been decoded and the caller's token obtained, and covers everything after: parsing, planning, access checks, cross-type pre-computation, SQL execution, merging, aggregation, pagination, projection, and transmitting the initial result set.

It bounds the initial result set only. A non-streaming query sends end before it expires; a streaming query sends watch. Past watch the stream is not time-limited, and what bounds it instead is MaxStreamingQueries, MaxDistinctStreamValues and backpressure (§6.6).

On expiry eventd cancels the query and sends an error. Any result messages already sent are discarded by the client, since no terminal message arrived (PSPU §3.16).

Cancellation has to reach two kinds of work. SQLite work is interrupted through sqlite3_interrupt or an equivalent progress-handler check — the same responsiveness problem as cancelling an index build (§3.4). Non-SQL work — MessagePack flattening, the cross-shard merge — checks the same deadline periodically, because a query can spend most of its time in neither the database nor the kernel.

Large scans over unindexed fields are the main timeout risk, and the adaptive indexing system reduces it over time by indexing whatever keeps being filtered on — which is also why a timeout is a signal worth watching rather than merely an error to retry.

6.6 The Streaming Machinery

Peios / Peios technical reference / eventd / Query Execution

The externally visible behaviour of a streaming query — what may be streamed, what applies during the watch phase, how DISTINCT streams behave, and when a slow client is dropped — is PSPU §3.27. This is the machinery underneath.

6.6.1 Commit generations #

eventd keeps a monotonic u64 commit generation counter for each streamable store: one for the event store as a whole, and one for the log store. Metric queries do not stream, so the metric store has none.

After a writer commits a batch it increments the counter for its store and wakes the streaming handlers waiting on it. A handler records the last generation it processed and waits until the counter exceeds it.

The event counter covers the whole store rather than one per shard. Several writer threads increment it, so a wake is "something committed somewhere" and a handler re-examines every shard it cares about — which is what it would have to do anyway, since a shard means nothing to the query path (§6.4).

The counter is process-local and never persisted; it has no meaning across a restart, and a streaming query does not survive one. On wraparound the next increment is treated as a wake for every handler and operation continues. At any commit rate a machine can sustain, wraparound of a 64-bit counter is not reachable.

6.6.2 Record order within a watch batch #

The initial result set uses the query's usual sorting and pagination. During the watch phase, newly visible event records are delivered by shard ordinal ascending, then database record ID ascending; log records are delivered by database record ID ascending. SORT, SKIP and TAKE do not reorder or trim these watch records.

DISTINCT watches consider values in this same order. The first unseen value is emitted using its first record's representation; equivalent later values are suppressed by the stream's seen set. The initial distinct result retains its usual value ordering.

Record IDs preserve insertion order within each store. Event shards commit independently, so there is no durable total chronological commit order across shards. Shard ordinal supplies a deterministic order within each observed batch; successive batches follow the stream cursor. A batch may include several commits observed together. Producer timestamps do not change delivery order, even when they arrive out of order.

6.6.3 Latency #

Delivery latency is bounded below by the commit interval of the store concerned, because a record is not streamable until it is committed.

StoreApproximate floorFrom
EventsMaxBatchLatencyMs, default 100 ms§2.4
LogsLogMaxBatchLatencyMs, default 500 ms§4.1

Under light load the actual latency is lower, because the adaptive batcher commits as soon as its input drains rather than waiting out the cap (§2.4). Under sustained load it converges on the cap.

A consumer needing better than this is not served by eventd at all: the KMES ring buffers are the low-latency path, they are specified in PSPK, and attaching to them directly costs the per-event access control that eventd exists to apply (§7).

6.6.4 The DISTINCT seen set #

A DISTINCT stream holds a per-query set of the values it has already emitted, initialised from the initial result set and added to as new values appear (PSPU §3.27).

It is bounded by MaxDistinctStreamValues (§A), and exceeding the bound terminates the query with an error rather than evicting. Eviction would make the output wrong rather than merely truncated: a forgotten value would be re-emitted as newly seen, and "newly seen" is the entire meaning of the result.

The set is per query and in memory, which is why the bound exists and why it is separate from the general query concurrency limit — sixty-four streams each holding a hundred thousand values is a different memory profile from sixty-four ordinary queries.

6.6.5 Cross-type re-evaluation #

Pre-computed cross-type ranges describe the past and are discarded when the watch phase begins (PSPU §3.27).

A metric condition costs one index seek per batch: the selector has already been constrained to exactly one series, so finding the active sample at the batch's latest candidate timestamp is a single lookup on idx_samples_series_timestamp (§5.2).

An existence condition is evaluated per candidate record rather than per batch, because the centred window is relative to each record's own timestamp and a matching record may be near some of a batch and not the rest.

The per-batch metric evaluation is an approximation, and the reason it is acceptable is the ratio between the two intervals: a commit batch spans a fraction of a second and a metric sample fifteen, so every record in a batch normally maps to the same sample. At sub-second metric resolution it filters more coarsely, and records near a threshold crossing are included or excluded as a group.

6.6.6 Backpressure #

Backpressure is detected on the socket send buffer. When a result message cannot be sent because the buffer is full, the query is terminated immediately; eventd never blocks on the send.

Blocking would put a slow reader in the path of eventd's own work, and the write path is what would suffer. A streaming client is the lowest-priority consumer of eventd's time, and dropping it is the same principle as dropping an ingestion datagram (PSPU §3.4), applied on the way out.

7.1 The Model

Peios / Peios technical reference / eventd / Access Control

eventd enforces access on the read path only, using KACS Security Descriptors and the KACS AccessCheck API. Every query is evaluated against descriptors that determine which records — and which fields within a record — the caller may see, and everything else is filtered out silently (PSPU §3.28).

7.1.1 eventd decides nothing itself #

eventd implements no access check logic of its own. Every decision is delegated to kacs_access_check and kacs_access_check_list, which run the full KACS AccessCheck pipeline: integrity checks, restricted token evaluation, confinement, conditional ACE evaluation, and the SACL audit walk.

What eventd contributes is the three things AccessCheck needs and cannot know — which descriptor applies (§7.2), which object type list describes the record (§7.3), and what the caller's token is (§7.4) — and then it acts on the verdicts.

The alternative would be reimplementing an access check algorithm that already exists, in a daemon that would then have to be kept in agreement with it forever.

7.1.2 Enforcement is at query time #

All events, logs and metrics are stored regardless of who will ever be allowed to read them, and two callers querying the same store see different results.

Three reasons make this the only workable arrangement:

  • Audit integrity. An audit event has to be stored whether or not anyone can currently read it. Filtering at storage time would let a descriptor decide what gets recorded, which is the one thing an audit store must not permit.
  • Descriptors change. An administrator can grant or revoke access retroactively, and only query-time evaluation makes that meaningful.
  • Callers differ. Several principals with different access levels query the same store, and there is one copy of the data.

7.1.3 Who may connect #

The query socket lets every authenticated caller connect: its descriptor is D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;OW)(A;;FW;;;AU), set in place of the one it would inherit from eventd's closed runtime directory. What a caller may read is decided by eventd, per identifier and per field, against the descriptors below, not by the socket. A socket that admitted fewer callers would only leave a grant to anyone else unusable. How many queries one caller may hold is bounded per user (§6.5).

7.1.4 Caller identity #

When a client connects to the query socket, eventd obtains its token by reading the peer-token socket option (getsockopt(SOL_KACS, KACS_SO_PEER_TOKEN)) on the connected descriptor. The token represents the peer's identity as captured at connection time.

If the call fails, eventd denies the query entirely. It has no fallback identification and no anonymous mode. A client that connects at the Anonymous impersonation level conveys the Anonymous token (S-1-5-7), and eventd answers it with an error and closes the connection, whatever a descriptor would grant Everyone.

The snapshot property matters most for streaming queries, which may run indefinitely: a client whose group memberships change, or whose access is revoked, continues to be evaluated against the token it connected with until it disconnects (§7.5).

7.1.5 Rights #

RightBitValueMeaning
EVENTD_READ00x0001Read records matching the pattern.
EVENTD_CLEAR10x0002Delete records matching the pattern.
EVENTD_ADMINISTER20x0004Change eventd's own policy — the INDEX command (§7.2).
EVENTD_PUBLISH30x0008Publish metric records under the matching name (§7.6).

The generic mapping passed to AccessCheck is in §B.

EVENTD_ADMINISTER is distinct from EVENTD_READ deliberately. Accelerating a field costs write throughput on every record thereafter, so INDEX is a way for a caller to degrade the system for everybody, and a caller permitted only to read has not been permitted to do that (PSPU §3.23).

Note

EVENTD_CLEAR is defined and nothing yet uses it: no operation deletes records on a caller's behalf, and retention deletes on nobody's behalf (§3.6). It is reserved for administrative deletion. The consequence worth knowing is that a descriptor written today with GENERIC_WRITE already grants it, and will start granting a real capability the moment one exists.

7.1.6 What is not controlled here #

Event emission is KMES's: kmes_emit and kmes_emit_batch require SeAuditPrivilege, and eventd is not involved.

Log and metric ingestion has no per-record control at all. The Security Descriptor on each ingestion socket is the entirety of it (§7.6).

7.2 Patterns and Descriptors

Peios / Peios technical reference / eventd / Access Control

Access is defined on named patterns, each standing for a category of observability data and each carrying a descriptor. The three data types have independent pattern namespaces:

NamespacePatterns match
Eventsevent type
Logslog origin
Metricsmetric name

7.2.1 Matching #

A pattern matches by dot-delimited prefix. The pattern kacs matches the exact string kacs and any string beginning kacs. — and matches neither kacs_extended nor kacsfoo, because the dot is the hierarchy separator and not a mere character.

* is the wildcard default and matches everything.

Ingestion constrains metric names to the identifier grammar and origins to that grammar plus a producer component (PSPU §3.7, §3.10), which is what keeps a producer from choosing a name containing the wildcard or a backslash — a name that would otherwise match a rule its producer was never meant to satisfy, or store its descriptor somewhere other than where the administrator who wrote it believes.

An origin naming a producer within a service — jellyfin/HealthCheck, jobs/<guid> — resolves from the part before the slash, so the matching above starts at jellyfin and at jobs. A service's hooks, reload commands and health checks therefore answer to the descriptor written against the service itself, and every submitted job's output answers to one descriptor at Logs\jobs. The slash never enters a descriptor path, which is what keeps it out of the registry namespace it would otherwise be a separator in.

7.2.2 Resolution #

For a concrete identifier, eventd resolves the applicable descriptor by walking up the hierarchy:

  1. Look for an exact match on the full identifier, kacs.audit.access.checked.
  2. Remove the last dot-separated component and look again, kacs.audit.access.
  3. Repeat: kacs.audit, then kacs.
  4. Fall back to the wildcard, *.

The first match wins; a more specific pattern overrides a less specific one.

7.2.3 Storage #

Descriptors are registry values under the eventd security subtree:

Machine\System\eventd\Security\Events\*
Machine\System\eventd\Security\Events\kacs
Machine\System\eventd\Security\Events\kacs.audit.access.checked
Machine\System\eventd\Security\Logs\*
Machine\System\eventd\Security\Logs\loregd
Machine\System\eventd\Security\Metrics\*
Machine\System\eventd\Security\Metrics\cpu
Machine\System\eventd\Security\Admin

Each type's wildcard default is load-bearing. If a default is missing, eventd denies access to all data of that type: resolution that reaches the end of the hierarchy without a match is a denial, not a grant (PSPU §3.28).

Storing them in the registry rather than in eventd's own databases means the registry's access control protects them, and an administrator edits them with the ordinary registry tools rather than through eventd.

7.2.3.1 Who may change them #

A descriptor is stored whole, as one value, SACL included. Whoever may write that value may therefore replace the SACL as well as the DACL, and removing a SACL this way needs no SeSecurityPrivilege: the registry checks the right to set the value, not the right to change audit policy. Write access to the Security subtree is therefore audit-policy authority, and only those trusted to change audit policy should hold it. A tool that edits a descriptor's DACL must write the SACL back unchanged.

eventd gives Machine\System\eventd\Security a protected DACL: SYSTEM and Administrators full control, Authenticated Users read, which is what eventd and its clients need to resolve descriptors. Protection stops a broader grant on Machine\System\eventd, such as one made to let someone tune eventd, from reaching the audit policy. eventd sets it only while the key's DACL is still wholly inherited, so an administrator's own choice there is never overwritten.

7.2.4 Defaults on first boot #

eventd creates the three wildcard keys, the descriptor for its own health metrics and the administrative descriptor if they do not exist:

KeyDefault
…\Security\Events\*SYSTEM and Administrators: EVENTD_READ on all fields.
…\Security\Logs\*SYSTEM, Administrators and Authenticated Users: EVENTD_READ.
…\Security\Metrics\*SYSTEM and Administrators: EVENTD_READ | EVENTD_PUBLISH; Authenticated Users: EVENTD_READ.
…\Security\Metrics\eventdSYSTEM, Administrators and Authenticated Users: EVENTD_READ; nobody EVENTD_PUBLISH, because eventd writes its own health without the socket (§5.7).
…\Security\AdminSYSTEM and Administrators: EVENTD_ADMINISTER.

Every one of the five also carries a SACL with one failure-audit ACE for Everyone, covering EVENTD_READ, EVENTD_ADMINISTER and EVENTD_PUBLISH, and nothing else: a check under a default descriptor that is denied any of them is audited, whoever made it, and a granted one is not (§7.4).

The asymmetry reflects sensitivity. Events include security audit data and are restricted to administrators; logs and metrics are operational data and are readable by any authenticated user. Publication is fail-closed for ordinary services until a more-specific pattern grants their service SID EVENTD_PUBLISH (§7.6). An administrator can tighten either side independently.

On upgrade, eventd replaces a stored descriptor only when its binary form exactly matches a default an earlier eventd shipped at that key: the Metrics wildcard's former read-only default, and each key's former default without a SACL. Those are replaced by the current default, SACL and all. A value an administrator changed is never treated as a default and never rewritten.

7.2.5 Conditional ACEs #

Descriptors on eventd security objects may carry conditional ACEs, and KACS evaluates them as it would anywhere.

eventd passes no eventd-specific local claims to AccessCheck: local_claims_ptr is null and local_claims_len is zero. Conditions referencing token claims that KACS itself supplies still evaluate; conditions referencing eventd-local claims observe them as absent.

A stable set of eventd-local claims — the event's type, the log's origin, the time of day — is a plausible later addition, and defining one is a commitment to keep those claim names meaningful thereafter.

7.2.6 The administrative descriptor #

INDEX (PSPU §3.23) is checked against Machine\System\eventd\Security\Admin, with EVENTD_ADMINISTER as the desired access. Its default grants SYSTEM and Administrators.

eventd refuses INDEX outright if no administrative descriptor exists, by the same fail-closed rule as the read path. Keeping it in the registry means the registry's own descriptor protects the policy and a recreated eventd-meta.db cannot reset it (§3.5).

7.3 Per-Field Control

Peios / Peios technical reference / eventd / Access Control

A descriptor can grant read access to some fields of a record and not others, using KACS object ACEs and object type lists. A caller authorized for a pattern but not for a field receives the records with that field absent — indistinguishable from a record that never carried it (PSPU §3.28).

7.3.1 Object type lists #

For each access check eventd builds an object type list: a tree with the data type's root at level 0 and, beneath it, the fields' dotted paths, one node per segment. subject is at level 1, subject.token at level 2, subject.token.sid at level 3. A field's node is the one at the end of its path, and every node above it is a prefix of the path, ending before a ., named as a field would be (below): the node for subject.token has the GUID of the name subject.token. A prefix that several fields share is one node, so the list names no GUID twice, and the list is in preorder, each node's subtree directly after it, as KACS requires.

Level 0: root GUID for the data type
  Level 1: event
    Level 2: event.time
    Level 2: event.type
    Level 2: event.cpu
  Level 1: emitter
    Level 2: emitter.class
    Level 2: emitter.token
      Level 3: emitter.token.guid
    Level 2: emitter.true-token
      Level 3: emitter.true-token.guid
    Level 2: emitter.process
      Level 3: emitter.process.guid
  Level 1: access
    Level 2: access.granted
  Level 1: subject
    Level 2: subject.token
      Level 3: subject.token.sid
  Level 1: source
    Level 2: source.name

The list is as deep as the deepest path, with no level limit. MS-DTYP's object type lists stop at level 4; KACS sets no depth limit, only one on the number of nodes, 1024 to a list (KACS_ACCESS_CHECK_MAX_OBJECT_TYPE_COUNT). Every prefix counts toward it. A record whose list would be longer cannot be checked, and a query reaching one fails rather than showing it.

A name with no . — a log field, a fixed metric field, most metric labels — is a node at level 1. A metric label key may contain ., and is then a path like any other: a label disk.read is beneath the node disk.

kacs_access_check_list returns a verdict per node, and eventd uses the verdict on each field's node to include or exclude that field. The verdicts on the prefix nodes are not used.

The three root GUIDs — one for events, one for logs, one for metrics — are in §B.

7.3.2 Field GUIDs are derived, not registered #

A field's GUID is computed deterministically with UUID v5 (RFC 4122):

field_guid = uuid_v5(EVENTD_FIELD_NAMESPACE, field_name)

with the namespace UUID in §B, and field_name the field's query-language name as UTF-8.

There is no registry of field GUIDs and no allocation step. The same name always yields the same GUID, so an administrator writing a descriptor computes the GUID from the field name with the same algorithm that eventd will use when it builds the list. This is the one part of eventd's access control that a third party reproduces rather than merely consumes.

Derivation rather than registration is what makes payload fields tractable at all: event payload schemas belong to the emitting subsystems, eventd has no catalogue of them, and a new event type carrying a new field needs no registration anywhere before a descriptor can name it.

Which names are used:

Datafield_name
Event header fieldits field path (PSPU §3.22) — event.time, event.type, event.cpu — never its column's name
Event payload fieldthe flattened dot path — access.granted, subject.token.sid, source.name
Log fieldthe column name — origin, message, is_error
Fixed metric fieldtimestamp, boot_id, name, type, value
Metric labelthe label key — core, device

Payload fields suppressed by flattening or by a header collision — a value or map at a header field's path — are not query-language fields, so they get no GUID (PSPU §3.22). A GUID naming a header field's path is the header field's, whatever the payload holds there.

A descriptor written before header fields were named by path, granting timestamp or cpu_id by GUID, now grants the payload field of that name rather than the header field, because the GUID is the name's. Such a grant must be rewritten with the path to keep its meaning. Metric label keys cannot collide with the fixed metric fields, because ingestion rejects records whose labels do.

7.3.3 The GUID does not encode scope #

A field GUID names a field and nothing else. access.granted produces the same GUID whatever event type carries it.

Scoping comes from the descriptor hierarchy: an object ACE naming the access.granted GUID inside the descriptor for pattern kacs means "the access.granted field of KACS events". The same ACE in a different pattern's descriptor means the same field of that pattern's records.

7.3.4 Granting a subtree #

An object ACE applies to its node and to every node beneath it, so an ACE naming a prefix applies to every field whose path it begins: subject covers subject.token.sid, subject.process.pid and any subject field an emitter adds later. The header fields are paths like the rest: event covers every event.* header field, and emitter every emitter.* header field together with the payload fields beside them, such as emitter.process.pid.

KACS decides each node once, first ACE first. A field may therefore be read when, of the ACEs that match the caller and name its GUID, the GUID of any prefix of its path, the data type's root, or no object type at all, the first in DACL order to decide EVENTD_READ allows it. Neither the more specific nor the more general ACE wins as such:

DACL, in ordersubject.token.sidsubject.process.pid
deny subject, allow subject.token.siddenieddenied
deny subject.token.sid, allow subjectdeniedread
allow subject.token.sid, deny subjectreaddenied

A canonically ordered DACL (PCDS §5.5) puts every deny before every allow, so there a deny anywhere on a field's path hides it.

A deny on a field does not reach its siblings: denying subject.token.sid leaves subject.token.auth-id and subject.process.pid as the rest of the DACL decides them, and denying subject.token hides every token field and leaves subject.process. KACS does carry a deny up to the node's ancestors, and a grant up to a node all of whose children are granted, but only onto the prefix nodes and the root, never onto another field.

A field can also be a prefix of another in the same record, since a payload is opaque: a payload value at emitter sits beside the header's emitter.class. Its node in the record's list then has nodes beneath it, whose grants and denies KACS carries up to it. eventd therefore checks such a field again in a list of its own, holding only its prefixes and itself, and uses that verdict, so that the same first-ACE rule decides it as decides any other field.

A descriptor written before the list was a tree keeps its meaning for every field it names by full path: that field's GUID, and so its node, is unchanged. What is new is that an ACE naming a prefix, which named a node in no list before, now covers the fields beneath it.

7.3.5 Writing one #

An object ACE with no object type GUID applies to the root and therefore to every field. One with a field GUID applies to that field.

To grant a security team full read access to KACS events, and a monitoring team only the time, type and CPU:

  • Allow SecurityAdmins, EVENTD_READ, no object GUID
  • Allow MonitoringTeam, EVENTD_READ, object GUID = event.time
  • Allow MonitoringTeam, EVENTD_READ, object GUID = event.type
  • Allow MonitoringTeam, EVENTD_READ, object GUID = event.cpu

MonitoringTeam querying KACS events receives records containing exactly those three keys. Payload fields, identity GUIDs and the remaining header fields are absent.

To let an auditing team see who acted but not the token's groups:

  • Deny AuditTeam, EVENTD_READ, object GUID = subject.token.groups
  • Allow AuditTeam, EVENTD_READ, object GUID = event
  • Allow AuditTeam, EVENTD_READ, object GUID = subject

AuditTeam receives every event.* header field and every subject field except subject.token.groups.

7.3.6 Building the list per record #

The list is constructed from the fields actually present in the record being checked: the root node, then the tree of their paths.

Event records contribute every header field plus every non-suppressed flattened payload field present in that particular payload. Different event types produce different lists, because they carry different payloads — and two events of the same type can too, since a payload is opaque MessagePack and nothing requires two of a type to agree.

Log records have a fixed field set — timestamp, origin, is_error, message, job_id, boot_id — so the list is the same for every log record.

Metric records contribute the five fixed fields plus the series' label keys, which vary per series.

Derived aggregate outputs — count, sum, avg, min, max — are omitted from the list entirely. They are not source fields, have no GUID and no access identity of their own, and are visible when the caller is authorized for the records and source fields they were computed from (PSPU §3.28).

7.3.7 Metric aggregate headers #

A metric aggregate or window result does not restore timestamp, name or type if source-field authorization removed that header. The private time used for window placement and ordering is independent of the returned record, so hiding timestamp does not change the numeric result or move samples into another window.

When an output combines several contributing series or metric identifiers, each returned header must be visible in every contributing source. A first contributor with broader access cannot restore a header denied by another. Per-series output retains each series' own visibility. This also applies to latest-value aggregates, transformed windows, and cached rollup results: a cached value does not bypass the current source-field check. Explicit references to a denied field still filter the source records as specified by PSPU §3.28.

7.4 Enforcement

Peios / Peios technical reference / eventd / Access Control

The order in which eventd evaluates a query is fixed (PSPU §3.18), and access control is the third phase — before predicates, transforms, grouping, aggregation, ordering and pagination. What follows is the sequence within that phase.

  1. Obtain the caller's token from the connection (§7.1). Failure denies the query.
  2. Parse the query to establish its data sources and filters (§6.1).
  3. Discover the concrete identifiers the query could touch — event type strings, log origin strings, metric name strings. A broad selector authorizes nothing by itself: EVENTS, EVENTS kacs.*, LOGS without FROM and METRIC cpu.* are each resolved identifier by identifier.
  4. Resolve and check each discovered identifier: find its descriptor by hierarchical matching (§7.2), and build the object type list for the fields the query references (§7.3). A query that references none asks instead about every field the descriptor's allowing object ACEs name, since those may make its records visible though the query names none of them; a GUID does not say its path, so each is a level-1 node of that list. Call kacs_access_check_list, and cache the verdict for this (token, identifier, field set) (§7.5).
  5. Apply record verdicts. An identifier is invisible when the caller may read neither its root nor any field: its records are excluded from the logical row set before aggregation, ordering, pagination and formatting. One whose root is denied but some field granted stays, for its records to be shaped to those fields (step 10). For a cross-type source, a denied identifier is treated as having no matching data.
  6. Apply field verdicts to predicates. Where the query references a field in a predicate or shaping clause and a matching identifier does not grant it, that identifier's records contribute nothing — exactly as if its root had been denied. The query is not rejected.
  7. Cross-type sources get the same treatment. An invisible identifier, or a denied field needed to evaluate the condition, makes the condition evaluate as though no matching cross-source data existed.
  8. Execute, with root filtering already part of the logical row set.
  9. Re-resolve per result identifier. For each distinct concrete identifier in the resulting rows, resolve its descriptor, build the object type list with field GUIDs, call kacs_access_check_list with the token, the descriptor, EVENTD_READ, the list and an audit context naming the pattern the descriptor was resolved from (below), and cache the per-field results.
  10. Shape each record. Look up the cached results for its identifier and fields. Exclude the record entirely if neither the root nor any of its fields was granted; otherwise include it, with each field only if its node was granted. A grant of the root alone grants every field, since an ACE with no object type applies to the whole tree (§7.3). A grant of some fields alone gives records holding exactly those fields, and a record carrying none of them is excluded.

7.4.1 Which clauses count as referencing a field #

Step 6 applies to every clause that reads a value rather than merely displaying one:

  • ordinary WHERE predicates
  • metric label filters in a primary selector
  • GROUP, COUNT BY, TOP N BY, SORT and DISTINCT fields
  • event and log aggregation arguments — SUM, AVG, MIN, MAX
  • metric transforms and terminal aggregations, all of which read the fixed value field: RATE, DELTA, P50, P95, P99, AVG, MIN, MAX, SUM, AVG_OVER, MIN_OVER, MAX_OVER, SUM_OVER
  • a metric boot filter, which reads boot_id; and an explicit metric type predicate, grouping, sort or distinct, which reads type

SELECT is not on this list. It shapes output and is applied last, so a field it omits was still available to every earlier phase — and conversely, selecting a field the caller may not read removes the field, not the record.

7.4.2 Denial is silent, not fatal #

Step 6 excludes rather than rejects, and this is the decision most worth being explicit about, because rejecting is the more informative behaviour and that is exactly the objection to it.

A rejection would tell the caller that some identifier exists, matches its query, and carries a field it may not read — three facts about data it was not permitted to see, delivered by the mechanism meant to withhold them, and enumerable by trying queries and watching which ones fail. Excluding costs the caller a result narrower than it appears; rejecting costs the model the property it rests on.

Cross-source fields already worked this way (step 7); primary-source fields now match them.

7.4.3 Field authorization does not depend on presence #

A field's authorization is resolved from the name as written, against each concrete identifier, whether or not any record of that identifier actually carries it.

Payload fields vary between records of the same type, so a rule turning on presence would require the scan that authorization is meant to precede.

7.4.4 Internal values are not fields #

Row identifiers, series identifiers, ordering tiebreakers and series type checks are not query-language source fields unless the mode exposes them as fixed fields or the query names them (§6.2). Errors raised by internal checks never carry a denied field's value.

A metric result's value is a source field, because it is either a raw sample or a scalar derived from raw samples.

7.4.5 Filtering is part of the logical result #

Access filtering is not a presentation step. Aggregating, ordering or paginating over unreadable records would leak them through counts, through ordering, and through the gaps in pagination.

eventd may push authorization predicates into SQL when the identifier set is known, or read candidates and filter them before aggregating (§6.3). The externally visible result is identical to filtering first, and COUNT, COUNT BY, TOP N BY and every other aggregate reflect only what the caller may see.

7.4.6 The audit trail #

Whether an access check produces a KACS audit event is decided by the SACL audit walk in the AccessCheck pipeline, against the descriptor's SACL. Every default descriptor eventd creates carries a failure-audit ACE for Everyone at every eventd right (§7.2), so out of the box every denied check produces a kacs.audit.access.checked record, and a granted one produces none unless an administrator adds a success ACE. KACS writes such a record from the AccessCheck syscall only when the caller holds SeAuditPrivilege enabled. eventd's service definition requires that privilege, and its token holds it enabled; without it the check would be made and nothing recorded.

eventd passes each check an audit context naming the object it guards, in the form PGSS §6.7 defines: a MessagePack map of the object's kind and, under the kind's own name, the pattern whose descriptor was checked.

CheckAudit context
Reading events{kind: "event-namespace", event-namespace: {pattern: "kacs"}}
Reading logs{kind: "log-namespace", log-namespace: {pattern: "loregd"}}
Reading or publishing metrics{kind: "metric-namespace", metric-namespace: {pattern: "cpu"}}
INDEX (§7.2){kind: "eventd-admin"}

The pattern is the one the descriptor was found under (§7.2), not the identifier asked about, and for a log origin it never holds the producer after a /. KACS copies the map into its kacs.audit.access.checked record as object.kind and object.<kind>.pattern, and marks the record with fields.attestation.userspace, since those values are eventd's claim. The audit trail therefore records exactly which observability data was read, by whom — the client, as subject.* — rather than merely that eventd performed a check. Reading and publishing a metric name the same object; the record's access.requested says which was checked.

Those audit events are themselves KMES events, which eventd consumes and stores, and which are governed by the event patterns like any other, under kacs (§7.2). Reading the audit store is auditable.

7.5 Caching

Peios / Peios technical reference / eventd / Access Control

An access check is a syscall through the full AccessCheck pipeline. A query returning ten thousand records cannot afford ten thousand of them, so results are cached — at two levels, plus the descriptor resolution underneath both.

7.5.1 Record-level #

Verdicts are cached for the life of one query — for a stream, the whole stream — under the connection's token (§7.1), and keyed by concrete identifier, not by pattern: two event types under one pattern are two keys. When a descriptor contains no object ACEs, the check is a plain grant or deny on the root, so every check of an identifier reaches the same verdict.

Each identifier costs one pre-check before any record is read, for the fields the query references (§7.4 step 4), and one result check for each distinct field set its records carry (§7.4 step 9). A query returning ten thousand events across twenty event types, each type's records of one shape, performs at most forty checks.

7.5.2 Field-level #

When the descriptor does contain object ACEs, the verdict depends on which fields the record carries, since different payloads produce different object type lists (§7.3). The result check is therefore cached per (identifier, field set), and is so whatever the descriptor holds.

In practice events of one type carry the same fields, so this is effectively one result check per event type. Log records have a fixed field set, so a log query reaches two checks per origin: the pre-check and one result check. Metric records vary by series label keys.

The pathological case is an event type whose payload fields differ from record to record, which produces a distinct field set — and a distinct cache entry, and a distinct syscall — for each shape encountered.

7.5.3 Descriptor resolution #

Resolving a pattern to a descriptor is itself cached, across queries rather than within one, since it costs a registry read and a hierarchy walk (§7.2).

eventd watches the security registry subtree and invalidates cached resolutions and cached check results when a descriptor changes. That is what makes a revocation take effect on the next query rather than at the next restart.

If the registry watch fails after startup, eventd discards the descriptor cache and operates fail-closed for new resolutions until the watch is re-established. A cache it cannot trust to be current is worse than none: continuing to serve from stale entries would make a revocation silently ineffective, and the failure would be invisible. With the cache discarded every resolution is new, so the descriptors resolved before the failure are not kept either. This is a degraded state, not a failure — eventd keeps ingesting and keeps answering queries, which see no records until the watch is re-established (§9.3).

7.5.4 During a stream #

Verdicts reached for a streaming query's initial result set are reused through the watch phase, with two exceptions.

A new concrete identifier appearing in a streamed batch — an event type or log origin not present in the initial results — is resolved and checked before the record or its distinct value is used. It has never been authorized, and inheriting a verdict from a sibling pattern would be a grant nobody made.

A descriptor change invalidates the cache as it does anywhere, and subsequent batches are re-checked against the new one.

The token is not re-examined. It was captured at connection (§7.1), so a client whose memberships change mid-stream continues under what it connected with, and a client whose access is revoked keeps receiving records until it disconnects. The bound on that exposure is the client's own connection lifetime, which for a dashboard may be days.

Note

Re-obtaining the peer token periodically would narrow the window, and would also mean a streaming query could change what it returns halfway through for reasons the client cannot see. The snapshot is the simpler contract and is what PSPU §3.14 states; the exposure is the cost.

7.5.5 Metric publication #

The metric ingestion thread owns a separate bounded cache for EVENTD_PUBLISH checks (§7.6). It is keyed by the conveyed token's token_id and modified_id plus concrete metric name. It caches denials as well as grants, clears as a unit when full, and is invalidated by the same descriptor generation as the read caches.

This cache is deliberately thread-local. A recurring metric performs a borrowed string lookup without allocation, shared locking or AccessCheck; the only per-datagram identity operation left is querying the fresh token fd's stable statistics before that fd is closed.

7.6 The Write Path

Peios / Peios technical reference / eventd / Access Control

The three write paths establish provenance differently because their inputs come from different places.

7.6.1 Events #

Emission is KMES's business. kmes_emit and kmes_emit_batch require SeAuditPrivilege, and eventd is not in the admission path — it consumes what KMES delivers (§2.2).

The identity stamps on an event are the kernel's, captured from kernel state at the write. An emitting process cannot set, influence or suppress them. That is what makes an event's emitter.process.guid evidence, and why a payload value at that path never shadows it (PSPU §3.22).

7.6.2 Logs use peinit as a broker #

Service processes do not reach the log socket. eventd replaces the inherited DACL on LogSocketPath with this protected DACL before its first receive:

D:P(D;;0x2;;;SU)(A;;GA;;;SY)(A;;GA;;;OW)

It sets the DACL alone. The socket stays owned by the service SID of eventd's own virtual Service identity, which created it, and the OW allow lets that owner manage it; making SYSTEM the owner would need a privilege the long-running daemon deliberately does not hold.

SU is the Service logon group, S-1-5-6. Every phase-2 service token, including a SYSTEM service token, carries it. peinit's bootstrap SYSTEM token does not. The deny is evaluated before the SYSTEM allow, so a service cannot write merely because its user SID is SYSTEM; peinit can.

peinit determines origin from the output pipe it is draining and may batch records from several origins in one datagram. eventd therefore does not request or check a KACS token for each log datagram. The socket admission attests the broker and the broker attests the origin, without token-fd churn or an AccessCheck on the log hot path.

A log origin proves which service context peinit associated with the line. It does not prove that the line's message is truthful.

7.6.3 Metrics carry the producer token #

Every authenticated caller may send to the metric socket: eventd replaces the descriptor it would inherit from its closed runtime directory with D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;OW)(A;;FW;;;AU). Who may publish what is decided below, per metric name, against the token each datagram carries, so a socket that admitted fewer callers would only leave a grant of EVENTD_PUBLISH to anyone else unusable.

The cost of an open socket is that anyone signed in can send datagrams, and the socket is lossy (§9.1): a caller sending in bulk can crowd out other producers' samples, though none of its own unauthorized records is stored. An operator who wants the socket narrower sets another descriptor on it after eventd starts, for example with sd set. eventd sets its own again each time it starts.

A metric producer enables KACS_SO_PASS_TOKEN once on its persistent sending socket. KACS attaches the producer's effective identity to each datagram as KACS_SCM_TOKEN. eventd uses recvmsg with room for exactly one token and no ordinary file descriptors.

eventd discards the whole datagram before MessagePack parsing when:

  • no token arrived
  • the data was truncated
  • the ancillary data was truncated
  • querying the token or resolving policy failed

For a valid datagram, eventd resolves each record's metric name through the ordinary hierarchical Metrics descriptor namespace (§7.2) and runs AccessCheck against the conveyed token for EVENTD_PUBLISH. A denied record is discarded; authorized sibling records in the same datagram continue.

The wildcard Metrics descriptor grants EVENTD_PUBLISH to SYSTEM and Administrators, not Authenticated Users. A package that owns a metric prefix installs a more-specific descriptor granting its service SID that right. Existing wildcard descriptors that exactly match eventd's old read-only default are upgraded in place; administrator-modified descriptors are never rewritten.

Authorization covers the metric name, not its labels or value. An authorized producer can create arbitrarily many valid label sets under that name (§5.3).

7.6.4 The publication cache #

A full AccessCheck is not performed per sample or per datagram. The metric ingestion thread owns a bounded verdict cache keyed by:

(token_id, modified_id, concrete metric name)

KACS reuses the captured token object while a persistent sender socket keeps the same effective identity, so ordinary traffic repeatedly hits the same entry. A hit performs no allocation and no AccessCheck.

MetricAuthorizationCacheSize bounds the total entry count. When full, eventd clears the cache rather than maintaining an LRU list on the write path. This makes churn expensive for the producer causing it without adding pointer updates to every successful lookup.

The descriptor cache carries a generation. Any security-registry change advances it and clears all local publication verdicts before they are reused. The cache stores denials as well as grants, so repeatedly sending an unauthorized name does not repeatedly invoke KACS.

7.6.5 Rejected input #

Missing identity, truncation, denied records and policy errors increment in-memory diagnostic counters. Policy errors may produce rate-limited standard-error text. None produces a durable event or log record of eventd's own: doing work proportional to hostile input would create an amplification path (PSPU §3.4). The one record a denial can cause is KACS's: under a descriptor whose SACL audits failure, as every default does (§7.2), the publication check that denies a name writes a kacs.audit.access.checked record. Denials are cached like grants, so that is one record per token and name until the cache is cleared, not one per datagram.

8.1 Dependencies

Peios / Peios technical reference / eventd / Startup And Shutdown

eventd needs four subsystems before it can do anything.

SubsystemFor
KMESEvent ingestion. Available as soon as PKM is loaded.
LCS and loregdConfiguration. eventd reads every setting from the registry.
KACSAccess control — the AccessCheck API for query authorization, and the peer-token socket option (KACS_SO_PEER_TOKEN) for caller identification.
peinitLifecycle management and pre-start state-directory provisioning.

eventd is a peinit-managed service, started after loregd is available — it cannot read a single configuration value without the registry, and it has no compiled-in defaults for the six paths it needs (§A).

The boot ID is not a service-manager API. eventd reads Linux's /proc/sys/kernel/random/boot_id, validates its UUID text, and converts it to PCDS GUID layout (§3.7).

8.1.1 The dependency that is not one #

KACS is needed to serve queries and not to ingest. The drain, write and retention paths never call it. That asymmetry is what lets eventd keep ingesting through a KACS outage while refusing every query (§9.3), and it is the right way round: losing the ability to read the audit store is recoverable, losing the events is not.

8.1.2 Ordering in the boot #

eventd is one of the platform daemons and is Critical: peinit reboots the system rather than continuing without it. It comes up after loregd and authd, and it stops before them on the way down — it is among the last services shut down, because everything else's shutdown is worth recording.

The window before eventd exists is real and peinit covers it by buffering service output until the log socket appears. Events emitted during that window are not lost either: they sit in the KMES ring buffers, and eventd's first drain after attaching reads them from tail_pos (§2.2).

8.2 The Bootstrap Sequence

Peios / Peios technical reference / eventd / Startup And Shutdown

Startup proceeds in seven phases, and either completes or fails entirely. The phases group the work by concern rather than fixing its order: eventd reads the boot ID (step 13) before it opens any store, so that each shard can be searched for it as it is opened; it opens eventd-meta.db (step 12) before the shards; and it binds each ingestion socket as soon as its store is open, so the log socket (step 18) is bound before metrics.db (step 11) is opened.

8.2.1 Phase 1 — Configuration #

  1. Read every configuration key under Machine\System\eventd\. The six required keys are EventStorePath, LogStorePath, MetricStorePath, QuerySocketPath, LogSocketPath and MetricSocketPath; a missing or invalid one fails startup.
  2. Open and validate the three provisioned store directories without following symbolic links. A missing directory, unsafe component or descriptor that is not equivalent to the protected platform default fails startup (§3.3).
  3. Read the optional keys and apply compiled-in defaults for those absent (§A).
  4. Arm a persistent watch on the subtree, for runtime changes (§8.3).

8.2.2 Phase 2 — KMES attachment and shard sizing #

  1. Query the KMES logical slot count with kmes_attach(KMES_ATTACH_QUERY_SLOTS, …), walk every slot, skip EINVAL holes, and assign dense internal ordinals to successful logical CPU IDs (§2.2). The calls require SeSecurityPrivilege in the effective token. Discovering no buffers fails startup.
  2. Map each per-CPU ring buffer.
  3. Resolve the active shard count from StorageShards — the attached KMES-buffer count when it is 0, the configured value otherwise.
  4. Compute the shard-to-CPU assignment (§2.3).

8.2.3 Phase 3 — Storage #

  1. Open or create each active event shard: verify the schema version, open in WAL mode with synchronous=FULL, create tables and indexes if new, and quarantine on reported corruption (§3.3). Discover historical shards matching the naming pattern, and open those with a recognised schema read-only; exclude the rest from the query path.
  2. Open or create logs.db in the log store directory — schema verified, WAL, synchronous=NORMAL, quarantine on corruption (§4.3).
  3. Open or create metrics.db in the metric store directory, likewise (§5.4). The series cache starts empty and fills on demand.
  4. Open or create eventd-meta.db. Load the index counters and desired set, load the sequence checkpoints for diagnostics only, and discover each shard's material indexes from its schema (§3.5).

8.2.4 Phase 4 — The boot boundary #

  1. Read /proc/sys/kernel/random/boot_id, require valid UUID text, and convert it to PCDS GUID layout. An unavailable or malformed value fails startup.
  2. Read and merge every committed receipt_ranges row for that boot from every readable active and historical shard, grouped by logical CPU ID (§2.2, §3.1).
  3. Search those shards for any committed row or receipt carrying the boot ID. None means the boot's first successful eventd start; otherwise this is a restart within the boot (§3.7).
  4. Initialise each drain's recovery coverage from the merged receipts. It begins at the ring's oldest survivor, skips covered sequences, re-ingests uncovered survivors, and records as gaps only sequences in neither source (§8.5).

8.2.5 Phase 5 — Sockets #

  1. Create the query socket at QuerySocketPath. A stale pathname left by a crash is unlinked first if it is an AF_UNIX socket; if the path exists and is not a socket, startup fails. Its inherited descriptor is replaced with the protected one that lets every authenticated caller connect (§7.1).
  2. Create the log socket at LogSocketPath, then replace its inherited DACL with the protected deny-Service, allow-SYSTEM broker DACL, leaving it owned by eventd's service SID (§7.6).
  3. Create the metric socket at MetricSocketPath, then replace its inherited descriptor with the protected one that lets every authenticated caller send (§7.6).
  4. Establish and verify the Security Descriptor on all three, before any of them accepts or receives anything (§7.6).

The stale-socket rule distinguishes the two cases deliberately. Unlinking a leftover socket is recovery from eventd's own crash; unlinking a regular file at a configured path would be destroying something that is not eventd's, and a path pointing at the wrong thing is a configuration error worth failing on.

Recognising a leftover socket needs no right on it. If an operator has narrowed its descriptor so that eventd may no longer delete it, eventd, which still owns it, first puts its own descriptor back, so a narrowed socket never stops the next start.

8.2.6 Phase 6 — Threads #

  1. One writer thread per active shard.
  2. One drain thread per attached logical CPU, each beginning to reconcile and read its ring buffer.
  3. The log ingestion thread.
  4. The metric ingestion thread.
  5. The retention coordinator thread.
  6. The adaptive indexing policy thread.

8.2.7 Phase 7 — Ready #

  1. Write and commit an eventd.daemon.started event in the boot recording whether this start is a restart, the shard count and the per-CPU recovered coverage points (§3.2). The commit happens before readiness is signalled.
  2. Signal readiness to peinit.

Committing before signalling is what makes the startup record trustworthy. A readiness signal sent before the commit could be followed by a crash that loses the record, leaving a boot in which eventd demonstrably ran and left no trace of having started.

8.2.8 Failure #

If any phase fails, eventd does not signal readiness. It logs the failure to standard error where standard error exists, and exits non-zero. peinit's restart policy decides what happens next.

Partial startup is not permitted. There is no degraded mode in which eventd runs without one of its three stores, or without KMES. It either completes the sequence or fails.

The all-or-nothing rule is a simplification. A later revision might allow log and metric ingestion to proceed with KMES unavailable, but that means partial-failure state to manage in every subsequent path — what a query against a store that was never opened does, what happens when the missing subsystem returns — and the failure mode it protects against is one peinit already handles by restarting.

8.3 Configuration at Runtime

Peios / Peios technical reference / eventd / Startup And Shutdown

eventd watches Machine\System\eventd\ and reacts to changes without restarting — for the settings that can be changed that way.

Notifications arriving during startup are queued and processed after readiness is signalled (§8.2). Applying a configuration reload to half-initialised state would mean every phase having to tolerate its inputs changing underneath it.

8.3.1 What applies immediately #

Tuning parameters: batch sizes and latencies for all three writers, retention periods and the delete batch size, adaptive index thresholds and windows, the WAL checkpoint threshold, the query request and response sizes, the query timeout, the cross-type window and lookback limit, and the query and streaming concurrency limits.

A live MaxBatchSize change is an atomic update to the event writers' next commit threshold. It does not resize, replace or drain their startup-fixed handoff channels (§2.3). The log and metric batch settings likewise affect only subsequent transaction thresholds.

Every applied change, whichever key it touches, also requests an index policy run (§3.4), which writes the metadata database (§3.5), and a retention pass (§3.6), so a changed threshold or a shortened retention period takes effect without waiting for either interval.

8.3.2 What waits for a restart #

ChangeWhy
Socket pathsThe sockets are bound and clients are connected to them.
Store pathsThe databases are open, and moving a store is a data migration, not a setting.
StorageShardsShard-to-CPU assignment, writer threads and handoff channels are all built from it at startup (§2.3).

The watch notices these changes and eventd defers them rather than attempting a live migration. Shard count changes in particular are expected once in a machine's life, and rebalancing writer threads while events are in flight is a large mechanism for a rare event.

8.3.3 What is neither #

Security Descriptors under Security\ are not configuration in this sense. The registry watch invalidates the descriptor cache and the next query resolves afresh (§7.5), so a grant or revocation takes effect immediately without anything being "applied".

8.3.4 Recording it #

eventd emits an eventd.config.changed event for every change applied at runtime, carrying the value's name and its old and new values as integers, each left out when no value was stored (§3.2).

Invalid values are ignored and the previous value is retained — an administrator who types a batch size outside its range does not get a daemon that stops working, and the retained value is the one already in use rather than the compiled-in default. Unknown keys in the subtree are ignored entirely.

8.3.5 SIGHUP #

SIGHUP re-reads the configuration, equivalent to a watch notification (§8.5). It exists for the case where the watch itself is not delivering — a registry outage, or a watch that failed and has not re-armed (§9.3) — and gives an administrator a way to force the read rather than restarting the daemon.

8.4 Shutdown

Peios / Peios technical reference / eventd / Startup And Shutdown

When peinit signals a stop, eventd persists as much in-flight data as it can without blocking indefinitely.

8.4.1 The sequence #

  1. Stop accepting. Unlink all three socket paths so no new client can reach them, and stop accepting query connections. Existing streaming queries are terminated with an error. The log and metric socket descriptors stay open, and eventd goes on reading them until every query connection still being served has ended: a sender that reached either socket before its path was unlinked can still deliver to it.
  2. Drain ingestion. Shut the log and metric sockets for reading, so that a later send fails with EPIPE and its sender keeps the datagram (peinit buffers it and replays it to the next eventd), rather than having it queued where nothing will read it. Then read and process the datagrams still in the receive queues, and close those descriptors. This is bounded by the receive queue, at most net.unix.max_dgram_qlen datagrams plus one (§4.1), so it completes quickly.
  3. Final event drain. Each drain thread performs one last drain cycle from its ring buffer.
  4. Final commit. Every writer commits its current batch immediately, whatever its size. The log and metric writers do the same.
  5. Record sequence state. Derive each logical CPU's highest contiguously covered sequence from committed receipt ranges and write it to sequence_checkpoints for diagnostics (§3.5).
  6. Emit the shutdown event. Write eventd.daemon.stopped with the per-CPU sequences, using the daemon-wide shard assignment rule (§2.6). If the receipt ranges could not be read in step 5, the record carries no sequences rather than zeroes (§3.2). If no shard is writable, the event is skipped and the failure logged to standard error.
  7. Close databases. Close every connection, writer and reader. SQLite checkpoints the write-ahead log automatically on close.
  8. Unmap. Unmap every ring buffer and close the per-CPU descriptors.
  9. Exit.

Steps 1 and 2 are deliberately split. Unlinking the pathnames stops new senders finding the socket while the descriptors stay open, so whatever is already queued is still readable — closing them at step 1 would discard the queue, which is the data most recently produced and therefore most likely to explain why the system is being stopped. Shutting the sockets for reading before the drain is what makes the drain final: nothing can join the queue behind it to be discarded when the descriptors close.

The final checkpoint in step 7 matters most for the log and metric stores, which run synchronous=NORMAL and whose durability boundary is the checkpoint rather than the commit (§4.1).

8.4.2 A failed event writer #

When an event-shard writer exits, it closes its handoff queue and releases the pending control requests. A caller waiting for a final-commit barrier, synthetic-event commit or maintenance result then observes failure instead of waiting for a reply from a writer that has stopped. These requests are not acknowledged as committed. Producers waiting for queue capacity wake with a closed-queue error; already admitted handoffs are released once their producer publishes or cancels them. Healthy writers retain their normal backpressure and commit acknowledgements.

A reservation racing with closure either joins the admitted handoffs or fails with a closed-queue error. The final drained check cannot miss a reservation that succeeds, including on processors with weaker memory ordering. Closing also wakes threads that are entering a queue wait; it does not depend on another publication to release them.

8.4.3 The timeout #

Shutdown is bounded by peinit's service stop timeout. If the sequence has not finished, eventd aborts and exits immediately.

What an aborted shutdown costs:

  • Uncommitted event batches are lost. Those events remain in the KMES ring buffers and are available at the next start, provided they have not been overwritten by then.
  • Uncommitted log and metric batches are lost, which is acceptable by design.
  • The diagnostic sequence metadata may be stale. It does not matter: startup derives coverage from committed receipt ranges and reconciles it with the ring buffer (§2.2).

Every consequence is one the restart path already handles, which is why aborting is safe rather than merely tolerable.

8.5 Crash Recovery and Signals

Peios / Peios technical reference / eventd / Startup And Shutdown

8.5.1 After a crash #

An ungraceful termination — a segmentation fault, a kill, an out-of-memory kill — leaves four things true.

The ring buffers are unaffected. KMES writes regardless of consumer state, and events emitted while eventd was down accumulate there.

The databases are consistent. WAL mode guarantees committed transactions survive, and SQLite rolls back the in-flight batch on the next open.

There may be uncovered sequences. Events in an uncommitted batch were not persisted, but some may still survive in KMES. On restart eventd merges committed receipt ranges from all shards, re-ingests each uncovered survivor, and writes a gap only for a sequence present in neither receipts nor the ring (§2.2, §2.5).

Socket-buffered data is gone. The kernel discards a socket receive queue on process exit, taking whatever logs and metrics were waiting. Acceptable by the loss model.

No manual recovery is needed and none is offered. eventd restarts, re-attaches, resumes draining, and records only what was truly missed. The boot-boundary logic recognises the restart from committed rows or receipts rather than a flag written in advance (§3.7) — which is the point, since a crash is precisely the case where nothing was written in advance.

8.5.2 Signals #

SignalBehaviour
SIGTERMBegin graceful shutdown (§8.4).
SIGINTBegin graceful shutdown.
SIGQUITWrite a diagnostic dump to standard error, then begin graceful shutdown.
SIGHUPRe-read configuration from the registry (§8.3).
SIGPIPEIgnored, so a write to a socket whose peer has gone — a query client that disconnects mid-response — fails with EPIPE on that connection instead of ending the daemon.

Every other signal keeps its default behaviour.

8.5.3 The diagnostic dump #

SIGQUIT writes human-readable text to standard error before step 1 of the shutdown sequence, so that it reflects the daemon's state while it is still running rather than while it is tearing down. It includes at least:

  • the current boot ID
  • the active shard count and the readable historical shard count
  • the per-CPU committed receipt coverage and highest covered sequence
  • the current non-streaming and streaming query counts
  • the metric series cache occupancy
  • KMES events discarded for claiming one of the five types eventd writes itself (§3.1)
  • log records discarded for an origin outside the origin grammar, and the most recent such origin where one exists (§4.1)
  • metric datagrams missing identity, truncated, denied by name policy or rejected by an authorization error
  • the cumulative metric type-mismatch count and the latest conflicting metric name and expected and received types, where one exists
  • the last observed write error for each store, where one exists

The format is not a stable machine interface and its wording may change.

Most of what an operator asks of a running eventd — whether the writers keep up, whether the series cache is thrashing (§5.3), whether query slots are exhausted (§6.5), whether a store has been failing writes — is answered over time by its health metrics (§5.7). The dump is for what those cannot carry: the counts of rejected ingestion input, which no query client may see (PSPU §3.4), the latest error text and rejected names, and the receipt coverage. It also still works when the metric store is the thing that is broken. Standard error is the destination because peinit captures it, so the dump reaches the log store by the ordinary path — and reaches standard error directly when the log store is broken.

9.1 Losing Events

Peios / Peios technical reference / eventd / Failure Modes

Every other failure in this chapter costs something recoverable. This one does not, which is why the whole ingestion pipeline is shaped around delaying it.

9.1.1 Ring buffer overrun #

When events are emitted faster than eventd drains them, the per-CPU ring buffers fill and KMES overwrites its oldest entries.

  • eventd sees it as a sequence gap on the affected CPU (§2.5).
  • An eventd.events.lost record is written, naming the missing range.
  • The range is added to that CPU's eventd.events.lost (§5.7).
  • Draining resumes from the oldest survivor at tail_pos.

The events are gone. No other copy exists, and a gap record is a tombstone rather than a recovery — it records what was lost and when, which is the most that can be offered.

Four mechanisms delay it:

MechanismEffect
Adaptive batch sizing (§2.4)Commits as often as throughput allows, so the writer stays close to the drain rate.
Index shedding (§3.4)Drops per-insert index cost under pressure, including all of it at once in the emergency case.
Sharding (§2.3)Scales write throughput with the shard count.
Ring buffer capacityThe absorption window, sized by an administrator.

The first three are eventd's and operate automatically. The fourth is KMES's and is the one an operator can enlarge for a workload that bursts predictably.

9.1.2 Query timeouts #

A query exceeding QueryTimeoutMs is cancelled and the client receives an error (§6.5). Read-only connections are released; nothing is lost, and the query simply did not finish.

Streaming queries are bounded only up to watch; past that the watch phase is not time-limited.

The main risk is a large scan over a field with no index, which adaptive indexing reduces over time by indexing whatever keeps being filtered on (§3.4). A timeout is therefore worth reading as a signal about the index set rather than only as an error to retry.

9.1.3 Ingestion backpressure #

When a log or metric socket's receive queue is full, the kernel refuses the send: a non-blocking sender receives EAGAIN, and a blocking one waits (§4.1). eventd is not notified and does not count the refusal, so no health metric shows it (§5.7).

This is by design and is not a failure to be tuned away (PSPU §3.4). The one operational note is that the queue is not being drained while a batch is committing, because the same thread does both jobs (§4.1) — so a burst arriving during a commit is the common case for log loss.

9.2 Storage Failure

Peios / Peios technical reference / eventd / Failure Modes

9.2.1 Disk full #

When the filesystem holding a store reaches capacity, SQLite writes fail.

After any write failure consistent with disk-full or quota exhaustion, eventd schedules an immediate retention run across every store whose retention is enabled, under the ordinary bounded-batch rules (§3.6, §4.4, §5.5). Retention is the only lever eventd has that frees space, and waiting up to an hour for the next scheduled pass would waste the window in which recovery is still cheap.

A write refused for want of space emits no eventd.store.quarantined; that event reports only a store found corrupt and quarantined (§2.6).

Capacity diagnostics name the operation that failed. A synthetic-event, quarantine-report, maintenance or rollup-maintenance failure is not by itself a report of a discarded ingestion batch. Actual failed batches are reported as discarded, with event-batch loss ranges as described below.

9.2.1.1 The event store #

A failed INSERT or COMMIT does not crash the writer thread.

The batch is lost, and it is not recoverable: those events were already consumed from the ring buffer, so KMES no longer has them. The writer records the per-CPU sequence ranges of the failed batch in an in-memory lost-batch list, and on its next successful commit emits eventd.events.lost records for every accumulated range before writing new events. Each record's timestamp is that of the last lost event in its range, since no later event revealed it (§2.5).

That ordering matters. Emitting the gap records first means the store never contains events written after a loss without the record of the loss preceding them.

The writer also logs the failure to standard error immediately — including the CPU identifiers and sequence ranges — which peinit captures. That is the only visibility available while the disk is still full and the gap record cannot yet be written.

If eventd crashes before the disk recovers, the in-memory list dies with it. Nothing is silently lost even so: on restart, committed receipt ranges are reconciled with the current ring survivors. Uncovered survivors are ingested again, and sequences present in neither source become ordinary restart gaps (§2.2, §3.7). The record may be coarser — one gap rather than several — but the loss is still recorded.

Meanwhile events accumulate in the ring buffers. If the disk stays full long enough, they overrun and additional loss occurs, detected by the same mechanism (§9.1).

9.2.1.2 The log and metric stores #

A failed commit loses the batch. The writer retries on the next one. Acceptable under the loss model, and no lost-batch accounting exists for either — there is nothing to reconcile against, since neither has sequence numbers.

9.2.2 Corruption #

Corruption from a hardware error, a filesystem bug, or an incomplete write during a kernel crash.

Detection at startup is structural: eventd verifies that the required tables and indexes exist. It does not run PRAGMA integrity_check, which scans the entire database and costs time proportional to its size — unacceptable for a large event store on every boot. Corruption that leaves the schema intact, such as a single bad page, is found later, at query or write time, when SQLite touches it.

At startup, when SQLite reports corruption in a required active store, eventd quarantines the files, creates a fresh empty database at the original path, and continues (§3.3). It logs the corruption and emits eventd.store.quarantined once a shard is available to hold it.

At write time, eventd stops writing to the affected database, emits eventd.store.quarantined if it can, quarantines and replaces the database, and resumes writes to the replacement.

At query time, a handler encountering corruption fails the affected query with an error. It does not return the rows it managed to read: partial data from a database SQLite has declared corrupt is indistinguishable from complete data, and silently under-reporting an audit query is worse than failing it.

A missing or unrecognised schema version is not corruption. It is not repaired and not migrated: a required store with one fails startup, and a historical shard with one is excluded from the query path (§3.3).

Recovering data from a quarantined file is an administrative operation. eventd never attempts automatic repair, and the quarantined file is the only copy of whatever it held.

9.3 Losing Dependencies

Peios / Peios technical reference / eventd / Failure Modes

Two of eventd's four dependencies can disappear after startup without stopping it, and in both cases the ingestion path survives while the query path does not. That asymmetry is deliberate: events that are not collected are gone, and queries that cannot be answered can be asked again.

9.3.1 The registry becomes unavailable #

If LCS or loregd goes away after eventd has started:

  • eventd keeps its last known configuration. Changes are not applied until the registry returns.
  • Descriptor lookups fall back to the cache. A pattern the cache does not hold is denied, fail-closed (§7.5).
  • eventd keeps ingesting and keeps serving queries for descriptors it already resolved, indefinitely.
  • When the registry returns, the watch fires and eventd re-reads.

This is a degraded state, not a failure. eventd does not exit, and it does not stop collecting.

The related case is the watch failing while the registry is otherwise reachable. eventd discards the descriptor cache and operates fail-closed for new resolutions until the watch is re-established (§7.5), because a cache it cannot trust to be current would make a revocation silently ineffective. SIGHUP forces a configuration re-read in the meantime (§8.3).

9.3.2 KACS becomes unavailable #

If KACS goes away after startup:

  • reading the peer token (KACS_SO_PEER_TOKEN) fails on new query connections, so new queries are denied.
  • kacs_access_check and kacs_access_check_list fail, so a query in progress that needs a fresh check is denied.
  • Cached check results stay valid for the duration of the query that obtained them.
  • Event ingestion is unaffected. Neither the drain nor the write path calls KACS (§8.1).
  • Log ingestion is unaffected.
  • Metric ingestion continues, but every record needs an EVENTD_PUBLISH check (§7.6). A record whose verdict is cached for the sender's token and name is stored as usual; one needing a fresh check is refused and counted as an authorization error, and the rest of its datagram is still taken.

eventd keeps collecting and cannot answer. Query service resumes when KACS does.

9.3.3 KMES #

There is no partial mode. eventd attaches to every per-CPU ring buffer at startup and fails to start if it cannot (§8.2); there is no subsequent state in which KMES is present but unusable, because the mapping is established once and the read protocol has no call that can fail afterwards. A ring buffer resize is handled as a generation change, not as a failure (§2.2).

9.3.4 peinit #

peinit manages eventd's lifecycle but supplies no runtime service to it. The boot ID comes from /proc/sys/kernel/random/boot_id (§8.1). There is therefore no separate peinit-loss mode once eventd is running — peinit going away means the system is going away.

9.4 Resource Exhaustion

Peios / Peios technical reference / eventd / Failure Modes

9.4.1 Memory #

If the out-of-memory killer takes eventd, it is treated exactly as a crash (§8.5): the databases are consistent, the ring buffers are untouched, peinit restarts the daemon, and the gap is recorded.

The principal long-lived memory consumers are:

ConsumerProportional toBounded by
Series cachedistinct metric series held in memoryMetricSeriesCacheSize (§5.3)
Event-type and log-origin intern setsdistinct committed identifiers not yet cleaned from active cataloguescatalogue cardinality; no independent memory setting (§3.1, §4.2)
Adaptive index countersdistinct event fields queriedthe event query surface
SQLite page cachesconnections and active indexesper-connection configuration (§C)
Event handoff channelsoccupied slots and copied event bytesstartup-fixed internal slot and byte limits (§2.3)

The handoff limits do not follow live MaxBatchSize changes. The channels are small scheduling handoffs; KMES remains the real event backlog.

High-cardinality producers are the likely surprise. The series cache cannot grow past its limit, but the series table can grow and make it thrash on the thread that also drains the metric socket (§5.3). Identifier intern sets intentionally trade memory for zero catalogue work on known-name ingestion. The default 1 GiB metric retention ceiling and low-priority orphan cleanup bound persistent growth indirectly, but do not bound the number of active series between retention passes.

9.4.2 Query slots #

Reaching MaxConcurrentQueries, MaxStreamingQueries or MaxQueriesPerUser rejects new queries with an error rather than queueing them (§6.5). The per-user bound keeps one caller from occupying every slot; several callers together, or SYSTEM, still can.

Ingestion is unaffected, because queries and ingestion are separate channels and separate threads. That separation is what makes query-side exhaustion an inconvenience rather than data loss.

9.4.3 Descriptors #

An event query opens a read-only connection per shard database, and each connection holds one or two file descriptors (§6.4). Many historical shards multiplied by many concurrent queries reaches a process limit faster than anything else eventd does.

Where eventd cannot allocate what an admitted query needs, it fails that query rather than blocking a writer or exceeding its own limit (§6.1).

9.4.4 Writer stalls #

Two things stall a writer thread briefly, and both are bounded by design.

An index build in progress. Drain threads detect rising write pressure and signal cancellation; the writer aborts the CREATE INDEX, SQLite rolls back the partial index, and event writing resumes immediately (§3.4).

A writer-owned maintenance command. Retention and adaptive-index maintenance execute only as bounded, low-priority commands at transaction boundaries. Ingestion wins before the next command; there is no competing read-write connection or writer mutex (§3.6).

Neither stall loses anything by itself. Both contribute to ring buffer pressure, and prolonged enough, both end at §9.1.

9.5 Power Loss

Peios / Peios technical reference / eventd / Failure Modes

Sudden power loss is the failure the three stores' durability settings were chosen against, and it is where the hierarchy the whole daemon is built on becomes visible as three different outcomes.

StoreSettingWhat survives
Eventsynchronous=FULLEvery committed transaction.
Logsynchronous=NORMALTransactions up to the last checkpoint.
Metricsynchronous=NORMALTransactions up to the last checkpoint.

The event store loses only the in-flight batch — the events accumulated since the last commit, which the adaptive batcher keeps as small as throughput allows (§2.4). Unlike a process crash, a power cycle destroys the old KMES rings and changes the boot ID. eventd can neither recover that batch nor determine its final sequence range, so it cannot honestly manufacture an eventd.events.lost record for it. The last receipt coverage and the following boot's eventd.daemon.started bound the unknown loss.

The log and metric stores may lose everything committed since their last write-ahead log checkpoint, which under synchronous=NORMAL is the real durability boundary rather than the commit. How much that is depends on WalCheckpointPages and the write rate, and it can be considerably more than one batch.

The difference is bought and paid for deliberately. FULL costs an fsync on every commit, which at ten thousand events a batch is amortised and at three events a batch is not — and eventd commits small batches constantly under light load. The event store pays it because an event may be an audit record whose absence is itself the finding. The other two do not, because their loss is defined as acceptable and paying for durability they do not need would slow the paths that are most likely to be bursty.

Events are sacred; logs and metrics are important but not fundamental. Every durability decision in this manual is that sentence applied to a particular store.

9.5.1 What restart does #

Nothing manual. eventd starts, finds its databases consistent — WAL recovery is SQLite's, and an incomplete transaction is rolled back on open — reads the new kernel boot ID and starts that boot's per-CPU coverage before sequence 1 (§3.7). It does not compare a new boot's ring against the previous boot's receipts: those are different sequence namespaces.

This is the irreducible difference from a process crash. In the latter, KMES survives and receipt/ring reconciliation can distinguish committed, recoverable and truly missing sequences (§8.5). Across power loss, the old volatile source no longer exists.

10.1 Using evctl

Peios / Peios technical reference / eventd / Clients

evctl is eventd's native command-line client. Its normal operation has no subcommand: the one positional argument is a PSPU observability query.

evctl 'EVENTS kacs.* SINCE 1h ago TAKE 50'
evctl 'LOGS FROM authd ERROR ONLY STREAM'
evctl 'METRIC cpu.usage SINCE 10m ago AVG'

Quote the complete query. Without the quotes, the shell can split string literals or expand * patterns before evctl sees them. Query syntax and semantics remain those of PSPU §3.18–§3.27; evctl does not maintain a second flag-based query language.

10.1.1 Commands and queries #

Exact standalone words such as help and version occupy the command namespace. Anything whose first word is EVENTS, LOGS or METRIC, matched without regard to ASCII case, is a query. This leaves room for administrative commands without requiring the common query operation to be written as evctl query ....

The query can instead be read from a UTF-8 file or standard input:

evctl --file denied-events.evq
printf '%s\n' 'EVENTS eventd.* SINCE 1d ago' | evctl -

The default socket is /run/eventd/query.sock. --socket PATH selects a nonstandard configured socket. The server obtains the caller from the Unix socket peer token and performs every identifier and field access check; the client never opens an eventd database or substitutes its own authorization decision.

10.1.2 Output #

Data is written to standard output. Diagnostics and query errors are written to standard error, so redirecting or piping the result does not mix the two.

FormatSelectionUse
Pretty--format pretty (default)One self-describing record per line, with terminal emphasis on field names.
JSON Lines--format jsonlOne JSON object per record for text pipelines. Binary, extension and non-finite floating-point values use tagged objects rather than being discarded.
MessagePack--format msgpackA lossless binary sequence. Each record is preceded by a four-byte little-endian payload length.

Records do not have a uniform schema. A field absent from one line is not necessarily absent from later lines. Scripts should select the fields they need in the query or tolerate missing object keys.

10.1.3 Result commitment #

An ok response is not, by itself, a completed result. A non-streaming query only succeeds when eventd sends end; a streaming query's initial result only succeeds when eventd sends watch (PSPU §3.16). If eventd reports an error before that point, every preceding record belongs to an invalid partial result and must be discarded.

evctl therefore renders initial records into an anonymous temporary spool. It publishes the spool to standard output only after end or watch. The spool bounds heap use independently of the result count and has no pathname to clean up after a crash.

After watch, new records are written directly as they arrive. Closing evctl, including with Ctrl-C, closes the query connection and ends the watch.

As with every streaming client, a blocked output pipeline eventually applies socket backpressure. eventd may terminate that stream rather than allowing a slow observer to interfere with ingestion.

10.2 The eventd-client Library

Peios / Peios technical reference / eventd / Clients

eventd-client is a Rust library, in the eventd source tree, for programs that query eventd: evctl itself, Event Viewer, and any third-party tool. It speaks the query channel of PSPU §3.14–§3.17 and adds what every client would otherwise have to get right on its own.

It depends on the peios crate and nothing of eventd's own, so a program using it brings in neither SQLite nor eventd's storage.

10.2.1 Whole results or none #

PSPU §3.16 says an error that arrives before the terminal message voids every result message before it. The library keeps that rule itself, so a program cannot show part of a result as if it were all of it.

  • query(socket, text) runs a query that does not stream and returns every record once end has arrived, or the error and no records.
  • Tail::start(socket, text, capacity) follows a STREAM query. It reports the initial result once, complete, when watch arrives; then each live batch; then why the tail ended. If the query fails before watch, the only report is that it ended.

A query blocks until eventd's terminal message, for as long as eventd's query timeout (§6.5), so a program with a window to keep responsive runs it on another thread.

10.2.2 A tail that stops rather than grows #

A tail reads the socket on a thread of its own, as fast as eventd writes, and passes reports through a channel that holds at most capacity of them.

If the program stops taking reports, the channel fills, the thread stops reading, the socket's buffer fills, and eventd ends the query, since it never waits for a slow reader (§6.6). The program's next report is that the tail ended. Nothing grows without bound, in eventd or in the program.

Dropping the Tail closes the connection, which eventd treats as cancelling the query (PSPU §3.14).

10.2.3 Writing query text #

The text module writes a value into query text so that it is always exactly one value (PSPU §3.19).

  • string writes a quoted string: the quote and backslash escaped, and every control character as \n, \r, \t or \uXXXX.
  • binary writes x"…"; duration writes 90s, 2h and so on, in the largest unit that holds the duration exactly.

Every place the language takes an identifier or a pattern also takes a quoted string, so text a person typed should always go through string. Written bare, an event type called STREAM or WHERE would be read as the clause, since the pattern after EVENTS is optional. After LOGS FROM, which always takes an origin, the same word is read as the origin, but quoting is never wrong there either.

eventd's own tests parse the library's quoted text back through eventd's parser for strings chosen to break naive quoting, and require the value to come back unchanged.

10.2.4 What a caller may read #

eventd removes what a caller may not read and says nothing about it (PSPU §3.28). A program that wants to tell its user "you can't read events here", rather than show an empty list, has to work it out from the same descriptors eventd checks (§7). The access module does that.

  • The rights, eventd's generic mapping, the three namespaces with their root GUIDs, and the walk from an identifier to its pattern come from this module, and eventd uses the same definitions, so a client and eventd cannot disagree on them.
  • field_guid derives a field's GUID (§7.3). eventd-core computes the same derivation for itself, and a test holds the two equal.
  • resolve finds the descriptor eventd would check an identifier against: the identifier, each shorter dotted prefix, then *, with an origin's producer left off (§7.2).
  • access checks a descriptor the way eventd does: the calling process's own token, eventd's mapping, and the object type list of the data type's root and the paths of any fields asked about (§7.3), then every field the descriptor grants by name. Records are visible with EVENTD_READ on the root or on any field, as eventd shows them (§7.4).
  • field_tree builds that list for a set of field names, and field_reads makes the check over it, answering per field; eventd uses both, so the tree a client asks about is the one eventd builds.
  • field_grants lists the fields a descriptor grants by name: the object GUIDs of its allowing object ACEs. eventd uses it too, to find which identifiers a field-only grant makes visible.
  • readable says how much of events, logs or metrics the caller may read across every pattern written for them, since a more specific pattern can grant what * denies, and the other way round: everything, some (and which patterns it may not read), nothing, or unknown.

When the policy itself can't be read, readable says unknown and why, never "nothing": a caller denied the registry keys may still be granted the data.

Appendix A Configuration Keys

Peios / Peios technical reference / eventd

Every key lives under Machine\System\eventd\. eventd ignores unknown keys in the subtree. An invalid value is ignored and the value already in use is retained, and eventd emits an eventd.config.changed event for every change actually applied (§8.3).

A.1 Required #

No compiled-in defaults. A missing or invalid value fails startup (§8.2).

KeyTypeDescription
EventStorePathREG_SZProvisioned directory for event shards and eventd-meta.db; standard deployment /var/state/eventd/events/.
LogStorePathREG_SZProvisioned directory for logs.db; standard deployment /var/state/eventd/logs/.
MetricStorePathREG_SZProvisioned directory for metrics.db; standard deployment /var/state/eventd/metrics/.
QuerySocketPathREG_SZUnix socket path for queries.
LogSocketPathREG_SZUnix socket path for log ingestion.
MetricSocketPathREG_SZUnix socket path for metric ingestion.

A.2 SQLite storage #

KeyTypeDefaultRangeDescription
WalCheckpointPagesREG_DWORD1000100–100000WAL page threshold triggering a passive checkpoint, on shard, log, metric and metadata databases alike.

A.3 Event ingestion #

KeyTypeDefaultRangeDescription
StorageShardsREG_DWORD00–256Number of event shards. 0 means the successfully attached KMES-buffer count. Each shard costs at least three file descriptors, plus about two for every running query that reads every shard; a high count needs eventd's LimitNOFILE raised (§2.3).
MaxBatchSizeREG_DWORD10000100–100000Maximum events per writer transaction.
MaxBatchLatencyMsREG_DWORD10010–5000Maximum ms before an event batch commits.

A.4 Log ingestion #

KeyTypeDefaultRangeDescription
LogMaxBatchSizeREG_DWORD5000100–100000Maximum log records per transaction.
LogMaxBatchLatencyMsREG_DWORD50010–5000Maximum ms before a log batch commits.
MaxLogDatagramBytesREG_DWORD262144262144–1048576Maximum accepted log datagram size; the PSPU portable floor cannot be lowered.

A.5 Metric ingestion #

KeyTypeDefaultRangeDescription
MetricMaxBatchSizeREG_DWORD5000100–100000Maximum metric samples per transaction.
MetricMaxBatchLatencyMsREG_DWORD100010–5000Maximum ms before a metric batch commits.
MaxMetricDatagramBytesREG_DWORD262144262144–1048576Maximum accepted metric datagram size; the PSPU portable floor cannot be lowered.
MetricSeriesCacheSizeREG_DWORD500001000–1000000Entries in the LRU series resolution cache.
MetricAuthorizationCacheSizeREG_DWORD16384256–1000000Cached KACS publication verdicts, invalidated by security-policy generation.
HealthMetricIntervalSecondsREG_DWORD150–3600How often eventd records its own health as eventd.* metrics; 0 turns them off (§5.7).

A.6 Adaptive indexing #

KeyTypeDefaultRangeDescription
AdaptiveIndexWindowHoursREG_DWORD241–168Rolling window over which query frequency is measured.
AdaptiveIndexPolicyIntervalMinutesREG_DWORD6060–1440How often the desired index set is recomputed. The minimum of 60 prevents index churn.
AdaptiveIndexCreateThresholdREG_DWORD10010–10000Queries on a field within the window needed to add it.
AdaptiveIndexDropThresholdREG_DWORD101–1000Queries below which it is removed. Less than the create threshold, which is what supplies the hysteresis.

A.7 Index shedding #

KeyTypeDefaultRangeDescription
SheddingWindowSecondsREG_DWORD3010–300Sliding window for graduated shedding.
SheddingBatchPercentREG_DWORD7550–100Percentage of batches in the window exceeding 75% of MaxBatchSize that triggers graduated shedding.
EmergencySheddingBufferPercentREG_DWORD7550–95Ring buffer fill percentage triggering emergency shedding.

A.8 Retention #

KeyTypeDefaultRangeDescription
EventRetentionDaysREG_DWORD301–3650Maximum age of events.
EventRetentionMaxBytesREG_QWORD00–2^64−1Maximum total logical live size of the event shards. 0 means no limit.
LogRetentionDaysREG_DWORD141–3650Maximum age of log entries.
LogRetentionMaxBytesREG_QWORD00–2^64−1Maximum logical live size of the log store. 0 means no limit.
MetricRetentionDaysREG_DWORD901–3650Maximum age of metric samples.
MetricRetentionMaxBytesREG_QWORD1073741824 (1 GiB)0–2^64−1Maximum logical live size of the metric store. 0 explicitly disables the limit.
RetentionCheckIntervalMinutesREG_DWORD601–1440How often the retention coordinator runs.
RetentionDeleteBatchRowsREG_DWORD10000100–100000Maximum rows deleted in one retention transaction.

A.9 Querying #

KeyTypeDefaultRangeDescription
QueryTimeoutMsREG_DWORD300001000–300000Maximum query execution time.
MaxConcurrentQueriesREG_DWORD1281–4096Concurrent queries globally, streaming and non-streaming.
MaxStreamingQueriesREG_DWORD641–1024Concurrent streaming queries globally.
MaxQueriesPerUserREG_DWORD161–4096Concurrent queries, streaming or not, held by one caller's user SID; SYSTEM's are not counted (§6.5).
MaxDistinctStreamValuesREG_DWORD1000001000–10000000Values tracked by one DISTINCT streaming query.
MaxQueryRequestBytesREG_DWORD655361024–16777216Hard maximum query request payload.
QueryResponseTargetBytesREG_DWORD655361024–16777216Soft response batching target; one complete record may exceed it (§6).
MaxQueryHeldBytesREG_DWORD268435456 (256 MiB)16777216–4294967295What every running query together may hold to answer: sorted rows, aggregation groups and watch batches. A query in the default order holds nothing against it (§6.5).

A.10 Adaptive metric rollups #

KeyTypeDefaultRangeDescription
AdaptiveRollupMinSamplesREG_DWORD1000100–1000000Minimum raw inputs an eligible window query must scan before it may seed rollups.
AdaptiveRollupBatchRowsREG_DWORD51216–4096Maximum missing windows submitted in one bounded writer command.
AdaptiveRollupMaxRowsREG_DWORD1000000–10000000Global rollup-cache row cap. 0 disables cache reads and writes without changing results.

A.11 Cross-type filtering #

KeyTypeDefaultRangeDescription
CrossTypeWindowMsREG_DWORD150001000–300000Centred window for cross-type event and log existence checks.
CrossTypeMaxLookbackSecondsREG_DWORD6048003600–2592000Maximum range a cross-type filter may scan.

A.12 The security subtree #

Read-path descriptors live under Machine\System\eventd\Security\ and are not configuration in the sense above (§7.2):

Machine\System\eventd\Security\Events\*
Machine\System\eventd\Security\Events\<pattern>
Machine\System\eventd\Security\Logs\*
Machine\System\eventd\Security\Logs\<pattern>
Machine\System\eventd\Security\Metrics\*
Machine\System\eventd\Security\Metrics\<pattern>
Machine\System\eventd\Security\Admin

Security\Admin governs EVENTD_ADMINISTER, especially INDEX, and defaults to SYSTEM and Administrators. It is registry-protected policy, not data in eventd-meta.db (§3.5, §7.2).

A.13 When a change takes effect #

ChangeEffect
Every tuning parameter aboveApplied immediately.
Socket pathsRestart.
Store pathsRestart.
StorageShardsRestart.
Security descriptorsNext query; the registry watch invalidates the cache.

Appendix B Constants

Peios / Peios technical reference / eventd

Wire-protocol constants — framing, ingestion limits, the query request ceiling and response target — belong to the interfaces rather than to eventd and are in PSPU §3.A.

B.1 Access rights #

RightBitValueMeaning
EVENTD_READ00x0001Read records matching the pattern.
EVENTD_CLEAR10x0002Delete records matching the pattern. Reserved; nothing uses it yet (§7.1).
EVENTD_ADMINISTER20x0004Change eventd's own policy — the INDEX command.
EVENTD_PUBLISH30x0008Publish metric records under the matching name.

B.2 Generic mapping #

Passed to AccessCheck in the generic_read, generic_write, generic_execute and generic_all fields.

Generic rightValueComposed of
GENERIC_READ0x00020001EVENTD_READ | READ_CONTROL
GENERIC_WRITE0x0002000EEVENTD_CLEAR | EVENTD_ADMINISTER | EVENTD_PUBLISH | READ_CONTROL
GENERIC_EXECUTE0x00020001EVENTD_READ | READ_CONTROL
GENERIC_ALL0x000F000FEVENTD_READ | EVENTD_CLEAR | EVENTD_ADMINISTER | EVENTD_PUBLISH | DELETE | READ_CONTROL | WRITE_DAC | WRITE_OWNER

EVENTD_ADMINISTER and EVENTD_PUBLISH are in GENERIC_WRITE and deliberately not in GENERIC_READ or GENERIC_EXECUTE (§7.1, §7.6).

B.3 Field GUID namespace #

EVENTD_FIELD_NAMESPACE = {e7d3a1b0-5c2f-4e8a-9b1d-0a6f3c8e2d4b}

Field GUIDs are uuid_v5(EVENTD_FIELD_NAMESPACE, field_name) with field_name as UTF-8 (§7.3).

B.4 Data type root GUIDs #

The level-0 node of an object type list.

Data typeGUID
Events{a1b2c3d4-0001-4000-8000-000000000001}
Logs{a1b2c3d4-0001-4000-8000-000000000002}
Metrics{a1b2c3d4-0001-4000-8000-000000000003}

B.5 Field names #

Field GUIDs are computed from the algorithm, never hardcoded. The names they are computed from are these.

Event header fields. event.time, event.cpu, event.sequence, emitter.class, event.type, emitter.token.guid, emitter.true-token.guid, emitter.process.guid, event.boot.guid, the paths of PSPU §3.22. The events table's column names are not field names (§3.1).

Log fields. timestamp, origin, is_error, message, job_id, boot_id.

Fixed metric fields. timestamp, boot_id, name, type, value.

Event payload fields use the flattened dot path (PSPU §3.22). Suppressed paths, and payload paths at or beneath a header field's path, are not query-language fields and have no GUID.

Metric label keys use the key itself: core produces uuid_v5(EVENTD_FIELD_NAMESPACE, "core"). A label key can never be one of the five fixed metric field names, because ingestion rejects records whose labels collide with them.

B.6 Origin class #

ValueOrigin
0userspace
1KMES
2KACS
3LCS
4NTFE

The origin class is the header field emitter.class, held in the origin_class column, and the query language accepts the names of classes 0 to 3 as aliases for their values (PSPU §3.23 names no alias for NTFE).

B.7 Synthetic event types #

TypeEmitted when
eventd.daemon.startedeventd starts and attaches to KMES.
eventd.daemon.stoppedGraceful shutdown has committed everything read.
eventd.events.lostA sequence gap is detected on a CPU.
eventd.config.changedA configuration value is applied at runtime.
eventd.store.quarantinedA store is found corrupt and quarantined.

All five are tier essential. These five names, and no others, are reserved: a KMES event of one of these types is not stored (§3.1). Payload schemas are in §3.2.

B.8 Metric types #

ValueType
0counter
1gauge
2histogram

Stored in series.type. The query language exposes the names, not the numbers (PSPU §3.22).

B.9 Log severity #

ValueMeaning
0Normal — standard output.
1Error — standard error, or explicitly marked.

Stored as an integer in logs.is_error; exposed as a boolean by the query language, which accepts both forms (§4.2).

B.10 Series hashing #

FNV-1a, 64-bit, over the exact bytes of the canonical label string or the boundary blob.

ParameterValue
Offset basis0xcbf29ce484222325
Prime0x100000001b3
Stored ashash & 0x7fff_ffff_ffff_ffff

The high bit is cleared so the value fits SQLite's signed INTEGER. Hashes narrow lookups; identity is always confirmed against the full string or blob (§5.2).

B.11 Schema versions #

StoreVersion
Event shard1
Log store1
Metric store3
eventd-meta.db1

An unrecognised version is never migrated. For a required store it fails startup; for a historical shard it excludes the shard from the query path; for the metadata database it recreates from defaults (§3.3, §3.5).

Appendix C Recommended Optimisations

Peios / Peios technical reference / eventd

None of the following affects the storage format, the wire protocol, the query language, or any behaviour a client can observe. An implementation omitting all of them is complete. Each buys measurable throughput or latency with no behavioural trade-off, which is why they are collected here rather than described as design.

C.1 Arena allocation for event copies #

Drain threads copy events out of the ring buffer at rates reaching hundreds of thousands per second (§2.2). Using the system allocator for each variable-sized copy costs freelist bookkeeping, potential lock contention in a multi-threaded allocator, and occasional page faults when it asks the kernel for more.

A pool of arena chunks avoids all of it: hand out sequential space by bumping a pointer, transfer the owning chunk with the channel slot, and recycle it only after the writer consumes the last referenced event. The pool's charged bytes are part of the channel byte bound (§2.3), so the optimisation cannot create a hidden queue. Per-event allocation cost falls from allocator bookkeeping to a pointer bump.

C.2 Drain thread affinity #

A drain thread reading a per-CPU ring buffer benefits from running on the same NUMA node as that CPU. It is not necessary — the per-CPU design eliminates write contention regardless of where the consumer runs — but NUMA-local reads avoid cross-node traffic in the drain loop.

In the 1:1 case, pinning the drain thread to the CPU whose buffer it reads gives the best cache locality available: the pages are likely still in that CPU's L3 from the kernel's write.

C.3 Partial payload extraction #

Building flat result records means decoding payloads, and at thousands of rows that is the dominant query cost (§6.1).

Where a SELECT names specific payload paths, only those need extracting. A streaming MessagePack decoder that scans for the wanted keys and skips over everything else avoids materialising the payload at all, and for a payload with many fields where one or two are selected this cuts per-row CPU by an order of magnitude.

Without a SELECT, a streaming decoder emitting flattened key-value pairs still avoids building a full in-memory representation.

C.4 Prepared statement pooling #

Writer threads already prepare one INSERT each and reuse it (§2.4). Query handlers executing translated SQL benefit from the same treatment: a small LRU pool of prepared statements per read connection, on the order of fifty to a hundred, covers the case where an operator repeats similar queries and where a dashboard issues the same query on a timer.

SQLite caches the query plan in a prepared statement, so re-preparing identical SQL spends CPU on parsing and planning that was already done.

C.5 Batched socket reads #

The log and metric ingestion threads read datagrams one at a time by default. recvmmsg reads many in one kernel round trip — up to a thousand or so — and at a batch of 64 to 256 it cuts syscall overhead by that factor under sustained load, with no protocol or format change.

It also shortens the window in which the thread is not draining the socket, which is the window that loses datagrams (§4.1).

C.6 SQLite page cache tuning #

Each connection has a page cache, around 2 MB by default. For a shard writer connection it holds B-tree pages for the events table and its indexes, and under sustained writes with several adaptive indexes the hot working set can exceed the default — producing evictions and re-reads on the write path.

A reasonable heuristic is 2 MB plus 1 MB per active secondary index for a writer connection, and 512 KB to 1 MB for a read-only query connection, whose queries are short-lived.

C.7 Bounded shard connection pool #

The query path opens a read-only connection to every database in the event store directory, and each holds one or two descriptors (§6.4). With many historical shards this becomes the binding resource.

Active shard writer connections stay open for the process lifetime and are not candidates. Historical shard read connections are: opening them lazily when a query touches them, and closing them after a period of inactivity, bounds the descriptor count without affecting any result.

1.1 Overview

Peios / Peios technical reference / peipkg / Introduction

peipkg is the package manager of Peios: the program that fetches software from a repository, verifies it, and installs it onto a system. It is the consumer side of the package format and repository protocol (PSPU §5), and it is one of several programs built around that format.

1.1.1 What it does #

peipkg maintains a picture of what is installed, resolves what a requested change implies, and applies that change as a single transaction that either lands completely or does not land at all. Around that core sit the parts that make the core trustworthy: a per-repository trust state, a verification pipeline that runs to completion before any byte reaches its destination, and a journal that survives a power cut mid-write.

1.1.2 What is unusual about it #

It holds no identity. peipkg is not a daemon and has no service principal. It runs as whoever invoked it, and every file it creates or replaces is checked by the kernel against that caller's token. There is no standing privileged process to compromise, and the blast radius of a malicious package is exactly the authority of the person who installed it. The consequence runs both ways: peipkg cannot let a low-authority operator install something they could not have written by hand, and it does not need to be trusted to keep its own hands clean.

Packages carry no permissions. Every entry in a package is mode 0777, owned by uid 0, with no extended attributes. That is honest signalling rather than laxity: a mode bit in a package would imply a contract the kernel does not consult. What access control an installed file ends up with is decided at install time, from the parent directory's inheritable descriptor or from an explicit override the package declares and the operator approves.

There are no install scripts. A package cannot ship code that runs at install time. It can declare that one of three standard maintenance operations is required, from a closed enumerated set, and peipkg invokes that operation itself, from a fixed absolute path, with a cleared environment. Everything a package might otherwise want a script for — registering a service, seeding registry state — belongs to the higher-level artifacts that compose packages.

Installation targets a named root, not a path. The default root is the system root, but a system may define others — an initramfs image is the motivating case — and a package's dependency closure flows into the root the package occupies unless a dependency names a different one. A package never names a filesystem location; it names a root, and where that root lives is the installing system's business.

Several packages may contend for one filesystem name. A role is a virtual name that more than one installed package can provide, with at most one holding it. The holder's file answers the contended path through a symlink peipkg owns. Two registry daemons can be installed at once; only one is /usr/sbin/registryd.

1.1.3 The shape of an operation #

Every install, upgrade, and uninstall follows the same arc. peipkg acquires an exclusive lock, resolves the request against the installed set and the configured repositories' indexes, presents the resulting plan for confirmation, fetches and fully verifies every package the plan names, stages each file beside its destination, journals its intent, renames everything into place, commits the database, and only then runs any side effects.

The database commit is the single durability boundary. Before it, a crash rolls the whole transaction back from the journal's record of where each displaced file was moved to. After it, the transaction happened and there is only cleanup left. There is no state in which a transaction is half committed.

1.2 What This Manual Covers

Peios / Peios technical reference / peipkg / Introduction

This manual describes peipkg as it is built: the package manager, the producer toolchain that feeds it, the image composer that shares its machinery, and the repository publisher.

1.2.1 Covered here #

  • The programs and where their state lives (chapter 2)
  • Repository configuration, trust, and refresh (chapter 3)
  • Dependency resolution: satisfaction, candidate selection, and failure (chapter 4)
  • Installation: validation, staging, extraction, and registration (chapter 5)
  • Upgrade and removal, and how configuration files survive them (chapter 6)
  • Transactions: the lock, the journal, the commit, and crash recovery (chapter 7)
  • Rollback and recovery from an interrupted or failed operation (chapter 8)
  • Roles and claims (chapter 9)
  • Installation roots and image composition (chapter 10)
  • Side effects (chapter 11)
  • Producing packages with pekit (chapter 12)
  • The security model: privilege, audit, and operator authorisation (chapter 13)
  • What goes wrong and what it looks like (chapter 14)

1.2.2 Covered elsewhere #

The package format and the repository protocol are specified in PSPU §5 and are not restated here. Anything a third party has to reproduce exactly — the container layout, the manifest schema, version comparison, the payload rules, the signature construction, the index schemas, the freshness rules — lives there. This manual describes what peipkg does with those artifacts, and cites the specification rather than paraphrasing it.

Security descriptors and the access-check model belong to the kernel's access-control subsystem and are documented with it. peipkg supplies descriptor bytes at file-creation time and never interprets them.

Roles, role features, core features, and applets are separate subsystems that reference packages. A package is a distribution primitive beneath them.

Service definitions, registry seeds, and reconciller manifests are integration metadata belonging to the artifacts that compose packages, not to packages.

1.3 Terminology

Peios / Peios technical reference / peipkg / Introduction

Terms defined in PSPU §5.2 — package, manifest, files manifest, payload, repository, descriptor, index, virtual name, role, claim, holder, installation root, trust anchor — carry the same meaning here and are not redefined.

Terms specific to the implementation:

  • Transaction — the atomic unit of work. Every install, upgrade, uninstall, grant, and revoke executes within one, even when it contains a single operation.

  • Plan — the resolver's output: an ordered list of operations that, applied to the installed set, satisfies the request. A plan is computed entirely from index data, before anything is fetched.

  • Goal (or target) — one requested operation the operator named: install this, upgrade that, remove the other.

  • Candidate — an available package, drawn from a repository index, that the resolver may select.

  • World — the resolver's working model, keyed by (name, root): every installed package plus every operation in flight.

  • Journal — the record of a transaction's intent, stored as rows in the package database. It carries the backup map: for each displaced file, the sibling path its original was renamed to.

  • Staged file — a file written to a temporary sibling of its destination, within the same directory, before the transaction commits. Nothing appears at a final install path until the apply phase.

  • Backup — a displaced original, renamed aside within its own directory. Backups cost no additional disk space and are produced by a single rename.

  • Authorization — a resolver output demanding an explicit, action-specific act from the operator before the plan may be applied. Distinct from a notice, which is informational and never blocks.

  • Adoption — recording an existing unowned file as owned by an installing package, without rewriting it, when its content already matches what would have been installed.

  • Side effect — one of the three standard maintenance operations a package may declare (PSPU §5.24).

  • Recipe — a pekit.toml file plus its build script: the input to the producer toolchain describing how to turn an upstream source tree into one or more packages.

  • Lock (in composition) — the pinned, resolved closure an image composition records, so that the same inputs produce the same image. Not to be confused with the transaction lock, which is a mutual exclusion primitive.

1.4 Compatibility

Peios / Peios technical reference / peipkg / Introduction

1.4.1 Format and protocol version #

peipkg implements schema_version 1 of every document in PSPU §5: the manifest, the files manifest, the signature envelope, the repository descriptor, and both indexes. It rejects any other value, and rejects any value outside a closed enumeration — a side-effect identifier, a hash or signature algorithm, a key status, an index kind, a signature policy — rather than ignoring it.

Unknown fields are ignored everywhere except the signature envelope, which is parsed strictly.

1.4.2 Architectures #

x86_64 is the primary target and the one the system is built and tested for. aarch64 is recognised and is a secondary target.

Architecture identifiers are validated for format wherever they appear, but membership of the canonical set is not itself checked: a package declaring an architecture peipkg has never heard of parses cleanly and is simply not installable, because it matches neither the system's primary architecture nor noarch. This is the behaviour a future architecture addition needs, and it means an unrecognised architecture produces a resolution failure rather than a parse failure.

The system's primary architecture is recorded in the package database at first use. When no value is recorded, peipkg derives one from the architecture it was itself built for.

1.4.3 Multi-architecture #

Only one architecture's packages may be installed on a system at a time, alongside noarch packages. The architecture triplet convention (PSPU §5.15) applies regardless, so that today's packages stay compatible with a future extension that lifts the restriction.

1.4.4 The producer toolchain #

pekit is the producer. It builds through the same packing library peipkg reads with, so a package that packs has already been decoded by the consumer's own validators. Recipes are versioned by convention rather than by a schema field: a tool tolerates a top-level section it does not own and rejects an unknown key within a section it does.

1.4.5 Interoperability #

A package is a Zstandard-compressed pax tarball and can be inspected with ordinary tools. Extracting one on a non-Peios host yields world-writable files, because every entry is mode 0777 by design (PSPU §5.16); applying sensible host-native permissions afterwards is the extracting tool's job.

A repository is static files over HTTP. Anything that can serve a directory tree can host one, and anything that can fetch a URL and verify an Ed25519 signature can consume one.

2.1 peipkg

Peios / Peios technical reference / peipkg / The Tools

peipkg is the consumer: the program an operator runs to change what is installed on a system.

2.1.1 Verbs #

VerbEffect
installAdd packages, resolving and installing their dependency closure
upgradeMove installed packages to newer versions; with no name, every installed package
downgradeMove a package to an older version, requiring explicit authorisation
uninstallRemove packages, cascading to or refusing on dependents
undoRevert the effect of a previous transaction
claimInspect, grant, or revoke the holder of a role
repoAdd, list, remove, and refresh repositories
query, list, info, ownsRead the package database
verifyRe-hash installed files against what was recorded at install
recoverResolve an interrupted transaction
cleanGarbage-collect the index cache

install also accepts a local package file rather than a repository name. A local file has no originating repository and therefore no trust set to verify its signature against; it is accepted on the operator's say-so and its format is validated in full, but its authenticity is not established.

2.1.2 Flags that change what a transaction is allowed to do #

FlagEffect
--yesConfirm the routine "apply this plan?" prompt
--cascadeOn uninstall, remove dependents rather than refusing
--allow-staleProceed despite a repository's trust state exceeding its maximum age
--claim, --claim-all, --no-claimChange which roles an install claims (§9.4)
--dangerously-bypass-path-restrictionsPermit an out-of-layout payload from a package that declares itself a special system package

--yes confirms the routine prompt and nothing else. Every elevated action — a downgrade, a foreign replaces, a low-trust provider filling a high-trust role — raises a distinct authorization that --yes does not satisfy and that is confirmed on its own terms (§13.4). The same holds for a configuration file an uninstall would delete that has been modified since install: it is put to the operator per file — remove, keep, or abort — and with no answer on the input the uninstall aborts (§6.6).

2.1.3 Run by a program #

peipkg --driven <verb> is for a program, such as Package Manager, in place of a person: the plan, each question, progress and the outcome are JSON Lines on standard output, and the answers are read from standard input. It asks the same questions and refuses --yes. See The Driven Mode.

The query verbs (list, info, files, owns, search, history, repo list, root list) take --json and print one JSON document instead.

2.1.4 Exit behaviour #

A refused plan, a failed verification, and a rolled-back transaction all exit non-zero and name the condition. A committed transaction whose side effects failed exits zero with a warning: the packages installed, and a stale cache is recoverable by re-invocation (§11.3).

2.2 pekit

Peios / Peios technical reference / peipkg / The Tools

pekit is the producer: the build tool that turns an upstream source tree into one or more package files. It is described in full in chapter 12.

For the purposes of this chapter, three facts matter.

pekit builds through the consumer's own decoder. Packing runs the manifest it just generated back through the consumer's validators, so a package that packs successfully has already satisfied the rules a consumer applies on the way in. A recipe error therefore often surfaces as a manifest error at pack time rather than as a recipe error at validation time.

pekit's own version model is not the package version model. pekit tracks upstream releases — git tags, directory listings — and orders them by its own rules, which exist to answer "is there something newer upstream". Package versions are compared by PSPU §5.6. The two are separate, and where a recipe's template variables expose version components they come from pekit's model.

pekit signs. A package is signed at pack time with the keyring's signing.package_key, and the signature entry is the last thing written into the archive before compression.

2.3 peipkg-repo

Peios / Peios technical reference / peipkg / The Tools

peipkg-repo is the publisher: it maintains the static file tree a repository serves.

2.3.1 Verbs #

VerbEffect
initEstablish a new repository tree, with a descriptor and empty indexes
publishIngest one or more package files, derive both indexes, and sign everything
verifyCheck a published tree for internal consistency

2.3.2 What publishing does #

Publishing verifies each incoming package in full — archive structure, manifest, files manifest, per-file hashes, and its signature against the repository's own keys — before it derives anything from it. Index entries are then extracted directly from each verified manifest, never from operator input.

Both indexes are rewritten on every publication and both are stamped with the same index_version and generated_at. The new version is one greater than the highest either index carried, so the pair advances together and a consumer holding one has a usable freshness floor for the other.

2.3.3 Invariants it defends #

init refuses to run in a non-empty directory. Establishing a fresh repository at index_version 1 over an existing one would look to every consumer like an unrecoverable rollback.

publish refuses to re-publish a name, version, and architecture that already exists. That is the retention guarantee of PSPU §5.35 enforced at the point where it could be broken.

The one exception is explicit: publish --replace lets a package take the place of an archived entry with the same identity, and reports the digest it replaced. It exists for a distribution bootstrap, which builds its seed in a foreign environment and then rebuilds it natively at the same versions. It breaks retention for those versions, so it is only for a repository nobody consumes yet. Two packages with one identity in the same publish are still refused.

publish refuses a URL template that cannot distinguish one version from another, because such a template makes retention unkeepable: the second version published would overwrite the first.

Deriving the active index from the archive is a projection to the highest version per name. When two architectures of one package tie on version, the publisher stops with an error rather than choosing — a repository that silently picked one would advertise a different package than its operator intended.

2.3.4 What it does not do #

There is no verb for rotating a signing key or marking one revoked. Changing a descriptor's key list means editing repo.json and re-signing it by other means.

Publishing does not check a package's payload against the install-layout rules. A package whose payload lands outside the permitted destinations can be published and served; every consumer refuses it at install time instead.

2.4 peipkg-compose

Peios / Peios technical reference / peipkg / The Tools

peipkg-compose builds a filesystem tree from packages, without installing anything onto the machine doing the building. It is how an image is assembled.

It shares peipkg's resolver, its archive reader, its claim logic, and its package database schema. What it does not share is the runtime: it has no transaction journal to recover, no lock to hold against a running system, and no operator sitting in front of it.

Composition runs in two phases, and they can be run separately.

Resolve performs the full repository trust ceremony, resolves the requested package set against the configured repositories' indexes, and writes a lock: the pinned closure, with each package's URL, hash and declared sizes, and the trust state of every repository it draws from.

The source scan includes authenticated archive indexes when either a manifest constraint or a dependency declared by an active or local candidate can need historical versions. An unconstrained request for a development package can therefore resolve the older runtime revision it requires exactly. Shared scans check all candidates because a later lock can select a different closure from the same source universe; the scan remains immutable between locks. Repositories may omit archive indexes, but a dependency requiring an unavailable historical version still fails resolution.

Build reads the lock, fetches each package, checks its bytes against the hash the lock recorded, verifies its signature against the trust state the lock carries for its source, and assembles the tree — extracting payload, materialising claim links, and seeding a package database so that the resulting image knows what it contains.

Chapter 10 describes composition in detail, including what it does not do that an installed system's peipkg would.

2.5 The Package Database

Peios / Peios technical reference / peipkg / The Tools

peipkg's entire persistent state is one database. It is a transactional store — SQLite in write-ahead-logging mode — and that choice is load-bearing rather than incidental.

2.5.1 Why it is a real database #

Three of this manual's guarantees rest on it.

The commit is atomic. A transaction's new installed state and the closing of its journal entry are written in one database transaction, so the transaction is committed or it is not. There is no window in which it is partly committed, which is why recovery never has to finish a half-done commit (§7.8).

Reads see a consistent snapshot. A query beginning at some moment sees committed state as of that moment, regardless of a write committing underneath it. This is what lets a read-only query run without taking the transaction lock at all.

Constraints are enforced by the schema. The rule that two packages cannot own the same non-directory path is a partial unique index, not an application check — so it holds even against a code path that forgot to look.

peipkg refuses to open a database that is not in write-ahead-logging mode, rather than proceeding with weaker guarantees than it documents.

2.5.2 What it holds #

ContentPurpose
Installed packagesName, version, architecture, originating repository, install time, and the stored manifest
Owned filesOne row per path a package owns, with its type and the hash recorded at install
RepositoriesBase URL, trust keys with their statuses, priority, signature policy, and the recorded freshness floor
Role holdersWhich package holds each role
Claim linksWhich links have been materialised, and for which role and slot
TransactionsThe journal: pending and completed transactions, their operations, and the backup map
Machine metadataThe system's primary architecture, and the registered installation roots

The journal is rows in this database rather than a separate file with a separate format. Recording intent and committing are ordinary database writes, and the journal inherits the database's transactional guarantees.

2.5.3 Protection #

The database is stored under a security descriptor granting write access to the tier of principals permitted to install packages on the system. That descriptor is the journal's integrity protection: a principal outside the tier cannot forge a journal entry, and a principal inside it already holds installation authority, so a write from within is not an escalation.

The staging area is stored under the same descriptor.

Note

An earlier design signed each commit record with a key bound to a dedicated package-manager principal. peipkg holds no principal of its own (§13.1), so there is no key to sign with; the descriptor on the database is the protection instead. Narrowing that descriptor so the journal is writable only through the package-manager executable becomes possible once an elevated-executable mechanism exists, and would be a tightening rather than a correction.

2.6 On-Disk State

Peios / Peios technical reference / peipkg / The Tools

peipkg keeps four kinds of state, in three places.

2.6.1 The package database #

One transactional store, holding everything in §2.5. It is the authoritative record of what is installed.

2.6.2 Repository configuration #

One file per repository, in a configuration directory. Each declares the repository's base URL, its trust anchors, its signature policy, its priority, and two tuning values: a minimum acceptable index version, and a maximum trusted age.

The configuration file is the operator's; the recorded trust state — the verified descriptor, its keys and statuses, and the freshness floor — lives in the database. Adding a repository writes both. Removing one deletes both, and leaves installed packages alone.

Configuration is read with no authorisation check of its own. The security descriptor on the configuration directory is what decides who may change a repository's transport policy or its trust anchors.

2.6.3 The index cache #

Fetched indexes and their signatures are cached, content-addressed, with a small pointer file naming the current object for each repository. Caching avoids re-parsing; it does not avoid re-verifying. Every operation that relies on a cached index verifies its signature again, and cross-checks its version and generation timestamp against the freshness floor recorded in the database.

peipkg clean removes cache objects no pointer references.

2.6.4 Staged files and backups #

Neither is a separate directory. A staged file is written as a sibling of its destination, in the destination's own directory, under a name carrying the transaction identifier; a backup is a displaced original renamed to a sibling under a similar name.

Both choices follow from the same requirement: the rename that commits a file is intra-directory, so that it is atomic and cannot fail with EXDEV because the staging area is on a different filesystem. Backups additionally cost no disk space, because nothing is copied.

Where a destination's basename is long enough that adding the marker would exceed the filesystem's name limit, the basename is truncated to fit.

2.7 The Driven Mode

Peios / Peios technical reference / peipkg / The Tools

The driven mode is for a program that runs peipkg in place of a person at a terminal. Package Manager uses it. It changes the channel, not what is asked: every question a terminal run asks is still asked, each authorisation is still separate and audited, and --yes is refused.

Pass --driven before the verb:

peipkg --driven install org.gnu.make
peipkg --driven upgrade --dry-run

It works with every verb. It matters for the verbs that change things: install, upgrade, downgrade, uninstall, undo and claim.

--driven is not the same as the query commands' --json. --json prints one JSON document and reads nothing. --driven is a conversation that lasts the whole run.

2.7.1 Events #

Standard output carries one JSON object per line. Each has an event member naming its kind.

EventMembersMeaning
planoperations, authorisations, notes, other_rootsThe resolved plan, before anything is asked
questionid, kind, and members by kindSomething to answer on standard input
progressphase, step, steps, packageThe transaction has reached a step
warningtextWhat a terminal run prints as peipkg: warning:
messagetextAnything else a terminal run would print
donesummary, transactionTerminal: the command finished
cancelledreasonTerminal: an answer refused, so nothing was changed
errorcode, messageTerminal: the command failed

Every run ends with exactly one terminal event. Read the outcome from it, not from the exit status.

2.7.1.1 The plan #

Each entry of operations has:

  • kind: install, upgrade, downgrade or remove;
  • name;
  • from and to, the versions, where they apply;
  • repository, and local (true for a package given as a file);
  • size_download and size_installed, in bytes, for a package being brought in;
  • root, only for an operation in a root other than the one invoked (§10.3).

authorisations lists the elevated actions, as text. Each is also asked as its own question. notes lists the resolver's notices, such as a request satisfied through a provides. other_roots lists any other root the plan changes.

A plan with no operations ends with done, summary nothing to do. A --dry-run ends with done, summary dry run, after the plan. So peipkg --driven upgrade --dry-run gives the available updates.

2.7.1.2 Questions #

A question has an id, counting from 1 within the run, and a kind.

KindMembersAnswers
authorisetext, the elevated actionyes or no
proceednoneyes or no
modifiedpackage, pathremove, keep or abort

The authorisations come first, one per elevated action (§13.4), then proceed. A modified question arrives later, during staging: an uninstall would delete a configuration file that has changed since it was installed (§6.6). The conversation therefore lasts until the terminal event.

2.7.1.3 Progress #

phase is one of these, in order:

PhaseStep
fetchOne per package brought in: fetched and verified
stageOne per package brought in: its content written beside its destinations
applyEvery change moved into place
commitThe new state recorded; past this, the transaction stands
finishBackups discarded, directories reclaimed, side effects run

step counts from 1 to steps within the phase. package is present for fetch and stage. A cross-root transaction reports each root's phases in turn.

2.7.2 Answers #

Write each answer as one JSON object on a line:

{"id": 1, "answer": "yes"}

The id must be the question's. An answer with another id, or one that cannot be read, is refused, with a warning saying so. The end of input is a refusal too, as it is at a terminal: a closed input never approves anything.

2.7.3 Error codes #

CodeMeaningThe way on
staleA repository's trust state or metadata is past its maximum age, and refreshing did not cure it (§3)Run again with --allow-stale, which is audited
busyAnother package operation holds the lockTry again once it finishes
deniedThe operator may not change what the transaction touches (§13.1)None from this account
unownedA file that belongs to no package would be replaced (§5)Move the conflicting file aside and retry. For install only, --overwrite-unowned also permits replacement and keeps the displaced copy
alternate-upgradeA package is upgraded by some other means (§6)Run again with --bypass-alternate-upgrade
unresolvableNo plan satisfies the request (§4)Change the request
untrustedA configured repository's trust ceremony has never run, as with one an image ships (§3)peipkg repo add <name> runs it, or remove the repository
failedAnything elseRead message

message is the text a terminal run prints, for showing to a person. Act on code.

2.7.4 Cancelling #

Before proceed is answered, refusing any question cancels and changes nothing. Once it is answered, the transaction runs to its end.

If the driving program goes away, the transaction still finishes: peipkg ignores the broken pipe, and a question still to come reads the end of input, a refusal. A modified question refused this way aborts the transaction, which rolls back. Killing peipkg itself mid-transaction leaves a pending journal, which the next run rolls back before doing anything else (§8).

2.8 feat client interface

Peios / Peios technical reference / peipkg / The Tools

feat is a separate feature tool, not a package transaction verb. This reference keeps its client-facing state and protocol details together with the peipkg driven protocol it uses. For human-operated commands and interrupted-feature recovery, see Using feat.

2.8.1 Where its state is kept #

A feature's definition is read-only, in /libexec/features/<name>/. Its state is the registry's, under Machine\System\Features\<name>, in the value State:

StateMeans
0Not installed
1Installing (interrupted, if nothing is running)
5Installed
6Turning on
10On
9Turning off
4Removing

Changing a feature needs write access there. As shipped, only Administrators have it; everyone can read it, so anyone can see what is set up.

2.8.2 For a program #

2.8.2.1 --json #

feat list --json answers with a JSON array, one object a feature, and feat info <name> --json with one object:

MemberIs
nameThe feature's name, its directory under /libexec/features/.
title, descriptionWhat it says it is, from its feature.toml, or null. A description's paragraphs are separated by a blank line, with no other line breaks.
stateAs feat list writes it.
definedfalse for a feature whose definition is gone, known only by the state the registry still holds.
phasesThe scripts it has: install, enable, disable, uninstall.
may_changeWhether you may change its state, asked of the registry (see below).
metadata_problemPresent only when its feature.toml couldn't be read, saying why. The feature is still listed.

may_change is found by opening, for writing, the registry key a change would write: the feature's own key, or where that isn't made yet, the key it would be made in. Nothing is written. It says nothing of what the feature's scripts may do, which they find out as they run.

2.8.2.2 --driven #

feat --driven <command> <name> makes a change and reports it as JSON Lines on standard output, one event a line, for a program running feat on someone's behalf (Feature Manager is one). The events are the part of peipkg's driven mode that applies; feat asks nothing, so it reads nothing, and a script's standard input is empty.

EventMembersMeans
progressphase, step, stepsA script is starting: phase is install, enable, disable or uninstall, step of the steps this change will run.
messagetextA line a script wrote, on its standard output or error, or what feat says it did.
donesummary, stateThe change is made, or needed nothing; state is the feature's now.
errorcode, messageThe change failed.

Exactly one done or error ends the run. error's code is one of usage, not-found, denied, state, script (a script failed; its output came before, as messages) and failed.

A program that goes away mid-change doesn't stop it: feat carries on and the scripts run to their end. list and info aren't changes, so --driven refuses them with a usage error; ask them with --json.

3.1 Configuration

Peios / Peios technical reference / peipkg / Repositories

A repository is configured by a file naming its base URL and the policy peipkg applies to it.

SettingMeaning
Base URLWhere the descriptor and everything it points at are served from
Trust anchorsExpected key fingerprints, supplied out of band
Signature policyrequired or optional (PSPU §5.37)
PriorityA positive integer; lower is higher priority
Minimum index versionThe out-of-band freshness floor applied at first add
Maximum trusted ageHow long a repository's trust state stays usable without a refresh
Insecure transportWhether a non-HTTPS base URL is permitted for this repository
Allow SD overridesWhether packages from here may declare security descriptors (PSPU §5.20)

The default signature policy for a new repository is required. The default maximum trusted age is 30 days. The default priority is the same for every repository, including the official one, so the ordering the resolver applies is whatever the operator configured rather than something peipkg assumes.

An explicit maximum trusted age of zero is rejected rather than treated as "use the default", so that a mistyped value cannot silently disable the freshness check.

3.1.1 The local handle #

A repository has a name in the descriptor and a handle in the local configuration. They are conventionally the same, and nothing requires them to be: peipkg identifies a repository internally by the local handle, and compares an index's declared repository name against the descriptor's name, which it records when it verifies the descriptor.

Comparing against the handle instead made a configuration whose handle differed from the descriptor's name add successfully, write a valid cache, and then fail its cached-index check on every later operation — permanently, and with the repository dropped from resolution.

A repository recorded before the descriptor name was kept — or one in unsigned mode, whose descriptor was never verified — has none to compare against, and falls back to the handle it was recorded under.

3.1.2 Transport #

A base URL is HTTPS unless the repository's insecure-transport setting permits otherwise. The setting is per-repository; there is no global form. peipkg warns on each add, refresh and use of the cached index for package planning while this allowance is enabled.

file:// base URLs are accepted for local development. They are exempt from the transport check rather than gated by it, so a repository on removable or network-mounted media is added without the operator acknowledging the transport.

Changing the insecure-transport setting after a repository has been added means editing its configuration file. There is no verb for it, and so no prompt and no audit event accompanies the change.

3.1.3 Allowing security-descriptor overrides #

A package may declare a security descriptor for an entry it ships, and the format cannot check that its producer had any authority over the principals that descriptor grants access to. §5.20 makes that the consumer's judgement, so it is configured per repository.

It defaults to off, and a package carrying overrides from a repository that has not been vouched for is refused outright rather than installed with the overrides dropped. A repository whose packages legitimately ship descriptors — the system's own, above all, since fsbase is what declares the descriptors for /home and /tmp — must say so:

allow_sd_overrides = true

The default fails towards less authority, which is the direction that shows: an install stops and says why. Failing the other way would let a repository nobody had thought about set access control on the machine, and nothing would report it.

3.2 Adding a Repository

Peios / Peios technical reference / peipkg / Repositories

Adding a repository is the trust ceremony of PSPU §5.37: the moment an operator decides that a particular key speaks for a particular URL.

3.2.1 The ceremony #

peipkg fetches the descriptor and its detached signature, fetches the public key for each anchor fingerprint the operator supplied, checks each fetched key against the fingerprint that named it, and verifies the descriptor's signature against those anchor keys and only those. On success it records the descriptor's full key list, with statuses, as the repository's trust state.

If none of the fetched keys matches a configured anchor, the add is refused. The error lists both the configured anchor fingerprints and the fetched key fingerprints in four-character groups. Fetched fingerprints are labelled not yet trusted: this diagnostic helps compare a transcription against an independently obtained anchor; it is not a reason to adopt a key supplied by the repository itself. No trust state or index cache is recorded on this mismatch.

A matching anchor must still verify the descriptor's signature. On the success path, peipkg does not yet display a fingerprint comparison or prompt for confirmation before recording trust.

3.2.2 Two forms #

peipkg repo add <name> <url> --anchor <fingerprint> is the interactive form: everything comes from the command line.

peipkg repo add <name> is the configured form: the URL, the anchors, and the policy are already on disk, placed there by an image or a configuration manager, and the command performs the ceremony against them. When the ceremony fails for a repository whose configuration file already existed, that file is deliberately left in place — the operator put it there, and deleting it would discard their configuration over a transient network failure.

3.2.3 Bootstrapping the freshness floor #

The first index a repository serves establishes the floor that §3.4's rollback protection enforces from then on. A repository that publishes a minimum acceptable index version alongside its anchors lets peipkg refuse an add whose first index falls below it; without one the floor is whatever the first fetch returned.

Adding a repository that is already configured does not reset the recorded floor. The configured form of the command needs no arguments and reads as idempotent, so a convergence loop or a re-provisioning script runs it routinely; it re-runs the anchor ceremony and adopts the served descriptor's trust state, but the served index passes the same freshness gate a refresh applies (§3.4). An index below the recorded floor is refused, and one exactly at it counts as no progress, so the last-refresh time does not advance either.

Removing a repository is the only route that clears a floor, and it clears the whole recorded trust state with it. That is what makes lowering a floor an explicit operator act rather than the side effect of a command that looks like a no-op.

3.2.4 Fetching keys before verifying #

The descriptor names the URLs its keys are published at, so peipkg reads an unverified document to know where to fetch from. It fetches every key the descriptor declares, not only those matching the supplied anchors.

3.2.5 Official anchors #

The anchors for the official repository come from a file installed by the base system, outside any package. Bootstrap trust is a property of the image rather than of the package format: peipkg relies on the anchors being present when it first runs.

3.3 Priority

Peios / Peios technical reference / peipkg / Repositories

Every configured repository carries a numeric priority. A lower number is a higher priority.

Priority decides two things: which repository's candidate the resolver prefers when several satisfy the same dependency (§4.3), and which of two repositories counts as the more trusted when a cross-repository guard fires (§3.7).

3.3.1 What priority is not #

peipkg has no notion of an "official" repository as a distinct kind. Wherever the format's rules speak of a non-official repository acting against an official one, peipkg substitutes a comparison of numeric priorities. The two coincide exactly when the official repository has been given the lowest number — which is the recommended configuration but not something peipkg enforces, since every repository including the official one is created at the same default priority.

3.3.2 Local files #

A package supplied as a local file rather than fetched from a repository carries an empty repository name and priority zero. Zero is numerically the highest priority available, so a local file outranks every configured repository in candidate selection, and the same-repository preference of §4.3 is permanently inert for it.

3.4 Refresh

Peios / Peios technical reference / peipkg / Repositories

A refresh brings a repository's recorded trust state and cached index up to date.

3.4.1 The sequence #

peipkg fetches the current descriptor and its signature and verifies the signature against any key that was active or transitioning in the previously trusted descriptor — not against the new descriptor's own keys, which would make the update self-certifying. On success it records the new descriptor, replacing the previous key set, then fetches the active index and verifies it against the new keys.

A failed refresh leaves the previous trust state entirely intact and is reported. peipkg does not fall back to unverified state, and does not silently proceed on a stale cache.

3.4.2 The freshness gate #

An index that verifies is not necessarily current, so a refresh applies the rollback and freeze checks of PSPU §5.34 before accepting it.

  • An index whose version is below the recorded floor is rejected, even though it is correctly signed by a still-trusted key.
  • An index whose generation timestamp precedes the recorded one is rejected.
  • An index whose version and timestamp both equal the recorded values is treated as no progress: the fetch succeeded, but the last-successful-refresh timestamp is deliberately not advanced.

That third case is the anti-freeze rule, and it is the one that makes the maximum-trusted-age check below meaningful. An attacker serving the same signed index indefinitely does not get to keep a consumer's clock ticking forward.

The same floor applies to the archive index. A repository publishes both indexes at one index_version and one generation timestamp, precisely because a consumer records one floor per repository rather than one per index, so an archive index below the floor is a replay and is refused. Fetching an archive index at exactly the recorded floor is ordinary — that is the current archive — and it never advances the floor, which the active index owns.

Without this check an attacker who can substitute archive.json replays an old signed revision, and the archive index is the candidate source for every downgrade and pin: a request for a fixed version finds no candidate, and the operator is steered back onto a version that is still listed.

3.4.3 Maximum trusted age #

peipkg records the time of the last successful refresh per repository. When that exceeds the repository's maximum trusted age, an install, upgrade, or downgrade against that repository first attempts a refresh. If the attempt fails — or succeeds without progress — the operation is refused unless the operator supplies --allow-stale, which is an elevated authorisation of its own and is audited (§13.4).

Uninstall and undo are deliberately not gated. Removing something, and reverting a change, are exactly the operations an operator needs while offline or while a repository is compromised.

A configured age above 180 days produces a warning on every operation, so that a configuration effectively disabling the check stays visible.

3.4.4 What is not checked #

peipkg gates on how long since it last refreshed. It does not additionally gate on how old the index itself says it is. A repository that increments its index version on every publication while stamping an ancient generation timestamp satisfies the refresh check indefinitely.

3.5 The Index Cache

Peios / Peios technical reference / peipkg / Repositories

Indexes change when a repository publishes; peipkg reads them on every operation. The gap between those two rates is what the cache exists for.

3.5.1 Structure #

A fetched index and its signature are stored as content-addressed objects, with a small pointer file naming the current object for each repository. An older sidecar layout is still read, so a cache written by an earlier version stays usable.

peipkg clean removes objects no pointer references.

3.5.2 Re-verification #

Caching avoids re-parsing JSON. It does not avoid re-verifying signatures. Every operation that relies on a cached index verifies its detached signature again against the repository's current trust state.

peipkg additionally cross-checks a cached index against the freshness state recorded in the database, and rejects one whose index version or generation timestamp disagrees with what was recorded.

Note

The two checks close different holes. Re-verifying the signature stops substituted metadata being trusted between the cache write and the next read. The cross-check against recorded state stops an older validly signed index being dropped directly into the cache, which would otherwise bypass the refresh path where the freshness floor is enforced.

3.5.3 When the cache fails #

A cached index that fails to load or fails to verify fails the operation, naming the repository and saying to refresh it or remove it from the configuration.

Proceeding without the repository would resolve against a different set than the operator configured, and that is worse than losing candidates: a package the operator expected from the highest-trust source would come from wherever else it is available, and the cross-repository guards compare priorities against what is configured, so a repository absent from the resolution contributes nothing to them either. An escalation dressed as a warning.

3.5.4 Protection #

The cache is written with ordinary file permissions and carries no security descriptor of its own. Its integrity rests on the re-verification above rather than on who can write to it.

3.6 Transport

Peios / Peios technical reference / peipkg / Repositories

Everything a repository serves is a static file fetched over HTTP.

3.6.1 Size caps #

Every fetch is capped, so that a hostile or broken server cannot exhaust memory before anything has been verified.

ArtifactCap
Repository descriptor4 MiB
Detached signature4 KiB
Public key file64 KiB
Index64 MiB
Package filethe index-declared compressed size, plus an allowance

The package allowance is a flat 16 MiB above the declared compressed size, applied uniformly regardless of how large the package is.

3.6.2 URL resolution #

A URL in a descriptor or an index may be absolute, rooted, or document-relative, and each resolves as PSPU §5.36 describes: an absolute URL as-is, a leading-slash URL against the repository base, and anything else against the document that carried it.

3.6.3 Schemes #

HTTPS is required unless the repository's insecure-transport setting is enabled. peipkg warns for each configured repository during add, refresh and cached-index use for package planning. The warning names the repository and states that its configuration does not require HTTPS. It is emitted before the access, including when that access fails, and is a warning event in the driven interface. It describes the allowance even when a particular URL happens to use HTTPS.

file:// is supported for development and is exempt from the transport check entirely rather than gated by the insecure-transport setting.

3.6.4 Failure #

A fetch failure is reported and fails the operation. peipkg does not substitute cached data for a failed fetch without the operator saying so.

3.7 Cross-Repository Guards

Peios / Peios technical reference / peipkg / Repositories

Two relations let one repository act on another's packages, and both are gated.

3.7.1 Foreign replaces #

A replaces declared by a lower-priority repository, targeting a package originally installed from a higher-priority one, raises an authorization. The operator confirms it specifically; a general --yes does not satisfy it, and the authorising act is audited.

Note

Without the gate, a custom repository silently replacing an official-repository package is a routine upgrade away. The confirmation is what stops an escalation happening as a side effect of an operation the operator thought was maintenance.

3.7.2 Foreign conflicts #

A conflicts declared by a lower-priority repository, which would cause a higher-priority package to be removed as a cascade, is the mirror image: a denial-of-availability rather than an escalation.

peipkg resolves a conflict by rejecting the plan outright rather than by cascading removals, so the situation the guard describes does not arise: the low-trust package simply fails to install, and the high-trust one stays where it is.

3.7.3 A low-trust provider filling a role #

When the candidate that satisfies a dependency does so through provides from a lower-priority repository, while a higher-priority repository holds a name-matching package whose constraint check failed, peipkg raises an authorization and requires explicit confirmation. This is the same shape as the foreign-replaces guard: a less-trusted package taking over a name a more-trusted one was expected to fill.

The check runs when resolving a dependency. It does not run for a package the operator named directly, where it cannot fire in practice because a directly named goal carries no constraint for the higher-priority candidate to fail.

3.7.4 Origin that no longer resolves #

Each of these guards compares the priority of the repository a package came from against the priority of the repository acting on it. A package whose originating repository has since been removed — an orphan — has no configured priority to compare, so it carries a priority below every configurable value instead. Every guard that compares priorities therefore treats an orphan as at least as trusted as anything still configured, with no special case of its own.

That direction is the only safe one. Skipping the comparison, which is what an unset priority produced, meant a newly added low-trust repository could declare replaces against an orphaned ex-official package and take it over with no confirmation — so removing a repository lowered the protection on the packages it left behind. Removing a repository because its keys were stolen is exactly when that must not happen.

An orphan is not the same as a package with no origin. A raw local-file install never had a repository to lose, and is not treated as maximally trusted.

3.7.5 Orphaned packages #

list marks an orphan and names the repository that is gone; info says so in the origin line. Neither is refreshed, and no trust state stands behind either any longer.

An upgrade of an orphan is refused unless a configured repository now serves a candidate under its own name. A named upgrade errors — the alternative is reporting success having done nothing, since an unclaimed orphan produces no upgrade operation at all. An every-package upgrade warns per orphan and carries on, because one orphan must not stop the rest of the system moving.

A replaces from another package can still move an orphan aside. That is a takeover rather than an upgrade, and it is gated as an elevated action by the rule above.

4.1 Inputs and Outputs

Peios / Peios technical reference / peipkg / Resolution

Resolution is the step that turns "install this" into "do these things, in this order". It runs entirely on index data, before anything is fetched.

4.1.1 Inputs #

  • The goals: the operations the operator asked for. Install a package, upgrade one, downgrade one, or remove one.
  • The installed set: what is on the system now, from the package database, with each package's version, architecture, originating repository, and that repository's priority.
  • The available set: every package in every configured repository's index, each annotated with the repository it came from and that repository's priority.

The resolver's working model is keyed by the pair (name, root). The same package name installed in two roots is two independent entries, possibly at different versions.

4.1.2 Outputs #

Resolution produces either a plan or a rejection.

A plan is an ordered list of operations. It is partially ordered so that for every install, everything it depends on is already installed or is scheduled earlier; removals are ordered in reverse, so that a dependent is removed before the thing it depended on.

Alongside the plan, resolution emits two other things:

  • Authorizations — elevated actions the plan implies, each of which the operator confirms on its own terms before the plan is applied. A downgrade, a foreign replaces, a low-trust provider filling a role.
  • Notices — informational statements that never block. The substitution notice of §4.2 is one.

A rejection names which condition failed and which packages or constraints were involved.

4.1.3 Determinism #

The resolver is a pure function of its inputs: the same goals, installed set, and available set produce the same plan, every time. That is what makes a dry run trustworthy — the plan shown is the plan that would be applied.

Determinism holds with respect to the inputs as given, including the order candidates appear in. Where the selection rules of §4.3 leave two candidates genuinely tied, the one enumerated first wins, so a re-sorted index can change the outcome.

4.1.4 Index-only #

Resolution never downloads a package. Satisfaction checks and candidate selection use only what the index carries, which is why the index carries a package's relationships at all. Fetching is deferred until after the plan is computed and confirmed.

4.2 Satisfaction

Peios / Peios technical reference / peipkg / Resolution

A dependency is satisfied by a candidate when the conditions of PSPU §5.21 hold: the name matches directly or through provides, any constraint is met by the appropriate version, the architecture qualifier is met, and the candidate is in the dependency's root.

Three consequences of those rules shape how peipkg behaves.

4.2.1 A goal may name a role #

An install goal is satisfied under the same conditions as a dependency, with the goal's name in place of the dependency's. An operator may therefore ask to install sh, cc, or coreutils and receive whatever package provides it.

When a goal is satisfied by a candidate whose name differs from the one the operator typed, peipkg reports the substitution. This is a notice rather than an authorization: the operator is told, but an unattended run is not blocked.

Note

Reporting matters because the operator asked for one name and received a package with another. Reproducibility is not at stake — a consumer that records its resolved closure, as an image composition does, pins the chosen package, so the substitution is decided once rather than re-decided on every build.

A goal already satisfied by an installed provider is not recognised as satisfied: peipkg checks whether the goal's name is present, not whether something provides it. Asking to install a role that an installed package already provides therefore installs a second provider.

4.2.2 Upgrade and remove are name-only #

An install goal may resolve through provides. An upgrade, a downgrade, and a removal do not: they act on a package already installed under a specific name, and resolving them through provides would let an upgrade substitute a different package for the one the operator named.

4.2.3 The architecture qualifier #

The only qualifier value is any, and anything else is rejected. any means the candidate's architecture equals the depending package's effective architecture, or is noarch.

For a noarch depender the effective architecture is the system's primary architecture — a script's dependency on its interpreter resolves against the concrete system being assembled. peipkg applies that rule while selecting a candidate, not only when checking the finished plan.

Skipping the test during selection made any architecture satisfy a noarch depender, a foreign one included. That candidate entered the matching set, could win on version, was placed, and was caught only by the consistency check — which rejects the whole resolution rather than the one candidate. So a plan that a noarch candidate would have satisfied failed outright.

Candidate selection for a dependency also filters by installability now, as selection for a goal and for a name already did.

4.2.4 Concrete dependencies are resolved before capabilities #

Package names and virtual capabilities use the same dependency syntax. Peipkg therefore treats a dependency as concrete when an available package has that exact name, and resolves those dependencies before capability-only entries.

This matters when packaging derives a shared-library dependency automatically. A package may explicitly depend on its intended library package and also carry a derived dependency on that library's SONAME. Resolving the SONAME first could select another provider; resolving the explicitly named package first installs the intended provider, after which its SONAME satisfies the derived dependency. The result does not depend on the order in which those two dependencies appear in the manifest.

4.3 Candidate Selection

Peios / Peios technical reference / peipkg / Resolution

After filtering by the requested constraint and installable architectures, peipkg first selects one revision of each concrete package name. Within one name, repository preferences come first (rules 2 and 3 below), then the role version (rule 4), then the package's own version. An exact architecture match breaks a remaining version tie before repository-name ordering.

This allows a newer noarch release to replace an older x86_64 release of the same package without making an older revision win merely because of its architecture. An explicit version constraint or higher-priority repository can still select the older release.

When distinct providers remain, peipkg compares their selected revisions using rules 1 to 4 and 6 below. Rule 5 applies only to the first stage, within one concrete package name. The first rule that distinguishes two candidates decides.

Selecting the revision first also makes the result independent of catalogue order when a package's architecture changes.

  1. An exact architecture match beats noarch. A candidate whose architecture equals the system's primary architecture is preferred over a noarch candidate from a different provider.

  2. The depending package's own repository is preferred, bounded. When resolving a dependency for a package D, a candidate from D's repository is preferred over a cross-repository one — but only when D's repository is at least as trusted as the alternative. When D comes from a lower-priority repository than the cross-repository candidate, this rule does not apply and rule 3 decides.

    Note

    The bound is the point of the rule. Without it, a low-trust package pulls in low-trust transitive dependencies that shadow higher-trust alternatives, simply by virtue of being their depender.

    The rule applies when D is being installed or upgraded in this transaction. When D is already installed and is merely the reason a dependency is being resolved, peipkg does not consult the repository D was installed from, so the rule does not fire — and the same dependency can resolve to a different provider depending on whether D is being touched.

  3. Higher repository priority is preferred — a lower numeric priority (§3.3).

  4. A higher version of the name being resolved is preferred. That version is the candidate's own when it matched by name, and the matching provides entry's version when it matched through provides. A candidate matched through an unversioned provides states no version and is preferred less than any candidate that states one.

    Note

    Rule 4 compares the role, not the package. Two packages filling one role are unrelated software on unrelated version scales: asked which coreutils they offer, one project's 9.9 and another's 0.4 answer with the versions in their provides entries. Comparing those as package versions would decide the role by which project numbers its releases higher.

  5. Within one package name, a higher package version is preferred. This includes the package revision and breaks equal role-version ties during the first stage. An exact architecture match then breaks an equal package-version tie. This rule does not compare distinct providers.

  6. Ties break on the candidate's package name, by byte order, and then on its repository name.

4.3.1 Two versions of one package #

When two revisions provide the same role version, including an unversioned provides, the package's own version decides which revision represents that package. This comparison is only within one concrete package name: distinct providers have unrelated package version scales and continue to use the role version and name ordering above.

4.4 Failure Conditions

Peios / Peios technical reference / peipkg / Resolution

Resolution fails, producing no plan, when any of the following holds. Each failure names a machine-readable reason and a detail identifying the packages or constraints involved.

ConditionMeaning
UnsatisfiableA package in the candidate plan has a dependency no available package satisfies
ConflictTwo packages in the proposed resulting set trigger a conflict against each other
Architecture mismatchA package in the plan is built for neither the system's primary architecture nor noarch
Version regressionAn operation would move a package backwards without authorisation
CycleA dependency cycle the resolver cannot break by ordering
Too complexThe resolver's step budget was exhausted

4.4.1 Conflicts reject rather than cascade #

A conflict fails the plan. peipkg does not offer to remove the conflicting package to make room, which is why the cross-repository conflict guard of §3.7 has nothing to gate: a low-trust package cannot cause a high-trust one to be uninstalled as a side effect, because it cannot cause anything to be uninstalled.

4.4.2 Cycles are detected after provides resolution #

Cycle detection runs on the graph that remains once provides entries have been substituted for the dependency names they satisfy. A cycle in the raw name graph that disappears once provides is resolved is not an error.

4.4.3 Bounded work #

The forward walk carries an explicit step budget and stops with a "too complex" rejection when it is exhausted, so a pathological dependency graph cannot spin indefinitely. The algorithm itself is greedy and does not backtrack, so it is polynomial in the size of the available set regardless.

The consistency and planning passes that run after the walk are not covered by the step budget. They are polynomial too, but on a very large available set they are where the time goes.

4.5 Optional Dependencies

Peios / Peios technical reference / peipkg / Resolution

An optional dependency is never included in a plan automatically.

peipkg does not carry optional dependencies into the resolver at all: they are not part of the candidate model, so there is no path by which one could be installed without being asked for. An operator who wants one names it as a goal.

A package whose optional dependencies are absent functions correctly with reduced capability. A package that does not function without an "optional" dependency has mis-categorised it — that is a required dependency wearing the wrong label.

Optional dependencies still participate in claims. A claim path declared on an optional-dependency entry is an optional claim consumer (§9.6): the dependency need not be satisfied for the declaring package to install, and the path is materialised only if and when some eligible provider holds the role.

Note

The pattern this supports: a service that would use a log sink if one existed, and ships without one. It declares the sink's runtime path as an optional claim, installs with no provider present, and the path simply does not exist. Installing a provider later materialises the path retroactively against the declaring package's declaration; removing the provider again withdraws it, with no effect on the declaring package.

4.6 Removal Cascades

Peios / Peios technical reference / peipkg / Resolution

Removing a package that other installed packages depend on would leave the system inconsistent, so peipkg does one of two things, chosen per transaction.

Refuse is the default. The removal is rejected and the dependents that block it are named.

Cascade removes the dependents too. It is requested with --cascade, and it is the operator taking responsibility for a larger change than they typed.

Removals are ordered in reverse dependency order, so a dependent is always removed before the package it depended on.

4.6.1 Blocking relations #

A removal is blocked by an installed package that depends on the one being removed, and by an installed package whose replaces targets it. Both are computed against the state the transaction will produce, so a dependent that is itself being removed in the same transaction does not block.

4.6.2 System-critical packages #

Some packages are needed for peipkg itself, or for the system, to work: peipkg's own binary and its trust anchors, and the core system packages.

The intended guard is a refusal unless the operator supplies an operation-specific override — an --allow-critical flag — which is a foot-gun guard rather than a security boundary: under the access model the operator already holds whatever authority the underlying deletions require, so the guard exists to prevent an accidental removal disabling the system, not to deny an authorised operator who means it.

peipkg has no notion of a system-critical set, no such flag, and no guard. Uninstalling the package manager is an ordinary removal.

4.7 Roots and Resolution

Peios / Peios technical reference / peipkg / Resolution

A dependency is satisfied within a specific installation root. By default that is the same root as the depending package, so a package's closure flows into the root the package occupies. A dependency's root field overrides that, naming a different one.

4.7.1 Two resolution modes #

peipkg resolves in one of two modes.

Cross-root resolution honours a dependency's root field, placing the dependency in the root it names and producing a plan whose operations are grouped by root.

Single-root resolution treats every operation as belonging to one root. A dependency carrying a root field is placed in the depending package's root instead, and the field has no effect.

install resolves cross-root. Default upgrade also uses cross-root resolution in peipkg 8b588ae8; --no-recurse confines upgrade resolution and execution to the current root. The cross-root dependency chapter keeps that source-backed distinction separate from the earlier descriptions of downgrade, uninstall, and undo, whose paths are not verified by this upgrade correction.

4.7.2 Top-level placement #

Where an operator names a package directly with no explicit root, the package's default_root decides where it lands. A dependency's placement is never governed by the dependency's own default_root; only by the depending package's root and the dependency's root field.

4.7.3 Cascading across roots #

Default upgrade resolves the current root and its reachable named roots into one plan, with one approval. A named package is considered in each reachable root where it is installed. If the plan changes multiple roots, the coordinated executor verifies all packages, prepares the participants, then attempts commits root by root. Preparation failure attempts rollback; a commit failure can leave partially committed work needing recovery. These are not independent continue-on-error upgrade transactions.

See Named roots for operator checks and the pinned source/release scope, and transaction scope for the execution boundaries.

5.1 Preconditions

Peios / Peios technical reference / peipkg / Installation

An install begins with a package file already fetched, the repository it came from with its trust state, the current database state, and the plan that called for the install.

The following hold before the install proceeds:

  1. The package's hash matches what the repository index recorded.
  2. The package's signature, if present, verifies against the trust set scoped to its originating repository.
  3. The package is not already installed at the same version and architecture in this root.
  4. The package's architecture is the system's primary architecture or noarch.
  5. The package's dependencies are satisfied by the installed set, by the plan in flight, or by both together.
  6. No installed package conflicts with it.

A precondition failure aborts the install, and the transaction containing it is rolled back.

5.1.1 Verification before extraction #

For a transaction containing several installs or upgrades, every package's signature and index-hash verification completes before any package's payload is extracted.

Note

This prevents a class of multi-package attack: package A is verified, extracted, and its contents then influence the verification or extraction of package B — A installs a tool B's extraction invokes, or A creates a directory whose descriptor decides where B's files land. Verifying everything first means extraction operates on a known-good set of payloads.

The guarantee spans every root the operation touches. A cross-root transaction fetches and verifies every participating root's packages before preparing any of them, because a package extracted into one root is as present on the filesystem as one extracted into another — the attack above works just as well with A and B in different roots.

5.2 Validation

Peios / Peios technical reference / peipkg / Installation

Before anything is staged, the package is decompressed, parsed, and checked in full.

  1. Decompress and walk the archive, enforcing the layout and ordering rules of PSPU §5.12.
  2. Parse the manifest.
  3. Parse the files manifest.
  4. Check that every payload file has an entry in the files manifest and that every entry has a file — in both directions.
  5. Check that the manifest's name, version, and architecture match what the plan selected.
  6. Validate every payload path against PSPU §5.13 and every entry type against PSPU §5.12.
  7. Validate every symlink's target against PSPU §5.17.
  8. Validate every payload entry's destination against the permitted install destinations.

5.2.1 Destinations are checked here #

Step 8 is not a duplicate of the producer's own check. A package arriving on a target system need not have been produced by a cooperating producer, so validation performed while packing says nothing about the bytes about to be written. This is where the destination rules are actually enforced.

Ancestor directories are exempt. An archive carries an explicit entry for every ancestor of the content it ships, and those are structure rather than destination claims. A directory entry is checked only when no other payload entry sits beneath it — which is the archive shape of an explicit empty directory, and is a destination claim.

Special system packages need two keys. A package declaring special_system_package has waived the producer-side layout check. That grants nothing here: peipkg refuses an out-of-layout payload unless the operator also passed --dangerously-bypass-path-restrictions. When the declaration arrives without the flag, the refusal names the refused request, so that an operator can tell "this package asked for an exemption I did not grant" from "this package is malformed".

When both keys are present, the destination check is skipped entirely for that package, with no residual denylist. A package installed this way can write anywhere, including under /lcl/policy.

5.2.2 What is not re-checked #

The determinism rules of PSPU §5.11 constrain the archive's bytes: ordering, modification times, ownership, mode, extended attributes, and header format. peipkg checks entry ordering and nothing else. A package whose entries carry a mode other than 0777, a non-zero owner, an mtime unrelated to its manifest, or extended attributes is accepted and installed.

Because extraction ignores tar modes entirely (§5.4), such a package behaves no differently on Peios. Extracted elsewhere with an ordinary tar tool, it does not.

5.2.3 Size cross-checks #

The manifest's declared installed size has to equal the sum of the file sizes in the files manifest, and a package where the two disagree is rejected. The decompression bounds of PSPU §5.27 are enforced continuously during the walk, on every chunk of output.

The cap is computed from the size the manifest declares, plus the fixed overhead allowance, subject to the absolute 4 GiB ceiling. The index's declared sizes are parsed and carried onto the candidate but are not compared against the manifest's and are not used as the bound.

5.3 Preparation

Peios / Peios technical reference / peipkg / Installation

With the package validated, peipkg computes what the install will do: which files are created, which directories are created, which side effects will be scheduled.

5.3.1 Collisions #

A payload path already owned by another installed package is a collision, and the database refuses it: the rule that two packages may not own the same non-directory path is a partial unique index on the owned-files table.

That constraint is evaluated when the transaction's database changes are written, which is at commit — after the files have already been renamed into place. A colliding install therefore fails, but only after paying the full download, extraction, and on-disk replacement, and recovery depends on the rollback succeeding.

There is no earlier check. Collisions are not detected at plan time or at preparation time.

5.3.2 Disk space #

peipkg does not check free space before staging. Exhaustion is discovered when a write fails — during staging, where rollback is straightforward, or during the apply phase, where some files have been renamed into place and some originals are sitting at their backup paths.

A transaction may span several filesystems, since installation roots and the destinations within a root can be separate mounts, so a single global free-space figure would not be the right check even if one were made.

5.4 Extraction

Peios / Peios technical reference / peipkg / Installation

Payload entries are processed in archive order. Nothing appears at a final install path during this phase.

5.4.1 Staging #

Each regular file is written to a staged sibling of its destination: a temporary name in the destination's own directory, carrying the transaction identifier. Where a destination's basename is long enough that adding the marker would exceed the filesystem's name limit, the basename is truncated to fit.

Staging in the destination's own directory is what makes the commit-time rename intra-directory, and therefore atomic and immune to failing because the staging area is on another filesystem.

Directories are created as they are encountered, and every directory the transaction creates is recorded so that a rollback can remove it again. Symlinks are created with the linkname the tar entry carried, which was validated at parse time and so is known safe by the time extraction reaches it.

5.4.2 Modes #

Extraction ignores the tar entry's permission bits. Every file is created mode 0755, and every directory mode 0755.

This follows from the format: a package's modes are all 0777 and carry no information (PSPU §5.16), so the consumer has to choose something. What it chooses is uniform and executable.

5.4.3 Security descriptors #

peipkg creates entries without supplying an explicit security descriptor, so the kernel computes one by inheritance from the parent directory at creation time.

That is the default and it is what the overwhelming majority of entries get. Inheritance is expressed by the absence of a descriptor, not by peipkg computing one.

5.4.3.1 Overrides #

A package may declare a descriptor for a specific entry, through the manifest's sd_overrides (PSPU §3.3.5). Every override is checked before anything is installed — the descriptor decodes from base64, stays inside the size limit, and parses; the list is sorted and free of duplicates; and each path names a real payload entry that is not a symlink. §5.20 requires all of that to happen up front, because deferring it to the moment a descriptor is applied turns a malformed package into a partially completed install.

Overrides take effect before content is written. On Peios, a new file or directory receives its descriptor in the native create call. A staged filename is observable too; protecting only the final rename is insufficient.

All directory overrides in the transaction are collected and authorised before planning creates parent directories. Parent scopes are established before their children, including when a different package supplies the scope. Conflicting descriptors for the same directory are rejected. Image composition also establishes these scopes before parallel payload extraction.

A declared override on an existing directory is applied to that directory. Existing descendants are not recursively reset, and existing directories without an override retain their policy. This preserves unrelated operator customisations; changing a subtree's policy is not a migration of its children.

Off Peios, composition stamps or records image metadata before copying file content. The native creation path falls back only when the native syscall is unavailable. Access denial or an invalid descriptor fails the operation; the installer does not temporarily broaden the descriptor to let itself write.

5.4.3.2 The override policy #

The kernel checks that a declared descriptor is well-formed. Nothing checks that the package's producer had any authority over the principals it grants access to, and §5.20 makes that the consumer's question.

peipkg answers it per repository, from allow_sd_overrides in the repository's configuration. A package carrying overrides from a repository that does not permit them is refused — not installed with the overrides quietly dropped, which §5.20 forbids by name. A package declaring no overrides is unaffected: the policy gates declaring descriptors, not installing.

Every override applied is listed in the install report.

Note

This is a deliberate subset of §5.20. The spec also asks for each override to be shown to the operator with a diff against what inheritance would have produced, and for a per-repository allowlist of the principals an override may name. Both require peipkg to compute KACS inheritance itself in order to render the diff. Until a repository exists that is neither wholly trusted nor wholly refused, the per-repository switch enforces the binding rule and leaves the graduated middle unbuilt rather than half-built.

5.4.4 Hashing #

Per-file content hashes are verified during the verification pass, over the same immutable in-memory bytes that extraction then reads. The package is fully hash-checked before a single staged file is written.

Extraction itself re-checks nothing. The guarantee that what lands on disk is what was hashed rests on both passes reading the same buffer, which every current caller arranges by handing extraction a reader over already-verified bytes in memory.

5.5 Path Resolution

Peios / Peios technical reference / peipkg / Installation

An install path is resolved component by component, against a directory descriptor, never as a string handed to the kernel to walk. Each component is opened relative to the descriptor of its parent, and a component that is not a real directory — a symbolic link above all — ends the resolution with an error.

The descriptor of the destination's parent is then held from the moment the operation is planned until the commit that acts on it. Staging, displacement and installation are all performed against that one descriptor.

5.5.1 No symbolic link is ever traversed #

This holds for a link peipkg created moments earlier in the same transaction as much as for one that was already there. A well-formed package never has a payload entry whose ancestor is a symbolic link, so the refusal fires only on a malformed or hostile package, where aborting is what you want.

Symbolic links as leaf payload entries are ordinary and are created normally. What is forbidden is walking through one.

Two things follow, and only one of them is about a race.

A symbolic-link ancestor cannot redirect a write. The format's rules permit a package to ship a link whose target resolves into a permitted destination, and permit a different package to ship a file whose path descends through that link's location — each entry validates in isolation, and both are individually valid. Resolving the second package's path now stops at the link instead of following it. Without that, the file landed where the link pointed, the database recorded the archive's path, and the collision constraint of §5.3 compared the wrong name and never fired.

The check and the commit act on the same directory. The window between them is the whole transaction — a pre-existing entry is detected while the intent is journalled, and the rename that acts on it runs after every package has been downloaded, decompressed and staged. The pinned descriptor is what makes that window harmless: the commit renames within the directory it examined, not within whatever the path string resolves to by then.

5.5.2 The final component #

Exclusive-create semantics still apply to a staged file, so it cannot land on an existing name. A pre-existing entry at a destination is detected without following it and is displaced by a rename, which does not follow a link at either end — so a link sitting at an install path is renamed aside rather than written through.

5.5.3 Recovery #

Recovery has only the journal's strings; there is no descriptor to carry across a crash. It re-resolves them with the same refusing walk, so an undo lands on the paths the journal named and nowhere else. There is no in-flight window to race at that point, only a resolution to get right.

5.5.4 Implementation #

The walk is one openat per component with O_PATH|O_NOFOLLOW| O_DIRECTORY, and the commit is renameat against the pinned descriptor. O_PATH|O_NOFOLLOW alone would open the link itself; O_DIRECTORY is what turns that into the ENOTDIR the refusal rests on.

openat2 with RESOLVE_NO_SYMLINKS would fold the walk into a single syscall and add RESOLVE_BENEATH, RESOLVE_NO_XDEV and RESOLVE_NO_MAGICLINKS. It is worth having and is not needed for the guarantee: a walk that never follows a link and never accepts a .. component cannot leave the root by name.

Descriptors are cached per directory, so the count a transaction holds is the number of distinct directories it writes into, not the number of files.

Note

The format's symlink-target rules are the first layer of the defence PSPU §5.26 describes, and component-wise resolution against a pinned parent descriptor is the second. Both are in place.

5.6 Registration

Peios / Peios technical reference / peipkg / Installation

Once a package's files are staged, peipkg records what the install will mean:

  • the package's identity — name, version, architecture, and root;
  • the repository it came from;
  • the install timestamp;
  • every payload path it owns, with the type and content hash of each;
  • its manifest, stored whole, so that later operations can consult the package's own declarations without the package file.

These rows are written inside the transaction's database commit and become visible only when that commit succeeds.

5.6.1 Why the manifest is stored #

Several later operations need a package's own declarations rather than an index entry's copy of them: reconciling claims after an unrelated install, computing what a removal breaks, and re-deriving a role's claim-path set. Keeping the manifest means those operations do not depend on a repository still being configured, or still existing.

5.6.2 Ownership #

A file is owned by the package whose install created it. Ownership is what makes uninstall, peipkg owns, and the collision constraint possible, and it is recorded per path rather than per package prefix.

Directories are recorded as owned but are shared: several packages may own the same directory, and the collision constraint applies only to non-directory entries.

5.7 Pre-Existing Files

Peios / Peios technical reference / peipkg / Installation

A path may already exist on the filesystem without belonging to any installed package — left by a manual copy, inherited from a non-Peios installation, or written by something outside the package manager.

5.7.1 The unowned-file policy #

Before replacing a destination, peipkg checks its recorded owners. A path owned by the package being changed follows ordinary replacement; a path owned by another package is handled by collision checking. For an existing path with no recorded owner, unownedPolicy selects one of three outcomes:

  • Adopt matching content. A regular file must have the payload's content hash; a symlink must have the same target. A different entry kind never matches. The operation may proceed and record ownership.
  • Refuse differing content unless overwriting was explicitly authorised. The unowned error names the package and conflicting path. The existing content is preserved.
  • Displace differing content when overwriting was authorised. The existing entry is renamed aside, and its backup survives transaction commit. A warning reports the displaced file and backup name.

These behaviours are implemented for payload installation and role claims. The comparison is about entry kind and content, not equality of all metadata. Matching content permits the normal file operation; it does not promise that the existing inode is retained.

5.7.2 Recovering from a refusal #

Move the conflicting file aside and retry the operation. install also accepts --overwrite-unowned, which authorises displacement while keeping the previous content. Upgrade, downgrade, undo, remove and claim have no equivalent command-line flag; a driven client cannot retry those commands by adding it.

Ordinary replacement backups are discarded on commit (§8.2). A backup kept because an unowned file was displaced is the exception and remains available to the operator.

6.1 The File Diff

Peios / Peios technical reference / peipkg / Upgrade and Removal

An upgrade replaces one installed version of a package with another. It is conceptually a single atomic operation combining an install of the new version with an uninstall of the old, arranged so that no moment leaves the system without the package's content.

The same procedure handles a downgrade. The two differ only in which version comparison applies; the format treats them identically.

6.1.1 The four categories #

An upgrade is computed as a diff between the old version's file set, read from the package database, and the new version's, read from the new package's files manifest.

CategoryMeaningAt commit
AddedIn the new set, not the oldStaged and renamed in
ReplacedIn both, with different contentOld renamed aside, staged renamed in
UntouchedIn both, with identical content hashLeft alone
RemovedIn the old set, not the newRenamed aside

An untouched file keeps its inode: nothing is staged for it and no rename runs. It still gets an ownership row under the new version, so the upgrade's file set is complete and a later uninstall removes it.

Two conditions narrow the category, both of them cases where a file's content is not the only thing being installed at that path:

  • The file must actually be present. One the operator deleted is not untouched, and an upgrade is the right moment to put it back.
  • A path carrying a signature sidecar or a §3.3.5 descriptor override is always rewritten. Both are applied to the staged inode, so the path needs one even when its content has not moved.

6.1.2 Ordering at commit #

Added files are renamed into place. Replaced files have their original renamed aside first, then the staged file renamed in. Removed files are renamed aside.

A directory the previous version owned that the new payload does not carry is released, and reclaimed after commit if it is then unowned and empty (§6.6).

6.2 Configuration Files

Peios / Peios technical reference / peipkg / Upgrade and Removal

A package's configuration under /usr/etc/ is seed configuration: the package's defaults, not the running system's effective configuration.

6.2.1 Modified detection #

When an upgrade would replace a configuration file, peipkg compares the file's current on-disk content hash against the hash recorded for it at install.

  • Unmodified — the content matches what was recorded. The file is replaced like any other.
  • Modified — the content differs. The operator's file is left in place, the new version's default is written beside it as <name>.peipkg-new, and the divergence is surfaced in the operation report.

The check applies to paths under /usr/etc/, and also to paths under a bare /etc/ — the latter only so that packages installed before the layout moved to /usr/etc/ keep their protection. It does not permit installing to /etc/, which is a merged view rather than storage.

6.2.2 What is recorded #

On the modified branch, the file peipkg writes is the .peipkg-new sibling, but the ownership row it records names the original path and carries the new version's hash.

Two things follow. peipkg verify compares the recorded hash against what is on disk, so it reports that file as modified on every run, permanently, for a file peipkg itself deliberately preserved. And the .peipkg-new sibling is recorded nowhere, so it is owned by no package, is not removed by an uninstall, and is not attributed by peipkg owns.

6.2.3 Interaction with the missing untouched category #

Because §6.1's untouched category is not computed, a configuration file whose content is identical between the two package versions is classified as replaced. Modified detection then fires on the operator's edit, writes a .peipkg-new carrying exactly the content the package already shipped, and warns about a divergence from a version that changed nothing.

6.2.4 Where this is going #

The intended end state is the one the filesystem layout describes: runtime configuration is materialised by reconciller daemons from registry state, operators do not edit configuration by hand, and an upgrade replaces seed configuration unconditionally. Modified detection is what prevents an upgrade destroying a hand-edited file until that framework exists.

The two models converge rather than compete: once reconcillers claim a set of paths, modified detection applies only to the unclaimed remainder, and reconciller-managed seed files are replaced outright.

6.3 The Upgrade Procedure

Peios / Peios technical reference / peipkg / Upgrade and Removal

6.3.1 Preconditions #

Every install precondition (§5.1) holds for the new version, except that "not already installed" is replaced by "the installed version has the same name and architecture, in the same root, at a different version".

In addition: the new version's dependencies are satisfied, or will be by other operations in the same transaction; no installed package depends on the current version in a way the new version cannot satisfy; and a downgrade carries an explicit authorisation.

6.3.2 The steps #

  1. Validate the new package exactly as an install does (§5.2).
  2. Diff the old file list against the new (§6.1).
  3. Stage every added and replaced file to a temporary sibling of its destination, verifying its content hash.
  4. Apply, at commit: rename added files in; rename replaced originals aside and the staged files in; rename removed files aside.
  5. Schedule side effects, including those implied by files being removed as well as those the new version declares.
  6. Re-register: replace the database's record for this package with the new version's identity, file list, and manifest.

6.3.3 Dependents #

An upgrade that would leave a dependent's constraints unsatisfied cannot proceed on its own. The resolver includes the dependent's upgrade — or its removal — in the same transaction, or the resolution fails.

6.4 Replaces

Peios / Peios technical reference / peipkg / Upgrade and Removal

A replaces relation expresses supersession: this package takes over from that one, typically after a rename.

An upgrade triggered by a replaces follows the ordinary upgrade procedure with the replaced package treated as the currently installed version, even though its name differs. The database record for the replaced package is removed and a record for the replacing package is created. From the system's point of view the replaced package is uninstalled and the replacing one installed; the file diff ensures no payload file is spuriously removed during the transition.

6.4.1 The guard #

A replaces declared by a package from a lower-priority repository, targeting a package installed from a higher-priority one, requires explicit operator confirmation before it is applied (§3.7). A general --yes does not satisfy it.

The guard compares repository priorities. For a package whose originating repository has since been removed, there is no priority to compare and the guard does not fire.

6.4.2 Removal interaction #

A package cannot be uninstalled while another installed package's replaces targets it, unless that package is being removed in the same transaction.

6.5 Downgrade and Undo

Peios / Peios technical reference / peipkg / Upgrade and Removal

6.5.1 Downgrade #

A downgrade is an upgrade whose new version is older than the installed one. The procedure is identical, with one additional precondition: the operator has explicitly authorised it.

The authorisation is raised by the resolver as an elevated action, and is not satisfied by the routine confirmation prompt.

Note

Explicit authorisation is required because a downgrade is unusual: it implies the operator wants to revert to a known-good earlier state, often after a failed upgrade. Making them opt in ensures the downgrade is intentional rather than the result of a misconfigured constraint.

A downgrade target has to be available from a configured repository's archive index, or from a package file already cached. Versions pruned from an archive cannot be reached without an externally supplied package file.

6.5.2 Undo #

peipkg undo reverts the effect of a previous transaction. It works by re-resolving against the archive index with downgrades permitted, and applying the resulting plan as an ordinary transaction, rather than by restoring the previous transaction's backups.

Two consequences follow from that choice. Undo needs the archive index, and therefore a reachable repository or a warm cache — it is not an offline operation. And it produces a new transaction with its own journal and its own rollback, rather than unwinding an old one, so the guarantees are the same as any other change.

6.5.3 What a version revert does not revert #

Reverting a package's version does not revert anything outside that package's payload: registry state, configuration materialised by reconcillers, runtime data under /var/, and any user data are unaffected.

Comprehensive system rollback, including state under registry control, is a higher-level concern handled by recovery snapshots. Package-level version revert covers the common case of an update breaking something.

6.6 Uninstall

Peios / Peios technical reference / peipkg / Upgrade and Removal

6.6.1 Preconditions #

The named package is installed; no other installed package depends on it unless the plan removes them too; and no other installed package's replaces targets it.

Blocked removals cascade or refuse, per §4.6.

6.6.2 The steps #

  1. Enumerate the package's owned paths from the database.
  2. Check each non-directory path: that no other installed package also owns it (below), and — for a configuration file — that its content still matches the hash recorded at install (§6.7). A modified configuration file is put to the operator before anything is touched.
  3. Prepare the removal.
  4. Remove: rename each path aside as a backup rather than deleting it, so that the uninstall can be rolled back.
  5. Schedule side effects implied by what was removed.
  6. Deregister: delete the package's record, withdraw any role it held, and reconcile any role whose claim paths it declared (§9.7).
  7. Reclaim the directories the package owned that are now owned by no package and are empty, deepest first.

Backups are discarded when the transaction commits, except the backup of a modified configuration file whose removal the operator authorised: that one is kept beside where the file was.

6.6.3 Modified configuration files #

A configuration file — a path under /usr/etc/ or the legacy /etc/, the same scope the upgrade's modified-detection uses (§6.2) — whose content differs from the recorded hash is surfaced before the removal is prepared, and the operator chooses per file: remove it, with the previous content kept at the backup path and the authorisation written to the audit stream; keep it, in which case it stays where it is and becomes unowned, since the package's ownership row goes with the package; or abort the transaction. Anything but an explicit remove or keep aborts, end-of-input included, so --yes does not answer the question and a non-interactive uninstall of a package with a modified configuration file refuses, naming the file. Binaries, libraries and data are not hashed at uninstall.

6.6.4 Directories #

Removing the package's rows releases its ownership of its directories. After the commit, each such directory that no installed package owns any longer and that is empty is removed, deepest first, so a directory is attempted after everything it contained. A directory another package still owns, or that holds anything — an unowned file a runtime wrote, an operator's addition — is left where it is, silently: those are reasons to keep it, not failures. Only an unexpected error in removing an empty unowned directory is reported, as a warning on the operation report; the transaction has already committed.

An upgrade applies the same rule to the directories the previous version owned that the new payload does not carry (§6.1).

6.6.5 Overlapping ownership #

Two packages owning one non-directory path is prevented by the database schema, but a degraded state — a corrupted database, a manual intervention — could produce one. Each non-directory path scheduled for removal is checked against the ownership table first. A path that another installed package also owns is left in place, since the other owner still needs it, and the operation report carries a database-integrity warning naming the path and both owners, so that the operator knows the database needs inspection rather than the filesystem.

6.7 Modified Files

Peios / Peios technical reference / peipkg / Upgrade and Removal

A file whose on-disk content no longer matches the hash recorded at install has been modified since installation — by a person, by a program, or by corruption.

6.7.1 Where the check runs #

peipkg compares recorded hashes against disk in three places.

peipkg verify does it on demand, across every recorded file, and reports what differs.

An upgrade does it for configuration files, to decide whether to preserve an operator's edit (§6.2).

An uninstall does it for configuration files too, and puts each modified one to the operator before the removal is prepared: remove it with the previous content kept as a backup, keep it as an unowned file, or abort (§6.6).

Note

A modified file at removal time is either a customisation that removal destroys, or an unauthorised modification of a system file. Surfacing it is what lets the operator tell which, and the choice is theirs.

6.7.2 The cost of checking #

Hashing every installed file at uninstall is expensive: a large package on slow storage takes seconds. The check is therefore restricted to the paths where customisation is expected — the configuration scope the upgrade already uses, /usr/etc/ and the legacy /etc/ — and skipped for binaries, libraries and data. A hand-patched binary is removed without a question; peipkg verify is the tool for finding one.

7.1 Atomicity

Peios / Peios technical reference / peipkg / Transactions

Package transactions coordinate recorded package state and changes to files. That does not make all filesystem changes, every root's database, and post-commit maintenance one instantaneous switch.

For the cross-root path reviewed in peipkg 8b588ae8:

  • Packages for all participants are fetched and verified before extraction.
  • Preparing roots persists recovery data and moves files into place. Those files can become visible before a root's package-state commit.
  • Package state commits separately in each root. A commit failure can leave some roots committed and others pending; already committed roots are not undone by rolling back a sibling.
  • A preparation rollback can itself fail, leaving a pending journal and files that must not be treated as restored.

These boundaries follow the coordinated execution sequence, preparation and per-root commit, and rollback-error handling. Logical errors, power loss or other system faults therefore require checking the actual journal and files. The older blanket promises that uncommitted work is invisible and partial effects never occur do not describe this path.

7.1.1 The single durability boundary #

Within one root, updating package metadata and closing that root's journal transaction share a single database transaction. Its database commit is that root's durability boundary. It is not a global cross-root commit.

A pending cross-root journal does not by itself determine recovery direction: if none of the participants committed, recovery rolls pending work back; after a participant commits, recovery rolls pending siblings forward using persisted completion data. Missing data or another failure can prevent completion. See Crash recovery.

The source qualification here does not verify every single-root failure path or the revision shipped in an installed image. Use the operator recovery checks and visibility limits before treating a failed or interrupted operation as unchanged, restored or ready to use.

7.2 Scope

Peios / Peios technical reference / peipkg / Transactions

A transaction may contain any combination of installs, upgrades, and uninstalls on different packages.

7.2.1 Ordering #

Operations are ordered so that at commit time no operation depends on a package whose install has not been committed first. Forward operations are topologically sorted dependencies-first; removals are sorted and reversed, so a dependent is removed before what it depended on.

7.2.2 One operation per package #

A transaction cannot contain two operations affecting the same package. This is structural rather than checked: the resolver's world is keyed by (name, root) and emits at most one forward operation per key, deriving removals as the complement.

An upgrade is how a version transition is expressed. A hard reinstall is a removal and an install, in two separate transactions.

7.2.3 Cross-root transactions #

An operation touching several installation roots produces one transaction per root, sharing a cross-root identifier. Locks are acquired for every participating root, in resolved-path order, so that two concurrent cross-root operations cannot deadlock against each other.

Every package of every participating root is fetched and verified before any root is prepared. Verify-all-before-extract (§5.1) is an obligation across the whole operation rather than within one root of it: a package extracted into one root is as present on the filesystem as one extracted into another, so preparing root by root would put one root's payload in place before the next root's signatures had been looked at.

After that verification, every participant is prepared before the commit loop begins. Preparation failure aborts and attempts to reverse prepared changes; rollback errors can leave a pending journal. The commit loop then attempts each prepared root's commit, continuing those attempts after a failure and reporting a partial-commit error.

This ordering follows peipkg 8b588ae8's coordinated executor. It does not establish a globally atomic filesystem switch: preparation moves files, and post-commit maintenance for one root can run before another root's commit is attempted.

Cross-root recovery chooses rollback or roll-forward from participant state. See Named roots for operator checks and the pinned source/release scope.

7.3 The Lock

Peios / Peios technical reference / peipkg / Transactions

At most one transaction is in progress at a time in a given root.

peipkg acquires an exclusive lock before beginning. A second invocation detects the lock and fails immediately with a "transaction in progress" message rather than waiting.

7.3.1 Staleness cannot happen #

The lock is a flock(2) advisory lock on a file in the root's state directory, held by the running process. The kernel releases it when the process exits, however it exits — cleanly, by signal, or by being killed.

That makes staleness impossible by construction. There is no timeout, no liveness probe, and no process-identity comparison, because there is nothing that could hold a lock after the holder is gone.

Note

The alternative — a lock file with a recorded process identifier, a kill(pid, 0) == ESRCH liveness probe, and a timeout — is the design this avoids. A timeout-only staleness check breaks single-writer atomicity outright: a long-running but perfectly live transaction, a fifty-package install on slow storage, can be declared dead by another invocation, and both then proceed concurrently. An advisory lock is not a weaker version of that design; it is a stronger one.

A second structural guard backs it up: the database carries a partial unique index permitting at most one transaction in the pending state, so even a defect that bypassed the lock could not produce two concurrent pending transactions.

7.3.2 What the lock does not cover #

A read-only query against committed state does not take the lock. The database provides snapshot-isolated reads, so a query sees a consistent view of committed state as of the moment it began, regardless of a write committing underneath it. Listing installed packages during a long install is safe and does not block.

7.4 The Commit Procedure

Peios / Peios technical reference / peipkg / Transactions

The sequence below explains one root's package-database durability boundary. It is not the whole cross-root execution sequence. In the reviewed coordinated executor, all participants are prepared before the per-root commit loop. Files can already be visible, and another root can already be committed when one root's commit fails. See Scope and Atomicity for the pinned boundaries.

7.4.1 1. Record intent #

Before any file moves, the transaction's intent is written to the journal: the set of file operations, and for each one the staged file's path and the path its displaced original will be backed up to — the backup map. Directories the transaction will create are recorded too.

The journal is rows in the package database, so recording intent is an ordinary database write.

7.4.2 2. Apply file operations #

For each operation: rename any displaced original aside as a backup, then rename the staged file into place.

Throughout this phase every change is individually reversible from the backup map. Nothing has been deleted; a replaced file is sitting beside its own destination under a different name.

7.4.3 3. Commit #

In a single database transaction, write the new installed state — the package rows, the owned-file rows, the claim holder and link rows — and mark the journal's pending transaction committed.

This database commit is that root's durability boundary. Its package metadata and journal closure are committed together; this does not make filesystem visibility or other roots' commits atomic with it.

7.4.4 4. Invoke side effects #

Deduplicated across the whole transaction, after the durability boundary. A side-effect failure therefore cannot roll the transaction back: the transaction is already committed, side effects are idempotent, and a failed one is reported and corrected by re-invocation.

7.4.5 5. Clean up #

Discard staged files, and delete the backups.

7.4.6 What the ordering buys #

A crash before step 3 leaves the journal's transaction pending, and recovery rolls it back from the backup map. A crash after step 3 leaves it committed, and recovery has only step 5 to finish.

That describes a single-root recovery boundary, not coordinated recovery. A pending cross-root participant can instead need roll-forward after a sibling committed; rollback can also fail and remain pending. Follow the source-scoped recovery path. The atomic database commit does not establish that there is no observable intermediate filesystem or multi-root state.

7.5 The Journal

Peios / Peios technical reference / peipkg / Transactions

The transaction journal is part of the package database. A pending transaction is rows in the database store, not a separate file with a separate format.

7.5.1 What it holds #

Row kindContent
TransactionIdentifier, state, schema version, and for a cross-root operation the shared cross-root identifier
OperationOne per package operation: kind, package, root
FileOne per file operation: final path, staged path, backup path, action
DirectoryOne per directory the transaction created, so rollback can remove it
Commit payloadFor a cross-root transaction, the state a roll-forward would need

Recording intent and committing are ordinary database writes, and the journal inherits the database's transactional guarantees.

7.5.2 Integrity #

The database is stored under a security descriptor granting write access to the tier of principals permitted to install packages. That descriptor is the journal's integrity protection: a principal outside the tier cannot forge an entry, and one inside it already holds installation authority, so a write from within is not an escalation.

The staging area is under the same descriptor.

7.5.3 Attribution #

Claim link operations do not have an operation row of their own within an install. They are appended to the last staged package operation as a carrier, so the file rows recording a claim link change are attributed to whichever package sorted last. A standalone grant or revoke uses a synthetic operation named for the role, and is attributed correctly.

The consequence is confined to history display; recovery is action-agnostic and unaffected.

7.5.4 Versioning #

Each transaction records the journal schema version it was written under. A peipkg version that can read that schema recovers the transaction directly; one that cannot refuses, with an error naming the schema version rather than a generic failure.

That is what makes upgrading peipkg itself unremarkable (§8.5): the binary running the next recovery may be a different version from the one that started the transaction, and the version stamp is how it knows whether it can.

7.6 Side-Effect Batching

Peios / Peios technical reference / peipkg / Transactions

A transaction may contain several operations that each declare the same side effect. They are deduplicated and invoked once per transaction, after every file-level operation is complete and after the database commit.

Distinct side effects are invoked in an unspecified order. The recognised set is chosen so that order between them does not matter.

7.6.1 Timing #

Side effects are not invoked during extraction, and not once per package. Installing ten packages that all ship shared libraries rebuilds the library cache once, after every package's libraries are in place — not ten times against ten partial states.

7.6.2 What is not scheduled #

An operation that only removes files schedules no side effects, because side-effect declarations are read from the package being installed and a removal has none in flight. The removed package's manifest is in the database and could supply them.

The visible consequence: uninstalling the last package that owned a shared library leaves the library cache naming a file that no longer exists, and removing kernel modules leaves the module dependency cache stale. Both are corrected by the next transaction that does declare the relevant effect.

An upgrade is different: side effects implied by files the upgrade removed are scheduled alongside those the new version declares.

7.7 Visibility

Peios / Peios technical reference / peipkg / Transactions

The database state visible to a query reflects only committed transactions.

Reads are snapshot-isolated: a query beginning at some moment sees a consistent view of committed state as of that moment, regardless of a write transaction committing while it runs. This is a property of the store rather than of peipkg's use of it, and it is why a read-only query needs no lock.

Committed package metadata is not the same as filesystem or journal visibility. A pending journal is evidence of work in progress, and staged files move to their final paths during application. In the reviewed cross-root path, preparation persists recovery data and applies file moves before the per-root package-state commits. Outside processes can therefore observe files from a transaction whose package metadata has not committed.

peipkg does inspect its own uncommitted state — verifying a staged file, computing what remains to apply — which is not a visibility leak.

7.7.1 Where the boundary is softer #

Several things about an in-flight transaction are observable from outside.

Staged files and backups are siblings of their destinations rather than files in a private directory, so they are visible in a directory listing under names carrying the transaction identifier. They are not at the paths anything would look them up by, but they are there.

And within the apply phase, a file is momentarily absent between its original being renamed aside and its replacement being renamed in. Anything opening that exact path in that window sees nothing.

Across roots, commits and post-commit maintenance happen root by root. A snapshot of one database does not prove the state of every participant or which binaries a running process has mapped. See Atomicity and the operator recovery checks. These cross-root observations are pinned to peipkg 8b588ae8, not a runtime test or a guarantee about every shipped revision.

7.8 Crash Recovery

Peios / Peios technical reference / peipkg / Transactions

Before permitting new work, peipkg checks the journal for a pending transaction.

  • No pending transaction — nothing to do.
  • A pending transaction — roll it back. Restore every displaced original from the backup map, remove the directories the transaction created, discard its staged files, and clear it from the journal.

Recovery is itself crash-safe: every action it takes is a rename from the backup map, and every step checks the current state before acting, so re-running after an interruption converges on the same result.

7.8.1 Rollback rather than roll-forward #

For a single-root transaction there is no roll-forward. Because the database commit is the single durability boundary and is itself atomic, a recovered transaction is only ever committed — in which case the database says so and there is nothing to recover — or pending, in which case it rolls back.

Note

Roll-back-only recovery is possible because the database is a transactional store. A design that applies file changes after a separate commit-intent record needs roll-forward to finish what that record promised. Here the database commit and the fact that the transaction is done are the same atomic event, so there is nothing left to finish.

7.8.2 Cross-root: the exception #

A cross-root operation commits one root at a time. Once a root has committed, its transaction is done and cannot be undone by rolling back a sibling.

In peipkg 8b588ae8's coordinated recovery, pending participants roll back if none committed. If a sibling has committed, recovery completes pending participants from persisted completion data instead. Missing or malformed data needed for roll-forward causes refusal. Recovery can itself fail after another participant has been reconciled.

Keep the named-root topology intact and make every participant reachable from the same anchor used for the operation. The CLI's reachable-root scan does not establish that absent or unregistered participants are detected. Its rolled back cross-root transaction message also covers successful roll-forward; inspect per-root history instead of treating that phrase as the recovery direction.

The roll-forward path applies persisted database metadata and attempts backup cleanup. It does not invoke the normal post-commit side-effect runner. The operator recovery checks cover verification and the remaining maintenance work. These statements are pinned to source, not a runtime test or a guarantee about every released image.

7.8.3 When it runs #

Recovery runs at the head of every install, upgrade, and uninstall, before the requested work begins and under the lock. It is not something an operator invokes; peipkg recover exists, but the ordinary path is automatic.

A pending cross-root transaction discovered by a single-root operation is refused rather than recovered, and blocks all further work in that root until peipkg recover is run.

Automatic recovery emits no audit event. The recover command does, so a rollback recovered by the next ordinary install leaves no record in the audit stream while the same rollback performed deliberately does.

8.1 Transaction Rollback

Peios / Peios technical reference / peipkg / Rollback and Recovery

A transaction can be rolled back at any point before the database commit (§7.4 step 3). After that commit the transaction is committed, and what remains is cleanup rather than rollback.

Important

The procedure below retains an earlier single-root description; it is not a newly verified implementation contract or a manual recovery recipe. In the reviewed cross-root path, a pending participant cannot be rolled back in isolation after a sibling commits. Coordinated recovery may need roll-forward, and rollback errors retain a pending journal, rather than unconditionally closing it before restoration. Start with operator recovery for that path and its source/release scope.

8.1.1 When it happens #

  1. Any step of any operation fails — a hash does not verify, disk space runs out, a security descriptor is rejected.
  2. The operator cancels.
  3. The process terminates abnormally before commit, in which case rollback runs on the next invocation (§7.8).

8.1.2 The procedure #

  1. Discard the transaction's staged files.
  2. Leave the pending database changes uncommitted — the commit never ran — and clear the transaction from the journal.
  3. Restore every displaced original by renaming its backup back into place.
  4. Remove the directories the transaction created.
  5. Release the lock.

Side effects are not involved. They run only after the database commit, so a rolled-back transaction never reached one and there is nothing to undo.

8.1.3 Ordering #

File operations are reversed in the opposite order to the one they were applied in, and each step checks the current state before acting, so a rollback interrupted partway and re-run reaches the same result.

8.2 Backups

Peios / Peios technical reference / peipkg / Rollback and Recovery

A backup is made by renaming the displaced original aside within its own directory, never by copying it.

The old file's content is retained in place under a different name, so a backup costs no additional disk space and is produced by a single atomic rename. The journal's backup map records, for each displaced file, the name its original was renamed to.

Restoring a backup on rollback is the inverse rename. Discarding one on commit is a delete.

8.2.1 Naming #

A backup and a staged file are both siblings of the destination, in the destination's own directory, under names carrying a marker and the transaction identifier. Where the destination's basename is long enough that adding the marker would exceed the filesystem's name limit, the basename is truncated.

Two long sibling basenames that differ only past the truncation point therefore produce the same temporary name.

8.2.2 Retention #

Backups are discarded as soon as the transaction commits.

The design permits keeping them beyond commit for a configured window, to support reverting a committed transaction from local state. That is not implemented, which is why peipkg undo works by re-resolving against the archive index rather than by restoring backups (§6.5), and why undo needs a reachable repository.

8.3 Completeness

Peios / Peios technical reference / peipkg / Rollback and Recovery

A successful rollback leaves the system indistinguishable from its state immediately before the transaction began: file contents and existence as before, the package database as before, and the journal carrying no pending transaction.

Security descriptors come back with the files. A restore-by-rename preserves a displaced original's descriptor exactly, because the file was never rewritten. For newly created content the question does not arise, since the file is removed rather than restored.

8.3.1 When rollback itself fails #

A rollback can fail: an I/O error, a filesystem gone read-only, a permission change mid-operation. The intended behaviour is that such a rollback is reported as a failed rollback, the system is treated as indeterminate, and further transactions are prevented until an operator resolves it.

Earlier wording here said rollback errors were discarded at every call site and the journal closed as rolled back regardless. That is not the reviewed cross-root path: in peipkg 8b588ae8, rollback failure is reported and the affected journal remains pending. Follow the cross-root recovery checks with all participants reachable. This source correction does not establish the behaviour of every other rollback path or every installed revision.

If inspection instead establishes the previously described closed-journal failure, its consequences are:

  • the failure is not reported;
  • the transaction leaves the pending state, so the next invocation's recovery finds nothing and does not retry;
  • some originals remain at their backup paths and some new files remain at their final paths;
  • the history shows an authoritative-looking rolled-back record;
  • the database and the filesystem disagree, with nothing to reconcile them.

peipkg verify can identify recorded files whose hashes no longer match what is on disk. Use it alongside transaction state and the operation's diagnostics; a rolled-back history label alone is not proof of restoration.

8.4 Indeterminate State

Peios / Peios technical reference / peipkg / Rollback and Recovery

A failed rollback, a corrupt journal, or an unrecoverable backup mismatch leaves the system in a state peipkg cannot reason about.

8.4.1 The intended handling #

A recovery mode with five properties:

  1. Every write operation — install, upgrade, uninstall — is refused until recovery completes.
  2. Read operations proceed but carry the indeterminate-state warning in their output.
  3. A forensic report is available on demand, identifying the pending transaction's operations, files whose on-disk content does not match the recorded hash, database records inconsistent with the journal, and any orphaned staged files or backups.
  4. An explicit resolution command accepts an operator decision: roll the pending transaction back, or accept the current on-disk state and discard the journal and backups.
  5. Resolution is a deliberate operator action and is never performed automatically.

8.4.2 What exists #

The list above is proposed handling, not a supported recovery interface. Earlier wording here said recovery only rolls back and writes are never refused after failure. Do not apply those blanket statements to the source-verified cross-root path: rollback errors retain pending work, an ordinary single-root operation refuses to recover it in isolation, and coordinated recovery can roll forward after a sibling commits.

That correction does not establish the five proposed facilities or verify every single-root failure path. Inspect the actual journal, diagnostics and installed revision, and use the operator recovery checks rather than assuming a universal recovery direction or write policy.

8.4.3 What an operator can do today #

peipkg verify re-hashes every recorded file against what is on disk and reports the differences. That is the closest available thing to the forensic report, and it is the tool for establishing what a failed operation actually left behind.

peipkg recover explicitly attempts recovery and emits an audit event where the automatic path does not. Coordinated cross-root recovery chooses rollback or roll-forward from participant state; its console message alone does not establish that direction.

Beyond that, reconciling the database with the filesystem is manual: identifying files sitting at backup paths, deciding whether the old or the new content is wanted, and reinstalling the affected packages to restore agreement.

8.5 Upgrading peipkg Itself

Peios / Peios technical reference / peipkg / Rollback and Recovery

Upgrading the package manager is not a special case.

The peipkg binary is one file among a transaction's payload, staged beside itself and renamed into place like any other. The swap is a single atomic rename, so there is no half-written binary to recover from: recovery reconciles a transaction that crashed between its atomic steps, not a file caught mid-write.

8.5.1 The one wrinkle #

After a self-upgrade, the binary running the next recovery may be a different version from the one that started the transaction.

This is handled by versioning the journal format. Each transaction records the schema version it was written under. A peipkg version that can read that schema recovers the transaction directly; one that cannot refuses with an error naming the schema version, leaving the transaction for a version that can.

No immutable copy of the previous binary is required. If recovery needs the prior binary, it is already present as that binary's ordinary backup — until the transaction commits, at which point backups are discarded (§8.2).

Note

A package-manager upgrade that commits successfully but installs a defective binary is not a recovery case: there is no incomplete transaction. It is handled by running the prior binary — retained as the transaction's backup, if the transaction has not yet committed — to perform an ordinary downgrade. Once the transaction has committed and the backups are gone, the route is a downgrade using whatever peipkg is now installed, or an externally supplied package file.

8.6 Reverting a Version

Peios / Peios technical reference / peipkg / Rollback and Recovery

Reverting an installed package to an earlier version is an ordinary downgrade, and runs through the ordinary transaction machinery with the ordinary guarantees.

8.6.1 The procedure #

  1. Query the archive index for the available versions of the package.
  2. Select the desired one.
  3. Run an upgrade with that version as the new version, which requires explicit downgrade authorisation (§6.5).

8.6.2 Constraints #

The target has to be available from a configured repository's archive index, or already cached locally. A version pruned from the archive cannot be reached without an externally supplied package file.

Reverting may require adjusting dependents whose constraints the older version does not satisfy. The resolver determines that, and the plan may include further downgrades or removals.

8.6.3 Scope #

Reverting a package's version reverts that package's payload and nothing else. Registry state, configuration materialised by reconcillers, runtime data under /var/, and user data are unaffected.

Note

Comprehensive system rollback, including state under registry control, is a higher-level concern handled by recovery snapshots and other mechanisms outside the package manager. Package-level version reversion covers the common case of "this update broke something; put it back".

9.1 The Model

Peios / Peios technical reference / peipkg / Roles and Claims

A role is a virtual name several installed packages may contend to own on the filesystem, with at most one holding it. The holder's file answers the contended path through a symlink peipkg owns.

Two registry daemons can be installed at once; only one of them is /usr/sbin/registryd.

The declarations are specified in PSPU §5.23. What follows is what peipkg does with them.

9.1.1 The pieces #

A role has one or more slots, each materialising one filesystem name. A slot's claim paths are where the links appear; a target is the holder's file each link points at.

The claim-path set for a slot is the union of every installed consumer's declared path for it, plus the holder's own provider-declared default path if it has one. The materialised links are the cross-product of that set with the holder's targets.

9.1.2 Links are relative #

A claim link's body is a relative path, computed from the link's location to the target: a link at /usr/sbin/registryd pointing at /usr/sbin/loregd is written as loregd.

Only the database keeps the absolute logical target. Anything reading the link itself — an auditor, an unrelated tool — sees the relative form.

The reason is relocatability. A root assembled by the composer, or installed under an alternate root, or packed into an initramfs archive, is moved around before it is ever booted. An absolute link body would point outside it.

9.1.3 Links belong to peipkg #

A claim link is owned by the package manager, not by any package. It never appears in a package's payload and is never recorded as a package-owned path.

That is what lets two eligible providers coexist: neither ships the contended path, so the one-package-per-path rule is never engaged by the providers themselves.

9.2 Eligibility

Peios / Peios technical reference / peipkg / Roles and Claims

A package is an eligible provider of a role when it has a provides entry naming the role whose claims field declares a target for at least one slot. Only an eligible provider can hold a role.

A package that depends on a role and declares a claim path for it, without providing the role, is a consumer only: it contributes paths and can never hold.

9.2.1 What peipkg checks #

The declaration shape is validated on both sides. A consumer-side slot descriptor carries a path and no target; a provider-side one carries a target and optionally a path. A claims field on a conflicts entry is rejected outright. Slot names are validated against the package-name grammar.

Claim paths and targets are held to the payload path-syntax and safety rules of §5.13 — absolute, valid UTF-8 in Normalization Form C, no NUL, control or backslash bytes, no empty, . or .. component, and within the component, depth and total length limits. The manifest decoder applies the same single copy of those rules that the archive reader applies to a payload path, so the two cannot drift.

A claim path is also confined to the locations §5.23 admits: under one of the permitted install destinations of §5.14, under /run/, or the root-level name /init. A manifest declaring a claim path anywhere else — /etc/passwd, /opt/tool, /run itself, /init/x — is rejected when it is decoded, before anything is planned. A provider's target is confined to the §5.14 destinations alone, because it names a payload path: /run/ and /init are claim-only locations that no package can ship to.

9.2.2 Targets are checked against the package's own payload #

A provider's target must name a path the declaring package itself installs, and peipkg checks that at install time against the payload it actually received. The producer-side library offers the same check and pekit runs it, but a producer-side check is a lint: it says nothing about a package built anywhere else, which the format explicitly contemplates.

The check also constrains targets by destination for free. A target that must be a payload path inherits the payload path rules, because those are enforced on the same entries.

9.2.3 Existing content at a claim path #

/lcl/policy is unreachable as a claim path or target whatever else permits it (§5.14), and the destination set keeps a claim link inside the managed tree. A newly materialised link also follows the unowned-file rule (§5.7): an identical relative symlink is adopted without replacing its inode; differing content is refused unless overwrite was explicitly authorised. An authorised replacement retains the displaced content at the transaction's backup path and reports that path in a warning.

This applies both during package installation and during a standalone claim grant. Every command other than install has no overwrite flag, so conflicting unowned content must be moved aside deliberately before retrying. The shared refusal message recommends this recovery for every command; install can also use its --overwrite-unowned option. Repointing a recorded claim link remains an atomic replacement. A path owned by a package that will remain installed is refused (§9.6).

9.3 Auto-Claim

Peios / Peios technical reference / peipkg / Roles and Claims

Installing a package that is an eligible provider of one or more roles claims, by default, every one of those roles that is currently unheld. The new provider becomes the holder, and the role's claim paths are materialised against its targets.

Auto-claim applies only to unheld roles. Installing a provider of a role another package already holds does not change the holder: the new provider is installed and eligible, and the incumbent keeps the role unless the operator directs otherwise.

Note

The two halves are deliberate. Auto-claiming an unheld role means a freshly installed sole provider just works — install the registry daemon and its binary appears, with no second command. Leaving held roles alone means installing an alternative provider never silently steals a live system name out from under the incumbent.

9.3.1 Two providers in one transaction #

When a transaction installs two eligible providers of the same unheld role, the role goes to the one whose package name sorts lexicographically first.

The rule exists so that the outcome is a consequence of the inputs rather than of the order the resolver happened to place them in, which would make an install non-deterministic.

9.4 Install Flags

Peios / Peios technical reference / peipkg / Roles and Claims

Three flags modify what an install claims. Let the provided roles of a package be the roles it is an eligible provider of.

FlagEffect
--no-claimClaim nothing, including unheld roles
--claim <roles>Claim each named role, even if another package holds it
--claim-allClaim every provided role, including held ones

The claim set applied is the union of two sets:

  • the auto set — the provided roles currently unheld — which is empty under --no-claim; and
  • the force set — the roles named by --claim, or every provided role under --claim-all.

Roles in the force set are claimed even when held. Roles in the auto set are claimed only because they are unheld.

Note

The flags compose. --no-claim --claim registryd empties the auto set and forces exactly one role: it claims that role and nothing else, not even a second unheld role the package provides. This is the idiom for "claim only this".

9.4.1 Contradictions #

Two combinations are rejected as self-contradictory: --claim-all with --claim, and --claim-all with --no-claim.

--no-claim with --claim is not contradictory — it is the idiom above.

9.4.2 Naming a role the package does not provide #

A role named by --claim that nothing in the transaction provides is rejected: a package cannot hold a role it is not an eligible provider of.

The check is against every package in flight, not only the one the operator named. So peipkg install foo --claim bar succeeds when a package pulled in as a dependency of foo provides bar, and claims it for that package.

9.5 The claim Command

Peios / Peios technical reference / peipkg / Roles and Claims

peipkg claim inspects and changes holders independently of install.

FormEffect
peipkg claim <role>Report the role's holder, its materialised claim paths, and the installed eligible providers
peipkg claim <role> grant <package>Make the named package the holder, repointing every claim link
peipkg claim <role> revokeRevoke the current grant, removing the links and leaving the role unheld

The bare form lists every installed eligible provider, including the current holder.

A grant to a package that is not installed, and a grant to a package that is installed but not an eligible provider, each fail with their own error.

A revoke on a role that is not held is an error rather than a no-op.

A grant or revoke prompts for confirmation, satisfied by --yes, and executes within a transaction that rolls back on failure like any other.

9.5.1 Addressed by role #

A role is addressed by name, never by path. Because a role has one holder across all of its slots and paths, granting it moves every one of its claim paths together.

Note

Addressing by name rather than by path keeps a multi-path, multi-slot role coherent: one command moves the whole role, however many files it materialises. A path-addressed form would invite a role being half-moved.

9.6 Materialisation

Peios / Peios technical reference / peipkg / Roles and Claims

Materialisation is reconciliation: peipkg recomputes what the role's links should be from current state, and applies the difference.

9.6.1 When it runs #

Every transaction recomputes the desired link set over all post-transaction manifests and all holders — not only for roles whose holder it changed.

That is what makes retroactive materialisation work. Installing a package that merely declares a consumer-side path for a role another package already holds creates the link against the existing holder, and does not re-open the question of who holds it. Removing such a package removes the links only it declared, leaving paths other packages still declare in place.

Reconciliation is idempotent: reconciling against unchanged state produces no filesystem change.

9.6.2 Held but unmaterialised #

Holder state lives in its own table, keyed by role, and is never inferred from whether a link exists.

A role whose computed claim-path set is empty materialises no links and remains held. Installing a package that declares a path for it later materialises the link retroactively, against the holder already on record.

Note

A sole provider installed before any consumer is held but unmaterialised: it holds the role, but until some package declares a dependency-side path — or the provider itself declares a default — there is no path to point anywhere. Tracking holder state independently of link existence is what lets the later install create the link without re-deciding the holder.

9.6.3 Collisions #

Before materialising a link, peipkg checks that no installed package owns the claim path — judged against the state the transaction will produce, not the state it started from.

The distinction is load-bearing in both directions. An in-flight package's ownership rows are not written until commit, so reading the table directly would let a transaction that installs both a package owning a path and a provider claiming it see no owner, queue a payload operation and a claim operation for one destination, and commit both. And it would refuse the legal case: uninstalling the owner in the same transaction that materialises the claim.

That freed-path case works. The removal's own file operation is dropped and the claim's takes over the displacement, because two operations on one path would journal two rows for one destination and contend for one backup name.

A package owning a directory at the claim path is a hard collision. A link cannot occupy a directory path, and treating it as permissible meant renaming the package's directory aside to make room — relocating it and its contents, after which the non-empty backup could not be removed at commit and was left behind.

The reverse direction is guarded too. A package payload whose destination is a live claim path is refused, naming the role. Allowing it would have been unrepairable: reconciliation compares its record of the link set against its own desired set, finds them equal, and never looks at disk, so a link displaced by a payload was gone permanently with the database still asserting it. The link belongs to the manager, placed because a role names it; a package that wants the path is in conflict with the role rather than entitled to take it.

9.6.4 Repointing #

A holder swap repoints every one of the role's links, within a single transaction, and each repoint is atomic. The new link replaces the old in one step, and the displaced link is then moved to the backup path a rollback restores from.

Note

Atomic repoint matters because a claim path is typically on the critical path of a running system — a contended daemon binary may be executed at any moment. Two renames leave the path absent for a full rename round-trip, on every link of the role, which is long enough for a supervisor to get ENOENT from an exec.

The payload path deliberately keeps backup-by-rename instead. There the displacement has to happen before the new content lands, because the displaced original is what a rollback restores.

9.7 Withdrawal

Peios / Peios technical reference / peipkg / Roles and Claims

When the package holding a role is uninstalled, the role's claim is withdrawn: peipkg removes the role's links within the uninstall transaction and the role becomes unheld.

peipkg does not automatically promote another eligible provider.

If other eligible providers remain installed, the withdrawal is surfaced to the operator, naming them and the command to assign a new holder:

Claim 'registryd' withdrawn — packages 'org.example.altregd', 'org.example.thirdregd'
also provide it. Run 'peipkg claim registryd grant org.example.altregd'
to assign a new holder.

Note

Withdrawal leaves the role unheld rather than auto-promoting because the replacement is the operator's choice: the remaining providers are alternatives that were deliberately not chosen while the incumbent held the role. Auto-promoting one would silently repoint a contended system name at a provider nobody selected. Surfacing the alternatives with a ready-to-run command makes reassignment one step without making it implicit.

9.7.1 Revocation is not withdrawal #

Revoking a role is the operator taking a grant away. Withdrawal is the same end state reached automatically when the holder is removed — a package withdrawing its own claim rather than an operator revoking a grant.

A revoke does not surface the remaining providers the way a withdrawal does, even though the resulting state is identical.

9.7.2 Whether withdrawal is safe #

It depends on the role's consumers. If every installed consumer reaches the role through an optional dependency, withdrawal breaks no required dependency and is informational.

If a required dependency is left with no holder for a name it hard-codes, the operator is removing something the running system needs. That is the scenario a system-critical guard would catch, and peipkg has no such guard (§4.6).

9.7.3 Ordering within an uninstall #

Because claim operations ride the last staged package operation, an uninstall of the holder deletes the target file before it deletes the link. There is a transient window within the transaction in which the link dangles — invisible to anything that only sees committed state, but visible to a concurrent reader.

10.1 Named Roots

Peios / Peios technical reference / peipkg / Installation Roots

An installation root is a self-contained filesystem tree with its own package database. The default root is the system root — the anchor. A system may define others; the motivating case is an initramfs image, built and maintained alongside the main system through the same package graph.

A package names a root, never a filesystem location. Where a root lives is the installing system's business, which is what makes a tree relocatable and what stops a package dictating layout.

10.1.1 The grammar #

A root reference is one or more segments joined by ., each matching [a-z0-9][a-z0-9_-]*. Any reference containing / is rejected as a reference.

The grammar is implemented three times — in the manifest decoder, in the command-line parser, and in the producer toolchain — and all three agree, all three reject a /.

10.1.2 Nesting is structural #

There is no parent field anywhere. A root's registry lives in that root's own database, so nesting falls out of where the registration is recorded: initramfs.subroot is resolved by reading the anchor's database for initramfs, then reading that root's database for subroot.

Registered paths are stored relative to the owning root, which is the other half of relocatability.

10.2 Registration and Resolution

Peios / Peios technical reference / peipkg / Installation Roots

10.2.1 The registry #

A root's named children are rows in that root's own package database: name, path relative to the owning root, and creation time.

The registry is runtime fact rather than configuration. It records where a root actually is, so it lives with the rest of what the system knows about itself, not in the operator's configuration tree.

peipkg root add, remove, list, and show manage it. The composer registers roots declaratively from its manifest.

10.2.2 Resolving a reference #

The --root option accepts either form, and the discriminator is explicit:

  • A reference containing / is a literal filesystem path, used unchanged.
  • A reference without / is a name. It is split on . and walked segment by segment: open the current root's database, look up the segment, join its relative path, and recurse into that root's own database for the next segment.

An unregistered segment is a hard error. peipkg never creates a root implicitly.

A visited set of resolved absolute paths rejects cycles, so a registry that points a root at one of its own ancestors fails rather than looping.

A manifest may never carry the path form. The discriminator exists only at the command line, where an operator legitimately wants to install into a directory that is not a registered root — a mounted target, a scratch tree.

10.3 Cross-Root Dependencies

Peios / Peios technical reference / peipkg / Installation Roots

A dependency is satisfied within a root. By default that is the depending package's root, so a package's closure flows into the root the package occupies. A dependency's root field names a different one.

{ "name": "peiosutils", "root": "initramfs" }

declares that the dependency is required in the initramfs root, wherever the depending package lives.

10.3.1 Routing #

Every placement decision passes through one point: given a dependency and the depending package's root, choose the target root. An empty root field, or single-root mode, keeps the dependency where the depender is; otherwise the name is looked up in the live registry.

A root naming something not registered is a resolution failure — unsatisfiable, naming the root — rather than a silent fallback.

Cross-root edges are honoured in plan ordering and in the reverse-dependency check that decides what a removal breaks.

10.3.2 Which verbs route #

install resolves cross-root. In peipkg 8b588ae8, upgrade also resolves one combined plan across the current root and its reachable named roots by default. --no-recurse confines upgrade resolution and execution to the current root. The resolver setup honours the root named by a cross-root dependency during that default upgrade. See Named roots for the operator flow, transaction boundaries and source/release qualification.

The remaining verbs, downgrade, uninstall, and undo, were described here as single-root resolution, where a dependency's root field is inert. The pinned upgrade correction above does not verify those other paths; do not infer that every verb has the same cross-root routing.

10.3.3 Satisfier identity #

A satisfier is identified by the pair (name, root). The same package name installed in two roots is two independent installations, possibly at different versions, and a dependency is satisfied only by an installation in the root it names.

Note

Cross-root dependencies are what let a root be composed through the dependency graph. An initramfs package depends on ordinary packages — a shell, core utilities — and has them placed into the initramfs root, either implicitly by living there or explicitly through root. The depended-on package declares no root affinity of its own; where it lands is the depender's and the operator's choice. This generalises the fixed build-host/target split other systems draw to an open set of named roots.

10.3.4 Top-level placement #

Where an operator names a package directly with no explicit --root, the package's default_root decides where it lands. peipkg applies it once, before building the resolver's requests, and only when the operator did not pass --root — a defaulted root does not count as explicit.

If the named packages declare no default, the current root is used. If they declare exactly one distinct default, the whole install is re-rooted there. If they declare two or more different defaults, peipkg stops and tells the operator to split the command or pass --root, rather than picking one.

A dependency's placement is never governed by its own default_root; only by the depending package's root and the dependency's root field.

10.3.5 Cross-root garbage collection #

Removing the last thing in a root that required a cross-root dependency does not remove that dependency from the other root. Cross-root autoremoval is not implemented; the dependency stays until something removes it explicitly.

10.4 Composing a Root

Peios / Peios technical reference / peipkg / Installation Roots

peipkg-compose builds a populated root from nothing: offline, deterministically, without touching the host filesystem and without executing any package's code. It is the counterpart to peipkg, which mutates a live system.

It is not an image builder. An outer tool calls compose, then chroots to pack the initramfs, squashes the root, and builds the boot image.

10.4.1 The manifest #

A TOML document with a schema version, declaring:

KeyMeaning
archThe primary architecture, which becomes the composed database's recorded value
source_dateThe composition timestamp recorded in the package database and generated metadata
local_packagesGlobs of package files on the build host that join the candidate set — the bootstrap path
[[repository]]Name, base URL, priority, signature policy, trust anchors, transport allowance, minimum index version
[[root]]A name and a path, declaring a named root nested inside the output
[[package]]A name, an optional version constraint, an optional repository pin, an optional root

Unknown keys anywhere are rejected. A package pinning an undeclared repository, or placed in an undeclared root, is an error. Package identity is (root, name), so the same package may be requested in two roots.

10.4.2 The lock #

Resolution writes a lock: the pinned closure, with each package's source, absolute URL, hash, root, and the compressed and installed sizes its repository index advertised. It carries a digest of the manifest, so that building against a stale lock is caught.

The lock also records, per repository the closure draws from, that repository's signature policy and the trust set resolution established for it — each key's fingerprint, public bytes, status, and any expiry. Trust is established once, at resolve time; recording it is what lets the build phase verify signatures without repeating the ceremony. A lock naming a repository package whose source it records no trust state for is rejected as malformed.

The digest covers the packages, their constraints, their repository pins, the [[root]] declarations and each package's root placement — everything that influences resolution. Moving a package between roots, or changing where a root lives, changes the digest, so a build refuses the stale lock rather than silently reproducing the previous placement.

The lock's root key is a path relative to the output, while the manifest's root key of the same name is a name. A lock is therefore bound to one root layout.

10.4.3 The three phases #

Resolve performs the full trust ceremony for each declared repository in a throwaway database that never reaches the output, fetches the active index — and the archive index when a constraint might need historical versions — and reads any local package files into synthetic candidates. Manifest pins filter the candidate set. Resolution then runs against an empty installed set.

Elevated actions the plan implies are printed as warnings and the build proceeds, because compose runs unattended with nobody to authorise them.

Fetch downloads every package within the bound the lock's size_compressed sets, checks its bytes against the hash the lock recorded, verifies its inline signature against the trust set the lock carries for its source, validates its format, and cross-checks the archive's own manifest against the lock entry. Every package is verified before any is extracted.

Signature verification applies the source's recorded policy: under required an unsigned package is refused, and under either policy a signature made by a key the repository has since marked revoked, or by a transitioning key past its expiry, is refused as well. Key status is evaluated against the clock at build time, not at lock time, so a key revoked after the lock was written stops verifying — a build from an unchanged lock can begin to fail, and that is the intent.

Decompression is bounded by the size_installed the lock carries from the index, and the archive's manifest must declare the same figure. The manifest is inside the stream being bounded, so it cannot supply the bound.

Assemble buckets packages by root and, per root, validates the payload layout against the fetched bytes, resolves claims, seeds the database, extracts payloads, and materialises claim links. The whole tree is built under a temporary name and renamed into place on success.

Payload files and symlinks retain the archive's canonical modification time, which format verification requires to equal the package's build.timestamp. Declared directories are timestamped after extraction, claim links, and generated metadata are complete. A directory shared by several packages uses the newest owning package's timestamp, independent of extraction order. Symlink timestamp updates do not follow their targets. A filesystem that cannot represent the required timestamp causes composition to fail rather than silently changing it.

This keeps an unchanged dependency closure stable across fresh build roots. Changing package identities must still invalidate reused build outputs: a newly selected package can contain different bytes with an older canonical timestamp.

10.4.4 What it seeds #

Into each root's database, in one transaction: the recorded architecture, a package row per package carrying the verbatim manifest, an owned-file row per payload entry, claim holder and link rows, and the named-root registrations.

Owned-file rows are written before any file is extracted, so a cross-package path collision aborts the build before anything lands.

Journal rows are deliberately left empty: a composed root has no transaction history because nothing was ever applied to it incrementally.

10.4.5 What it writes that no package owns #

Two things: a repository configuration file for each declared repository, and a license inventory naming every composed package's license and provenance.

Neither appears in the owned-file table, so neither is upgradable or verifiable by peipkg afterwards. Both land under paths a package could not install to.

10.4.6 What it deliberately omits #

No side effects — a composed root's library cache, module dependency cache, and man index are never built. No security descriptor materialisation. No audit events. No repository trust state or index cache in the output, so the composed system performs its own trust ceremony on first use. The trust state the lock carries is an input to the build, not something the build writes into the root.

10.4.7 Layout enforcement #

The permitted-destination check runs at compose time, against the fetched bytes rather than the producer's word for them, and the two-key rule applies exactly as it does at install: a package declaring itself a special system package composes only when the composition also grants the bypass, and a declaration without the grant produces an explanatory refusal.

10.4.8 Root-level views #

The composer synthesises no root-level runtime view. It creates payload entries, claim links, the repository configuration, and the license inventory, and nothing else. Where a boot root needs the psABI-fixed interpreter path to reach the loader before any view is mounted, that mapping is ordinary package payload from the base-filesystem package — shipped for each independent boot root, including the initramfs — rather than something the composer invents.

10.4.9 Roots with nothing in them #

A declared root that no package is placed in is created anyway, with its own database holding nothing. A declared empty root is a reasonable thing to want — somewhere to install into later — and the alternative, registering a root whose directory does not exist, made --root <name> on the composed system fail when peipkg tried to open its database.

10.5 Compose commands and manifest

Peios / Peios technical reference / peipkg / Installation Roots

For the operator workflow, start with Composing a root. This page keeps the full command and build-artifact contract alongside the implementation description.

peipkg updates an existing root through package transactions, with documented recovery and visibility limits. peipkg-compose is its counterpart on the assembly side. Instead of changing a system that already exists, it builds a fresh, fully package-owned root directory from nothing — offline, deterministically, and verifiably, with no live system involved anywhere in the process.

peipkg-compose is a separate binary, not a peipkg subcommand. You run peipkg-compose, and its two verbs (lock and build) are the whole of its command surface.

Given a declarative TOML manifest that names a package set and the repositories to draw it from, compose produces a populated root directory: package payloads laid out at their installed paths, a seeded peipkg state database at var/lib/peipkg/db.sqlite, and each repository written out as lcl/conf/peipkg/<name>.repo. The result is a legitimate peipkg-managed system — once booted it can manage itself. The operator repository interface requires an explicit peipkg repo add <name> trust ceremony for supplied configuration before use; do not assume a first refresh grants trust.

It is meant for image builders — an outer image-assembly tool, or a person standing one up by hand. It builds the package tree without modifying the host installation, and can run on a non-Peios host or a host of a different architecture. Its lock output and an optional --record-xattrs output are separate artifacts you also choose.

10.5.1 The mental model #

Compose runs in two stages, and the two verbs map onto them.

Stage 1 — resolve. Read the manifest, resolve the requested package set against the declared repositories into a complete transitive closure, verify each repository's trust chain, and pin the result. This stage needs repository metadata; its output is the lock file.

Stage 2 — assemble. Fetch the pinned .peipkg payloads, verify them against the lock's hashes, and lay out the root directory. This stage needs only package bytes, not metadata, and can run fully air-gapped once a lock exists.

lock does Stage 1 alone. build does Stage 2, running Stage 1 first when it needs to. Everything build-stamped in the output is derived from the manifest's source_date, so given the same manifest, lock, and packages, the output is reproducible.

10.5.2 peipkg-compose lock #

peipkg-compose lock <manifest> [-o <lock>]

Resolve the manifest against its repositories, verify the trust chain, and write the lock file. lock builds nothing — it produces only the lock.

The <manifest> positional is required. Flag order is not significant: the manifest may appear before or after -o.

OptionEffect
-o <lock>Output lock path. Defaults to the manifest path with a trailing .toml replaced by .lock.toml — so image.toml locks to image.lock.toml. Only the single-dash -o form exists here; there is no --out for lock.

10.5.3 peipkg-compose build #

peipkg-compose build <manifest> --out <dir> [--locked | --update]
                     [--dangerously-bypass-path-restrictions]
                     [--record-xattrs <file>] [--no-dependencies]

Assemble the root directory. The <manifest> positional is required; flag order is not significant.

--out <dir> is required — omitting it is a usage error. The directory named by --out must not already exist: compose creates it, and refuses to build into or over an existing tree.

OptionEffect
--out <dir>The output root directory. Required. Must not already exist.
--lockedRequire an existing lock and do not resolve. Fails if no lock is present. Fetches only package bytes, taking integrity from the lock's hashes — the air-gap-friendly path.
--updateRe-resolve from scratch, overwrite the lock, then build.
--dangerously-bypass-path-restrictionsPermit packages that declare special_system_package to compose payloads outside the payload layout rules. An image built from a package set including the base filesystem needs this; peiso passes it through from bypass_path_restrictions in the image's build spec, so the grant stays a visible decision of the image rather than something the composer assumes.
--record-xattrs <file>Record each implied security.peios.sig or security.peios.sd attribute in deterministic JSONL instead of setting it on the output tree. Use this when an unprivileged builder will carry the attributes into an image, or when composing a disposable build root. Each line has path, name, and a base64-encoded value; the file must not already exist.
--no-dependenciesInstall exactly the packages the manifest names and ignore their dependencies. The result is not a self-sufficient root: it is for a builder that lifts only those packages' own files out of the composition and meets their run-time needs elsewhere, and so can draw on a repository that does not yet hold their dependencies. Signature and hash verification are unchanged. The lock records no_dependencies = true, and a build refuses a lock resolved in the other mode.

--locked and --update are mutually exclusive.

With neither flag, build follows Cargo's build / Cargo.lock behaviour:

  • if the sibling lock is missing, it resolves, writes the lock, and builds;
  • if the sibling lock is present, it verifies the lock still matches the manifest and then builds.

Use --locked for reproducible or air-gapped rebuilds where no metadata resolution should happen at all, and --update when you want to pick up newly-available versions and refresh the lock in the same run.

10.5.4 The manifest #

The manifest is a TOML file describing the package set to assemble and the repositories to draw it from. Any filename works — it is passed positionally — and its sibling lock is <stem>.lock.toml. The manifest and the lock are tool-local build artifacts; they are not an on-wire Peios format.

The parser is strict: an unknown key anywhere in the manifest is a hard error, not a warning. A mistyped field name fails the build rather than being silently ignored.

schema = 1
arch = "x86_64"
source_date = "2026-07-01T00:00:00Z"

local_packages = ["./bootstrap/*.peipkg"]

[[repository]]
name = "core"
base_url = "https://repo.example.org/core"
priority = 10
signature_policy = "required"
trust_anchors = ["a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"]

[[root]]
name = "initramfs"
path = "boot/initramfs"

[[package]]
name = "base-system"

[[package]]
name = "nginx"
version = ">=1.27, <1.28"
repository = "core"

[[package]]
name = "live-boot"
root = "initramfs"

10.5.4.1 Top-level fields #

FieldTypeRequiredMeaning
schemaintyesManifest schema version. Must be 1.
archstringyesTarget architecture; becomes the image's primary architecture.
source_datestringyesAn RFC 3339 timestamp. Fixes every build-stamped time in the output, for reproducibility — the manifest's SOURCE_DATE_EPOCH.
local_packagesarray of stringsnoPaths or globs of local .peipkg files. A bootstrap path for packages that live in no repository.
[[repository]]array of tablesnoPackage sources. Also written into the image as .repo files.
[[root]]array of tablesnoNamed roots, for a multi-root image.
[[package]]array of tablesyesThe packages to install. Must be present and non-empty — an empty package set is an error.

10.5.4.2 [[repository]] #

A repository table has the same shape as a .repo file. These entries drive metadata fetch and trust verification during the build, and are written verbatim to lcl/conf/peipkg/<name>.repo, so the booted system inherits the same repositories and the same trust anchors.

FieldTypeRequiredMeaning
namestringyesRepository name; also the .repo filename stem.
base_urlstringyesWhere the repository's index and packages are served from.
priorityintnoSelection priority. Default 50.
signature_policystringnorequired (default) or optional.
trust_anchorsarray of stringsnoHex key fingerprints trusted to sign this repository's index.
allow_insecure_transportboolnoPermit non-TLS transport. Default false.
min_index_versionintnoReject an index older than this version.

10.5.4.3 [[package]] #

Each package table names one package to install. Package identity is (root, name) — the same package may be requested in more than one root, but a duplicate (root, name) pair is an error.

FieldTypeRequiredMeaning
namestringyesThe package to install.
versionstringnoA version constraint — a range like ">=1.27, <1.28" or an exact pin like "9.9.1". Omitted, or "*", means no version constraint; the resolver applies its source, architecture and version preferences. See the selection rules and documentation discrepancy.
repositorystringnoPin the source repository. Must name a declared [[repository]].
rootstringnoTarget a declared [[root]] — like peipkg --root TARGET install. Empty means the package's own default_root if it has one, otherwise the anchor root.

There is no default_root key in the manifest. default_root is a property each package carries from its repository index, and compose honours it automatically whenever a package's root is unset — just as the live consumer does.

10.5.4.4 [[root]] #

Each root table registers a named root in the built image.

FieldTypeRequiredMeaning
namestringyesA single named-root segment matching [a-z0-9][a-z0-9_-]* — no dots.
pathstringyesLocation relative to the output root. May not be absolute, may not escape with .., and may not be ..

10.5.5 Documentation differences to check #

This command/manifest interface and the adjacent implementation description are not fully reconciled. The composition reference describes inline signature verification in addition to locked hashes; the hash-only stage summary here is not a reason to bypass signature checks. Its omissions section says there is no security-descriptor materialisation, while this interface documents application or recording with --record-xattrs. Do not assume an uninspected output has the security metadata you require.

The existing interface below retains those documented options and their contracts. Where that difference affects whether an image is safe to deploy, verify the output and the behaviour of the tool version used to build it.

10.5.6 The lock file #

The lock is generated by lock, or written implicitly by a default or --update build. Its header reads generated by peipkg-compose — do not hand-edit; it is never edited by hand.

For the full transitive package closure, the lock pins each package's exact version, architecture, source repository (or local for a local .peipkg), the fetch URL, and the content SHA-256 hash of the .peipkg. It records each package's target root when that is not the anchor, along with the manifest's arch and source_date and a digest of the manifest.

A build refuses a lock that no longer matches its manifest. A mismatched arch, a mismatched source_date, or a changed manifest digest all reject the lock. On the default path the error tells you to re-run with --update to refresh it.

The two explicit flags pin down the two ends of this behaviour:

  • build --locked requires the lock to already exist and does no resolution at all — the reproducible, air-gapped rebuild.
  • build --update re-resolves from scratch and rewrites the lock.

An unattended compose run cannot authorise the elevated actions that the live consumer would stop and prompt for. Rather than fail the build, compose surfaces those as stderr warnings. See Elevated authorisation for what those actions are.

10.5.7 Named roots and claims in a composed image #

A composed image can be multi-root, and both mechanisms settle more simply here than on a live system — the detail lives on their own pages; this is only how compose relates to them.

Named roots. Each [[root]] becomes a named-root registry entry in the built image's database, so the booted system resolves --root <name> and cascades upgrades into those roots. Every root is a subdirectory nested under the single --out tree, and the whole multi-root image is assembled as one tree — compose needs none of the consumer's cross-root transaction machinery. The repositories and the named-root registry live at the anchor root.

Claims. A fresh build is the simplest case of claim reconciliation: there are no incumbents and exactly one known, closed package set, so every provided claim is auto-claimed. When two packages provide the same claim, the holder is chosen deterministically — the lexicographically smallest package name, matching peipkg install. Claim symlinks are written with targets relative to the link's own directory, so the root stays self-contained and relocatable.

10.5.8 What ends up in the root #

A successful build leaves a directory that is a valid peipkg-managed system:

  • every package's payload laid out at its installed paths;
  • a seeded peipkg state database at var/lib/peipkg/db.sqlite, recording the installed set, the named-root registry, and the settled claims;
  • each declared repository written to lcl/conf/peipkg/<name>.repo, so the booted system inherits its repositories and their trust anchors.

The build is atomic as a whole tree. Because --out must not already exist, compose assembles into a sibling staging directory on the same filesystem and, on success, renames it into place atomically. A failed or interrupted run leaves the staging directory behind for inspection rather than a half-populated --out — there is no partial output to clean up or mistake for a finished one.

10.5.9 What compose does not do #

Compose's contract stops at producing a valid peipkg root. It is deliberately narrow.

  • Not a full image builder. It produces only a directory tree — no bootloader, kernel, initramfs contents, registry seed, or peinit wiring. Those belong to whatever assembles the image around it.
  • Not a live-system tool. It never touches the host /. There is no three-phase transaction, no commit boundary, no rollback journal, and no crash-recovery — the output is disposable, and its only atomicity is the single whole-tree rename above.
  • Not the producer side. It does not build or sign .peipkg files and it does not serve repositories — building and signing is pekit's job, and publishing is peipkg-repo's. Compose consumes ordinary peipkg packages and repositories unchanged, exactly as PSPU §5 defines them.
  • No side effects. depmod and man-db are not run, and no audit events are emitted. The composer does not build these caches; see Invocation for how package transactions schedule them.
  • Security descriptors are applied, where a package declares one. Their security.* namespace normally requires privileged host access. An unprivileged image builder can pass --record-xattrs to receive the implied attributes as JSONL and write them into the image directly instead; the composed directory then carries none of them. Unlike peipkg install, compose applies or records overrides unconditionally: composing a root from nothing is the operator's own act, and there is no running system whose access control could be changed behind their back.

No environment variables are read, and there is no --version flag.

10.5.10 Exit status #

CodeMeaning
0Success — or the help output (-h, --help, help).
1A compose operation failed — resolution, trust verification, a fetch, a hash mismatch, a stale lock a --locked build could not use, or a file operation.
2A usage error — no arguments, an unknown verb, a missing manifest, a missing --out, extra arguments, --locked with --update, or a bad flag.

11.1 The Recognised Set

Peios / Peios technical reference / peipkg / Side Effects

A package cannot ship code that runs at install time. It can declare that one of two standard maintenance operations is required, and peipkg invokes it.

IdentifierRebuilds
depmodThe kernel module dependency cache — modules.dep and its companions under /usr/lib/modules/<release>/
man-dbThe man page index, /var/cache/man/index.db or its equivalent, which apropos and whatis read

The set is closed. A manifest declaring anything else — ldconfig included: Peios has one shared-library directory and no loader cache (§5.24) — is rejected, and a duplicate within the array is rejected.

11.1.1 When each is required #

A package containing kernel modules declares depmod; one containing none does not. A package containing man pages is expected to declare man-db — a recommendation rather than a requirement, because man page lookup degrades to a filesystem scan without it.

peipkg validates the declared values against the enumeration. The consumer does not validate them against the payload; the producer's packer does, refusing a payload whose kernel modules and depmod declaration disagree in either direction. A package built elsewhere that declares depmod with no modules behind it runs nothing at install time and is reported as a warning (§11.2).

11.1.2 What side effects are not #

  • Not a general install-script mechanism. The closed enumeration is precisely what prevents arbitrary code execution at install time.
  • Not a way to register a service. Service integration belongs to the higher-level artifacts that compose packages.
  • Not a way to seed registry state.
  • Not a way to apply security descriptors, which belong to file creation.

A package whose required behaviour cannot be expressed through the manifest is incomplete and cannot be installed through the package format alone. That behaviour is supplied by the artifact that composes the package.

11.2 Invocation

Peios / Peios technical reference / peipkg / Side Effects

Each side effect maps to one fixed command, shaped by the installation root the transaction acted on.

IdentifierHost root (/)Alternate root
depmod/libexec/depmod -a <release>, once per affected kernel release/libexec/depmod -b <root> -m /usr/lib/modules -a <release>, once per affected release
man-db/bin/mandb -qNot invoked; the operation report says so

11.2.1 Hardening #

Three properties make invocation safe against a package trying to influence it.

A fixed absolute path. The set is closed, so peipkg knows each tool's location and never searches a path variable. A package cannot shadow the intended tool, and because the location is a root-level runtime view, populating the writable stratum behind it needs separate local-administrator authority that a package does not have.

A cleared environment. Each tool runs with exactly LC_ALL=C and PATH=/bin. Nothing is inherited from the invoking context, which closes the environment-injection route.

Standard input closed. Each tool runs with its input attached to the null device.

Output is captured and length-capped, so a runaway tool cannot flood the operation report.

11.2.2 The kernel release #

depmod names the release it indexes, and runs once per release whose module set the transaction changed. The releases come from the transaction's own file lists — every staged package's payload and every removal's ownership rows, under usr/lib/modules/<release>/ — rather than from the running kernel, so installing modules for a kernel other than the one booted (the normal case during a kernel update, and always the case in an image build) indexes the release that changed. A package shipping modules for two releases gets two invocations; a release split across two packages gets one.

A transaction that declares depmod but neither installs nor removes a kernel module runs nothing and reports a warning, rather than falling back to indexing the running kernel.

11.2.3 The root #

The tools invoked are always the host's binaries, at the host's fixed absolute paths, and each is directed at the root the transaction acted on. For the host root the bare forms in the table run. For an alternate root — a named root, an initramfs image, a mounted target — each root of a cross-root transaction schedules its own side effects against itself.

depmod is given the root with -b and the module directory beneath it with -m /usr/lib/modules. The second argument is what makes the first work: kmod's default module directory is /lib/modules, which on a running Peios is the runtime view of /usr/lib/modules, but an alternate root is storage with no views mounted over it, so without -m the tool would look under <root>/lib/modules and index nothing.

man-db is not run against an alternate root. The man index is a cache that the reading system's own man-db configuration locates and keys, and the host's mandb cannot be pointed at another root's configuration. The operation report carries one warning naming the effect and the root; page lookup in that root falls back to a filesystem scan until that system's own next man-db side effect rebuilds the index.

peipkg-compose runs no side effects at all, so a composed root's caches are never built by the composer either.

11.3 Timing and Failure

Peios / Peios technical reference / peipkg / Side Effects

11.3.1 Timing #

Side effects run at transaction commit, after the database commit, after every package in the transaction has completed extraction and registration.

They are deduplicated across the transaction: several packages declaring man-db produce one invocation. Distinct effects run in an unspecified order, which is safe because the recognised set is chosen so that order between them does not matter.

Running once, at the end, is what keeps the system consistent during a multi-package install: the man-page index is rebuilt after every package's pages are in place, not after each package individually against a partial state.

11.3.2 Failure #

Side effects run after the durability boundary, so a failure cannot roll the transaction back — the transaction is already committed.

A failure becomes a warning on the operation report and the transaction stands. The operation exits successfully: the packages installed, and only a cache lagged.

Because side effects are idempotent, a failed one is self-correcting. Re-invoking it — explicitly, or as part of the next transaction that declares it — reaches the correct state.

Note

Rolling a committed transaction back because a cache rebuild exited non-zero would be disproportionate. The two recognised tools are stable with few failure modes outside system-level corruption, and every one of those failure modes is recoverable by running the tool again.

11.3.3 Removals schedule too #

A side effect runs when a transaction removes files whose absence affects its target, as well as when it adds them. Removing the last package that owned a kernel release's modules reindexes that release; removing a package that shipped man pages reindexes the man database.

A removal declares nothing at the time — it has no incoming manifest — so the declaration is read from the manifest stored for the package being removed, and depmod's affected release comes from that package's ownership rows rather than from a payload.

An upgrade schedules effects implied by the files it removed as well as those the new version declares.

12.1 The Recipe Family

Peios / Peios technical reference / peipkg / Producing Packages

A recipe describes how to turn an upstream source tree into one or more packages. It is not a single file: pekit reads a family of TOML documents, each with its own role.

FileRole
pekit.tomlThe recipe: source acquisition, and the build, test, install, clean, and gen targets
workspace.pekit.tomlWorkspace marker: member globs, distro-wide environment, derivation policy
package.pekit.tomlShared package metadata — the base layer
<selector>.package.pekit.tomlOne emitted package per file
packages.pekit/An alternative directory holding the two above
env.pekit.toml, <name>.env.pekit.tomlA build entry's environment, wrapper, and dependency provider
<name>.keyring.pekit.tomlA secrets tree exported into the build environment
pekit.lockMachine-written source pin
<patches>/seriesThe patch series, plain text rather than TOML

12.1.1 Strict parsing everywhere #

pekit rejects an unknown key at every level, including the top level of every file, and every sub-parser carries its own closed key set.

There is no owner partition and no tolerance for a section pekit does not recognise, because there is only one tool reading these files. Adding a section a future version might own makes the recipe fail to load today.

12.1.2 The layer merge #

A package's definition is assembled from up to five layers, in order: the workspace base, the fetched source tree's base, the recipe's base, the source tree's member file, and the recipe's member file.

A scalar from a later layer overrides an earlier one when it is non-empty. A map or a slice replaces the earlier one wholesale — it does not merge key by key.

That rule has a consequence worth knowing when a base layer declares a dependency's root. The dependency constraints and the dependency roots are two separate maps built from one table, and each is replaced only when the overriding layer's own map is non-empty. A member layer overriding a dependency with the plain constraint form leaves the roots map untouched, so the base layer's root survives and is applied to the new constraint. There is no way to un-set a root a base layer declared.

12.2 The Recipe

Peios / Peios technical reference / peipkg / Producing Packages

pekit.toml has ten top-level keys.

KeyTypeMeaning
out_dirstringStage and output base, relative to the recipe root. Defaults to out
envtableEnvironment for target commands
wraptableA command wrapper applied to every target command
sourcetableWhere the source comes from
delegatebool or tableBorrow build targets, environment, wrapper, or package definitions from the fetched source tree
build, test, install, cleantablesTarget namespaces
gentableGeneration targets, with their own verification
source_packagetableWhether and under what name to emit a corresponding-source package

12.2.1 Environment #

[env] maps variable names to values. A name matches the usual shell identifier pattern, and cannot begin with the reserved prefix pekit uses for its own variables.

Declaration order is preserved, so a later variable may reference an earlier one — CXXFLAGS = "$CFLAGS ..." works because CFLAGS was declared above it.

12.2.2 Wrapping #

[wrap].command is a shell string or an argv array containing exactly one {{command}} placeholder. In argv form the placeholder is a whole argument, and cannot be the program name.

Every target command runs through the wrapper, which is how a whole recipe is built inside a sandbox or under a cross-compilation shim without each target knowing about it.

12.2.3 Targets #

A target namespace takes one of two shapes. A bare namespace — the table itself carries a command — is a single target named main. Otherwise each sub-table is a named target. Mixing the two is an error.

KeyMeaning
commandRequired. A shell string, or a non-empty argv array
needsOther targets that run first
clear_outWhether to clear the target's stage directory first. Defaults to true
dependenciesBuild-tool dependencies, in the build namespace only

Build-tool dependencies are grouped by provider — peipkg, apt, and so on — and each entry maps a capability name to a constraint string. The name is validated as a capability, so sonames and pkg-config module names are legal; the constraint is a non-empty string, with * meaning any.

These are the tools the build needs, and they drive the environment the build runs in. They are not the dependencies the resulting package declares.

12.2.4 Generation targets #

A gen target has its own shape: no needs, no clear_out, and a verification half.

KeyMeaning
commandRequired. Regenerates the artifact
verify_commandChecks that the committed artifact is up to date
verify_on_build, verify_on_testWhich targets in that namespace the verification gates
dependencies, verify_dependenciesAs for a build target; the verify set, when present, replaces rather than extends

The two gating keys are three-valued. Absent gates every target in that namespace; an empty array gates none; a populated array gates exactly those named. Setting either without a verify_command is an error.

This is how a generated file — an ABI table, a constants header — is kept honest: the artifact is committed, and a build fails if regenerating it would change it.

12.3 Sources

Peios / Peios technical reference / peipkg / Producing Packages

[source] says where the upstream tree comes from. At most one reproducible source may be declared — a version-control source or a fetched artifact — and declaring both is an error.

12.3.1 Version control #

KeyMeaning
urlRequired
refTemplated, defaulting to the version placeholder
versionsA constraint capping which upstream tags are eligible
tag_regexA pattern filtering tags during version enumeration

12.3.2 A fetched artifact #

KeyMeaning
urlRequired, templated
extractWhether to unpack the artifact
rootThe subdirectory of the unpacked tree that is the source root
versionsA constraint cap
file_regexA pattern applied to a directory listing to enumerate versions; named version-component captures can map upstream filenames to canonical package versions
checksumA single hash, or a table mapping version to hash

12.3.2.1 Signature verification #

A [source.url.signature] block makes upstream signature verification mandatory for that source.

KeyMeaning
urlTemplated; defaults to the source URL with a signature suffix
ofartifact or decompressed — which bytes the signature covers
key_filesRequired and non-empty: the pinned public keys
fingerprintsAn allowlist of acceptable signing fingerprints
ignore_expiryBoolean, default false. Permit new signatures made after the pinned key expired; use only for an upstream known to continue signing with that key

This is an entirely separate trust system from package signing. It verifies that the upstream tarball is the one upstream published, using upstream's own keys, pinned per recipe. It has no relationship to the Ed25519 signature the resulting package carries.

ignore_expiry bypasses only signing-key expiry. Pekit still verifies the cryptographic signature, pinned fingerprint, present-time revocation state, explicit signature lifetime, and signature timestamp.

12.3.3 A local tree #

[source.local].path names a directory relative to the recipe root. It is not a reproducible source and cannot be locked.

12.3.4 Patches #

source.patches names a single bare directory in the recipe root containing a series file: a plain-text list of patches, applied in order. It requires a reproducible source, since patching a local tree in place would mutate the operator's own working copy.

12.3.5 The lock #

pekit.lock records what was actually fetched: for each source, the version, the URL and content hash or the ref and commit, the signing key that verified it, and when it was locked.

It is trust-on-first-use and tamper-evident afterwards: the first fetch establishes the pin, and every later fetch is checked against it.

12.3.6 Delegation #

A recipe may delegate — declare that its build targets, environment, wrapper, or package definitions come from the fetched source tree rather than from the recipe. A delegating recipe is a thin pointer at a project that carries its own packaging, and it is how a project whose source tree already contains package definitions is distributed without duplicating them.

12.4 Package Files

Peios / Peios technical reference / peipkg / Producing Packages

Each emitted package is described by its own file, layered over a shared base.

KeyMeaning
formattar (the default) or peipkg
clear_outWhether to clear the package's stage directory first
buildsWhich build targets to stage; inferred from the file references when omitted
packageThe metadata table
filesSource reference to destination
symlinksDestination to target
excludesPatterns removed from the matched set
multipackFan this one definition into several packages
publishWhere to publish the result
dependencies, optional_dependencies, conflicts, provides, replacesThe manifest relationships
side_effectsThe declared maintenance operations
sd_overridesPath to security descriptor
claimsRole declarations

The manifest-facing lists are top-level tables in the package file rather than members of the metadata table.

A peipkg-format package additionally requires a version, an architecture, and a license — the last a distribution requirement stricter than the format's, which treats the license field as optional.

12.4.1 Metadata #

[package] carries the name, version, architecture, description, license, homepage, default_root, and special_system_package.

default_root is validated against the named-root grammar. special_system_package waives the layout check at pack time — but not the side-effect check — and grants nothing at install or compose time.

12.4.2 Claims #

[claims.provides.<role>.<slot>] and [claims.dependencies.<role>.<slot>] are checked at recipe load, and the two sides are not interchangeable: a provides slot names the target this package ships and may add a default path; a dependencies slot names only the path the role is reached at. peipkg enforces that asymmetry at install, so accepting the wrong shape here would mean a successful build, a signed package, and a failure on somebody else's machine.

A [claims.*] stanza naming a role the package neither provides nor depends on is an error. Claims are attached by role name, so a mistyped role would otherwise simply not appear in the shipped manifest, with no diagnostic — the one place a typo escaped pekit's unknown-key rejection, because the key is a role name rather than a schema field.

12.4.3 Field rules #

Pack encodes the manifest and decodes it back through the consumer's own validators before emitting anything, so a package violating a §5.18 field rule — the name grammar, the version form, the printable-ASCII description, the array limits — fails at the farm rather than at each consumer's install. The farm has the recipe, the maintainer and a fast feedback loop; the install has none of them.

12.4.4 Relationships #

Dependencies take either of two forms:

libfoo = ">= 1.2"
libfoo = { constraint = ">= 1.2", root = "initramfs" }

A table with no constraint matches any version. The table form is the only way to place a dependency in another root; there is no string sugar for it.

Conflicts, provides, and replaces are plain name-to-string maps. Conflicts deliberately carry no root: a conflict is root-local by construction, and the consumer rejects a root on a conflicts entry outright.

side_effects is a list of strings passed through verbatim: pekit does not check membership of the recognised set, the consumer does. What pekit does check is agreement with the payload, at pack time and in both directions — see Building and signing.

12.4.5 Files #

[files] maps a source reference to a destination. The key grammar is the interesting half:

ReferenceMeans
@recipe:<path>A literal tree in the recipe directory
@source:<path>A literal tree in the fetched source
@workspace:<path>A literal tree in the workspace
<target>:<path>The staged output of a build target
:<path>The staged output of the main target
<path>Rewritten to the owning layer's prefix

Globs are supported, and a directory source maps its whole subtree.

A value may be a plain destination string, or a table with a path and an override flag. override is not a merge flag: it excludes that one entry from the layout validation, per file, invisibly in the resulting package.

That is a second producer-side waiver alongside special_system_package — narrower, but undeclared in the artifact, so a consumer sees an ordinary package that simply fails validation at install time.

12.4.6 Symlinks and excludes #

[symlinks] maps a destination to its target, with the same override option. excludes is a list of reference-grammar patterns removed from whatever the file map matched.

12.4.7 Multipack #

[multipack].enum fans one package definition into several. It takes a static list of values, or a derived form naming a path and a pattern to enumerate from. Each value binds a placeholder available throughout the definition, so one stanza can emit a package per kernel module, per locale, or per plugin.

12.4.8 Publish #

[publish.localdir] is an array of tables naming a path and whether to overwrite. It is the local development route; a real repository is published with the repository tool.

12.5 Templating and Derivation

Peios / Peios technical reference / peipkg / Producing Packages

Two mechanisms mean the shipped manifest is not simply what the recipe wrote.

12.5.1 Templating #

Placeholders are substituted throughout a package definition: {{version}}, {{major}}, {{minor}}, {{patch}}, {{suffix}}, {{prerelease}}, {{buildmeta}}, and {{multipack}}.

They apply to the metadata scalars, to the keys and values of every relationship map, to side effects, to claim paths and targets, to file references and destinations, to symlink targets, to publish paths, and to the source ref and URL.

They deliberately do not apply to a dependency's root, which names a registered root rather than something derived from a version.

The components come from pekit's own upstream version model, not from the package version model. For a package version carrying a Peios revision, the revision lands in {{prerelease}}. An upstream's unseparated lettered release suffix, such as the c in IANA tzdata 2026c, lands in {{suffix}} instead.

A version the model does not parse — one carrying an epoch or a tilde — has no components at all, and every token but {{version}} is then refused with an error naming the version. {{version}} still renders, from the raw string, so a definition that uses only it is unaffected. Substituting an empty string instead produced a silently wrong result: a publish destination missing its major number, with no diagnostic anywhere.

The idiom for "this package depends on its sibling at this build's version" is a templated constraint:

libpeios = "{{version}}"

which pins the upstream version and leaves the revision unconstrained.

12.5.2 Derivation #

pekit derives capabilities from the built payload and merges them on top of what the recipe declared. A hand-written entry always wins.

Shared libraries. Sonames are read from the built objects: a library's own soname becomes a provide, and a binary's needed sonames become dependencies. Symlinks are skipped, so a versioned library and its development link do not both claim the same soname. A shared-library-shaped file carrying no soname produces a warning.

Where the workspace's symbol-version policy names a soname and a token prefix, the symbol versions a binary actually references become a version floor on that soname's dependency — so a binary using a recent C library symbol depends on a library version that has it.

pkg-config modules. Each .pc file becomes a provide named for the module, versioned from its version field. Its required modules become dependencies with ordered constraints, merged from both the public and private requirement lists. Variable references are expanded to a fixed point. An unparseable version or constraint is dropped with a warning rather than failing the build, and modules the package provides itself are subtracted from what it requires.

The consequence to hold onto: a shipped manifest routinely declares dependencies the recipe never wrote. Reading a recipe tells you what was declared, not what was shipped.

12.6 Building and Signing

Peios / Peios technical reference / peipkg / Producing Packages

12.6.1 The build #

pekit fetches or updates the source, applies the patch series, and runs the targets a package's builds list names, each into its own stage directory, honouring the dependency edges between them.

Every command runs with the assembled environment: pekit's managed variables, then any explicitly granted keyring values, then the user layers in order (the workspace, its selected environment file, the delegated source and its environment file, the recipe, and the recipe's environment file). In an isolated workspace every target runs in the sandbox the selected environment prepares, and no wrapper can replace it. See Environments and keyrings for the complete rules.

Generation targets run their verification where the gating keys direct, so a build fails if a committed generated artifact is stale.

12.6.2 Packing #

Packing collects the file map's matches from the stage directories, adds the symlinks, subtracts the excludes, applies templating, merges derived capabilities over declared ones, and hands the result to the packing library.

That library builds the manifest, the files manifest, and the archive according to PSPU §5, and then decodes its own output through the consumer's validators. A package that packs has already satisfied the rules a consumer applies on the way in — which is why a recipe error often surfaces as a manifest error at pack time.

The layout check runs here, over the whole file map minus any entry marked as an override.

A regular file of 8 GiB or more is refused before anything is hashed or written, naming the file and its size. The container has no representation for it: the ustar header's size field tops out at 8 GiB − 1 and §5.11 forbids the PAX size record that would extend it, and a consumer's 4 GiB decompression bound (§5.27) would refuse the package in any case. The size is the one recorded when the file map is walked, the same figure the tar header is written from.

The side-effect check runs here too, over the whole file map including overrides: an override escapes the layout rules, but a kernel module still needs indexing wherever it was declared. It enforces §5.24 in both directions for the effects whose trigger is a payload file pattern.

PayloadDeclarationResult
A .ko or .ko.* under usr/lib/modules/no depmodError
No kernel moduledepmod declaredError
A file under usr/share/man/no man-dbWarning
No man pageman-db declaredWarning

depmod is an error because §5.24 makes it a MUST and the failure it prevents is silent: a stale modules.dep makes modprobe resolve a dependency chain and then fail on a file that is not there, far from the package that caused it. man-db is a warning because §5.24 makes it a SHOULD — lookup falls back to a filesystem scan, which is suboptimal rather than broken.

Warnings are reported even when an error is also raised, so one run tells the author everything.

A side effect whose trigger is not a payload pattern is simply not in the checkable set. The rule is where the trigger is mechanical, pack enforces it, which leaves room for a future effect that depends on what a package means rather than on what it contains — the declaration stays the author's, and pack validates it rather than deriving it.

special_system_package does not waive this check, unlike the layout one. Special packages stage exotic layouts, which is why those rules let them through; what maintenance a payload needs afterwards is a separate question, and the kernel's module tree is exactly the payload that most needs depmod.

12.6.3 Signing #

The signing key is supplied through the keyring, under a well-known key name. The signature is computed over the uncompressed tar bytes preceding the signature entry and written as the archive's last entry, before compression.

A package built with no signing key configured is a conformant unsigned package, installable only from a repository whose policy permits unsigned content.

Private keys are read as raw key bytes or in a standard encrypted-key container. Neither encoding is specified by the format, which cares only about the resulting signature — but both are a real interface, since the keyring names a file that some other tool may have produced.

12.6.4 Binary signing #

Separate from the package signature is the signature the kernel checks on individual files — the ML-DSA-65 blob of PSPK chapter 3 that gives a binary its PIP tier and lets firmware reach a device. A build target declares which of its outputs get one and with which key:

[build.main.sign.pip]
"usr/bin/peinit" = "tcb.priv"
"usr/lib/firmware/**" = "tcb.priv"

Each entry maps an output-relative path or glob to the keyring leaf naming the private key. Signing runs after the build command, over the staged output, before packing. Every failure is fatal: a pattern that matches nothing, a key that does not load, a file matched by two patterns naming different keys, or a signed file that does not verify back through an independent check.

Where the signature lands depends on the file. An ELF file gets a .peios.sig section, reserved and zero-filled before hashing so the bytes the kernel hashes are the bytes that were signed. Any other file — a firmware blob, a data file — gets a detached signature, <file>.peios.sig, written beside it with the blob over the whole file. The file is untouched; the sidecar is packed as an ordinary payload entry and peipkg turns it into the security.peios.sig extended attribute at install (PSPU §5.16). A glob that sweeps a sidecar back up does not sign the signature, so ** is safe.

Each signed file produces one sign event naming the placement — section or sidecar — the keyring entry and the key's fingerprint.

12.6.5 Corresponding source #

For any recipe with a reproducible source producing packages in the package format, pekit emits a corresponding-source package by default: the pristine upstream artifact, the applied patch series, and the build-controlling recipe files, laid out under the source destination.

The emitted package's name is recorded in the built package's manifest, so a consumer holding a binary can find the source that produced it.

A recipe turns this off with [source_package] enabled = false, and the only reason to do so is that there is no source: the upstream artifact is itself the thing shipped, so a corresponding-source package would be that artifact again under another name. Device firmware is the standing example — the blobs are what the vendor publishes, there is nothing they were built from that Peios could ship, and the firmware packages carry license_class = "firmware" for the same reason. A recipe that does build something from an upstream source keeps the default, whatever the licence: the corresponding-source package is how a binary's provenance is answered, not a statement about the licence's terms.

12.7 Reproducibility

Peios / Peios technical reference / peipkg / Producing Packages

A package is reproducible when the same inputs produce byte-identical output. The format's determinism rules (PSPU §5.11) are necessary but not sufficient: they constrain what the archive looks like, not how the producer arrived at its contents.

12.7.1 What the format fixes #

Entry ordering, modification times tied to the recorded build timestamp, uniform ownership and mode, no extended attributes, canonical extended header records, and a fixed header format. Given the same uncompressed tar stream and the same key, the signature is deterministic too.

12.7.2 What it does not fix #

Compression. The level, the implementation, its version, and its frame parameters all change the resulting bytes and none is constrained. pekit pins its own choice, so pekit reproduces pekit; two producers seeking byte-identical output have to agree on compression out of band.

Manifest serialisation. The manifest's bytes are inside the archive that gets hashed and signed, so two semantically identical manifests with different whitespace produce different packages. pekit's serialisation is compact, unescaped, with a single trailing newline, with fields in schema order and a fixed rule about which optional fields are always emitted. That is pinned in the producer rather than in the format.

12.7.3 What the build has to control #

Everything the build process can observe: timestamps, file ordering, locale, environment, ambient filesystem state, and build paths.

The established techniques apply. SOURCE_DATE_EPOCH gives every build tool one timestamp to stamp its outputs with. A sealed environment — a container or a virtual machine — pins the full build dependency closure, which is itself a build input that determines the output. LC_ALL=C and TZ=UTC suppress locale-dependent ordering and time formatting. And build-path normalisation keeps absolute paths out of debug information and out of anything else a compiler embeds.

12.7.4 Verifying it #

The manifest records the build's farm identifier, its source reference, optionally the recipe tree's version-control identity and the producing tool's revision, and the timestamp. A third party with the same inputs can re-run the build and compare the output bytes.

The format supplies the inputs for that verification and does not mandate it.

12.7.5 Before publishing #

Worth checking before a package is published: that its hash matches what was recorded, that its signature verifies against the publishing key, that it installs cleanly in a fresh environment, that its dependency declarations reference packages that exist and constraints that are satisfiable, and that re-running the build from the same inputs produces identical bytes.

The cost of catching an error before publication is low; the cost of a published incorrect package is re-publication and a user-facing rollback.

13.1 The Privilege Model

Peios / Peios technical reference / peipkg / Security

peipkg holds no principal, no identity, and no access rights of its own.

It runs as the calling operator. Every file operation it performs — creating, replacing, or deleting — is checked by the kernel against the caller's token and the target's security descriptor. If the caller is authorised the operation succeeds; if not, it fails. peipkg contributes no authority.

This is verifiable in the shape of the program. There is no daemon, no broker, and no socket. Nothing in it changes identity: no user or group is assumed or dropped, no capability is manipulated, no ownership is changed on any file it writes. No privilege is requested anywhere. The only privilege its operation touches is the one audit emission consumes passively (§13.3).

13.1.1 What follows #

The authority to install a package is exactly the authority to write the directories the package installs into — an ordinary matter of security descriptors on the permitted destinations. A deployment grants installation authority by granting those write rights to whichever principals it intends to be able to install software.

A package's declared security descriptor overrides can only assign descriptors the calling operator already has the authority to assign. Where applying a given descriptor requires a particular right — WRITE_DAC for a discretionary access list, WRITE_OWNER and SeRestorePrivilege for an owner assignment — that right is held by the operator, not by peipkg. A package cannot, through peipkg, obtain authority the operator running it does not hold.

That consequence is currently theoretical rather than load-bearing, because overrides are parsed and then never applied (§5.4).

Note

An earlier design ran the package manager as a dedicated service principal holding broad system rights — a posture comparable to a root process. Peios instead makes it an ordinary unprivileged program: there is no standing principal to compromise, no privilege to confine, and the blast radius of a malicious package is bounded by the authority of whoever ran it.

The cost is that scoped, curated installation — letting a low-authority operator install one approved package without granting general write access — is not something peipkg can provide. That is the job of the higher-level roles and features layer, which can act as a privileged broker.

13.2 Blast Radius

Peios / Peios technical reference / peipkg / Security

Because peipkg holds no principal of its own, there is no standing privileged identity to confine and no privileged process to compromise. The authority exercised during an install is the caller's, and only for the duration of that invocation.

The blast radius of installing a malicious or defective package is therefore bounded, precisely, by the authority of the operator who installed it: a malicious package can do nothing the operator could not already do directly.

That makes the trust decision — which repositories an operator configures, and which packages an operator with broad authority chooses to install — the operative security boundary. Signature verification and the repository trust model exist to inform that decision. They do not substitute for it.

13.2.1 What peipkg does not do #

peipkg needs write access to the payload destinations and network access to fetch from configured repositories, and nothing beyond that.

  • It performs no kexec.
  • It loads no kernel modules. The depmod side effect rebuilds a module dependency map; it does not load anything.
  • It loads no BPF programs.
  • It writes no registry state. Its bookkeeping is a private store, and the database schema says so in as many words.

The one thing that reaches outside its own state is a side effect, and that is a fixed absolute path, with a cleared environment, from a closed set of three (§11.2).

Note

peipkg has its own thing it calls a registry: the table recording which named roots exist. That is a table inside the private store, not the system registry, and the claim above is about the latter.

13.3 Audit

Peios / Peios technical reference / peipkg / Security

Every package peipkg installs, upgrades or uninstalls writes an audit record, and so does every repository add, removal and refresh, every claim grant or revoke, every run of peipkg recover, and every elevated action put to the operator. Appendix A2 lists the types and their fields; the records are defined in peipkg's evman fragment, installed as /usr/share/evman/peipkg.evman.

Events go into the kernel event subsystem, KMES, through the kmes_emit system call. Because emission is a local kernel call rather than a message to a userspace daemon, it has no unreachable-destination failure mode: there is no reachability probe, no fail-closed rule, and no retention journal. Whether and when events are drained and persisted is the historian's concern, not peipkg's.

13.3.1 What a record carries #

FieldWhere it lives
OperationThe event's type
TimeThe kernel-stamped header
The caller's token, true token and processThe kernel-stamped header, as GUIDs
The acting principalThe payload: subject.token.sid
The packageThe payload: name, version, previous version, architecture
Source repositoryThe payload: source.repository.name
Transaction identifierThe payload: transaction.id
Outcome, with an error code and the error textThe payload

A record's fields follow the platform's event conventions: a field path is a chain of nested maps, and a value that does not apply is left out rather than written as an empty string or zero. A removal carries no architecture or source repository, a package installed from a local file carries no repository, and a request refused before any transaction opened carries no transaction identifier.

The acting principal is the user SID of the token peipkg runs under, read from that token. It is peipkg's own word, written so that "who installed this" can be answered from the record alone. The kernel also stamps the caller's effective token, its true token and its process identity onto every emission, as GUIDs, where they cannot be forged or suppressed by the emitting program. If peipkg cannot read its own token, the record is still written without the SID, and peipkg warns.

One record per package. A transaction writes one record for each package it touched, typed by what happened to that package rather than by the command: an upgrade that pulls in a new dependency writes peipkg.package.installed for the dependency, and undoing an install writes peipkg.package.uninstalled. A downgrade and an undo that moves a package backward are recorded as peipkg.package.upgraded; the record's two versions show the direction.

The source repository is per package, because one plan can draw from several repositories. It is how a bad install is traced back to the repository that served it.

The transaction identifier joins a record to the transaction ledger. In a cross-root transaction each package's record carries the transaction of the root the package went into, so every record joins to its own root's ledger.

13.3.2 Successes and failures #

A committed transaction writes one successful record per package. One that fails writes one failed record per package its plan held, with outcome.reason set to the same stable error code the driven mode reports (stale, busy, denied, unowned, alternate-upgrade, unresolvable, untrusted or failed) and the error text in outcome.detail. A failure that carries a transaction identifier was rolled back; one without was refused before it began. A request refused before a plan existed writes one failed record for each package it named, and an upgrade of everything, which names none, writes one record without a package name.

A failed repository add or removal, and a failed peipkg recover, are recorded as failed. An operator who declines an elevated action is recorded too, as peipkg.action.authorised with a false outcome.

An operator who declines the routine proceed prompt writes nothing: the transaction never started.

An automatic recovery at the head of an ordinary operation writes nothing. The same rollback performed deliberately through the recover command does, and so does every run of it, one with nothing to recover included.

peipkg-compose writes nothing at all.

13.3.3 Tiers and the emission policy #

The package records, the repository add, removal and reconfiguration records, and the authorisation record are essential: they are always written. peipkg.claim.changed, peipkg.repository.refreshed and peipkg.transaction.recovered are standard, so the system's emission policy can switch them off; peipkg consults it before building their payload.

13.3.4 What emission depends on #

Emitting requires an audit privilege on the caller's token. peipkg warns and continues when emission fails, so an operator with write access to the payload destinations but without that privilege installs packages with no peipkg audit record, essential types included.

On a kernel without the emit call at all, emission is silently treated as a successful no-op, so "audit is working" and "audit is absent" look the same.

Note

peipkg's own events are a semantically meaningful summary of an operation. They are not the security boundary. The authoritative record of what changed on disk is the kernel's own audit of the underlying file operations, which the calling operator cannot suppress. A forged or omitted peipkg event cannot conceal a file operation from the kernel's record — which is why the gaps above are gaps in diagnosis, not in accountability.

13.4 Operator Authorisation

Peios / Peios technical reference / peipkg / Security

Several points call for operator authorisation: a deliberate, explicit act specific to the elevated action in question, distinct from the routine prompt to proceed, never inferred or defaulted, and recorded in the audit stream.

13.4.1 What is gated #

Elevated actionGate
DowngradeA per-action prompt, audited
A low-trust provider filling a high-trust roleA per-action prompt, audited
A foreign replaces against a higher-priority packageA per-action prompt, audited
Proceeding on stale trust stateThe --allow-stale flag, audited, no prompt
Installing unsigned content under an optional policyWarned per package on every operation; not gated, not audited
Enabling insecure transportNot gated, not audited
Resolving an interrupted transactionNot gated, not audited

The three prompted actions are raised by the resolver as authorizations, presented individually, and confirmed on their own terms. The authorising act and what it authorised are recorded.

13.4.2 --yes does not satisfy them #

--yes confirms the routine "apply this plan?" prompt. Authorizations are collected and confirmed before that prompt is reached, so --yes satisfies none of them.

With input closed, an authorization prompt reads end-of-input and returns a refusal, so a non-interactive invocation of an elevated action cancels rather than proceeding. That is the right direction to fail.

13.4.3 Confirming a specific action #

The text interface displays the action's detail and requires a phrase naming both the action and package. For example, downgrading widget requires authorise downgrade widget, followed by a newline. The other action names are low-trust-provides and foreign-replaces. Use the phrase printed by the prompt; matching is case-sensitive, with surrounding whitespace ignored.

A generic y or yes refuses an elevated action. The first refusal cancels the plan, and each answer given is audited. End-of-input, including an unterminated phrase, also refuses. --yes still skips only the routine proceed prompt.

Scripts may supply these specific phrases through standard input; a terminal is not required. This prevents a generic stream of affirmatives from approving every gate, but is not proof of a human's presence or a cryptographic authorisation. The structured driven interface continues to accept a separate yes answer tied to each authorisation question's ID.

13.4.4 Flags outside the frame #

Three flags waive a check with a bare boolean and no authorisation record: the path-restriction bypass, and — where they exist — a critical-package override and an unowned-file overwrite. The first touches the payload layout rules directly, and none of the three appears in any audit event.

13.4.5 Where this is going #

The intended end state binds authorisation cryptographically: a fresh, kernel-authenticated authorisation from a principal holding rights beyond the operator's routine set, carrying the transaction identifier, the full operation specification, a nonce, and a timestamp; validated by the kernel rather than by peipkg; and emitted as an audit record co-signed by the authorising principal, so the trail does not rest on peipkg's own honesty.

That depends on kernel primitives that do not exist yet — an asymmetric key bound to a token, and event-payload signature verification. Until they do, authorisation is the deliberate act described above, and peipkg does not present it as the stronger guarantee.

13.5 Trust Anchors

Peios / Peios technical reference / peipkg / Security

A trust anchor is a key fingerprint an operator supplies out of band, and it is the root of everything the repository trust model builds on (§3.2).

13.5.1 Where they come from #

Two places, and only two: the --anchor option at repository-add time, and the trust-anchors key of a repository's configuration file.

The intended third place — a file installed by the base system, outside any package, carrying the official repository's anchors — does not exist, and nothing looks for one.

13.5.2 What that means #

The configuration directory those files live in is the writable local tier. So the official repository's anchors, when an image ships them, sit in mutable local state protected by that directory's security descriptor rather than in a read-only base-system location.

The configured form of repo add performs the full trust ceremony against anchors read from that directory. Anything able to write there can substitute the official repository's anchors before the ceremony runs.

Placing a rogue repository configuration is not by itself an escalation — adding a repository is an operator action, and the security descriptor on the directory is what decides who may take it. Substituting the anchors of a repository the operator believes is already trusted is a different thing.

13.5.3 The redundancy that is planned #

The intended end state distributes anchors redundantly: the base-system file, plus a registry key populated at first boot, with peipkg cross-checking every available source byte for byte, refusing repository operations on disagreement — with the base-system file authoritative — and refusing on a missing source in a rescue-media boot.

The model is additive: once the registry source exists, the cross-check applies without changing the file's role as authoritative.

Neither leg is present today. The registry source is correctly inert, since the registry it would live in is not yet available. The base-system file it would be checked against is simply absent.

13.6 Clock Dependence

Peios / Peios technical reference / peipkg / Security

Several checks depend on the local system clock: a signing key's validity window, the maximum trusted age, index staleness, and build provenance timestamps.

An attacker able to manipulate the local clock can extend a transitioning key's validity, evade a staleness check, or hide a compromise-detection window.

peipkg does not gate on clock sanity, and does not pretend to. There is no build-timestamp comparison, no time-synchronisation state query, and no override flag for a clock peipkg thinks is wrong. The clock-dependent checks assume a sane clock.

Note

The intended end state refuses operations when the clock is plausibly wrong — when the current time precedes peipkg's own recorded build timestamp, or when no reliable time source has reported a successful synchronisation since boot. A reliable source means a cryptographically authenticated time protocol, or agreement among several independent unauthenticated ones; plain unauthenticated time synchronisation, being trivially substitutable by an attacker in a network position, does not on its own qualify.

Operators in environments where clock manipulation is a concern will want an authenticated time source in use.

13.7 Threats Out of Scope

Peios / Peios technical reference / peipkg / Security

The following are explicitly outside what the package manager defends against. Each is a real concern; each is addressed elsewhere.

Compromise of an installing operator's identity. peipkg runs with the operator's authority (§13.1). If that identity is compromised, no format-level defence helps. The kernel's own audit and recovery mechanisms apply.

Side-channel attacks on installed binaries. Speculative-execution, cache-timing, and similar attacks against installed software are the kernel's concern and the software's, not the package format's.

Physical attacks on storage. A physically compromised disk can have its package database or installed files altered offline. Hash verification at use time — outside the package manager — is the appropriate defence.

Compromise of the build farm. A compromised farm can sign malicious packages with trusted keys. Detection requires independent reproducible-build verification (§12.7).

The build farm identifier recorded in each manifest can be constrained per repository as defence in depth — refusing packages from a farm not on a configured allowlist. That is not implemented, and it would protect only against a stale farm whose key was rotated but whose identifier is still recognised, or against an attacker who obtained a key without the farm's operational identity. It is not a format-level guarantee.

Network-level censorship or denial of service preventing package fetching. An operational concern.

Note

These exclusions are honest acknowledgements rather than weaknesses to dismiss. What they have in common is that each sits either below the package manager — where the kernel is the right layer — or above it, where the operator's judgement about what to trust is the only thing that can help.

14.1 An Interrupted Transaction

Peios / Peios technical reference / peipkg / Failure Modes

The system loses power, or the process is killed, partway through an operation.

14.1.1 What survives #

The transaction's journal is rows in the package database, written before any file moved, carrying the backup map. The database is a transactional store, so the journal is either there or it is not — never half-written.

14.1.2 What happens next #

For an ordinary single-root transaction, the next install, upgrade, or uninstall acquires the lock, finds the pending transaction, and rolls it back: every displaced original renamed from its backup path back into place, every directory the transaction created removed, every staged file discarded, and the transaction cleared.

That happens automatically and silently. No prompt, and no audit event.

14.1.3 If the crash came after the commit #

The transaction is committed and there is nothing to recover. What is left is cleanup: staged files and backups that were never discarded.

Those are siblings of their destinations under names carrying the transaction identifier, and they are removed by the cleanup step whenever it next runs.

14.1.4 If the transaction spanned several roots #

The pinned cross-root recovery path rolls pending participants back if none committed. If a sibling committed, it rolls pending participants forward from persisted completion data; missing or malformed data needed for completion causes refusal. Recovery can itself fail and must not be treated as a whole-system rollback.

A pending cross-root transaction found by an ordinary single-root operation is refused in isolation. Run peipkg recover explicitly from the original anchor with the named-root topology intact and every participant reachable. Its console wording is not proof of recovery direction, and roll-forward does not retry normal post-commit maintenance. Follow the operator checks before treating the system as ready.

14.1.5 If peipkg was upgraded in between #

The journal records the schema version it was written under. A peipkg that can read it recovers the transaction; one that cannot refuses, naming the schema version, and leaves it for a version that can.

14.2 A Failed Rollback

Peios / Peios technical reference / peipkg / Failure Modes

The rollback itself fails: a write error, a filesystem gone read-only, a permission change mid-operation.

14.2.1 What is left #

Some originals restored, some still at their backup paths. Some new files removed, some still at their final paths. The database holding the pre-transaction state, because the commit never ran.

14.2.2 What peipkg does #

For the reviewed cross-root preparation rollback in peipkg 8b588ae8, it reports rollback errors and retains the affected pending journal. Use coordinated recovery, with all participants reachable from the original anchor.

Earlier wording here described errors being discarded and the journal closed as rolled back. That closed-journal failure remains a diagnostic case to distinguish; it is not the contract of the pinned cross-root path. This correction does not verify every single-root path or released image.

14.2.3 What that means for the next operation #

First inspect whether the journal is pending or closed. A pending cross-root transaction is not safely recovered in isolation by an ordinary single-root operation. Preserve its participants and recovery files.

In the previously described closed-journal case, recovery finds no pending transaction to retry even though restoration failed. A rolled-back record can then look authoritative while files still disagree with it. Neither case makes file verification optional.

14.2.4 The signal that is available #

peipkg verify re-hashes every recorded file against what is on disk. Files the rollback failed to restore will not match their recorded hashes, and will be reported as modified.

A directory listing around the affected paths shows the leftovers directly: siblings carrying the backup and staged markers with the failed transaction's identifier.

14.2.5 Getting back to a known state #

Retain staged and backup siblings while recovery and diagnosis still need them. Identify the affected paths, inspect transaction state, and verify what was restored before deciding whether old or new content is wanted. Follow the operator recovery checks for unresolved files and package records; do not delete recovery material or start another package change merely to make the leftovers disappear.

14.3 A File That Does Not Match

Peios / Peios technical reference / peipkg / Failure Modes

peipkg verify reports an installed file whose content no longer matches the hash recorded at install.

14.3.1 What it can mean #

A legitimate edit. Someone changed a configuration file, or patched a binary in place.

Corruption. Storage failure, or a rollback that did not complete (§14.2).

Tampering. Something modified an installed file.

Neither, for a preserved configuration file. An upgrade that preserved an operator's edit records the new version's hash against the original path while leaving the operator's content there. That file is reported as modified on every run afterwards, permanently, and nothing about it is wrong (§6.2).

14.3.2 Distinguishing them #

The fourth case is identifiable: a file under a configuration path with a .peipkg-new sibling is a preserved edit, and the sibling holds what the package shipped.

For the rest, the recorded hash is what the package's files manifest said at install, so re-downloading the package and comparing is conclusive about what the content should be.

14.3.3 Reinstalling #

There is no reinstall verb. Restoring a file to what its package shipped means removing the package and installing it again, in two transactions, or downgrading and upgrading across the same version.

14.4 An Unreachable Repository

Peios / Peios technical reference / peipkg / Failure Modes

14.4.1 During a refresh #

The fetch fails, the failure is reported, and the previous trust state is retained untouched. peipkg does not fall back to unverified state and does not silently proceed.

peipkg refresh reports each repository's failure and exits non-zero.

14.4.2 During an install #

If the repository's trust state is within its maximum age, the cached index is used and the operation proceeds normally.

If it has aged out, peipkg attempts a refresh first. A failed refresh — or a refresh that returns the same index it already had — refuses the operation, unless the operator passes --allow-stale.

Uninstall and undo are not gated this way, so removing something and reverting a change still work offline.

14.4.3 When the cache is unusable #

A cached index that fails to load or fails to verify produces a warning, and resolution continues without that repository.

The consequence is worth watching for: a package the operator expected from that repository is resolved from wherever else it is available, at a lower priority, with only a warning to say so.

14.4.4 When a repository has been removed #

Packages installed from it stay installed, and their recorded origin stays with them. They are not marked, not flagged in query output, and not refused for upgrade.

Because their origin no longer resolves to a configured repository, the cross-repository guards do not fire for them (§3.7).

14.5 Disk Exhaustion

Peios / Peios technical reference / peipkg / Failure Modes

peipkg checks free space before it stages anything, so exhaustion normally arrives as a refusal that costs nothing. A write can still fail — another process can consume the space in between, and the estimate is of payload rather than of the metadata the filesystem writes alongside it — and where in the operation that happens decides how bad it is.

14.5.1 During staging #

The cleanest case. Nothing has been renamed into place, the transaction rolls back, and the staged files are discarded — recovering the space they consumed.

14.5.2 During the apply phase #

Some files have been renamed in and some originals are sitting at their backup paths.

Because backups are renames rather than copies, the apply phase itself consumes almost no additional space: the space was consumed during staging. A failure here is more likely to be a different error than exhaustion.

Rollback restores from the backup map, which is again renames, so it does not need space either.

14.5.3 During the commit #

The database commit needs space for its own write-ahead log. A failure there leaves the transaction uncommitted, and rollback proceeds normally — but a database that cannot write is a database that cannot record a rollback, which is where §14.2 begins.

14.5.4 The check #

A transaction's requirement is the incoming file content of its added and replaced entries, aggregated across every operation. Backups cost nothing, because they are renames rather than copies, and a removal frees rather than consumes. A replaced file's old bytes are not subtracted: they are still on disk under the backup name until the commit succeeds, which is exactly when the space is needed.

The figure is computed per filesystem, not as a single total, because a transaction can span several — installation roots and destinations within a root can be separate mounts. Destinations are grouped by the filesystem id their directory reports, so the mount table never has to be read.

The comparison is against the blocks available to an unprivileged writer, not the free blocks: the reserved fraction is not peipkg's to spend. A 32 MiB margin is left on top, covering the inodes, directory entries, journal blocks and tail padding the filesystem writes that the payload figure does not include. The check runs before the journal is opened, so a refusal leaves nothing to recover from.

14.6 A Broken Claim

Peios / Peios technical reference / peipkg / Failure Modes

A claim path is missing, dangling, or pointing at the wrong thing.

14.6.1 Missing #

The role may simply be unheld — its holder was uninstalled and nothing was promoted (§9.7). peipkg claim <role> reports the holder and the remaining eligible providers, and granting to one of them restores the link.

The role may be held with no consumer declaring a path, in which case there is nothing to materialise and nothing is wrong.

Or a package payload may have been installed over the link. peipkg does not prevent that: the link is renamed aside, the payload file takes the path, and the claim-link record survives. Reconciliation then compares its record against its own desired set, finds them equal, and never notices. The link does not come back on its own.

14.6.2 Dangling #

The holder's target does not exist. Either the target names a path the holding package does not ship — which peipkg does not check at install time (§9.2) — or something removed it.

14.6.3 Pointing at the wrong thing #

A claim path cannot land outside the managed tree: the manifest decoder confines it to the permitted install destinations, /run/ and /init (§9.2). A new claim refuses differing unowned content at that path by default. If package installation was explicitly authorised to overwrite unowned content, the displaced file is retained and the warning names its backup path. An identical relative symlink is adopted without replacement.

For a link that is already recorded as manager-owned, a holder change still atomically repoints it. As described below, reconciliation compares database records rather than checking for manual changes to existing claim links.

14.6.4 Repairing #

Granting a role to a provider repoints every one of its links from current state, so a grant — even a grant to the current holder's alternative and back — is the blunt instrument that rebuilds a role's links.

Where the database's record disagrees with the filesystem, reconciliation will not detect it, because it diffs the recorded set against the desired set rather than against disk. Reinstalling the holder is what refreshes both.

Appendix A State and Paths

Peios / Peios technical reference / peipkg

A.1 Per installation root #

LocationContent
<root>/var/state/peipkg/db.sqliteThe package database: installed packages, owned files, repositories, claims, named roots, and the transaction journal (§2.5)
<root>/lcl/conf/peipkg/<name>.repoOne repository configuration file per configured repository
<root>/var/state/peipkg/cache/The index cache: content-addressed index and signature objects, and a pointer file naming the current object per repository

The database is the only authoritative state. The configuration directory is the operator's, and the cache is disposable.

A.2 Transient names #

PatternMeaning
<destination>.peipkg-staged-<txnid>A file written but not yet committed, a sibling of its destination
<destination>.peipkg-backup-<txnid>A displaced original, a sibling of its destination
<name>.peipkg-newA package's new default beside a configuration file the operator had edited

The first two are removed at commit or at rollback. The third is permanent and is owned by nothing.

Where a destination's basename is long enough that adding the marker would exceed the filesystem's name limit, the basename is truncated to fit.

A.3 Composition #

LocationContent
<manifest-stem>.lock.tomlThe pinned closure a composition resolves to
<out>.peipkg-compose-tmpThe tree under construction, renamed into place on success
<out>/usr/share/licenses.jsonThe license inventory the composer writes, owned by no package

A.4 Side-effect tools #

IdentifierHost root (/)Alternate root
depmod/libexec/depmod -a <release>, once per affected release/libexec/depmod -b <root> -m /usr/lib/modules -a <release>, once per affected release
man-db/bin/mandb -qSkipped with a warning; man-page lookup falls back to a filesystem scan

These are peipkg's invocations, not commands to copy as a manual recovery recipe. The release comes from the transaction's installed or removed module files, not the running kernel. If no affected release is found, depmod is skipped with a warning; peipkg does not fall back to bare depmod -a. ldconfig is not a recognised side effect.

See Invocation for root and release selection and Timing and Failure for post-commit warnings: a side-effect failure does not roll back an already committed package transaction.

The command forms follow peipkg 8b588ae8's side-effect implementation. Its execution path returns warnings after commit. These source forms do not establish which revision an installed image contains.

Each with a cleared environment of LC_ALL=C and PATH=/bin, and with input closed.

depmod sits in /libexec rather than /bin because it is machine-facing: this and the kernel's own make modules_install are its only callers, and no documented workflow has a person running it. The fixed-path property the design depends on is unaffected — /libexec is a merged view with the same local-stratum protection as /bin, so a package still cannot shadow the tool.

A.5 Producer files #

FileRole
pekit.tomlThe recipe
workspace.pekit.tomlThe workspace marker
package.pekit.toml, <selector>.package.pekit.tomlPackage definitions, layered
packages.pekit/An alternative directory for the above
env.pekit.toml, <name>.env.pekit.tomlBuild environments
<name>.keyring.pekit.tomlSecrets, including the package signing key
pekit.lockThe source pin
<patches>/seriesThe patch series

Appendix B Audit Events

Peios / Peios technical reference / peipkg

Every event described here is emitted into the kernel event subsystem (§13.3). Each type and field is defined in peipkg's evman fragment, installed as /usr/share/evman/peipkg.evman, which is the authoritative description; this appendix summarises it.

These types also appear in the Peios Events Index, a page per type, alongside every other event the system emits.

B.1 Types #

TypeTierWritten for
peipkg.package.installedessentialEach package a transaction installs, or a request to install one that failed
peipkg.package.upgradedessentialEach package a transaction moves to another version — upgrade, downgrade or undo — or a request to that failed
peipkg.package.uninstalledessentialEach package a transaction removes, or a request to remove one that failed
peipkg.repository.addedessentialA repository add and its trust ceremony, successful or failed
peipkg.repository.removedessentialA repository removal, successful or failed
peipkg.repository.reconfiguredessentialEach trust-relevant setting a repository add changed
peipkg.repository.refreshedstandardA refresh, successful or partly failed
peipkg.claim.changedstandardA claim grant or revoke, successful or failed
peipkg.transaction.recoveredstandardEvery run of peipkg recover, successful or failed
peipkg.action.authorisedessentialAn elevated action put to the operator, authorised or declined

An essential type is always written. A standard type is written unless the system's emission policy switches it off.

B.2 Payload fields #

Every record carries subject.token.sid, the user SID of the token peipkg ran under. A field that does not apply is absent.

FieldCarried byContent
transaction.idpackage, claimThe transaction; in a cross-root transaction, the transaction of the package's own root. Absent when none opened
object.package.namepackage, authorisedThe package
object.package.versionpackageThe version installed, moved to, or removed
object.package.version-previousupgradedThe version moved from
object.package.architectureinstalled, upgradedThe package's architecture
source.repository.nameinstalled, upgradedThe repository the package came from; absent for a local file
object.repository.namerepository, authorisedThe repository
object.repository.urladdedIts base URL
config.namereconfiguredThe setting, as the repository file names it
config.text, config.text-previousreconfiguredsignature_policy, new and old
config.value, config.value-previousreconfiguredAny other setting, new and old: days or an index version; 1 or 0 for allow_insecure_transport; the anchor count for trust_anchors
object.claim.role, object.claim.holderclaimThe role, and its new holder; no holder on a revoke
operation.succeeded-count, operation.failed-countrefreshedRepositories refreshed and failed
operation.succeeded-countrecoveredInterrupted transactions rolled back
operation.nameauthorisedlow-trust-provides, foreign-replaces, downgrade, remove-modified-file, stale-trust-state or stale-index
object.file.pathauthorisedThe modified file, for remove-modified-file
outcome.successall but reconfiguredWhether it succeeded; for authorised, whether the operator agreed
outcome.reasonpackage, claim, addedOn failure, peipkg's error code
outcome.detailall but reconfigured and refreshedOn failure, the error text; for authorised, the action as it was put

A failed package record with a transaction identifier was rolled back; one without was refused before any transaction opened. A cross-root transaction that fails while preparing its roots leaves no identifier on its records.

B.3 Where events do not appear #

  • Automatic recovery at the head of an ordinary operation writes nothing.
  • Declining the routine proceed prompt writes nothing.
  • A repository add refused before it names a base URL writes nothing.
  • A refresh that fails before reaching any repository writes nothing.
  • A stale repository refused because --allow-stale was not given writes no authorisation record; only proceeding with one does.
  • Enabling insecure transport, and installing unsigned content under an optional policy, write no authorisation record.
  • peipkg-compose writes nothing at all.

B.4 What emission depends on #

An audit privilege on the caller's token. Without it, emission fails, peipkg warns, and the operation proceeds unaudited, essential types included.

On a kernel with no emit call, emission is a silent successful no-op.

Appendix C Catalogue package families

Peios / Peios technical reference / peipkg

Use peipkg search to find what your configured repositories currently offer. This appendix records the package-family, integration and build details; the install procedure covers applying a change.

C.1 Canonical package names #

New Peios catalogue packages use canonical reverse-DNS names. The leading segments identify the upstream namespace and the final segment preserves the familiar package name, even when that deliberately repeats part of the namespace:

com.amd.amd-ucode
org.gnu.bash
org.sourceware.elfutils
org.peios.peinit

Peios-built packages of third-party software retain the upstream namespace; Peios-owned software and Peios-specific integration packages use org.peios. The name identifies the packaged software, while the repository signature and build provenance identify who packaged it. It does not imply that upstream signed or endorsed a downstream package.

Related payloads retain that canonical base. For example, org.sourceware.elfutils-libs carries libdw and libasm, org.sourceware.elfutils-libelf carries libelf, and org.sourceware.elfutils-devel carries their development interfaces without requiring the command-line tools to be installed.

The elfutils tools include command manuals and eu-make-debug-archive for preparing an offline debugging archive. To archive one executable, use eu-make-debug-archive debug.a -e /path/to/program; its debugging information must be available. The optional --sudo mode requires a separately installed and authorized privilege helper. The debuginfod service/client and stackprof components are not included in this family.

GNU C Library administration commands and their manuals are in org.gnu.glibc-bin. Profiling and tracing commands, including the Perl-based mtrace, are in org.gnu.glibc-utils. Extra legacy character converters are in org.gnu.glibc-gconv-extra, and the reference manual is in org.gnu.glibc-doc, as HTML under /usr/share/doc/org.gnu.glibc/html/. These utilities retain Peios's fixed authority and resolver providers; installing them does not make local passwd files an account authority.

Libtraceevent's library symbols are in org.kernel.libtraceevent-libs-debuginfo, while plugin symbols are in org.kernel.libtraceevent-debuginfo. The tracing libraries provide their API manuals and HTML documentation through their respective -doc packages.

Mozilla root-certificate data is installed by org.mozilla.ca-certificates at /usr/share/ca-certificates/mozilla.crt. The package supplies server-authentication roots from the tracked Firefox release data; it does not install machine trust policy by itself. A flat PEM store cannot express Mozilla's per-root distrust dates, so loading it alone does not reproduce every Firefox trust restriction.

The eudev family follows the same rule: io.github.eudev-project.eudev contains the daemon, tools, rules and Peios service integration; io.github.eudev-project.eudev-libudev contains the runtime library; and io.github.eudev-project.eudev-devel contains the public header, linker name and pkg-config metadata. Installing libudev for an application therefore does not pull in the system device manager. The device-manager package uses peiosutils for the core applets in its coldplug hook; it does not install the GNU Coreutils bootstrap package into the running system.

FIGlet is installed as org.figlet.figlet. Its commands, font catalogue and manuals form one small runtime package; detached debugging symbols and sources use the conventional -debuginfo and -debugsource suffixes.

The file-identification command is com.darwinsys.file. Its libmagic ABI, architecture-independent format database and development interface are split as com.darwinsys.file-libmagic, com.darwinsys.file-magic and com.darwinsys.file-devel, so library consumers do not acquire the command.

GNU find and xargs are installed as org.gnu.findutils; their manuals are in org.gnu.findutils-common. The locate and updatedb tools are omitted until Peios has a service design for maintaining their global index. Because Peipkg normalizes POSIX ownership and mode metadata, find predicates such as -user, -group and -perm inspect that compatibility metadata; they do not query KACS policy and must not be used as authorization checks.

GNU Bison's parser generator, yacc frontend and parser skeletons are in org.gnu.bison. Install org.gnu.bison-devel when a project uses the traditional liby.a convenience library or the bison-i18n.m4 Autoconf macro. Generated-parser message catalogs are independently available through org.gnu.bison-runtime.

GNU Binutils' assembler, linker and object tools are in org.gnu.binutils; headers, linker names and static libraries are in org.gnu.binutils-devel. The optional profiler is org.gnu.binutils-gprofng, including its command manuals. Its library's linker name and static archive are separately available in org.gnu.binutils-gprofng-devel, which also installs the matching profiler runtime. Ordinary compiler builds do not need the profiler.

Binutils supports Zstandard-compressed DWARF debug sections through objcopy --compress-debug-sections=zstd and ld --compress-debug-sections=zstd. The runtime package brings in com.facebook.zstd-libs; applications do not need the development package merely to use these commands. objcopy --decompress-debug-sections restores uncompressed debug sections when another consumer needs them.

The Flex scanner generator is io.github.westes.flex; its flex++ frontend is included with the command. The independently usable libfl runtime is io.github.westes.flex-libs, while io.github.westes.flex-devel adds the C++ header, static archive and linker name without folding those development files into either runtime package.

GNU Awk is installed as org.gnu.gawk and provides both the gawk and awk command names. Loadable extensions and the password/group lookup helpers stay with the interpreter, reusable Awk libraries and manuals are in org.gnu.gawk-common, and org.gnu.gawk-devel provides gawkapi.h for building additional extensions. Arbitrary-precision MPFR arithmetic is enabled. Persistent arrays are disabled because their process-wide ASLR manipulation is outside the Peios process contract. The interactive debugger (gawk -D) has GNU Readline line editing and command history.

The GNU debugger is installed as org.gnu.gdb, with the gcore, gstack and gdb-add-index helpers, Python scripting, the text user interface (gdb -tui), Readline line editing and iconv character-set conversion. It reads compressed debug sections (zlib, zstd and LZMA MiniDebugInfo) and finds a program's symbols in the matching -debuginfo package by build ID under /usr/lib/debug. The remote debugging stub is the separate org.gnu.gdb-gdbserver, and org.gnu.gdb-devel adds the JIT reader interface header. Guile scripting, CTF trace files, Intel Processor Trace, debuginfod lookups and source highlighting are not included.

The Expat XML parser library is installed as io.github.libexpat.expat; its headers, pkg-config and CMake files are in io.github.libexpat.expat-devel, the static library in io.github.libexpat.expat-static, and the xmlwf well-formedness checker in io.github.libexpat.expat-tools.

DWZ, the DWARF duplicate-removal tool, is installed as org.sourceware.dwz. Catalogue packages are built with it, so their -debuginfo payloads are already deduplicated per file. It can also optimise zstd-compressed DWARF while retaining Zstandard compression. Debugedit's tools are org.sourceware.debugedit; its RPM-style find-debuginfo driver is the separate org.sourceware.debugedit-find-debuginfo, which brings in GDB and DWZ.

util-linux's libblkid and getopt are joined by three command packages: org.kernel.util-linux-schedutils (taskset, chrt, ionice and renice, for CPU affinity and scheduling), org.kernel.util-linux-script (script, scriptreplay and scriptlive, for recording terminal sessions) and org.kernel.util-linux-extra (flock, setsid, rev, col, colcrt and colrm). util-linux commands that duplicate peiosutils, or that manage logons, storage, capabilities or namespaces, are not included.

Tcl 8.6 is installed as org.tcl-lang.tcl: tclsh, libtcl and the script library. Its headers (including the private ones under /usr/include/tcl-private), stubs library, tclConfig.sh and C API manuals are in org.tcl-lang.tcl-devel. Expect is org.tcl-lang.expect, providing the expect interpreter and package require Expect, with org.tcl-lang.expect-devel for embedding; Expect's example scripts (autoexpect, unbuffer, mkpasswd and the rest) are not shipped. The DejaGnu test harness, runtest, is org.gnu.dejagnu, and its dejagnu.h unit-test header is org.gnu.dejagnu-devel.

Three C and C++ unit-testing frameworks are available for building and running test suites:

  • GoogleTest and GoogleMock are com.google.googletest, with headers, the CMake package (find_package(GTest)) and pkg-config modules in com.google.googletest-devel and static libraries in com.google.googletest-static. The shared libraries' sonames carry the full release version, so test binaries rebuild with each GoogleTest update.
  • CUnit (the maintained CUnity continuation of SourceForge CUnit) is com.gitlab.cunity.cunit, which holds the XML report stylesheets; headers, the pkg-config module and the CMake package are in com.gitlab.cunity.cunit-devel. CUnit builds only a static library, com.gitlab.cunity.cunit-static, which the development package brings in.
  • Check is io.github.libcheck.check, with headers, the pkg-config module and the AM_PATH_CHECK Autoconf macro in io.github.libcheck.check-devel and the static library in io.github.libcheck.check-static. checkmk, which generates Check test programs from compact descriptions, is the separate io.github.libcheck.check-checkmk.

GNU Gettext's catalog commands are installed as org.gnu.gettext, with manuals, extraction rules, project templates and the commands' translated messages in org.gnu.gettext-common. Project maintainers install org.gnu.gettext-devel for autopoint, gettextize, the Autoconf macros and public headers. The independently usable libasprintf, libgettextpo and libtextstyle ABIs are separate runtime packages. Glibc supplies Peios's libintl; Gettext therefore does not install a competing implementation, while its commands and shell integration retain full native-language support. Java, C#, Emacs and terminal-styling integrations are not included. The optional po-fetch and AI-assisted spit helpers are also omitted until their complete runtime dependencies are available as Peios packages.

GNU gperf is installed as org.gnu.gperf. Its generated C and C++ source has no gperf runtime dependency, so no development or runtime-library subpackage is needed; debugging symbols and their matching source are available through the conventional org.gnu.gperf-debuginfo and org.gnu.gperf-debugsource packages.

GNU GMP's C runtime is org.gnu.gmp; the C++ wrapper is the independently installable org.gnu.gmp-c++, so C-only consumers do not acquire libstdc++. Headers and linker names are in org.gnu.gmp-devel, while static archives are in org.gnu.gmp-static. Builds use GMP's generic x86-64 runtime dispatch rather than instructions selected from the package builder's CPU, so the published libraries remain portable across Peios x86-64 systems.

GNU Grep is installed as org.gnu.grep and provides the grep, egrep and fgrep command names. Its manuals and translated messages are in org.gnu.grep-common. Basic, extended, fixed-string and Perl-compatible (grep -P) regular expressions are all supported; grep -P uses the PCRE2 library from org.pcre.pcre2, which is installed alongside Grep.

GNU gzip is installed as org.gnu.gzip, containing gzip, gunzip, zcat and uncompress; its manuals are in org.gnu.gzip-common. The optional org.gnu.gzip-utils package adds the non-interactive shell helpers such as zgrep, zdiff, zforce, znew and gzexe together with their declared command dependencies. zless and zmore are omitted until the catalogue has a pager provider, so installing the utilities never leaves unusable commands.

The Integer Set Library runtime is installed as io.sourceforge.libisl.isl. Headers, the linker name and pkg-config metadata are in io.sourceforge.libisl.isl-devel; the static archive and its static GMP closure are in io.sourceforge.libisl.isl-static. Published builds use ISL's portable mode rather than selecting instructions from the package builder's CPU.

Kernel module administration commands are installed as org.kernel.kmod; the independently usable libkmod runtime is org.kernel.kmod-libs. Headers and pkg-config metadata are in org.kernel.kmod-devel, with the static archive in org.kernel.kmod-static. The tools support gzip-, xz- and zstd-compressed modules and PKCS#7 signature reporting. depmod is a machine-facing command at /libexec/depmod; package transactions invoke it when a kernel-module payload changes.

The Linux capabilities runtimes libcap and libpsx are installed as org.kernel.libcap. Capability inspection and file-capability commands are in org.kernel.libcap-tools, development interfaces in org.kernel.libcap-devel, and both static archives in org.kernel.libcap-static. Go and PAM integrations are not included in this package family.

Libconfig's C runtime is installed as io.github.hyperrealm.libconfig; its self-contained C++ runtime is io.github.hyperrealm.libconfig-c++. Headers, linker names, pkg-config and CMake metadata for both interfaces are in io.github.hyperrealm.libconfig-devel, while the static archives are in io.github.hyperrealm.libconfig-static.

The libnl protocol-library family is installed as io.github.thom311.libnl. Its command suite, CLI support library, dynamically loaded traffic-control modules and lookup databases are in io.github.thom311.libnl-tools. Install io.github.thom311.libnl-devel for the complete public header and pkg-config surface, and io.github.thom311.libnl-static for the seven static libraries.

The hardware performance-event discovery and encoding runtime is net.sourceforge.perfmon2.libpfm. Its public headers, linker name and API/PMU manuals are in net.sourceforge.perfmon2.libpfm-devel; the static encoder is in net.sourceforge.perfmon2.libpfm-static. Python bindings are not included.

GNU Libtool's command-line tools, macros and support files are installed as org.gnu.libtool. The configured libtool command records the target compiler and ABI paths, so this package is architecture-specific even though its commands are shell scripts. The independently usable libltdl runtime is org.gnu.libtool-ltdl; its headers and linker metadata are in org.gnu.libtool-ltdl-devel, with the static archive in org.gnu.libtool-ltdl-static.

GNU M4 is installed as org.gnu.m4. The package includes the command, localised messages and manual; matching source, debuginfo and debugsource packages are published alongside it.

GNU Make is installed as org.gnu.make. Install org.gnu.make-devel to compile loadable modules against its gnumake.h interface. Guile integration is not included. Recipes use Peios' /usr/bin/sh system-shell path by default.

Meson is installed as com.mesonbuild.meson. The package includes the meson command and manual, and declares Ninja as a runtime dependency because Meson's normal build workflow invokes it. Meson's Python implementation is private to the application rather than exposed as a system-wide Python library.

GNU MPFR's shared runtime is installed as org.gnu.mpfr. Its public headers, linker name and pkg-config metadata are in org.gnu.mpfr-devel; the static archive and its static GMP closure are in org.gnu.mpfr-static. The library is built with thread-safe storage and publishes matching source, debuginfo and debugsource packages.

ncurses terminal-information utilities are installed as org.gnu.ncurses. The wide-character ABI 6 libraries are in org.gnu.ncurses-libs, while the terminal database and tabset files are in org.gnu.ncurses-terminfo. Headers, linker names, pkg-config metadata and API manuals are in org.gnu.ncurses-devel; static libraries are in org.gnu.ncurses-static. Narrow-character, C++ and Ada compatibility surfaces are not included.

The Ninja build executor is installed as org.ninja-build.ninja. Its Bash and Zsh completions, Vim syntax file, README, source manual and ninja(1) command reference are included with the command. The command reference is generated from the packaged executable's help output. Ninja uses the system shell to execute build rules, so the package depends on org.git.kernel.dash; matching source, debuginfo and debugsource packages are published alongside it.

IANA timezone data is installed as org.iana.tzdata under /usr/share/zoneinfo. The build uses the backward-compatible fat TZif format so older timezone compilers preserve final transitions that change daylight saving status or an abbreviation without changing the UTC offset.

The numactl command suite is installed as io.github.numactl.numactl. The shared libnuma runtime is io.github.numactl.libnuma; its headers, linker name, pkg-config metadata and API manuals are in io.github.numactl.libnuma-devel, while the static archive and its static libatomic closure are in io.github.numactl.libnuma-static. Matching source, debuginfo and debugsource packages are published alongside the family.

OpenSSL's general cryptography runtime, providers, engines and vendor configuration are installed as org.openssl.libcrypto; the TLS runtime is org.openssl.libssl. The openssl command and its user manuals are in org.openssl.openssl, while certificate-management Perl utilities are in the architecture-independent org.openssl.openssl-perl package. Headers, linker names, pkg-config and CMake metadata, and API manuals are in org.openssl.openssl-devel; static libraries are in org.openssl.openssl-static. Package-owned configuration is stored beneath /usr/etc/ssl and appears at OpenSSL's compiled /etc/ssl lookup path through Peios's merged configuration view. Matching source, debuginfo and debugsource packages are published alongside the family.

The kernel DWARF and BTF tool suite is installed as org.kernel.pahole. It includes pahole and the accompanying dwarves inspection tools, shell utilities and runtime data. The public libdwarves ABI is split into org.kernel.libdwarves, with headers and linker names in org.kernel.libdwarves-devel, so library consumers do not acquire the command suite. Release builds use upstream's signed tarballs because those contain the embedded libbpf sources needed for a complete build; matching source, debuginfo and debugsource packages are published alongside the family.

The ELF metadata editor is installed as org.nixos.patchelf. It includes the patchelf command, manual and Zsh completion, with matching source, debuginfo and debugsource packages. The command can inspect and change an ELF object's interpreter, run path, dynamic dependencies, SONAME and executable-stack state. Upstream release archives are not maintainer-signed, so newly discovered releases use an explicit HTTPS trust-on-first-use exception and become immutable once recorded in Pekit's SHA-256 lock.

PCI inspection and configuration tools are installed as cz.ucw.pciutils. It includes lspci, setpci, pcilmr, update-pciids and their manuals. The shared libpci ABI and bundled PCI ID database are in cz.ucw.libpci; headers, linker input, pkg-config metadata and the API manual are in cz.ucw.libpci-devel, while the static archive is in cz.ucw.libpci-static. Builds enable compressed ID files, explicit DNS lookups, kernel-module lookup through libkmod and udev HWDB fallback. Matching source, debuginfo and debugsource packages are published alongside the family, using release archives authenticated by maintainer Martin Mares's OpenPGP key.

Standard network-name databases are installed as org.debian.netbase. The package places the protocol, service and Ethernet-type registries in /usr/etc; Peios's merged configuration view exposes those vendor defaults as /etc/protocols, /etc/services and /etc/ethertypes for libc and networking tools. The RPC registry remains part of glibc at /usr/etc/rpc. Locally managed configuration can therefore override these files without modifying package payloads. Perl's shared runtime depends on netbase because its Socket, IO and Net::Ping modules perform named protocol and service lookups.

Perl is installed as org.perl.perl, which also provides the perl virtual capability used by existing build dependencies. Architecture-independent core modules are in org.perl.perl-modules; the shared embedding library and XS modules are in org.perl.libperl; and public CORE headers and linker names are in org.perl.perl-devel. Language, utility and module manuals are split into org.perl.perl-doc. The threaded interpreter uses stable major.minor module paths so a patch update does not abandon locally installed modules, and links the catalogue's zlib and bzip2 rather than bundled copies. Crypt, GDBM and DB extensions remain disabled until their libraries have production packages. Matching source, debuginfo and debugsource packages are published alongside the family. CPAN currently offers Sigstore bundles rather than a detached OpenPGP signature Pekit can verify, so discovered releases become immutable through their Pekit SHA-256 lock after HTTPS retrieval until Sigstore verification is available.

The system pkg-config implementation is org.pkgconf.pkgconf. It provides both the pkgconf and pkg-config command names and the bomtool, spdxtool and pccritic metadata utilities. Install org.pkgconf.pkgconf-devel when regenerating Autoconf builds that need the pkg.m4 macros. The independently usable shared library is org.pkgconf.libpkgconf; public headers, linker name and pkg-config metadata are in org.pkgconf.libpkgconf-devel, with the static archive in org.pkgconf.libpkgconf-static. Matching source, debuginfo and debugsource packages are published alongside the family.

GNU Readline's line-editing and history libraries are installed as org.gnu.readline, linked against ncurses' libtinfo. Headers, linker names, pkg-config metadata and the readline(3) and history(3) manuals are in org.gnu.readline-devel, with static archives in org.gnu.readline-static. Interactive bc, the gawk debugger and Python's readline module (which serves input() and the basic REPL selected by PYTHON_BASIC_REPL) use it, giving Emacs- or vi-style editing, history recall and ~/.inputrc key bindings. Bash builds its own bundled copy of Readline and does not depend on this package.

Rsync is installed as org.samba.rsync, which also provides and replaces the earlier rsync package name. The main package supports IPv6, iconv, generic extended attributes, OpenSSL-accelerated checksums, xxHash, zstd and rolling checksum SIMD. POSIX ACL support is omitted because Peios uses KACS DACLs. The TLS transport wrapper is in org.samba.rsync-ssl, and the Python-based restricted SSH-command wrapper is in org.samba.rrsync, so ordinary local transfers do not acquire either interpreter. The command can be invoked in daemon mode manually, but Peios does not currently install an rsync peinit service. Matching source, debuginfo and debugsource packages are published alongside the family.

For now, commands require the complete canonical name:

peipkg install com.amd.amd-ucode

References to concrete packages in package metadata likewise record complete canonical names; virtual capability names remain unqualified. Unqualified-name resolution may be added in future when the final component identifies exactly one package, but it is not part of the current command contract.

1.1 Overview

Peios / Peios technical reference / netd / Introduction

netd is the Peios network manager. It is the executor of the interface layer of PNP, the Peios Network Policy: it reads which interfaces join which networks, and how they stand there, from Machine\System\Network, and makes the kernel match. It runs as a SYSTEM service started at boot, and it is the only program on a Peios machine that configures links, addresses and routes.

Three decisions shape everything else in this manual.

It is a reconciler, not a command interpreter. netd keeps no list of things it has done. On every input — a kernel link or address event, a registry change, a DHCP or router-discovery packet, a timer, a control request — it re-derives what each interface should look like and applies the difference from what the kernel reports. Nothing happens imperatively in a handler. Two consequences follow directly: a manual change to an interface netd manages lasts until the next pass, and a restarted netd derives its desired state afresh. While its first INIT-REBOOT is pending, it preserves the requested IPv4 address already installed and applicable netd-owned routes (§2.1). Static configuration is desired from the first pass and stays.

It owns addresses wholly and routes only by mark. On an interface netd has joined, every IPv4 address and every non-link-local IPv6 address is netd's: anything else is removed. Routes are netd's only when they carry routing protocol 200, the number netd stamps on every route it adds, so a route another program adds for itself survives. The kernel's own IPv6 link-local address is never touched.

It is the only router-advertisement listener. netd switches the kernel's own RA processing off on every interface before it does anything else, and acts on advertisements itself, so exactly one place decides what an advertisement means. The kernel still makes link-local addresses and answers neighbour solicitations; everything an advertisement could configure — addresses, the default route, DNS, the MTU — is netd's decision, filtered through the interface's profile.

netd builds and judges the interface layer with pnp-core, the same engine the kernel's NTFE uses for the packet layers, so a rule means the same thing in every layer. The kernel never reads Rules\Interface; netd never reads the packet layers. Each executor validates and refuses its own layers on its own.

1.1.1 Where netd sits #

   registry (Machine\System\Network)          peinit
        │ Rules\Interface, Profiles,            ▲ READY=1, LEVEL=<level>
        │ Hostname, Duid, ControlSecurity       │
        ▼                                       │
   ┌─────────────────────── netd ──────────────────────┐
   │ interface layer (pnp-core) → profile per interface │
   │ DHCPv4 · router discovery/SLAAC · DHCPv6           │──▶ rtnetlink (links,
   │ reconciler · network identification                │     addresses, routes)
   └────────────────────────────────────────────────────┘
        │ Interfaces\<id>\Status, Networks\<id>,        │ /run/netd/control.sock
        │ Readiness                                     ▼
        ▼                                         net, resolvd (subscribe)
   registry (written back)

What netd writes back to the registry is read by others: the kernel reads each interface's Status Network to give the packet layers their Network.* facts (PKM §6.5), services and scripts watch Readiness, and resolvd subscribes to netd's DNS snapshots over the control socket (PSPU §6.9).

1.1.2 What netd does not do #

  • Answer names. netd reports what each interface contributes to name resolution; resolvd routes and answers queries.
  • Filter packets. The packet layers are NTFE's. netd's own DHCP and router-discovery traffic passes them only because the shipped baseline policy permits it.
  • Decide who can bind a port. That is the kernel's port reservation table.
  • Associate with wireless networks. A supplicant brings a wireless link up; netd then treats it as any other interface.
  • Hold leases across a reboot. Nothing about a lease is persisted; a rebooted machine asks for the address its network last gave it (§5.7).

1.2 What this manual covers

Peios / Peios technical reference / netd / Introduction

This manual describes netd as built: the netd workspace's netd daemon, its dhcp4, dhcp6 and ndp state-machine crates, the libnetd wire crate, and the net command. It describes version 0.1.7.

1.2.1 What is covered elsewhere #

SubjectWhere
The interface layer's vocabulary — facts, verdicts, the collation laws — and every profile value, as an operator writes themThe network policy reference (~peios/networking/network-policy-reference)
The rule engine itself: ingestion, conditions, collation, refusalKernel TRM chapter 6 (PKM §6.4, §6.5); netd links the same pnp-core crate
The kernel's side of the network context — how Status Network becomes Network.* facts in the packet layersPKM §6.5
The netd → resolver channel's contractPSPU §6.9
What a resolver does with the snapshotsPSPU book 6 and the resolvd manual
The query-channel framing the control socket usesPSPU book 3
How peinit holds a service until a readiness level is publishedThe peinit manual
Every registry value netd reads or writes, one entry eachregman 'Machine\System\Network' on a Peios machine

Where this manual and the network policy reference both mention a profile value, the reference owns the meaning and this manual owns what netd does with it: how the value is parsed, when it takes effect, and how it combines with what a network offers.

1.2.2 Conventions #

Section numbers in this manual are written §n.m. A reference into another book is qualified with the book's short name: PKM §6.5 is the Kernel TRM, PSPU §6.9 the userspace protocols book.

Times are given as netd's monotonic clock sees them. Every timer netd runs — DHCP retransmission, lease boundaries, advertisement lifetimes, solicitation backoff — is measured on CLOCK_MONOTONIC, so setting the wall clock moves none of them.

1.3 Terminology

Peios / Peios technical reference / netd / Introduction

Terms from the policy language — rule, fact, verdict, backstop, priority, exception — mean what the network policy reference says. The terms below are netd's own.

TermMeaning
interfaceA kernel network link netd has seen, keyed by its kernel index while it exists and named in the registry by its interface id.
interface idA UUID-shaped digest of an interface's bus path and MAC (§4.1). Stable across boots and kernel renames.
verdictWhat the interface layer said about an interface: JOIN(profile), IGNORE or DOWN.
joined (managed)An interface whose verdict is JOIN. Loopback is never joined. Only joined interfaces get clients, contribute readiness, and appear in DNS snapshots.
profileA resolved key under Profiles\, inheritance applied: how a joined interface stands on its network.
generationOne build of the interface layer and the profiles from one reading of the registry. netd holds the last good generation; a refused one changes nothing.
passOne run of netd's converge sequence (§2.2): identify networks, judge links, start or stop clients, reconcile, publish.
desired stateWhat one interface should look like: up or down, its MTU, its addresses and its routes (§4.2).
reconcileDiffing desired state against the kernel's and applying the difference (§4.3).
networkWhat is on the other side of a joined interface's carrier, identified from what it has offered (§7.1) and recorded under Networks\<id>.
offerEverything a network has said: a DHCPv4 lease, router advertisements, a stateless DHCPv6 reply.
levelHow far an interface has got towards being usable: absent, link, addressed or routed (§8.2).
scopeWhat one interface contributes to name resolution, as sent to resolvd (§8.3).

2.1 Startup

Peios / Peios technical reference / netd / The Daemon

2.1.1 The service definition #

The netd package ships its service definition as a registry seed, netd-service.reg, applied by an image that names it. The definition:

ValueSettingWhy
ImagePath/usr/sbin/netd
Triggersboot
IdentitySYSTEMrtnetlink changes, the packet socket and a bind to udp/68 each need a privilege only SYSTEM holds
Providesnetworkthe role a dependent names: Requires = ["network:routed"]
Readiness0 (Notify)readiness is READY=1 on the notify socket
RuntimeDirectoriesnetdpeinit creates /run/netd for the control socket
RestartPolicy2 (Always)a stopped network manager is a machine whose leases silently expire
ErrorControl0

A second seed, netd-default-profile.reg, ships the baseline policy: the profile default and the rule wired (§3.1). It is separate so an image can run netd without agreeing that every wired interface joins whatever it is plugged into.

2.1.2 The startup sequence #

netd does these steps in this order:

  1. Opens rtnetlink: one socket for requests and dumps, and a second subscribed to the link, IPv4 and IPv6 address, and IPv4 and IPv6 route multicast groups. Failure is fatal.
  2. Opens the control socket at /run/netd/control.sock, removing a stale one first (§9.1). Failure is fatal.
  3. Opens the udp/68 absorber (§5.6). Failure is a warning: netd runs without it, and a DHCP server's unicast reply can draw an ICMP port-unreachable from the kernel.
  4. Switches the kernel's RA processing off on every interface that exists, and in all and default (§6.1). This happens before any link is brought up.
  5. Reads the configuration (§2.3) and builds a generation from it (§3.2). A generation that fails to build at startup is logged as refused, and netd runs with an empty policy, so the backstop ignores every interface until a good generation is written.
  6. Arms the registry watch on Machine\System\Network. Failure is a warning, and the configuration is then read once, never again.
  7. Dumps the kernel's state (links, addresses and routes, both families). Failure is fatal.
  8. Runs the first pass (§2.2): every interface is judged, every static address applied, every client that is due started.
  9. Sends READY=1 on the notify socket.

Readiness is reported after the first pass, not after any lease arrives. A lease is a network event, not a startup one. A service that needs an address waits on a readiness level (§8.2), not on netd being ready.

A tie between two rules is not checked at startup: the startup build refuses a generation only for the build errors of §3.2. A tie present at startup is judged per interface as a conflict (§3.3), so the interfaces it covers are ignored with a warning rather than the generation being refused.

If Machine\System\Network does not exist when netd starts, the configuration is empty and the watch cannot be armed, so a key created later is never read; a restart of netd picks it up.

2.1.3 Restart #

netd re-derives everything from the registry and the kernel. Its DHCP clients start again, and a client remembers nothing across a restart: each one begins with an INIT-REBOOT request for the address its network last leased (§5.7), and holds no lease until that request is answered.

During an interface's first DHCP start in this process, netd preserves its requested IPv4 address at the prefix already installed in the kernel. It also preserves its netd-marked IPv4 routes while Route.Offered allows them; an explicit Route.Gateway takes precedence and the current profile's metric applies. This provisional state lasts only while INIT-REBOOT is pending. An unchanged ACK replaces it with the confirmed lease without deleting the address or route, so IPv4 readiness stays in place.

A NAK or two unanswered requests end revalidation and discard the provisional state as the client enters fresh discovery (§5.2). Carrier loss or a changed profile clears it too. Later client restarts in the same process do not adopt kernel state again. A requested address that is not already installed is never added merely because it was remembered.

This is not a restored lease: no lease duration, expiry, DNS servers or other offered information is inferred from the installed address. Status still reports no lease until the ACK. DHCP-provided DNS information and IPv6 router-discovery state must be reacquired; this does not promise that every aspect of a daemon restart is invisible. IPv6 addresses and routes from advertisements remain desired only after discovery hears the routers again (§6.1). Static addresses are desired from the first pass and stay.

2.2 The loop

Peios / Peios technical reference / netd / The Daemon

netd is one thread around one poll(2). Every input is a descriptor in the poll set or a timer deadline, and every handler records what it learnt and lets the end of the iteration act on it. Because there is one thread, a handler that blocks holds everything up: no DHCP timer fires and no kernel event is read until it returns.

2.2.1 Inputs #

InputDescriptorWhat it leads to
Kernel eventsthe rtnetlink multicast socketa fresh dump, then a full pass
Registry changesthe watch on Machine\System\Networka reload (§2.3), then a full pass
udp/68 absorberthe absorber socketdrained and discarded
DHCPv4 repliesone packet socket per running clientthe client's actions; a reconcile if the network changed
Router advertisementsone ICMPv6 socket per interface doing router discoverythe engine's actions; a full pass if anything changed
DHCPv6 repliesone UDP socket per running DHCPv6 clientthe client's actions; a reconcile if the DNS facts changed
Control requeststhe listener and accepted client socketsthe request (§9.2); reconcile runs a full pass
Timethe earliest deadline of any clientevery client's timers, each acted on as above

The poll timeout is the earliest deadline any DHCPv4, router-discovery or DHCPv6 client holds, or a pending control request or reply holds (§9.1); with none, netd waits indefinitely. A deadline already acted on is not held again: once an address is deprecated, its preferred lifetime is behind it, and its next deadline is the end of its valid lifetime. So netd sleeps while addresses sit deprecated, which on an IPv6 network with temporary addresses is most of the time.

After a kernel event netd does not fold the event into its model. It dumps the whole state again and always runs a full pass, because a dump taken after an apply can already show a state whose consequences have not been decided yet.

2.2.2 A full pass #

A full pass runs these steps, in this order:

  1. identify networks on joined interfaces with carrier (§7.1);
  2. bring the interface table in line with the kernel's links and judge every non-loopback interface (§3.3), stopping the clients of an interface that lost carrier or whose outcome changed;
  3. start DHCPv4 clients where due (§5.1);
  4. start or stop router discovery and DHCPv6 (§6.1, §6.3);
  5. reconcile every interface (§4.3) and dump the kernel's state again.

It then repeats steps 1 to 5 while the dump differs from the one before the reconcile. The steps run at most four times in one pass, the first run included, even if the dump is still changing after the fourth.

Applying changes alters what the next decisions see: bringing a link up gives it carrier, carrier starts a DHCP client, and a lease adds an address. One pass would leave that chain waiting for a later event that, on a virtual NIC, has already arrived.

After the repeats, the pass applies the hostname (§8.4) and publishes: the machine level to peinit and the registry, every joined interface's Status, and a DNS snapshot to subscribers if it changed (§8.2, §8.3).

2.2.3 Reconcile without a full pass #

A DHCPv4 lease arriving, a lease being lost, link-local fallback, and a DHCPv6 answer each change only what an interface should carry, so they lead to a reconcile of every interface, the hostname, and a publish, without re-judging anything. If the same iteration also needs a full pass, only the full pass runs.

A DHCPv4 lease also marks its interface for re-judgement, because the network on the other side might now be identifiable (§7.1). Any interface so marked makes the iteration run a full pass.

2.3 Reading the registry

Peios / Peios technical reference / netd / The Daemon

netd reads its configuration from Machine\System\Network, whole, every time it reads it.

2.3.1 What is read #

Value or keyUsed for
Hostname (REG_SZ)the machine's name (§8.4). An empty string counts as unset.
ControlSecurity (REG_BINARY)the control object's descriptor (§9.1). An empty value counts as unset.
Duid (REG_SZ)the machine's DHCP unique identifier, as hex (§5.7). A value that is not hex is ignored with a warning, and replaced when netd next decides the DUID (§5.7).
Rules\Interface\ and everything under itthe interface layer (§3.2)
Profiles\ and everything under itthe profiles (§3.1)

Nothing else under the key is configuration to netd. Interfaces\ and Networks\ are read only for the values netd shares with the operator (§5.7, §7.2), at the moment it needs them.

A missing Machine\System\Network means no configuration: no hostname, no rules (so the backstop ignores every interface), no profiles.

2.3.2 Lowering #

Every value in the two trees is lowered to one of four shapes before anything interprets it:

Registry typeLowered to
REG_DWORD (4 bytes)an integer
REG_QWORD (8 bytes)an integer
REG_SZ, REG_EXPAND_SZa string, cut at the first NUL; other if not UTF-8
REG_MULTI_SZa list of strings, empty items dropped, invalid UTF-8 items dropped
anything else, or a DWORD or QWORD of the wrong lengthother

The rule builder refuses an other by name (§3.2), and the profile parser refuses it as a value of the wrong shape (§3.1), so an unusable value is never silently dropped. The default (unnamed) value of a key is skipped in both trees.

Values and subkeys are sorted by name, so two readings of an unchanged tree compare equal.

A tree is read at most 16 levels deep. Below that the read stops with a warning, and whatever lies there is not part of the configuration. Nothing else bounds the depth of a rule tree or a profile tree (§3.2).

2.3.3 A registry change #

The watch is armed on the whole Machine\System\Network subtree. On any event, netd reads the whole configuration again and compares it with what it holds:

  • If anything differs, the control object is rebuilt from ControlSecurity. If the rules or the profiles differ, a new generation is built (§3.2), and taken or refused.
  • Whatever differs, a full pass follows.

netd's own writes to Interfaces\ and Networks\ come back through the same watch. They cause a reload that finds nothing changed, and a pass that plans nothing.

A failed read of the watch's events re-arms the watch.

There is no reload command: a write is noticed within the time it takes the watch to deliver and a pass to run.

3.1 Profiles

Peios / Peios technical reference / netd / The Interface Layer

The profile vocabulary — what each value means to an operator — is the network policy reference's (~peios/networking/network-policy-reference#profiles). This article is what netd does with it.

3.1.1 Resolution #

Every key under Profiles\ is a profile, at every depth, named by its path below Profiles with / between levels. The Profiles key's own values are ignored.

Profiles are looked up case-insensitively: netd keys them by the lower-cased path and keeps the path as written for display and for Status Profile.

A key whose name is empty or contains / or \ refuses the generation.

3.1.1.1 Inheritance #

A profile's effective values are its ancestors' values with its own laid over them, per value name. A value a profile names replaces the inherited one whole — a list replaces a list, never appends to it — and a value it does not name is inherited. Value names compare case-insensitively, so address.offered overrides Address.Offered.

Each profile is parsed from its own effective values. A malformed value in a parent therefore refuses the generation at the parent, before any child is reached.

3.1.1.2 Enabled #

Enabled is a profile's switch, not a value: it is never inherited as one. A profile is enabled when its own Enabled and every ancestor's say so; absent means enabled. A child cannot re-enable itself under a disabled parent. A disabled profile is still resolved and parsed — a malformed value in it still refuses the generation — but a rule that names it abstains (§3.2).

Enabled accepts the boolean shapes below; anything else refuses the generation.

3.1.2 Accepted shapes #

Every value is parsed from its lowered form (§2.3):

ShapeAccepted
booleanan integer (non-zero is true), or a string on, yes, true, 1, off, no, false, 0 in any case
numbera non-negative integer that fits 32 bits, or a string of one, surrounding space trimmed
lista multi-string (empty items dropped), or a single string as a one-item list; an empty string is the empty list
stringa string, or an integer written as its decimal text

A value of any other shape — a list where a boolean belongs, a binary value — refuses the generation with the message profile <path>: <name> has the wrong shape, where <name> is the value's name in lower case.

3.1.3 Values #

ValueShapeWhat netd does with it
Address.Offeredbooleanruns a DHCPv4 client while IPv4 is in the families, and router discovery while IPv6 is
Address.Familieslistitems ipv4 and ipv6, in any case. The list replaces the default of both, so an empty list means neither family. Any other item refuses the generation.
Address.Staticlisteach item address/prefix, either family, prefix at most 32 or 128. An item that does not parse refuses the generation.
Address.LinkLocalbooleanlink-local fallback (§5.5)
Address.Temporarybooleantemporary IPv6 addresses (§6.2)
Address.OnExpirystringDrop or Keep, in any case. Anything else refuses the generation.
Route.Offeredbooleantakes the lease's gateway and classless routes and the routers' default route (§4.2)
Route.Gatewaylisteach item an address of either family. The last IPv4 item is the IPv4 gateway, the last IPv6 item the IPv6 gateway. An item that does not parse refuses the generation.
Route.Metricnumberthe metric of every route netd adds for the interface, and its place in DNS scope order
Mtu.Offeredbooleantakes the lease's MTU, else the routers'
Mtu.Valuenumberthe MTU to set. Below 68 refuses the generation.
Hostname.Announcebooleansends the machine's Hostname in DHCP option 12
Hostname.Offeredbooleanadopts a lease's hostname when Hostname is unset (§8.4)
Dns.Offeredbooleanadds the network's DNS servers and search domains after the profile's own (§8.3)
Dns.Serverslisteach item an address of either family. An item that does not parse refuses the generation.
Dns.Domainslisttaken as written; nothing validates a domain
Dns.Defaultbooleanpresent sets the scope's default-route flag; absent follows the interface's level (§8.3)
Dns.Exclusivebooleansets the scope's exclusive flag

A value name not in this table (case-insensitively) refuses the generation with profile <path>: unknown value <name>, the name as written.

After parsing, any Address.Static entry outside the profile's families is dropped without an error: the family switch wins.

3.1.4 A bare profile #

Every compiled default is false, empty or absent, apart from the families (both) and OnExpiry (Drop). A profile that sets nothing therefore brings its interface up and does nothing else: no clients, no addresses, no routes, no DNS. The friendly behaviour of the shipped default profile is four values in its seed: Address.Offered, Address.LinkLocal, Route.Offered and Dns.Offered, each 1.

3.2 Building a generation

Peios / Peios technical reference / netd / The Interface Layer

A generation is built whenever the rules or the profiles have changed (§2.3), and once at startup.

3.2.1 The order of the build #

  1. Profiles are resolved (§3.1). Any error refuses the generation.
  2. If Rules\Interface does not exist, the generation has no forest. Every interface meets the backstop, IGNORE, and the profiles are kept for nothing to name.
  3. Each subkey of Rules\Interface is a root rule. Its tree is lowered to pnp-core's input and built as the Interface layer with build_forest — the same ingestion, condition parsing and lints the kernel applies to the packet layers (PKM §6.5). Every build error refuses the generation.
  4. Every profile a JOIN names is looked up. A JOIN naming no profile refuses the generation with JOIN(<path>) names no profile.

3.2.2 Lowering a rule #

Integers and strings pass through. A value of any other lowered shape (§2.3) refuses the generation with rule <path>: value <name> has an unsupported type.

A list is passed through item by item, except in Actions. There, an item that is a JOIN naming a profile that exists and is disabled is replaced by NULL before the build, so the rule abstains and the forest's own collation answers without it: its parent, another tree, or the backstop. A JOIN of a disabled profile is not a fault.

A JOIN target is recognised loosely. Whitespace anywhere is ignored, JOIN( matches in any case, \ in the path is read as /, and the path is compared case-insensitively. So join ( Office\London ) names the profile office/london.

3.2.3 What refuses a generation #

Everything pnp-core refuses at the interface layer refuses it here, with netd's wording:

CauseMessage
An unknown fact in a conditionrule <path>: unknown fact <key>
A malformed operatorrule <path>: bad operator in <key>
A malformed patternrule <path>: bad pattern in <key>
Actions not a listrule <path>: Actions is not a list
A malformed actionrule <path>: bad action (…)
An action the interface layer does not speak (PASS, DROP, TAG, …)rule <path>: an action the interface layer does not speak
A key that does not exist at the interface layerrule <path>: <key> does not exist at the interface layer
Present on a fact never present at this layerrule <path>: <key> on a fact that never exists at this layer
Priority not an integerrule <path>: Priority is not an integer
Enabled not 0 or 1rule <path>: Enabled is not 0 or 1
A malformed rule namerule <path>: bad name
A JOIN naming no profileJOIN(<path>) names no profile
A malformed profilethe profile parser's message (§3.1)

The depth of a rule tree refuses nothing. It is bounded only by netd's registry read, which takes rules to 16 levels below Rules\Interface and ignores anything deeper (§2.3).

A condition on a fact that exists in the vocabulary but can never be present at the interface layer, such as DstPort, is not refused. It is a lint: logged as a warning, rule <path>: <key> can never hold at the interface layer, and the generation is taken.

3.2.4 A tie refuses at runtime #

A generation built while netd is running is judged against every non-loopback interface before it is taken. If any interface meets a conflict (two rules tied on priority naming different profiles, §3.3), the generation is refused with rules <a> vs <b> tie on interface <name>.

3.2.5 Taken or refused #

A taken generation replaces the old one whole, clears any recorded refusal, and is logged with its counts of rule trees and profiles. The pass that follows judges every interface against it.

A refused generation changes nothing. The last good generation stays in force, netd logs interface layer refused: <why>; the last good generation stands at error level, and records the reason, which status reports as refusal and net status prints as a policy REFUSED line. The refusal stays recorded until a generation builds.

The interface layer's refusal is netd's alone. The kernel reads the packet layers from the same registry state and refuses or takes them on its own (PKM §6.5): a dangling JOIN never stalls the firewall, and a bad Flow rule never stops an interface joining.

3.3 Judging an interface

Peios / Peios technical reference / netd / The Interface Layer

3.3.1 The facts #

An interface is judged on a snapshot carrying only interface and network facts:

FactValue
Interfacethe kernel's name for the link
Interface.Kindwired, wireless, loopback or other (§4.1)
Interface.Idthe interface id (§4.1)
Interface.Macthe hardware address, when the link has a 6-byte one
Interface.Paththe bus path; absent when sysfs gives none
Interface.Driverthe driver; absent when sysfs gives none
Network.Idthe id of the network identified on the link (§7.1); absent until one is
Network.Namethe record's Name; absent when unset or empty
Network.Trustthe record's Trust; absent when unset or empty
Network.Kindthe interface's kind, once a network is identified; absent before

A fact the interface lacks is absent, which makes a condition on it false, never an error.

3.3.2 When an interface is judged #

Every non-loopback interface is judged on every full pass (§2.2). Loopback is never judged and never managed: it has no verdict, no record under Interfaces\, and netd never changes it.

3.3.3 The verdict #

pnp-core evaluates the forest and netd reads the result:

  • JOIN(i): the profile the forest's ith JOIN names, looked up case-insensitively. netd stands the interface in that profile.
  • DOWN: the interface is kept administratively down.
  • anything else, including the backstop: IGNORE.

The attribution is the evaluation's: the path of the rule that spoke, or backstop when none did.

3.3.4 Conflicts #

When the evaluation reports a conflict — the highest-priority candidates tie and name different profiles — netd does not choose. The interface's outcome is IGNORE, its attribution is the tied rules' paths, sorted and joined with vs, and its warning is rules <a> vs <b> tie; the interface is ignored. The first time an interface meets a conflict, netd logs a warning.

A conflict reaches an interface in two ways. A generation taken at startup is not checked for ties (§2.1), and an interface that appears after a generation was taken is judged against it with no check. A generation built at runtime that ties on an interface already present is refused instead (§3.2).

3.3.5 A change of verdict #

The outcome compares the verdict and the profile's resolved values. When either differs from the interface's previous outcome, netd logs the new verdict and its rule, and stops the interface's clients:

  • the DHCPv4 client sends a RELEASE if it holds a lease (§5.4) and is discarded, along with its lease and any link-local address;
  • router discovery and DHCPv6 are discarded, and their addresses go at the next reconcile.

The pass then starts whatever clients the new outcome wants. Editing any value of the profile an interface stands in is a change of outcome, so it restarts the interface's clients against the new profile, rather than trusting a lease obtained under the old one.

3.3.6 What each verdict does #

VerdictClientsDesired state (§4.2)
JOIN(profile)started as the profile asksup, with the profile's addresses and routes
DOWNnonedown, carrying no addresses and no netd routes
IGNOREnonenone: netd plans nothing for the interface

An IGNOREd interface is left exactly as it is. If it was joined before, the addresses and routes netd gave it stay where they are, because nothing desires their removal.

4.1 Identity and carrier

Peios / Peios technical reference / netd / Interfaces

4.1.1 Kind #

netd classifies each kernel link once per dump:

LinkInterface.Kind
the loopback flag setloopback
link type Ethernet, with /sys/class/net/<name>/wireless or /sys/class/net/<name>/phy80211 presentwireless
link type Ethernet otherwisewired
any other link typeother

A bridge, a veth or a TAP device has link type Ethernet and is wired.

4.1.2 Bus path and driver #

The bus path comes from sysfs. netd resolves /sys/class/net/<name>/device and walks up from it to the nearest ancestor whose name is a PCI address (dddd:bb:dd.f), and names it pci-<address>. A device with no PCI ancestor is named by the device's own directory name. A link with no device (a virtual device) has no bus path. A virtio NIC sits one level below its PCI function, so it is named by the function.

The driver is the name the device/driver link points at (virtio_net, for example), and empty when there is none.

Both are read once, when netd first sees the link.

4.1.3 The interface id #

The interface id is a SHA-1 digest of the bytes peios-netd-ifid|, then the bus path if there is one, then |, then the six MAC bytes — or, for a link with no 6-byte hardware address, its name. The first 16 bytes of the digest are stamped as a version 5, RFC 4122 variant UUID and written in lower-case hyphenated form.

So the same card in the same slot has the same id on every boot, however the kernel names it, and a renamed link keeps its id. A virtual device has no bus path, so its id follows its MAC alone.

The id is the key of the interface's record under Interfaces\ (§8.1), the Interface.Id fact, the key of the RFC 7217 address derivation (§6.2), and the basis of its DHCP IAID (§5.7).

4.1.4 Appearing and disappearing #

When netd first sees a link, it reads the link's identity, logs interface <name> (<path>) is <id>, and, unless it is the loopback, writes accept_ra = 0 for it (§6.1).

When a link disappears from the kernel's dump, netd drops it and everything it held: its clients, lease, network and judgement. Nothing is sent on the wire. Its record under Interfaces\ stays.

4.1.5 Carrier #

netd counts an interface as having carrier when it is administratively up and the kernel reports IFF_LOWER_UP. When an interface that had carrier loses it, netd logs interface <name>: carrier lost and:

  • stops its DHCPv4 client: a RELEASE is sent if a lease was held, and the lease and any link-local address are forgotten (§5.4);
  • stops router discovery and DHCPv6;
  • forgets the network identified on it (§7.1), so Status Network is cleared and the Network.* facts go absent, for netd's own judgement and the kernel's packet layers alike.

The next reconcile then removes the leased and autoconfigured addresses and the routes that depended on them, because nothing desires them any more. Static addresses stay.

When carrier returns, the pass starts the clients again from scratch (§5.1, §6.1), and the DHCPv4 client asks first for the address the network last gave (§5.7).

4.2 Desired state

Peios / Peios technical reference / netd / Interfaces

On every reconcile netd computes, for each interface, what it should look like. That desired state is a link state, an MTU, a set of addresses and a set of routes. It is derived from the interface's verdict, its profile, and its clients' current state. During the first INIT-REBOOT it also includes the provisional IPv4 kernel state described in §2.1; netd keeps no general record of what it applied.

4.2.1 By verdict #

  • Loopback, or an IGNOREd interface: there is no desired state, and netd plans nothing for the interface.
  • DOWN: the link down, with no addresses and no routes.
  • JOIN: the link up, and everything below.

4.2.2 The metric #

Every route netd adds for an interface carries the interface's metric: the profile's Route.Metric when it is set, otherwise 600 for a wireless interface and 100 for anything else.

4.2.3 The MTU #

The profile's Mtu.Value when set. Otherwise, with Mtu.Offered, the lease's MTU (DHCP option 26), else the routers' advertised MTU (§6.1). Otherwise no MTU is desired and the link's MTU is left as it is.

4.2.4 IPv4 addresses #

  • every IPv4 entry of Address.Static, at its prefix;
  • the lease's address at the lease's prefix (§5.3), when a lease is held;
  • while the first INIT-REBOOT is pending, the requested address at its already-installed prefix (§2.1);
  • the link-local address (§5.5) at prefix 16, only when the client has fallen back to one and no lease is held and the profile has no static address of either family.

The order in which they are listed, or written in Address.Static, has no effect. netd adds the addresses an interface lacks in ascending address order (§4.3), and the kernel makes an address primary only when the interface holds no other address in the same subnet, so the first one added in a subnet is its primary. Of two statics in one subnet written 10.0.0.9/24, 10.0.0.3/24, both added in one pass, 10.0.0.3 is the primary.

While a lease that carried a broadcast address (option 28) is held, that broadcast is the one netd sets on every IPv4 address it adds to the interface: the lease's own address, and a static added in that time, whatever its subnet (§4.3).

4.2.5 IPv4 routes #

  • The default route goes via Route.Gateway's IPv4 entry when it is set. Otherwise, with Route.Offered, it goes via the lease's gateway: the first router of option 3, or, when the lease carried classless routes, the gateway of the classless 0.0.0.0/0 route. With neither, there is no IPv4 default route.
  • Classless static routes (option 121) with a prefix above zero are added, each via its gateway, only with Route.Offered.

During the first INIT-REBOOT, existing netd-marked IPv4 routes accompany the provisional address when Route.Offered is enabled. The profile's explicit gateway and current metric still take precedence (§2.1).

Route.Gateway is used as written; netd does not check that the gateway is on a subnet the interface has. A gateway the kernel cannot reach makes the route's add fail on every reconcile, logged each time (§11).

4.2.6 IPv6 addresses #

  1. every IPv6 entry of Address.Static, at its prefix, preferred, with a prefix route;
  2. every address router discovery currently wants (§6.2): stable-privacy and temporary addresses, each deprecated or not, each with or without a prefix route according to the prefix's on-link flag.

4.2.7 IPv6 default route #

Via Route.Gateway's IPv6 entry when it is set. Otherwise, with Route.Offered, via the default router router discovery has chosen (§6.1). With neither, there is no IPv6 default route.

4.2.8 Not desired #

netd never desires the kernel's own IPv6 link-local address, the kernel's prefix routes, or any route without netd's protocol mark. Those are outside what reconciliation compares (§4.3).

4.3 Reconciliation

Peios / Peios technical reference / netd / Interfaces

4.3.1 What netd owns #

On an interface with a desired state:

  • Every IPv4 address, including a 169.254 address, and every IPv6 address except a link-local one is netd's. An address netd does not desire is removed, whoever added it.
  • A route is netd's only if it carries routing protocol 200 (RTPROT_NETD). netd stamps it on every route it adds, and removes no route without it.
  • The kernel's IPv6 link-local addresses (fe80::/10) are never compared, added or removed. Every up interface has one, and neighbour discovery needs it.

Only routes in the main table, of unicast type and with an output interface, are read from the kernel at all.

4.3.2 The plan #

For each interface with a desired state, netd plans these operations, in this order:

  1. Link up, if it is desired up and is down.
  2. MTU, if one is desired, differs from the link's, and is at least 68.
  3. Address deletions: every address netd owns whose address and prefix length together match no desired address.
  4. Address additions: every desired address the kernel does not hold with the same flags, in ascending address order, IPv4 before IPv6.
  5. Route deletions: every protocol-200 route on the interface that is not desired.
  6. Route additions: every desired route the kernel does not hold.
  7. Link down, if it is desired down and is up — last, after its addresses and routes have gone.

Addresses compare by address and prefix length, plus the two flags netd sets: deprecated and no-prefix-route. A tentative flag (duplicate address detection still running) is erased before comparing. A change of flags alone is therefore an addition of the same address, which replaces it (below), never a delete and re-add that would reset standing connections.

An IPv4 address's broadcast address is not compared. An address keeps the broadcast it was added with: a lease whose broadcast changes, or a lease that arrives after a static was added, changes nothing on an address the interface already holds.

Routes compare on everything: destination, prefix length, gateway, metric and protocol. A change of metric or gateway is a deletion and an addition. For IPv6 routes, configured metric zero uses the kernel default of 1024: netd sends and compares that effective value, so an unchanged route converges. IPv4 metric zero and all nonzero metrics are unchanged. The configured Route.Metric still controls DNS scope ordering.

An interface whose desired state the kernel already matches gets an empty plan, and nothing is sent.

4.3.3 Applying #

Operations are applied one at a time, each as an rtnetlink request with an acknowledgement:

OperationRequest
link up or downRTM_SETLINK changing only IFF_UP
MTURTM_SETLINK with IFLA_MTU
address addRTM_NEWADDR with NLM_F_CREATE and NLM_F_REPLACE
address deleteRTM_DELADDR
IPv4 route addRTM_NEWROUTE with NLM_F_CREATE and NLM_F_APPEND, main table, protocol 200
IPv6 route addRTM_NEWROUTE with NLM_F_CREATE and NLM_F_EXCL, main table, protocol 200
route deleteRTM_DELROUTE

IPv4 route additions append a distinct gateway/interface at the configured metric. Two joined interfaces may therefore both hold default routes at the same metric; adding one preserves the other, and withdrawing one removes only its own route. Exact duplicates remain EEXIST. Equal-metric route selection follows the kernel's routing behavior; set distinct metrics when one interface should have a defined preference.

How addresses are phrased:

  • An IPv4 address carries IFA_LOCAL and IFA_ADDRESS set to itself, link scope for 169.254/16 and universe scope otherwise. Below a /31 it also carries a broadcast address: the broadcast of the lease the interface holds, if that lease gave one, otherwise the address's own subnet's all-ones address. The lease's broadcast goes on every IPv4 address added while the lease is held, statics included.
  • An IPv6 address carries universe scope and, when its prefix is not on-link, IFA_F_NOPREFIXROUTE. Its lifetimes are netd's to manage, so the kernel is told a valid lifetime of forever and a preferred lifetime of forever, or of zero when the address is deprecated. Source address selection then stops choosing a deprecated address, and connections already using it keep it.

A route with a gateway has universe scope, and one without has link scope. A route carries a metric only when it is above zero.

A failed operation is logged as reconcile: <operation> failed: <error> and the rest of the plan carries on, so one bad route never stops an address landing. EEXIST on an add and ENOENT or ESRCH on a delete mean the kernel got there first, and are not logged.

After every interface's plan is applied, netd dumps the kernel's state again, so readiness and the status reply describe what actually landed.

5.1 The client

Peios / Peios technical reference / netd / Dhcpv4

netd's DHCPv4 client is the dhcp4 crate: the RFC 2131 state machine as a pure, clock-injected type, driven by netd's loop and sockets.

5.1.1 Starting and stopping #

On every full pass, for each joined interface:

  • the interface wants a client when its profile has Address.Offered and IPv4 in its families;
  • it can run one when it is up with carrier.

A running client is stopped (§5.4) in the step of the pass that brings the interface table in line with the kernel's links, before clients are started (§2.2):

  • when the link loses carrier or goes down, logged as interface <name>: carrier lost;
  • when the interface's outcome changes, logged as the new verdict (§3.3). Any edit to the profile the interface stands in is a change of outcome, so it stops the client even when the edited profile still wants one, and the same pass starts a new one.

Only a change of outcome changes what an interface wants, so a client its interface no longer wants is always stopped the second way. A link that leaves the kernel takes its client with it (§5.4).

An interface that wants and can run a client but has none starts one, logging interface <name>: dhcp starting, provided:

  • the link has a 6-byte hardware address — otherwise no client is started, silently;
  • its packet socket opens (§5.6) — otherwise netd logs no packet socket and tries again on the next pass.

A new client starts with no lease. It is configured with:

SettingValue
chaddrthe interface's MAC
client identifier (option 61)the interface's ClientId (§5.7)
hostname (option 12)the machine's Hostname, only when the profile has Hostname.Announce and Hostname is set
randomness8 bytes of /dev/urandom XORed with the interface index, seeding transaction ids and jitter
a previous addressthe RequestedAddress of the interface's network, if one is known; otherwise that of the network named by its Status LastNetwork (§5.7)

With a previous address the client begins in Rebooting (RFC 2131 §3.2, INIT-REBOOT). Without one it begins in Selecting.

5.1.2 States #

StateEnteredLeaves on
Selectingstart without a previous address; a NAK while requesting or rebooting; a request that went stale; a reboot nobody confirmed; a lost leasean acceptable OFFER
Requestingan OFFER in Selectingan ACK (Bound), a NAK (Selecting), four unanswered transmissions (Selecting)
Rebootingstart with a previous addressan ACK (Bound), a NAK (Selecting), two unanswered transmissions (Selecting)
Boundan ACKT1 (Renewing)
RenewingT1; or an operator renew while bound, renewing or rebindingan ACK (Bound), a NAK (lease lost, Selecting), T2 (Rebinding)
RebindingT2an ACK (Bound), a NAK or the lease's end (lease lost, Selecting)

The status reply shows a client's state only inside its lease, which is present only while the client holds a lease (§9.2), and net status prints it on the lease line. So the state shown is always bound, renewing or rebinding. A client in Selecting, Requesting or Rebooting holds no lease, and neither shows a state for it. The init state exists only between a stop and the client being discarded.

A new transaction id is drawn on entering Selecting, Rebooting, Renewing and Rebinding. Requesting keeps the id of the Selecting exchange it came from.

5.1.3 What every message carries #

Every message is a BOOTREQUEST with hardware type 1 and length 6, the current transaction id, chaddr, and secs set to the whole seconds since the client started, capped at 65535. Every message except a RELEASE carries, in this order:

OptionContent
53the message type
61the client identifier
57maximum message size 1500
55the parameter request list: 1, 3, 6, 12, 15, 26, 28, 42, 51, 58, 59, 119, 121
12the hostname, when configured

The encoded message ends with option 255 and is padded with zeros to 300 bytes. An option longer than 255 bytes would be cut to 255.

5.1.4 The messages #

MessageWhenBroadcast flagciaddrOption 50Option 54Sent
DISCOVERSelectingset0the address of a lease that expired, until a NAK or a reboot timeout forgets it—broadcast
REQUESTRequestingset0the offered addressthe offer'sbroadcast
REQUESTRebootingset0the previous address—broadcast
REQUESTRenewingclearthe lease address——unicast to the server
REQUESTRebindingclearthe lease address——broadcast
RELEASEa stop while holding a leaseclearthe lease address—the serverunicast to the server

A RELEASE carries only options 53, 54 and 61.

"Broadcast" and "unicast" are the paths of §5.6: a broadcast leaves the packet socket from 0.0.0.0 to 255.255.255.255, rebinding included; a unicast leaves an ordinary UDP socket bound to the lease address.

5.2 Timers

Peios / Peios technical reference / netd / Dhcpv4

Every timer is on netd's monotonic clock.

5.2.1 Retransmission while acquiring #

In Selecting, Requesting and Rebooting each transmission schedules the next one after the current backoff plus a jitter drawn uniformly from −1 s to +1 s, then doubles the backoff, up to 64 s. The backoff starts at 4 s on entering each of the three states. So transmissions follow at roughly 4, 8, 16, 32, 64, 64, … seconds.

StateTransmissions before it gives upThen
Selectingnever gives up—
Requestingfour REQUESTs (about 0, 4, 12 and 28 s)at the next timer, about 60 s, Selecting with a new DISCOVER
Rebootingtwo REQUESTs (about 0 and 4 s)at the next timer, about 12 s, Selecting with a new DISCOVER and no option 50

5.2.2 "Nobody answered" #

On the third retransmission of a DISCOVER — the fourth DISCOVER, about 28 s after the first — the client reports that no offer came. netd then records the interface warning asked for an address; nobody answered and, when the profile wants it, falls back to a link-local address (§5.5). The client keeps discovering. The warning survives full reconciliation passes, including the pass triggered by adding the link-local fallback. It clears when a lease binds or that DHCP client stops (for example, on carrier loss or a profile change). A rule-conflict warning takes display precedence; rejudging an unchanged client does not erase its discovery warning.

In source 23af1f6, the report is made at most once between client starts: the flag is reset only when a client is started. A client that reported it, then got a lease, then lost it, does not report it again, so it does not fall back to a link-local address a second time.

The proposed source correction resets the flag after a valid ACK produces a lease. If that lease is later lost, unanswered discovery can report again at the same fourth-DISCOVER threshold, allowing the profile's link-local fallback to be requested again. An uninterrupted unanswered acquisition still reports only once: a malformed ACK, a NAK while Requesting, or a Requesting timeout does not rearm it. This describes the proposed source behavior, not a released package or an installed image; check the installed build before relying on repeat fallback.

5.2.3 Lease boundaries #

A lease's T1, T2 and length are measured from the moment its ACK arrived, and are fixed when the lease is read (§5.3).

  • At T1 a Bound client enters Renewing and sends a unicast REQUEST.
  • At T2 a Renewing client enters Rebinding and sends a broadcast REQUEST.
  • At the lease's end a Rebinding client loses the lease (§5.4) and enters Selecting.

A boundary is acted on before any retransmission due at the same moment.

5.2.4 Retransmission while renewing and rebinding #

Each REQUEST in Renewing or Rebinding schedules the next for half the time remaining to the state's next boundary (T2 while renewing, the lease's end while rebinding), but never sooner than 60 s from now. With a short lease the 60 s floor means a state's boundary usually arrives before its second REQUEST: a 20 s lease renews once at 10 s and rebinds once at 17 s, then lapses at 20 s.

An ACK in either state starts the lease's clock again from that moment.

5.3 Replies and leases

Peios / Peios technical reference / netd / Dhcpv4

5.3.1 Which replies are looked at #

A received message is ignored unless all of these hold:

  • it is a BOOTREPLY;
  • its transaction id is the client's current one;
  • its chaddr is the interface's MAC;
  • it carries a message type (option 53) the client knows.

5.3.2 The server lock #

Once a server has been chosen — the server of the lease held, or else the server identifier of the offer being requested — an ACK or NAK is accepted only if its option 54 names that server. A stranger who guesses the transaction id cannot take a lease away or substitute one.

In Rebooting, with no lease and no offer, no server has been chosen, so an ACK or NAK from any server is accepted.

5.3.3 What each state accepts #

StateMessageResult
SelectingOFFER with a non-zero yiaddr and a server identifiertaken: Requesting. The first acceptable OFFER wins; there is no comparison between offers.
SelectingOFFER without eitherignored
Requesting, Rebooting, Renewing, RebindingACK that reads as a lease (below)Bound, with the lease
Requesting, Rebooting, Renewing, RebindingACK that does not read as a leaseignored; retransmission continues
Requesting, RebootingNAKSelecting; the previous address and the offer are forgotten
Renewing, RebindingNAKthe lease is lost (§5.4); Selecting
any other combinationignored

An ACK in Renewing or Rebinding that grants a different address replaces the lease. The next reconcile removes the old address and adds the new.

5.3.4 Reading an ACK #

An ACK is a lease only when it has:

  • a non-zero yiaddr;
  • a server identifier (option 54);
  • a lease time (option 51) of exactly 4 bytes, and at least 4 seconds.

A lease time under 4 s is refused: it cannot hold T1 < T2 < lease, and a server asking for it wants the client to spin.

5.3.4.1 The prefix #

From the subnet mask (option 1) when it is a non-zero run of contiguous ones. Otherwise, absent or malformed, from the address's class: /8 for a first octet up to 127, /16 up to 191, /24 above.

5.3.4.2 Routes #

Classless static routes (option 121, RFC 3442) are read as a sequence of prefix length, the significant destination octets, and a 4-byte gateway. In source 23af1f6, a prefix length above 32, or an entry running past the end of the option, makes the whole ACK fail to read as a lease: it is ignored like any other unusable ACK.

The proposed source correction instead discards the entire malformed optional route list, including any valid entries before the malformed one, and keeps an otherwise-valid lease. The existing router-option (3) fallback then applies; without that option, the lease has no routes or gateway. This recovery does not relax whole-message or option framing, the mandatory lease requirements above, or the client and server-identity checks. It describes the proposed source policy, not a released package or installed image, and does not assert an RFC requirement to recover malformed route lists.

When the decoded classless route list is nonempty, the router option (3) is ignored. The lease's gateway is the first router of option 3, or, when classless routes are present, the gateway of the classless route with prefix 0.

5.3.4.3 T1 and T2 #

  • T1 is option 58 when it is above 0 and below the lease time minus 2. Otherwise it is half the lease time.
  • T2 is option 59 when it is above T1 and below the lease time. Otherwise it is seven-eighths of the lease time, rounded down, raised to at least T1 + 1 and capped at the lease time minus 1.

So T1 < T2 < the lease time always holds, whatever the server sent.

5.3.4.4 Other options kept #

OptionKept as
6DNS servers, every 4-byte entry
15the domain name: the bytes up to the first NUL; dropped if not UTF-8
119the domain search list, RFC 1035 names with compression pointers followed, at most 16 jumps per name
12the hostname offered: the bytes up to the first NUL; dropped if not UTF-8
26the MTU, when it is at least 68
28the broadcast address
42NTP servers, every 4-byte entry

Where an option appears twice, its first occurrence is used. Nothing else in the ACK is kept.

How each of these is used — and whether, by the profile — is in §4.2 (addresses, routes, MTU), §8.3 (DNS and NTP) and §8.4 (the hostname).

5.3.5 The record of a lease #

A lease is logged as interface <name>: lease <address>/<prefix> from <server> for <seconds>s. Binding clears the interface's link-local address and its warning, and marks the interface for re-judgement, because the network might now be identifiable (§7.1).

5.4 Losing a lease

Peios / Peios technical reference / netd / Dhcpv4

5.4.1 Lost by the network #

A lease is lost when a Rebinding client reaches the lease's end (§5.2), or when a NAK arrives while renewing or rebinding (§5.3). The client forgets the lease and enters Selecting, and netd acts on the interface's Address.OnExpiry:

  • Drop (the default): netd logs interface <name>: lease lost and forgets the lease. The next reconcile removes the address and the routes that came with it.
  • Keep: netd logs interface <name>: lease lost; keeping the address (Address.OnExpiry = Keep) at warning level and goes on desiring the lost lease — its address, gateway and routes — while the client looks for a new one. The kept lease is replaced the moment a new one binds.

With Keep, the status reply's lease describes the client, not the kept lease: it disappears with the client's lease, while the address stays on the interface.

Keep lasts while the client lasts. A stop (below) forgets the kept lease as it would any other.

5.4.2 Stopped by netd #

netd stops a client when:

  • the interface's outcome changes: another verdict, another profile, or any value of its profile edited (§3.3);
  • the interface loses carrier (§4.1);
  • the profile no longer wants a client (§5.1).

A client in Bound, Renewing or Rebinding sends a RELEASE to its server first: unicast from the lease address (§5.1, §5.6). After a carrier loss that RELEASE goes out on a link with no carrier, so the server never sees it. The client is then discarded, and the interface's lease and link-local address are forgotten, so the next reconcile removes both addresses.

A link that disappears from the kernel takes its client with it and sends nothing.

5.4.3 The operator's renew #

The control request renew (§9.2), and so net renew <interface>, moves a client in Bound, Renewing or Rebinding into Renewing at once: a new transaction id and a unicast REQUEST to the lease's server. The lease is not released first, and its address stays on the interface throughout. A client that holds no lease is left as it is, and the request still answers ok.

5.5 Link-local fallback

Peios / Peios technical reference / netd / Dhcpv4

With Address.LinkLocal in the interface's profile, an interface whose DHCPv4 discovery goes unanswered gets an IPv4 link-local address (RFC 3927), so two machines on a cable with no server can still reach each other.

5.5.1 When #

At the client's "nobody answered" report (§5.2), about 28 s after discovery began, netd logs `interface : no DHCP offer; link-local

` and the interface desires the address. Discovery carries on throughout.

The address is desired at prefix 16 only while no lease is held and the profile has no Address.Static entry of either family (§4.2). A profile with a static address therefore never shows one, even after the report.

5.5.2 Which address #

A pure function of the interface, so it is the same every time:

  • the seed is the last four bytes of the MAC, read as a big-endian 32-bit number, or, for a link with no MAC, the interface index times 2654435761, wrapping at 32 bits;
  • the host part is 256 plus the seed modulo 64768;
  • the address is 169.254.<host / 256>.<host % 256>.

That lands in 169.254.1.0 to 169.254.254.255, the range RFC 3927 allows for self-assignment.

netd does not probe for a conflicting holder or announce the address (RFC 3927 §2.2 and §2.4). Two machines whose MACs share their last four bytes choose the same address.

5.5.3 When it goes #

The link-local address is forgotten when a lease binds, and when the client is stopped (§5.4). The next reconcile removes it, because netd owns every IPv4 address on a joined interface (§4.3).

5.6 Sockets

Peios / Peios technical reference / netd / Dhcpv4

Before an interface has an address, nothing but a packet socket can send from 0.0.0.0. So every DHCPv4 broadcast leaves through AF_PACKET, with an IPv4 and UDP header netd builds itself, and every reply is read from the same socket.

5.6.1 The packet socket #

One per running client: AF_PACKET, SOCK_DGRAM, protocol ETH_P_IP, bound to the interface. In the proposed corrected source, a classic BPF filter passes only an IPv4 UDP datagram that is not a fragment and is addressed to port 68. It refuses both a non-zero fragment offset and the more-fragments flag; the don't-fragment flag is allowed. Everything read is checked again for an IPv4 header and UDP port 68, then decoded as a DHCP message.

In versions 0.1.7 (described by this manual) and 0.1.8, the filter tests only the fragment offset: a later fragment is refused but a first fragment passes and is decoded as if it were the whole datagram. The proposed source correction above is not present in those releases.

The socket sees every frame on the interface that passes the filter, so it receives both broadcast replies and replies unicast to the lease address.

5.6.2 The broadcast path #

DISCOVER, REQUEST in Requesting and Rebooting, and REQUEST in Rebinding go out on the packet socket to Ethernet ff:ff:ff:ff:ff:ff, as one IPv4 datagram:

FieldValue
source0.0.0.0
destination255.255.255.255
TOS0x10
identification0
flagsdon't-fragment
TTL64
UDP ports68 → 67
UDP checksum0 (none, which IPv4 permits)

5.6.3 The unicast path #

REQUEST in Renewing, and RELEASE, go out on a transient AF_INET UDP socket with SO_REUSEADDR and SO_BINDTODEVICE set to the interface, bound to the lease address on port 68, sent to the server on port 67, and closed. The kernel resolves the server's link-layer address. The reply is read from the packet socket.

Binding port 68 meets the kernel's port reservation for ports 1 to 1023, which the shipped seed grants to SYSTEM, netd's identity.

5.6.4 The absorber #

At startup netd binds one more UDP socket, to 0.0.0.0:68 with SO_REUSEADDR, and discards whatever arrives on it. Its only purpose is that a server's unicast reply finds a bound port, so the kernel does not answer it with ICMP port-unreachable. The packet sockets see the same datagrams. If it cannot be bound, netd runs without it (§2.1).

5.7 Identity and memory

Peios / Peios technical reference / netd / Dhcpv4

A DHCP server recognises a client by its client identifier, and a reservation on the server matches on it. netd keeps the identifier stable across boots and NIC swaps in the same slot, and remembers per network what it was given.

5.7.1 The DUID #

The machine's DHCP unique identifier, shared by DHCPv4 and DHCPv6 (RFC 4361), is decided once per netd process, the first time a client needs it:

  1. the registry's Machine\System\Network Duid, as hex, when it is set and parses;
  2. otherwise the file /var/state/netd/duid, when it holds at least 4 bytes;
  3. otherwise a new DUID-LL: 00 03 00 01 followed by the MAC of the interface whose client is starting. It is written to /var/state/netd/duid; a failure to write is logged and changes nothing else.

When the registry's Duid is absent, empty or not hex, the result of 2 or 3 is written to it, as colon-separated lower-case hex, replacing a value that was not hex.

Hex in Duid (and ClientId) can be written with :, - or spaces between digits, or none. A value with any other character, an odd number of digits, or no digits is not hex.

5.7.2 The client identifier #

Per interface, Interfaces\<id> ClientId:

  • when the value is set and is non-empty hex, it is sent as written;
  • when it is absent, the identifier is generated and written back: ff, then a 4-byte IAID, then the DUID. The IAID is the interface id's text folded into 4 bytes by XOR, byte i of the text into byte i mod 4;
  • when it is set but not hex, netd logs <id>: ClientId "<value>" is not hex; regenerating, where <id> is the interface id, generates one, and overwrites the value.

ClientId is a value both netd and the operator write. Writing it is how an operator makes netd present the identifier a server reservation expects. It is read when a client starts, so it takes effect at the next start (a carrier loss, a profile edit, a restart of netd).

5.7.3 The address a network gave #

While an interface holds a lease on an identified network (§7.1), netd writes the lease address to that network's Networks\<id> RequestedAddress whenever it differs from what is there.

When a client starts, it takes as its previous address (§5.1):

  1. the RequestedAddress of the network currently identified on the interface, when there is one; otherwise
  2. the RequestedAddress of the network the interface's Status LastNetwork names (§8.1).

With one, the client opens with an INIT-REBOOT REQUEST for it. A server that agrees answers with an ACK and the machine keeps the address in one round trip. One that refuses answers with a NAK and the client discovers afresh. Silence for two transmissions does the same (§5.2).

RequestedAddress is also the operator's: writing it is a soft reservation, an address the client asks for first and the server can refuse.

Nothing about a lease itself — its timers, its server, its options — is persisted. A machine that reboots asks for its address again.

6.1 Router discovery

Peios / Peios technical reference / netd / Ipv6

6.1.1 The kernel's RA handling is off #

netd writes 0 to /proc/sys/net/ipv6/conf/<name>/accept_ra for all, for default, and for every interface listed there, at startup before any link is brought up (§2.1). It writes it again for each non-loopback interface the first time it sees it. A missing file — a kernel without IPv6 — is a warning only for all.

The kernel still creates link-local addresses and answers neighbour solicitations. That is the protocol, not policy. Everything an advertisement could configure is netd's to decide.

6.1.2 When router discovery runs #

On every full pass, for each joined interface:

  • it wants router discovery when its profile has Address.Offered and IPv6 in its families;
  • it can run it when it is up with carrier and the kernel's link-local address on it has finished duplicate address detection. A tentative address cannot source a solicitation.

Running discovery that is no longer wanted or possible is stopped (ipv6 stopping): the engine and any DHCPv6 client are discarded, and the next reconcile removes their addresses and route. Nothing is sent on the wire, because neither protocol has a goodbye. An interface that wants and can run it starts it (soliciting routers), provided its ICMPv6 socket opens — otherwise netd logs no icmpv6 socket and tries again on the next pass.

6.1.3 The socket #

One raw ICMPv6 socket per interface, bound to it with SO_BINDTODEVICE, filtered to receive only type 134 (router advertisement), sending with a multicast hop limit of 255, and asking for each received packet's hop limit. The kernel computes and checks ICMPv6 checksums.

6.1.4 Solicitation #

A router solicitation is type 133, code 0, with a source link-layer address option carrying the interface's MAC, sent to ff02::2 with hop limit 255.

The first is sent as soon as discovery starts. The interval is 4 s for the next two, then doubles before each later one, up to 3600 s. Each interval carries a jitter of ±10%. So solicitations go at about 0, 4, 8, 16, 32, 64, … s.

Soliciting stops when an advertisement arrives with a router lifetime above zero. It begins again, from the first interval, as soon as every default router has expired.

6.1.5 Which advertisements count #

An advertisement is dropped unless it arrived with hop limit 255 and from a link-local source (RFC 4861 §6.1.2). A lower hop limit has crossed a router, and a non-link-local source is not a router on this link.

It is then decoded. The whole advertisement is refused when:

  • it is shorter than 16 bytes, or its type is not 134 or its code not 0;
  • an option has length zero, or runs past the end of the message.

Within an accepted advertisement, an option that does not parse is skipped and the rest stand:

OptionAccepted whenLimits
Prefix information (3)32 bytes long; prefix length ≤ 128; the prefix neither multicast nor link-localthe first 16 per advertisement
MTU (5)8 bytes long
RDNSS (25)at least 24 bytes, and a whole number of addresses16 servers per option; unspecified, multicast and loopback addresses dropped
DNSSL (31)at least 16 bytes16 domains per option; names uncompressed, lower-cased, labels of printable ASCII, 253 characters at most; a malformed name ends the list

Any other option is ignored. So are the advertisement's hop limit, reachable time and retransmission timer.

6.1.6 What an advertisement does #

  • Router lifetime above zero: the source becomes, or stays, a default router until that many seconds from now. Zero: the source is removed as a default router. Its prefixes, servers and domains stay.
  • M or O flag: stateless DHCPv6 is wanted (§6.3). Once set it stays set until discovery restarts; a later advertisement without the flags does not withdraw it.
  • MTU option: remembered as the link's advertised MTU. It is used only with Mtu.Offered, only when no lease gave one, and only when it is at least 68 (§4.2, §4.3).
  • Prefixes: §6.2. RDNSS and DNSSL: §6.3.

Whenever what the interface should carry changes — an address, its deprecation, the default router, the servers, the domains, the MTU, the DHCPv6 flag — netd runs a full pass. That includes a change caused by time alone: a lifetime running out is a change.

6.1.7 The default router #

Of the routers whose lifetime has not run out, netd uses the one with the numerically lowest link-local address, so the choice is stable while the set is. Router preference (RFC 4191) is not read.

6.2 Addresses

Peios / Peios technical reference / netd / Ipv6

6.2.1 Which prefixes form addresses #

A prefix information option forms addresses only when its A flag is set, its prefix length is exactly 64, and its preferred lifetime does not exceed its valid lifetime. Any other prefix option is ignored whole. A prefix with only the L flag gets no address, and no on-link route, because the kernel's RA handling is off.

A prefix seen for the first time is taken only with a valid lifetime above zero.

6.2.2 The stable address #

Each accepted prefix gets one stable address (RFC 7217): the prefix's first 64 bits, followed by the first 8 bytes of a SHA-1 digest of:

  1. the bytes peios-ndp-stable-iid|;
  2. the machine's 32-byte secret;
  3. the prefix's first 8 bytes;
  4. the interface id's text;
  5. a one-byte counter, starting at 0.

When the resulting interface identifier is reserved (RFC 5453: all zeros, 0200:5eff:fe00::/40, or fdff:ffff:ffff:ff80 to fdff:ffff:ffff:ffff), the counter is incremented and the digest taken again, up to 8 times.

So the same machine on the same network has the same address on every boot, a different network sees an unrelated address, and no MAC address ever appears in it. The address is a function of the interface id, so a kernel rename does not change it either.

6.2.2.1 The secret #

The 32-byte secret is read from /var/state/netd/secret the first time an interface starts router discovery. When the file does not hold exactly 32 bytes, a new secret is drawn from /dev/urandom and written there; a failure to write is logged and the new secret used for the rest of the process's life. Losing the secret renumbers the machine, and does nothing else.

6.2.3 Lifetimes #

A lifetime of 0xffffffff is forever. Others count from the moment the advertisement arrived.

When an advertisement updates a prefix already held:

  • the on-link flag and the preferred lifetime are taken as advertised;
  • the valid lifetime uses the two-hour rule from RFC 4862 §5.5.3(e), with the infinite-lifetime exception described below:
Advertised valid lifetimeRemaining valid lifetimeResult
above two hours, or above what remainsany finitethe advertised lifetime
two hours or less, not above what remainstwo hours or lessunchanged
two hours or less, not above what remainsabove two hourstwo hours from now
above two hoursforeverthe advertised lifetime
two hours or lessforeverforever

The table records the 0.1.7 behaviour this manual describes; it is also present in netd 0.1.8 source. The final row is a known deviation: infinity is above two hours, so RFC 4862 §5.5.3(e)(3) requires two hours from the new advertisement, including when the advertised lifetime is zero. A source build containing the proposed source correction uses that deadline; it still accepts advertised lifetimes above two hours, including infinity, and takes the preferred lifetime as advertised. This source correction does not establish that an installed package or image has changed.

When a prefix's preferred lifetime runs out, its addresses are deprecated: kept on the interface for standing connections, with a preferred lifetime of zero so that nothing new chooses them (§4.3). When its valid lifetime runs out, the prefix and its addresses are removed.

6.2.4 Flags on the address #

An address in a prefix advertised on-link (L set) is added normally, so the kernel adds the prefix route. One advertised off-link is added with IFA_F_NOPREFIXROUTE, so the kernel adds no prefix route for it.

6.2.5 Temporary addresses #

With Address.Temporary in the profile, each prefix also carries temporary addresses (RFC 8981):

  • one is formed with the prefix when the prefix arrives with a preferred lifetime above zero;
  • its interface identifier is random, avoiding the reserved identifiers, the stable address and the prefix's other temporaries;
  • its preferred lifetime is one day minus a random 0 to 599 s, so a rack of machines does not regenerate together, and its valid lifetime is two days. Each is cut to the prefix's own lifetimes.

When the newest temporary is no longer preferred while the prefix still is, a new one is formed. The old one stays, deprecated, until its valid lifetime ends. A prefix keeps at most four temporaries, dropping the oldest beyond that.

A temporary is deprecated when its own preferred lifetime or its prefix's has run out, and removed when its own valid lifetime or its prefix's has.

Address.Temporary is read when router discovery starts. Changing it is a profile edit, which restarts discovery (§3.3).

6.3 DNS and DHCPv6

Peios / Peios technical reference / netd / Ipv6

6.3.1 RDNSS and DNSSL #

Each server of an RDNSS option, and each domain of a DNSSL option, is held for the option's lifetime from the moment the advertisement arrived. 0xffffffff is forever, and a lifetime of zero removes that server or domain at once. A server or domain advertised again has its lifetime replaced.

6.3.2 Stateless DHCPv6 #

Peios gets IPv6 addresses by SLAAC alone. DHCPv6 is used for configuration only: an INFORMATION-REQUEST (RFC 8415 §18.2.6) for DNS servers and search domains. The stateful address exchange is not implemented, and an advertisement's M flag is treated as O.

6.3.2.1 When it runs #

A DHCPv6 client starts on an interface once its router-discovery engine has seen an advertisement with M or O (§6.1), provided the interface has a non-tentative link-local address and a MAC, and its socket opens. netd logs interface <name>: dhcpv6 information request. It stops with router discovery.

6.3.2.2 The socket #

UDP, bound with SO_BINDTODEVICE to the interface and to its link-local address on port 546, so two interfaces' clients never share a port. Messages go to ff02::1:2 port 547.

6.3.2.3 The request #

An INFORMATION-REQUEST (type 11) with a random 3-byte transaction id and these options:

OptionContent
1, client identifierthe machine's DUID (§5.7)
8, elapsed timehundredths of a second since this exchange began, capped at 65535
6, option request23 (DNS servers), 24 (domain list), 32 (information refresh time)

The first is sent when the exchange begins. Retransmissions follow after 1 s, doubling up to 3600 s, each with a jitter of ±10%, and never stop until a reply is accepted.

6.3.2.4 The reply #

A datagram is accepted only while an exchange is running, and only when:

  • it is a REPLY (type 7) with the exchange's transaction id;
  • it carries a client identifier equal to the machine's DUID;
  • it carries a non-empty server identifier.

Anything else is ignored, and retransmission continues. From an accepted reply, option 23 gives the DNS servers — when its length is a whole number of addresses, up to 16, with unspecified, multicast and loopback addresses dropped — and option 24 the search domains, read as a DNSSL's are (§6.1).

6.3.2.5 Refresh #

After a reply, the client asks again after the reply's information refresh time (option 32), or one day when there is none, but never sooner than 600 s. Each refresh is a new exchange with a new transaction id.

An answer that differs from the last logs dhcpv6 answered and leads to a reconcile and a publish. An identical one changes nothing.

6.3.3 Where these go #

Advertised and DHCPv6 servers and domains reach name resolution only through the profile's Dns.Offered, including link-local servers with the interface supplied by their DNS scope (§8.3). They are always part of what a network has shown for its identity and its record (§7).

7.1 Identification

Peios / Peios technical reference / netd / Networks

netd has to say "this is the same network as last time" with nothing on the wire that says so. The identity is a guess from what the network has offered, recorded with every signal it was made from, so that a later proof can replace the guess without changing the record's key.

7.1.1 Signals #

What a joined interface's network has shown, at the moment of the pass:

SignalFrom
kindthe interface's Interface.Kind
serverthe DHCPv4 lease's server identifier
subnetthe lease address masked to the lease prefix
gatewaythe lease's gateway (§5.3)
routerthe default router router discovery chose (§6.1)
prefixesthe subnet, then every non-deprecated autoconfigured address's prefix, each once
DNS serversthe lease's, then the routers' RDNSS servers, link-local ones included

7.1.2 The identity #

  • With a lease (a server and a subnet), the basis is dhcp:<server>|<subnet>/<prefix>.
  • Otherwise, with a default router and at least one autoconfigured prefix, the basis is ra:<router>|<prefix>/<length>, using the first prefix.
  • Otherwise there is no identity yet: nothing has been offered, or the interface is static only.

The network id is a SHA-1 digest of the bytes peios-netd-network|, the kind, |, and the basis, stamped and written as a version 5 UUID exactly as an interface id is (§4.1).

So two leases from the same server in the same subnet are the same network, whatever the address. A different server, a different subnet, or a different kind of interface is a different network. An IPv4 lease always wins over the IPv6 basis, so a dual-stack network is identified by its DHCPv4 server.

7.1.3 When #

Identification is the first step of every full pass (§2.2). It runs only on a joined interface that is up with carrier, and only when the signals yield an identity.

For each identity it creates or refreshes the network's record (§7.2) and reads back what the operator wrote on it. While a lease is held, it writes the lease address to the record's RequestedAddress (§5.7).

7.1.4 A newly identified network #

When the identity differs from the network the interface already had — including when it had none — netd logs interface <name>: network <name or id>, with (trust <trust>) when the record has one. It then marks the interface for re-judgement, so the same pass judges it with the Network.* facts present (§3.3). A rule conditioned on Network.Name or Network.Trust takes effect then. If it moves the interface to another profile, the interface's clients restart under that profile (§3.3).

The network an interface stands on is kept while it has carrier, even when a profile switch restarts the clients that identified it. A switch that its own identification triggered would otherwise forget the network and switch back. Losing carrier forgets it (§4.1).

The interface's Status Network names the identified network (§8.1). The kernel reads it to give the packet layers their Network.* facts (PKM §6.5), so it never names a network the link is not on.

7.2 Records

Peios / Peios technical reference / netd / Networks

Every network a joined interface has been identified on gets a record, Machine\System\Network\Networks\<id>. Records are never deleted by netd.

7.2.1 What netd writes #

On every identification (§7.1), netd creates the record if it is missing and writes these values under its Status subkey, each only when it differs from what is there:

ValueTypeContent
KindREG_SZthe interface kind it was seen on
ServerREG_SZthe DHCPv4 server; removed when there is none
GatewayREG_SZthe lease's gateway; removed when there is none
RouterREG_SZthe IPv6 default router; removed when there is none
PrefixesREG_MULTI_SZthe subnet and the autoconfigured prefixes, CIDR form
DnsServersREG_MULTI_SZthe DNS servers offered, either family
LastSeenREG_SZthe wall-clock time of the identification, in Unix seconds
LastInterfaceREG_SZthe kernel name of the interface it was seen on

LastSeen is rewritten on every pass that identifies the network, so a record's Status changes whenever netd runs a pass on a link that is on it.

Status is created with the descriptor O:SYG:SYD:P(A;;KA;;;SY)(A;;KR;;;WD): SYSTEM has full control and Everyone has read access. It is protected, so the hive root's inheritable grants do not reach it, and a hand edit is refused rather than silently reverted at the next pass. The descriptor is set only when netd creates the key; a Status key that already exists keeps whatever descriptor it has.

7.2.2 What the operator writes #

On the record itself, not under Status:

ValueRead asMeaning
NameREG_SZ, empty is unsetthe Network.Name fact
TrustREG_SZ, empty is unsetthe Network.Trust fact
RequestedAddressREG_SZ, an IPv4 addressthe address the next DHCPv4 client asks for first (§5.7); netd writes it too

Name and Trust are read at every identification, so an edit reaches the interface layer on the pass that follows the registry write (§2.3). They are opaque: netd compares nothing about them and gives them no vocabulary.

The kernel reads Name and Trust itself, from the record that an interface's Status Network names (PKM §6.5).

8.1 The inventory

Peios / Peios technical reference / netd / What Netd Publishes

8.1.1 The record #

Every non-loopback interface netd has seen has a key Machine\System\Network\Interfaces\<id>, named by its interface id (§4.1). netd creates it on the first pass that sees the interface. The key is the operator's, apart from ClientId, a value both write (§5.7). netd's findings are under its Status subkey.

Records are never deleted. The record of a card that has been removed stays, with what netd last wrote.

8.1.2 Status #

ValueContentPresent
Namethe kernel's name for the linkalways
Kindwired, wireless or otheralways
Macthe hardware address, lower-case, colon-separatedwhen the link has one
Paththe bus pathwhen there is one
Driverthe driverwhen there is one
VerdictJOIN, IGNORE or DOWNwhen a rule spoke; removed when the backstop answered or rules tied
Rulethe path of the rule that spoke, or the tied rules as <a> vs <b>when a rule spoke or rules tied; removed when the backstop answered
Profilethe profile path as writtenJOIN only
Readinessthe interface's level (§8.2)JOIN only
Networkthe id of the network identified on the link nowwhile one is identified; removed when the carrier goes
LastNetworkthe id of the network the link last stood onfrom the first identification; never removed

Every value is REG_SZ. A value is written only when it differs from what is there, and removed only when it is there, so a pass that changes nothing writes nothing to the registry.

Status is refreshed on every full pass for every non-loopback interface, and again at each publish (§2.2) for joined ones, so Readiness follows a reconcile that moved it without any link event.

Status Network is read by the kernel to give the packet layers their Network.* facts (PKM §6.5). It names the network only while netd has identified one on the link (§7.1).

8.1.3 The descriptor #

Status is created with the descriptor O:SYG:SYD:P(A;;KA;;;SY)(A;;KR;;;WD): SYSTEM has full control, Everyone has read access, and the DACL is protected, so the hive root's inheritable grant to Administrators does not reach it. Only netd, as SYSTEM, can write there. An administrator's hand edit is refused at the write, not reverted at the next pass. The descriptor is set only when netd creates the key.

8.1.4 The machine's values #

On Machine\System\Network itself netd writes:

  • Readiness, the machine level (§8.2);
  • Duid, when it was absent and netd generated or read one (§5.7).

8.2 Readiness

Peios / Peios technical reference / netd / What Netd Publishes

"The network is up" means different things to different services, so netd reports a level, not a flag. Levels are ordered: absent < link < addressed < routed.

8.2.1 An interface's level #

From the kernel's state after the last reconcile:

LevelWhen
absentthe interface is not up, or has no carrier
linkup with carrier, and no usable address
addresseda usable address, and no default route
routeda usable address, and a default route on the interface

A usable address is any IPv4 address other than a loopback one — a 169.254 link-local address counts, because it is the outcome of address acquisition — and any IPv6 address that is neither loopback nor link-local. Every up interface has an IPv6 link-local address, so counting one would make addressed mean nothing. Deprecated and tentative addresses count.

A default route is any route with prefix length 0, of either family, in the main table, out of the interface. It need not be one of netd's.

8.2.2 The machine's level #

The highest level among joined interfaces, or absent when none is joined. An IGNOREd or DOWN interface contributes nothing, whatever state it is in.

8.2.3 Publishing it #

At every publish (§2.2) netd computes the machine level. When it differs from the last one published — and at the first publish after startup, which precedes READY=1 — netd:

  1. logs machine readiness is <level>;
  2. sends the complete readiness set below in one datagram on the notify socket named by NOTIFY_SOCKET;
  3. writes Readiness on Machine\System\Network.

The notify set is determined by the highest machine level:

Machine levelLEVEL values, in wire order
absentabsent
linklink
addressedlink, addressed
routedlink, addressed, routed

Each value has its own LEVEL= line in the same datagram. The highest level comes last, so an older manager that retains only the last value continues to see the highest level. Upgrade peinit before or alongside netd to make every member available to dependents.

The set is sent only on a change. The notify channel is lossy by design and unacknowledged (PSPU §4.16), and a level is a statement of a current condition, so a lost one is corrected by the next change.

Each joined interface's own level is written as Status Readiness (§8.1).

8.2.4 What peinit makes of it #

peinit records the set netd last sent against the sending service, and resolves the network role to that service. A dependency on network:<level> waits until the service is active and its published set contains the requested level (peinit TRM §7.5).

A DHCPv4 lease that installs an address and default route in the same pass takes the highest machine level directly from link to routed. The notify datagram contains link, addressed and routed, so both network:addressed and network:routed dependents can start. peinit needs no knowledge of network level ordering; netd publishes every condition that holds. Losing the link replaces this set with absent, preventing new starts on any of the three previous conditions. Dependents already running keep running.

The registry, log and control status continue to report the highest machine level. net wait <level> polls over the control socket and succeeds when that level is at least the one named (§10.1).

8.3 The resolver channel

Peios / Peios technical reference / netd / What Netd Publishes

The channel's contract — that a resolver subscribes, and receives whole snapshots, never deltas — is PSPU §6.9. This article is netd's half of it.

8.3.1 Subscribing #

A subscribe request on the control socket (§9.2), which needs the query right, is answered with a snapshot at once, and the connection is kept: switched to non-blocking and added to the subscriber list. It is the one request that does not end its connection.

The first snapshot is the last one published, or, before any has been, one computed on the spot.

8.3.2 When snapshots are sent #

At every publish (§2.2) netd computes the snapshot and compares it with the last one sent. If it differs, it is sent to every subscriber. If it is equal, nothing is sent.

A subscriber that cannot take the whole frame at once — closed, or so far behind that its socket buffer is full — is dropped. A resolver reconnects and receives a fresh snapshot.

8.3.3 The snapshot #

A reply map with ok = true, kind = snapshot, hostname — the name netd last set (§8.4), empty if none — and scopes: one scope per joined interface whose level is above absent, ordered by the interface's metric, then its kernel index.

Each scope:

FieldContent
ifidthe interface id
namethe kernel name
serversDNS servers, as text, in the order to try them (below)
domainssearch domains, likewise
ntpthe lease's NTP servers (option 42)
addressesevery address the kernel holds on the interface, address/prefix, link-local ones included
default_routethe profile's Dns.Default when set; otherwise whether the interface's level is routed
exclusivethe profile's Dns.Exclusive
metricthe interface's metric (§4.2)
levelthe interface's level (§8.2)

8.3.4 Merging DNS facts #

Servers, in this order:

  1. the profile's Dns.Servers, always;
  2. with Dns.Offered: the lease's servers (option 6);
  3. with Dns.Offered: the routers' RDNSS servers, including link-local IPv6;
  4. with Dns.Offered: the DHCPv6 reply's servers, including link-local IPv6.

Domains, in this order:

  1. the profile's Dns.Domains, always;
  2. with Dns.Offered: the lease's domain search list (option 119) if it is not empty, otherwise its domain name (option 15);
  3. with Dns.Offered: the routers' DNSSL domains;
  4. with Dns.Offered: the DHCPv6 reply's domains.

Each list removes duplicates across the entire list, retaining the first occurrence and the source order above. This includes repeated values with other values between them.

The containing scope supplies the interface for link-local IPv6 servers. resolvd uses its index on UDP and TCP upstream sockets; the address text does not need a zone suffix. Deploy this publication change with a resolver that supports interface-scoped upstreams (resolvd TRM §4.7).

NTP servers are reported whatever Dns.Offered says. Whether to believe a lease's time servers is timed's decision, made against its own registry value. A snapshot describes what the network said.

The same merge feeds the dns and search fields of the status reply (§9.2), for every non-loopback interface, joined or not.

8.4 The hostname

Peios / Peios technical reference / netd / What Netd Publishes

8.4.1 Which name #

  1. Machine\System\Network Hostname, when it is set and not empty;
  2. otherwise the hostname option (12) of a lease. netd takes the first joined interface, in kernel index order, whose profile has Hostname.Offered and whose lease carries a hostname;
  3. otherwise none.

The selected name is checked by libnetd::hostname, the same rule used by System Settings and first-boot setup: after trimming outer whitespace, it must be one nonempty label of at most 63 ASCII letters, digits or hyphens, with no leading or trailing hyphen, and must not be localhost (case-insensitive). This applies to registry and lease names alike.

An invalid selected name leaves the kernel's name and netd's last applied name unchanged. It is logged as hostname <quoted name> refused: <quoted reason>, with control characters escaped, once while the same rejected candidate remains selected. A different candidate, a valid name, or no name clears that suppression. A nonempty but invalid registry name still takes precedence: it does not silently fall through to a lease. Likewise, the first selected lease name is checked without searching later leases for a replacement.

A valid name that the kernel nevertheless refuses is logged as sethostname(<name>): <error> and tried again at the next pass.

8.4.2 When #

After every full pass and every reconcile-only iteration (§2.2), netd calls sethostname(2) if the name differs from the last one it set, and logs hostname is <name>. It does not compare against the kernel's current hostname. A change made by anything else is not reverted until netd's own name changes.

When there is no name — the registry value removed and no lease offering one — netd does nothing: the kernel keeps whatever hostname it had.

The status reply and the resolver snapshot report the name netd last set, empty if it has set none.

8.4.3 What is recorded #

Each sethostname(2) that changes the kernel's name writes a netd.hostname.changed event, which is essential: the emission policy cannot switch it off. It carries config.name (always Hostname, whether the name came from the registry or from a lease), config.text (the name now in force) and config.text-previous (the kernel's name, read just before the call; the kernel's own (none) on the first change after boot). A call that leaves the kernel's name as it was — netd setting a name the kernel already holds, as after a restart of netd or a change made behind its back — records nothing, and neither does a name the kernel refuses.

The record's subject.token.sid is the principal that acted (PGSS §6.4). When the change is made during the pass a reconcile request (§9.2) asked for, that is the requester's user SID; netd refuses a request whose user it cannot read. A change netd makes on its own authority — after a registry change, a lease, or at startup — names netd's own user, SYSTEM; who wrote the registry value is LCS's to record.

8.4.4 Telling the network #

With Hostname.Announce in an interface's profile, a newly started DHCPv4 client sends the validated, trimmed registry Hostname in option 12 (§5.1). An invalid registry name is omitted. An existing client's announcement configuration is unchanged until it restarts. A name adopted from a lease is never announced.

9.1 The socket and its object

Peios / Peios technical reference / netd / The Control Socket

9.1.1 The socket #

netd listens on /run/netd/control.sock, a SOCK_STREAM Unix socket. At startup it:

  1. creates /run/netd if it is missing, sets its mode to 0755, and gives it the descriptor below;
  2. removes a socket file left by a previous run, logging removed a stale /run/netd/control.sock;
  3. binds, sets the socket file's mode to 0666, and gives it the same descriptor.

The descriptor on both: owner and group SYSTEM, and a DACL allowing SYSTEM GENERIC_ALL and Everyone GENERIC_READ | GENERIC_WRITE | GENERIC_EXECUTE. peinit creates /run SYSTEM-only and inheritable, so without it nobody else could reach the socket. Everyone can connect; the control object decides what each connection can do. A failure to set it is logged as an error and netd carries on.

9.1.2 The control object #

Every request is checked against the control object, a security descriptor netd holds in memory. It is Machine\System\Network ControlSecurity, a self-relative descriptor in a REG_BINARY value, when that is set and valid. When it is not valid, netd logs ControlSecurity is not a valid descriptor (…); using the default.

The compiled default: owner and group SYSTEM, and a DACL of:

TrusteeRights
SYSTEMNETWORK_ALL_ACCESS
AdministratorsNETWORK_ALL_ACCESS
EveryoneNETWORK_QUERY, READ_CONTROL

The object is rebuilt whenever the configuration changes (§2.3), so a written ControlSecurity applies to the next connection.

9.1.3 Rights #

RightValue
NETWORK_QUERY0x00000001
NETWORK_CONTROL0x00000002
NETWORK_ALL_ACCESS0x000F0003: both, plus the standard rights

The generic mapping:

Generic rightMaps to
readNETWORK_QUERY, READ_CONTROL
writeNETWORK_CONTROL, READ_CONTROL
executeNETWORK_QUERY
allNETWORK_ALL_ACCESS

9.1.4 The check #

For each request, netd opens the connecting peer's token through KACS and runs a real access check of the request's right (§9.2) against the control object. It never looks at SO_PEERCRED, so a deny-only group or a filtered token is judged as the kernel would judge it anywhere else. A peer whose token cannot be opened is denied, and so is one whose token's user cannot be read, since a change made for it is recorded under that user (§8.4).

A denied request is answered {ok: false, error: "access denied"}, and nothing else happens.

9.1.5 Nonblocking client I/O #

The control socket is served on netd's one thread (§2.2). Accepted connections are nonblocking and polled alongside network and registry inputs. An idle client, partial request, or client that does not read its reply does not hold the loop in a socket read or write.

At most 64 requests or replies are pending at once. While full, netd stops polling the listener until a slot becomes free; connections still waiting in the kernel backlog have not been accepted. Each iteration accepts at most 16 connections and reads or writes at most 4 096 bytes per ready pending connection. The 65 536-byte message ceiling is checked from the header before reading a request payload (§9.2).

A request must complete within two seconds of acceptance. A normal reply must be written within two seconds of being queued. Partial progress does not extend either deadline. Expiry closes the connection without a timeout reply. These deadlines participate in the same poll timeout as network-client timers. Completed subscriptions leave this pending-client set and use the existing nonblocking snapshot publisher (§8.3).

9.2 Requests

Peios / Peios technical reference / netd / The Control Socket

9.2.1 Framing #

The control socket is a PSPU observability query channel: one length-prefixed MessagePack map in each direction, one request and one reply per connection, except subscribe.

  • A frame is a 4-byte little-endian payload length, then the payload.
  • A length above 65536 is refused before any of the payload is read, with {ok: false, error: "message of <n> bytes exceeds the ceiling"}.
  • A request is a map with a string query. Unknown keys are ignored, and a key that appears twice refuses the request.
  • A reply is a map with a boolean ok, and either the result's fields or a string error.

An oversized, incomplete-at-EOF, or undecodable request is answered with an error reply and the connection ends:

Problemerror
no querymissing field query
an unknown queryunknown query "<query>"
a duplicate keyduplicate field <key>
renew without interfacemissing field interface
malformed MessagePackmalformed message: …

Expired client deadlines and failed socket I/O close the connection without a reply (§9.1). A normal reply that exceeds the message ceiling is replaced with an error reporting its size.

9.2.2 The requests #

queryRightDoesReply
statusNETWORK_QUERYnothingthe status (below)
subscribeNETWORK_QUERYkeeps the connection as a subscriber (§8.3)a snapshot now, and another on each change
reconcileNETWORK_CONTROLa full pass (§2.2){ok: true}
renew, with interfaceNETWORK_CONTROLthe client renews (§5.4){ok: true}, or an error

renew finds its interface by kernel name or by interface id. It answers no interface <name> when there is none, and <name> is not using DHCP when the interface has no DHCPv4 client.

9.2.3 The status reply #

FieldContent
hostnamethe name netd last set, empty if none (§8.4)
levelthe machine level (§8.2)
refusalwhy the newest generation was refused, or nil (§3.2)
interfacesone map per non-loopback interface netd has seen, joined or not, in kernel index order

Each interface:

FieldContent
ifid, name, indexthe interface id, kernel name and index
mac, path, driveridentity (§4.1); empty strings when absent
verdictJOIN, IGNORE or DOWN; nil when the backstop answered or rules tied
rulethe rule that spoke, backstop, or the tied rules <a> vs <b>
profilethe profile path, JOIN only
network, network_name, network_trustthe network identified now (§7)
warningdiscovery unanswered (§5.2), or a tie (§3.3); nil otherwise
up, carrierthe link's flags
levelthe interface's level (§8.2), computed for every interface
addressesevery address the kernel holds, address/prefix, link-local ones included
gateway, gateway6the gateway of the interface's first IPv4 and IPv6 default route, any owner's
dns, searchthe merged DNS facts (§8.3); empty for an interface not joined
leasewhile the client holds a lease: server, expires_in (whole seconds), state (§5.1), duration (the lease's length), and renew_at and rebind_at (T1 and T2), the last three in whole seconds after the lease was bound; nil otherwise. A client reading a netd that predates the last three finds them absent.

10.1 Commands

Peios / Peios technical reference / netd / The Net Command

net is netd's operator command. It talks to netd over the control socket, and reads the registry directly for rules and profiles, because those are configuration that netd merely executes. Changing the network is a registry write; net shows state and pokes the daemon. The task-oriented guide is ~peios/networking/the-net-command.

10.1.1 Subcommands #

CommandDoes
net statusprints the status reply (§9.2)
net renew <interface>sends renew
net reconcilesends reconcile
net rulesprints Rules\Interface from the registry: one line per rule, indented by depth, with its priority, (disabled) for Enabled = 0, its conditions, and its actions (NULL when it has none)
net profiles, net profile listprints Profiles\ from the registry: one line per profile, indented by depth, with the values it sets itself ((inherits only) when none) and (disabled)
net wait <link|addressed|routed> [seconds]waits for the machine level
net policyprints the kernel engine's status: enforcing or not, the generation, unwalked changes, a refused last walk, the context count
net policy wait [seconds]waits for the packet policy to be in force
net version, net --versionprints net <version>

rules and profiles fail when their key does not exist. Neither needs netd running.

10.1.2 net status output #

A hostname line ((unset) when empty) and a readiness line, then policy REFUSED: <why> (the last good generation stands) when there is a refusal. Then a block per interface: <name> [<id>], then verdict (<VERDICT>(<profile>) by <rule>, or (none) by <rule>), state, and readiness for a joined interface. Then, where present, hardware, network (<name> [<id>] trust <trust>), each address, gateway, gateway6, dns, search, lease (<state> from <server>, <n>s left) and warning.

10.1.3 net wait #

It asks for status every 500 ms and succeeds as soon as the machine level is at least the one named. An error reaching netd is printed and the wait goes on. The timeout is the second argument, or 60 s when absent or not a number. When it lapses, net prints net: timed out waiting for <level> and fails.

absent is accepted as a level, and is met at once.

10.1.4 net policy wait #

It reads /dev/peios-ntfe's status (ABI 5) once and takes the number of policy changes the engine has noted as its target. It then polls every 5 ms until the engine's count of changes walked reaches the target. If the last walk was refused, it fails, naming the errno and the generation that stands; otherwise it succeeds. The timeout is the argument, or 10 s. The counters are the kernel's (PKM §6.5). A device that answers with another ABI fails at once.

10.1.5 Exit status #

CodeMeaning
0success
1netd unreachable, an error reply, a timeout, a refused policy, or a registry key that cannot be opened
2usage: an unknown subcommand, a wrong argument count, an unknown level, or a non-numeric policy wait timeout

11.1 Failure modes

Peios / Peios technical reference / netd / Failure Modes

11.1.1 Where the evidence is #

  • net status: the policy refusal, each interface's verdict and the rule that gave it, its warning, its addresses and gateways, and its lease's state.
  • netd's log. Informational lines go to standard output; warnings and errors go to standard error. peinit forwards both to eventd (evctl 'LOGS FROM netd SINCE 1h ago TAKE 40'). Each line is also mirrored to /dev/kmsg at KERN_INFO, KERN_WARNING or KERN_ERR, so it reaches the kernel log and, at a permissive console log level, the console. If the mirror cannot be opened, netd says so once on standard error and tries again on the next line. Each mirrored line opens and closes its own /dev/kmsg descriptor, so boot and reconciliation bursts do not lose lines to the kernel's per-open rate limit (normally ten lines in five seconds). All three severities are retained. The mirror remains best-effort if opening or writing the device fails; stdout/stderr delivery is independent.
  • The registry: Interfaces\<id>\Status, Networks\<id>\Status, Readiness.

11.1.2 No address on a joined interface #

What net status showsLikely cause
warning asked for an address; nobody answered, a 169.254 address or noneno DHCPv4 server answered. Either there is none, or NTFE refused the exchange: the shipped baseline's DHCP rules were deleted or overridden. netd cannot see the verdict stream.
no lease line, no warningthe profile does not offer the address (Address.Offered, Address.Families), the interface has no MAC, or the packet socket would not open (logged). Or a server is answering but its ACK does not read as a lease: no server identifier, a lease time under 4 s, or a malformed classless route option (§5.3). The client then cycles through Selecting and Requesting, neither of which is shown (§5.1).
no IPv6 addressno advertisement arrived, or none was accepted: hop limit under 255, a non-link-local source, a prefix that is not an autonomous /64 (§6.1, §6.2). Also check Address.Families.

11.1.3 The wrong interfaces are configured #

What net status showsMeaning
policy REFUSED: …the newest generation was refused (§3.2). Everything shown is still the last good generation. Fix the named rule or profile; the next write is read at once.
verdict (none) by backstopno rule speaks for the interface. It is left as the kernel left it.
verdict (none) by <a> vs <b>, warning rules … tietwo rules tied on priority and named different profiles (§3.3). The interface is ignored until one outranks the other.
an interface that was joined is now IGNOREd, with its old addresses still on itby design: IGNORE touches nothing, removal included (§3.3)

11.1.4 Changes that do not stick #

  • An address added by hand disappears. netd owns every address on a joined interface, and the next pass removes it (§4.3). Add it to Address.Static instead.
  • A route added by hand stays, unless it carries protocol 200.
  • A pinned gateway never appears. Route.Gateway is not checked against the interface's subnets. When the kernel cannot reach it, every reconcile logs reconcile: AddRoute(…) failed, and the interface stays addressed.
  • The hostname does not change back when Hostname is removed. netd never unsets a name (§8.4).

11.1.5 Losing what was working #

  • A cable pull drops everything offered — the lease, autoconfigured addresses, routes, the network identity — and stops the clients (§4.1). When the carrier returns, the DHCPv4 client asks for the old address first, so a cooperative server restores it in one exchange.
  • A lease that cannot be renewed drops its address at the lease's end, unless the profile says Address.OnExpiry = Keep (§5.4). After that, the link-local fallback does not happen again in the same client's life (§5.2).
  • Editing a profile restarts its interfaces' clients (§3.3), so a DHCPv4 lease is released and asked for again.

11.1.6 netd is slow or unresponsive #

  • Everything pauses for seconds at a time. A program that connects to the control socket and does not send its request holds netd's single loop for up to 2 s per connection (§9.1). Look for a misbehaving client of /run/netd/control.sock.
  • net: cannot reach netd at /run/netd/control.sock. netd is not running, or /run/netd lost its descriptor. The service restarts netd always; evctl shows why it stopped.

11.1.7 Configuration not read #

  • netd ignores every registry change. The watch could not be armed at startup (registry watch unavailable … configuration is read once), or Machine\System\Network did not exist then (§2.1). Restart netd.

11.1.8 Getting back to a known state #

  1. Undo the last registry write. A refused generation names what to undo, and the next good write is taken at once.
  2. net reconcile runs a full pass without waiting for an event.
  3. net renew <interface> renews a lease without releasing it.
  4. Restarting netd re-derives everything from the registry and the kernel, and asks for the remembered IPv4 address with INIT-REBOOT (§2.1). The requested address already installed and applicable netd-owned routes stay while that request is pending. A NAK or unanswered revalidation discards this provisional state and starts fresh discovery; an unchanged ACK keeps IPv4 connectivity in place. Offered DNS and IPv6 information still have to be reacquired.

Appendix A Constants

Peios / Peios technical reference / netd

A.1 Paths #

PathUse
Machine\System\Networkconfiguration root, readiness, DUID (§2.3, §8)
/run/netd/control.sockcontrol socket (§9.1)
/run/netdruntime directory, created by peinit (§2.1)
/var/state/netd/duidthe DUID, when the registry has none (§5.7)
/var/state/netd/secretthe RFC 7217 secret (§6.2)
/usr/sbin/netdthe daemon
/proc/sys/net/ipv6/conf/<name>/accept_raset to 0 (§6.1)

A.2 Numbers #

ConstantValue§
Routing protocol stamped on netd's routes200§4.3
Default route metric100; 600 for wireless§4.2
Minimum MTU applied68§4.3
Converge repetitions per passat most 4§2.2
Registry tree depth read16§2.3
Control message ceiling65536 bytes§9.2
Control read and write timeout2 s§9.1
DHCPv4 first backoff, ceiling4 s, 64 s, ±1 s jitter§5.2
DISCOVER retransmissions before "nobody answered"3§5.2
REQUESTs in Requesting before rediscovery4§5.2
REQUESTs in Rebooting before rediscovery2§5.2
Renewing and rebinding retransmission floor60 s§5.2
Shortest lease accepted4 s§5.3
DHCPv4 maximum message size advertised1500§5.1
DHCPv4 message paddingto 300 bytes§5.1
Domain-search compression jumps per name16§5.3
Link-local range169.254.1.0–169.254.254.255§5.5
Router solicitation interval, ceiling4 s, 3600 s, ±10% jitter§6.1
Solicitations before backoff3§6.1
Prefixes, RDNSS servers, DNSSL domains per advertisement option16§6.1
SLAAC prefix length64§6.2
Two-hour rule7200 s§6.2
Temporary address preferred lifetime86400 s minus 0–599 s§6.2
Temporary address valid lifetime172800 s§6.2
Temporary addresses per prefix4§6.2
Stable address counter attempts8§6.2
DHCPv6 first retransmission, ceiling1 s, 3600 s, ±10% jitter§6.3
DHCPv6 refresh default, floor86400 s, 600 s§6.3
DHCPv6 servers and domains kept16 each§6.3

A.3 Identifiers #

IdentifierValue§
Interface id digest prefixpeios-netd-ifid|§4.1
Network id digest prefixpeios-netd-network|§7.1
Stable address digest prefixpeios-ndp-stable-iid|§6.2
DUID-LL header00 03 00 01§5.7
Status descriptorO:SYG:SYD:P(A;;KA;;;SY)(A;;KR;;;WD)§8.1
NETWORK_QUERY, NETWORK_CONTROL, NETWORK_ALL_ACCESS0x1, 0x2, 0x000F0003§9.1

1.1 Overview

Peios / Peios technical reference / resolvd / Introduction

resolvd is the Peios stub resolver. It is the one process on a machine that answers names: a DNS client that answers from what it knows about the machine, from its cache, or by forwarding to an upstream server. It is not a recursive resolver and not an authoritative server.

The contract it implements is the Name Resolution Interface, PSPU §6. This manual describes resolvd 0.1.5 as built: the order it does things in, the limits it applies, and what it does where the contract leaves a choice.

1.1.1 Three doors, one engine #

A question reaches resolvd by one of three doors (PSPU §6.3):

DoorWhereWho uses it
Native socket/run/resolvd/resolv.sockThe resolv command, the NSS shim, Peios tools
Stub listener127.0.0.53:53, UDP and TCPPrograms that speak DNS themselves
NSS shimlibnss_peios_net.so.2, loaded into every glibc processgetaddrinfo, gethostbyname and friends

The shim is not a separate resolver: it is a client of the native socket. The native socket and the stub listener both hand their questions to the same engine inside the daemon, and the engine is the only thing that decides an answer.

1.1.2 What is surprising about it #

It owns no network configuration. The servers, search domains and addresses of each interface arrive from netd over netd's control socket, as a whole snapshot each time anything changes. The registry holds only the fallbacks — servers to use when no interface offers any, extra search domains — and the static names that replace /etc/hosts. resolvd never writes /etc/resolv.conf; the file it ships is a constant that points at the stub listener.

A question goes to one interface. Each interface's contribution is a scope, and every name is routed to exactly one of them. Nothing is ever asked of every interface's servers at once. A single-label name is never sent upstream bare, and the cache is partitioned by scope, so an answer learned through a VPN disappears when the VPN's servers change or the VPN goes away.

One thread, and an engine with no I/O. The daemon is a single poll loop. The engine inside it is a pure state machine: it takes questions, upstream replies and the passage of time, and returns actions — bytes to send, transactions to abandon, answers to deliver. Every socket is the loop's. Everything is therefore serialised, which is why a reply that cannot be written promptly holds up every other client (§9.4).

It holds no privilege. resolvd runs under its own virtual service account. Port 53 is reached through a port reservation granted to its service SID, not through a capability.

It does not wait for the network. resolvd reports itself ready as soon as its two listening doors are open. localhost, the machine's own name and the registry's static names are answered with no network and no netd at all.

It does not validate. No DNSSEC validation is performed, and every answer from every door is reported unvalidated.

1.2 What This Manual Covers

Peios / Peios technical reference / resolvd / Introduction

This manual covers the resolvd source tree as of release 0.1.5:

ComponentPackageInstalled as
The daemondev.peios.resolvd/usr/sbin/resolvd
The operator commanddev.peios.resolv/usr/bin/resolv
The NSS shimdev.peios.resolvd-nss/usr/lib/x86_64-linux-peios/libnss_peios_net.so.2

1.2.1 Other packaged files #

dev.peios.resolvd also ships the constant /usr/etc/resolv.conf, the service definition and port reservation seeds under /usr/share/regim/, the registry reference /usr/share/regman/resolvd.regman, and the resolvd(8) page; dev.peios.resolv ships resolv(1).

Each package has a matching -debuginfo package, and dev.peios.resolvd-debugsource carries the sources for all three.

1.2.2 The source tree #

The source is one workspace of five crates, and the split is visible in what each part of the system can do:

CrateWhat it is
dnsThe DNS wire codec: names, messages, records. No I/O and no policy.
libresolvThe native channel's types, MessagePack codec and framing. No I/O beyond framing.
resolvdThe engine, the cache, the stub door's DNS rendering, and the daemon around them.
resolvThe operator command.
nssThe shim. Built on libresolv and libc, without libpeios (§7.1).

1.2.3 Covered elsewhere #

The contract. What any resolver standing in resolvd's place has to do is PSPU §6: the doors (PSPU §6.3), the native channel's framing and control object (PSPU §6.4), the requests (PSPU §6.5), outcomes and validation (PSPU §6.6), the resolution model (PSPU §6.7), the stub door (PSPU §6.8), the network manager channel (PSPU §6.9), the shim (PSPU §6.10), and the limits (PSPU §6.B). Where a chapter here touches the contract it cites it and adds only what resolvd does.

netd's side of the channel. Which interfaces appear in a snapshot, how their servers and domains are merged from profile and lease, and when a snapshot is sent, are netd's, and are described in the netd TRM. This manual covers what resolvd does with a snapshot once it has one.

Kernel mechanisms. Registry watches, access checks, file descriptors and port reservations are described in the Peios Kernel TRM. This manual says which of them resolvd uses and how.

1.3 Terminology

Peios / Peios technical reference / resolvd / Introduction

The protocol's terms — name, question, record, scope, route, outcome, source, door, synthetic answer, control object, stub door — are used as PSPU §6.2 defines them. The terms below are resolvd's own.

Task. One question the engine is working on: a name and a record type. A resolve or reverse request, and every stub query, is one task. A lookup is one task per address family it asks for.

Candidate. One name a task asks. A multi-label name has exactly one candidate, itself. A single-label name has one candidate per applicable search domain (§4.3), asked in turn.

Transaction. One query sent to one server over one transport, with its own DNS message identifier, its own case pattern, its own socket and its own deadline.

Attempt. A transaction counted against a candidate's attempt limit. Every UDP transaction is an attempt; the TCP transaction that follows a truncated reply is not (§4.6).

Routable scope. In source b4f7085, a scope that is up — at level link or better, in the sense of PSPU §6.2 — and has at least one server. Only routable scopes take part in routing and contribute search domains (§4.3, §4.4); an up scope with no servers takes part in neither.

The proposed source correction separates being up from having DNS servers, as PSPU §6.2 requires. All up scopes participate in search expansion and routing steps 1–4, with step 1 still requiring addressed or better and the exclusive flag. Only the ordinary lowest-metric choice in step 5 requires servers (§4.4). This is proposed source behavior, not a released package or an installed resolver; check the installed source revision before relying on it.

Scope key. The string that identifies a scope in the cache and in routing: the interface's ifid from netd, or fallback for the registry's fallback servers.

Demoted server. A server address whose last failure was less than 30 seconds ago, and which has not given a usable reply since; it is tried after its healthy peers (§4.6).

In-flight ceiling. The bound on outstanding upstream transactions, beyond which a question that needs the network is answered unavailable at once (§4.6).

The loop. resolvd's single thread and its poll (§2.5).

2.1 The Service

Peios / Peios technical reference / resolvd / Startup And Configuration

2.1.1 The service definition seed #

resolvd is started by peinit from Machine\System\Services\resolvd. The package ships the definition as a registry seed, /usr/share/regim/resolvd-service.reg, which is inert until an image names it in its [registry] autoapply list.

2.1.2 The service definition values #

ValueTypeDataEffect
ImagePathREG_SZ/usr/sbin/resolvdThe binary. resolvd reads no command-line arguments.
TriggersREG_MULTI_SZbootStarted at boot.
IdentityREG_SZServiceRuns under its own virtual service account.
ReadinessREG_DWORD0Notify: ready when resolvd says so.
RuntimeDirectoriesREG_MULTI_SZresolvdpeinit creates /run/resolvd.
RestartPolicyREG_DWORD2Always restarted.
LimitNOFILEREG_DWORD16384Descriptor headroom for 4 096 upstream transactions, their client waiters, 512 pending clients and daemon descriptors.
ErrorControlREG_DWORD0
DisplayNameREG_SZStub resolver
DescriptionREG_SZ(one sentence)

2.1.3 Identity and privilege #

resolvd holds no privilege and needs none. Its one claim on the machine that an ordinary process could not make — binding UDP and TCP port 53 — is met by a port reservation rather than a capability (PSPU §6.8).

Without the reservation applied, the stub listener cannot bind and resolvd exits at startup (§2.2, §9.3).

2.1.3.1 The port reservation seed #

The reservation is a second seed, /usr/share/regim/resolvd-port.reg, also inert until an image applies it. It writes one value:

KeyValueType
Machine\System\Network\TcpIp\PortReservationstcp,udp:53REG_BINARY

2.1.3.2 Who holds the reservation #

The data is a security descriptor granting the reservation to SYSTEM and to resolvd's service SID, S-1-5-80-3864064249-1823296737-2008945602-1354971773-2894779966, so it follows the service rather than whichever account the service happens to run as.

The selector tcp,udp:53 is more specific than the kernel package's tcp,udp:1-1023 and so decides port 53 alone. The descriptor bytes are generated from the SID by a tool in the source tree, not written by hand.

2.1.4 Readiness #

With Readiness 0 peinit waits for a notification. resolvd:

  • sends the datagram READY=1 to the socket named by the NOTIFY_SOCKET environment variable once the native socket and both stub sockets are open and the registry has been read;
  • does not wait for netd: readiness is sent after the first attempt to reach netd whether that attempt succeeded or not;
  • sends nothing when NOTIFY_SOCKET is unset, and logs a failure to send as readiness notify: <error> and carries on starting.

2.1.5 Restart #

RestartPolicy 2 restarts resolvd whenever it exits. A restarted resolvd removes the socket file its predecessor left, logs removed a stale /run/resolvd/resolv.sock, and binds a fresh one (§2.4). Everything resolvd holds is in memory, so a restarted resolvd has:

  • an empty cache;
  • no demoted servers;
  • every counter at zero (§4.10);
  • no scopes, and the kernel's hostname, until the first snapshot after netd is reached again (§3.3).

2.1.6 Log lines #

2.1.6.1 The log line format #

Every line resolvd logs has the form

resolvd: <level>: <text>

where the level is info, warn or error. Informational lines go to standard output; warnings and errors go to standard error. peinit forwards both streams to eventd, marking only standard error as is_error=true. The lines are read with:

evctl 'LOGS FROM resolvd SINCE 1h ago TAKE 40'

2.1.6.2 The kernel log mirror #

Each line is also offered to /dev/kmsg, at kernel priority 6, 4 or 3 for info, warn and error. The mirror is opened when the first line is logged, after that line has been written to its output stream, and the open is never retried.

/dev/kmsg is writable by SYSTEM only, so for resolvd the open fails, and resolvd writes one line to standard error saying so, directly after the first log line:

resolvd: warn: /dev/kmsg mirror unavailable (<error>): log lines go to stdout/stderr only

The mirror warning follows the first log call. That first line may be on stdout or stderr, so readers must not assume a cross-stream ordering. On a start that fails, the first logged line is the fatal one, such as native socket: <error> (§2.4). On a start that succeeds, it is a warning from earlier in startup when there is one, such as removed a stale /run/resolvd/resolv.sock (§2.4) or a Dns … warning (§2.3), and otherwise the result of the first attempt to reach netd, subscribed to netd or netd not reachable (<error>); retrying, which comes before listening on … (§2.2). The mirror line is written once, and nothing further about the mirror is written. resolvd's lines never reach the console or dmesg.

2.2 Startup Sequence

Peios / Peios technical reference / resolvd / Startup And Configuration

2.2.1 The sequence #

  1. The native socket. The runtime directory and the socket are created and given their modes and descriptors (§2.4). A failure to create the directory, set a mode, remove a stale socket file, bind, or make the listener nonblocking is fatal: resolvd logs native socket: <error> at error level and exits with status 1. A failure to write a descriptor is not fatal (§2.4).
  2. The stub listener. A UDP socket and then a TCP listener are bound to 127.0.0.53:53. A failure of either is fatal: resolvd logs stub listener on 127.0.0.53:53: <error> and exits with status 1.
  3. The registry. Machine\System\Network\Dns is read whole (§2.3). A missing or unreadable key is not an error; every value takes its default.
  4. The registry watch. A subtree watch is armed on Machine\System\Network (§2.3). If it cannot be armed, resolvd logs registry watch unavailable (<error>); configuration is read once and runs on the configuration it read in step 3 until it restarts.
  5. Query entropy. The engine is given a kernel-randomness provider. Every upstream transaction obtains fresh bytes through nonblocking getrandom; startup does not seed a userspace generator or read /dev/urandom. There is no clock fallback.
  6. The engine's inputs. The engine is given the kernel's hostname, the fallback servers and search domains, and the static names. A kernel hostname that is empty or (none) counts as no hostname.
  7. The control object is built from ControlSecurity (§5.2).
  8. netd. The first attempt to subscribe is made immediately (§3.1). Its failure is not fatal and is retried with backoff (§3.2).
  9. The listening line. resolvd logs listening on /run/resolvd/resolv.sock and 127.0.0.53:53 at info level.
  10. Readiness. READY=1 is sent (§2.1).
  11. The loop is entered (§2.5).

2.2.2 Answering from the moment of readiness #

Both doors are open, and answering, before readiness is reported, and neither waits for netd. A question asked the moment resolvd is ready is answered at once: synthetic and static names are answered, and anything that needs a server is answered unavailable until either a snapshot arrives or fallback servers are configured (§3.2).

2.2.3 Exiting #

resolvd has no shutdown path of its own. Apart from the startup failures above, nothing a client or an upstream server sends makes it exit.

2.2.3.1 Ending on a signal #

resolvd installs no signal handlers, so it ends when peinit signals it, with the default disposition, and leaves /run/resolvd/resolv.sock behind. The next start removes the stale socket file before binding (§2.4).

2.2.3.2 A poll failure exits #

When poll fails with anything other than EINTR, resolvd logs poll: <error> at error level and exits with status 1. EINTR is retried.

2.3 Registry Configuration

Peios / Peios technical reference / resolvd / Startup And Configuration

The registry holds resolvd's fallbacks and its static names, under Machine\System\Network\Dns; which keys a resolver reads is fixed by PSPU §6.9. Nothing about the live network is read from the registry; that arrives from netd (§3).

2.3.1 The Dns key and its defaults #

The key is opened for query and enumerate-subkey access. When it does not exist or cannot be opened, every value below takes its default.

ValueType readDefaultUsed for
FallbackServersREG_MULTI_SZ, or one REG_SZ / REG_EXPAND_SZnoneThe fallback scope's servers (§4.4)
ExtraSearchDomainsREG_MULTI_SZ, or one REG_SZ / REG_EXPAND_SZnoneSearch domains after every interface's (§4.3)
ControlSecurityREG_BINARYthe compiled defaultThe control object (§5.2)
Hosts\a subkeyemptyStatic names (§4.2)

2.3.2 Reading strings #

  • A REG_SZ or REG_EXPAND_SZ is one string: its data up to the first NUL, read as UTF-8. When those bytes are not UTF-8, the value is treated as absent, with no log line.
  • A REG_EXPAND_SZ is read as it is stored; nothing in it is expanded.
  • A REG_MULTI_SZ is split at every NUL. Empty strings are skipped, and a string that is not UTF-8 is skipped silently, with no log line, while the other strings are used.
  • A value of any other type is treated as absent.

2.3.3 FallbackServers #

  • Each string is trimmed of surrounding whitespace and parsed as an IPv4 or IPv6 address. A string that does not parse is skipped and logged as Dns FallbackServers: ignoring malformed address "<string>", with the string as stored — untrimmed, in double quotes, with quotes, backslashes and control characters escaped. A lone REG_SZ that is empty or only whitespace, a whitespace-only string in a multi-string, and an address with a zone such as fe80::1%eth0 (§4.7) are all logged this way.
  • There is no way to give a port; every server is asked on port 53.

The order of the list is the order servers are tried in (§4.6).

2.3.4 ExtraSearchDomains #

Each string is parsed as a domain name, with or without a trailing dot. It is not trimmed, so a leading or trailing space becomes part of a label. A string that does not parse, or that is the root — a lone empty REG_SZ, or . — is skipped and logged as Dns ExtraSearchDomains: ignoring malformed domain "<string>", quoted as for FallbackServers.

2.3.5 ControlSecurity #

  • A REG_BINARY value with at least one byte is taken as a self-relative security descriptor. An empty value, a value of another type, or an absent value selects the compiled default, with no log line.
  • Bytes that do not form a valid descriptor also select the compiled default, and resolvd logs ControlSecurity is not a valid descriptor (<error>); using the default each time it builds the control object from them: at startup, and at every configuration change (below).

2.3.6 Hosts\ #

  • Every value under Machine\System\Network\Dns\Hosts is one static name. The value's name is the host name and its data is one address (REG_SZ or REG_EXPAND_SZ) or several (REG_MULTI_SZ), read as above.
  • A value name that is not UTF-8 is skipped silently. The registry refuses such a name when the value is written, so the check is defensive.
  • A value name that does not parse as a domain name is skipped and logged as Dns Hosts: ignoring malformed name "<name>".
  • A value name that parses to the root — an empty name, or . — is skipped silently.
  • Each address is parsed as for FallbackServers, and a malformed one is skipped and logged as Dns Hosts: ignoring malformed address "<string>".
  • A name left with no valid address, including one whose value has any other type, is not a static name at all.
  • Names match case-insensitively, so Printer and printer are the same static name.

Where the same address is listed under several names, the reverse lookup of that address returns one of them (§4.2). A static name named localhost, or under it, never answers a forward question (§4.2).

2.3.7 Changes at run time #

2.3.7.1 The watch on the network key #

resolvd watches Machine\System\Network, recursively and for every kind of change, rather than the Dns key itself: the Dns key need not exist at boot, and a watch on a missing key cannot see it appear. Watch events are read into a 16 384-byte buffer.

2.3.7.2 What triggers a re-read #

Every non-empty batch of watch events, from anywhere under Machine\System\Network, causes the whole Dns key to be read again, as at startup. netd's own writes under that key cause such re-reads too. Each re-read logs the Dns FallbackServers, Dns ExtraSearchDomains and Dns Hosts warnings above again for every malformed string still present, whether or not anything has changed.

2.3.7.3 An unchanged configuration #

The fresh configuration is compared with the one in use. When nothing differs, nothing is applied and nothing more is logged.

2.3.7.4 A changed configuration #

When anything differs, resolvd, in this order:

  1. rebuilds the control object from ControlSecurity;
  2. replaces the fallback servers and search domains, discarding every answer cached through the fallback scope if the server list differs in content or in order;
  3. replaces the static names;
  4. logs configuration changed at info level.

2.3.7.5 When a change takes effect #

  • Every change applies to the questions and requests that arrive after it; nothing needs a restart.
  • Static names are never cached, so a new or removed Hosts\ value is visible to the next question.
  • Answers already cached through an interface scope are not discarded by a registry change.

2.3.7.6 When the watch fails #

  • If reading watch events fails, resolvd logs registry watch: <error>; re-arming and arms a new watch. The key is not read again when the new watch is armed, so a change made in between is applied only with the next event.
  • If arming the new watch also fails, watching stops without a further message, and the configuration in use stays as it is until resolvd restarts.

2.4 Sockets and Descriptors

Peios / Peios technical reference / resolvd / Startup And Configuration

2.4.1 The native socket #

The native door is a SOCK_STREAM Unix socket at /run/resolvd/resolv.sock (PSPU §6.4), inside resolvd's runtime directory rather than directly in /run, which resolvd's account cannot write.

At startup resolvd:

  1. creates /run/resolvd if peinit has not, and sets its mode to 0755;
  2. replaces the directory's DACL (below);
  3. removes any existing /run/resolvd/resolv.sock, logging removed a stale /run/resolvd/resolv.sock at warn level when there was one, and a removal that fails is fatal (§2.2);
  4. binds the socket, sets its mode to 0666, and replaces its DACL in the same way;
  5. makes the listener nonblocking.

2.4.1.1 A socket left by a previous run #

resolvd leaves its socket behind when it ends (§2.2), so a start that follows an earlier run finds one unless something else has removed it. The previous run gave its own account full access to the directory and the socket (below), so the new process removes the file in step 3, logs the removed a stale … line, and binds a fresh socket. This holds for every restart of the service: svctl restart, a stop and a start, and a restart by the policy after resolvd exits or is killed.

2.4.1.2 The DACL on the directory and socket #

The DACL written to both the directory and the socket is:

TrusteeAccess
SYSTEMGENERIC_ALL
resolvd's own account (its token's user)GENERIC_ALL
EveryoneGENERIC_READ, GENERIC_WRITE, GENERIC_EXECUTE

resolvd's own entry is what lets the next run start. Everyone's read, write and execute include neither DELETE nor deleting a child, so without it the next start could not remove the stale socket: it would exit with native socket: Permission denied (os error 13), and the restart policy would loop on that until the file was removed by hand. If resolvd cannot read its own identity it logs at warn level

could not read resolvd's own identity (<error>); a restart may not be able to replace <path>

and writes the DACL without that entry.

2.4.1.3 The owner of the directory and socket #

Only the DACL is written, so each object keeps the owner it was created with. peinit creates /run/resolvd before resolvd starts (§2.1), so the directory is owned by SYSTEM; resolvd creates the socket, which is owned by resolvd's account. Reaching the socket grants nothing: every request is checked against the control object (§5.2).

2.4.1.4 A descriptor that cannot be written #

If the descriptor cannot be written, resolvd logs at error level

could not set a descriptor on <path> (<error>); programs other than SYSTEM and administrators will not be able to reach the socket

and carries on. The socket then keeps whatever descriptor it was created with, and for ordinary processes the native door — and with it the NSS shim — is unreachable while the stub listener still works (§9.3).

2.4.2 The stub listener #

  • Two sockets, a UDP socket and a TCP listener, are both bound to 127.0.0.53 port 53 and both nonblocking (PSPU §6.8).
  • Nothing is bound on ::1 or on any other address.

Binding port 53 depends on the port reservation (§2.1).

2.4.3 Other descriptors #

DescriptorCountLifetime
The registry watch on Machine\System\Network0 or 1From startup; replaced on a watch error (§2.3)
The netd channel0 or 1While subscribed (§3.1)
Native connections reading or awaiting resolutionup to 256 totalReading: at most 5 s (§5.1); resolving: until completion or definite departure (§4.1)
Stub TCP connections reading or awaiting resolutionup to 256 totalReading: at most 10 s (§6.1); resolving: until completion or definite departure (§4.1)
Queued native reply writersup to 256Until written, failed, or the one-second write deadline (§2.5)
Queued stub TCP reply writersup to 256Until written, failed, or the one-second write deadline (§2.5)
Upstream socketsone per transaction, at most 4 096 including retriesOne transaction (§4.7)

2.4.3.1 Connections whose request has been read #

A connection whose request has been read keeps occupying a slot in its door's 256-connection allowance until resolution completes or a definite hangup/error abandons it. The engine's attempts and timeouts (§4.6) govern this phase; the request-delivery deadline no longer applies. A completed answer that cannot be written immediately moves to the separate bounded reply-writer queue (§2.5).

UDP questions share the one listener socket, but retain at most 256 waiting origins in total and 16 per source address and port (§6.1).

2.5 The Event Loop

Peios / Peios technical reference / resolvd / Startup And Configuration

2.5.1 One thread and one poll #

resolvd runs on one thread. Every input is a descriptor in one poll call, and the engine sees time only when the loop hands it the current instant.

2.5.2 What is polled #

Each iteration builds the poll set afresh:

  1. the native listener, except during its accept-error backoff;
  2. the stub UDP socket;
  3. the stub TCP listener, except during its accept-error backoff;
  4. the netd channel, while connected;
  5. the registry watch, while armed;
  6. every native connection still sending its request;
  7. every stub TCP connection still sending its query;
  8. every upstream socket — for reading, or for writing while a TCP connection is still being established or its query is still being sent;
  9. native and stub TCP replies waiting to write, for writability;
  10. native and stub TCP streams awaiting engine answers, for hangup and error indications only. Readability and read-half-close are not requested, preserving supported half-closes without spinning on EOF.

2.5.2.1 The poll timeout #

The poll timeout is the earliest of: the next upstream transaction deadline, the next netd reconnection time while disconnected, the moment the oldest stub TCP connection reaches its 10-second bound, the moment the oldest native connection reaches its 5-second bound, the earliest queued reply write deadline, and the end of either listener's 250 ms accept-error backoff. With none of these pending, the loop sleeps until a descriptor is ready.

The timeout is the time to that moment in whole milliseconds, rounded down. A moment less than a millisecond away gives a timeout of zero, so the loop polls again without sleeping until the moment has passed.

2.5.3 Service order #

After poll returns, ready descriptors are serviced in this order:

  1. waiting native and stub TCP streams with definite hangup/error, cancelling their questions and upstream transactions;
  2. upstream sockets — replies that have arrived are answers already owed;
  3. queued native replies, then queued stub TCP replies, whose sockets are writable;
  4. the stub UDP socket, reading at most 64 datagrams;
  5. the stub TCP listener, accepting at most 64 connections;
  6. stub TCP connections still sending queries;
  7. the native listener, accepting at most 64 connections;
  8. native connections still sending requests;
  9. the netd channel;
  10. the registry watch.

The fixed datagram/accept batches yield to the other doors and timers even while one listener remains continuously ready.

2.5.4 Timers after descriptors #

Once the ready descriptors have been serviced, the timers run, in this order: transactions at or past their deadline fail over to their next attempt (§4.6), a due netd reconnection is attempted (§3.2), stub TCP connections 10 seconds old or older are closed, expired reply writes are closed, and native connections 5 seconds old or older are closed.

2.5.5 Answers within an iteration #

A question that can be answered without the network — synthetic, from the cache, or refused — has its answer prepared and its first write attempted within the iteration that read it.

A question that needs the network sends its first transaction within that iteration too, and has its answer prepared in the iteration that sees its deciding reply or its last deadline. Delivery may continue over later writable events for a slow native or TCP reader.

2.5.6 Nonblocking reply writes #

Native and stub TCP replies get one nonblocking write attempt immediately. If the socket cannot accept the whole frame, the unwritten suffix is queued and resumed when that socket is writable. Each ready event makes one write attempt; partial progress is retained without duplicating bytes. The connection closes after the whole frame is written, on a write error, or when its fixed one-second write deadline expires. Progress does not extend that deadline. Other inputs and timers continue to be serviced.

At most 256 unfinished native replies and 256 unfinished stub TCP replies are queued. Each frame is bounded by its protocol ceiling (§5.1, §6.2). Even when a queue is full, a new reply gets its immediate write attempt; if it too needs queue space, that connection closes. Ready readers can therefore still receive replies while stalled readers occupy the queue.

Stub UDP replies and upstream queries are not written this way. The netd subscription request is written with a two-second timeout when the channel is opened (§3.1).

3.1 Subscription

Peios / Peios technical reference / resolvd / The Netd Channel

The channel is the subscribe request of PSPU §6.9, on netd's control socket. This chapter describes resolvd's end of it; what netd puts in a snapshot and when it sends one is described in the netd TRM.

3.1.1 Connecting #

3.1.1.1 The subscribe request #

When a connection attempt is due (§3.2), resolvd:

  1. connects to /run/netd/control.sock;
  2. sets a two-second write timeout and writes the subscribe request in netd's framing — a four-byte little-endian length and a MessagePack map;
  3. switches the socket to nonblocking mode.

The request needs netd's NETWORK_QUERY right for resolvd's account; whether that is granted is netd's decision.

3.1.1.2 A failed attempt #

A failure at any of the three steps is a failed attempt, and the next one is scheduled by the backoff of §3.2.

3.1.1.3 A successful attempt #

On success resolvd logs subscribed to netd at info level, discards anything buffered from an earlier connection, and waits for the first snapshot as an ordinary read on the channel. Only a successfully decoded snapshot resets its backoff (§3.2); connection success alone does not.

3.1.1.4 Nothing else is written to netd #

resolvd writes nothing more on the channel after the request.

3.1.2 Reading the stream #

When the channel is readable, resolvd reads everything available, in 8 192-byte chunks, and then takes every complete frame from what it holds, in order:

FrameWhat resolvd does
A snapshot replyApplies it (§3.3), once the whole read has been taken. Several in one read are applied one after another, so the last wins.
An error replyLogs netd refused the subscription: <message>, and drops the channel once the whole read has been taken.
Any other well-formed replyIgnores it.
A payload that does not decodeLogs netd sent something unreadable: <error>, and drops the channel once the whole read has been taken.
A length above 65 536 bytesStops taking frames: the rest of the buffer is discarded and the channel dropped.

3.1.2.1 Frames behind an error are still taken #

An error reply or an unreadable payload does not stop the frames behind it. Every complete frame already read is still taken, in order, and any snapshot among them is applied. The same holds when the read ended at end of file or a read error: the frames read before it are all taken. Only a length above 65 536 stops the taking, and the frames before it have been taken by then.

3.1.2.2 The channel is dropped before the read's snapshots are applied #

When a read ends with the channel to be dropped, it is dropped — and lost the netd channel; reconnecting logged — after every frame has been taken and before any snapshot from that read is applied. In the log, the lost … line therefore comes before the netd: … summary lines (§3.3) of the snapshots read with it, and status then shows netd as false together with the scopes of those snapshots, until the channel is reconnected.

3.1.2.3 A partial frame #

A partial frame stays buffered until the rest arrives.

3.1.2.4 End of file or a read error #

End of file, or any read error other than EINTR or EAGAIN, drops the channel. EINTR is retried, and EAGAIN ends the read.

3.1.2.5 Dropping the channel #

Dropping the channel closes the socket, discards the buffer, logs lost the netd channel; reconnecting at warn level — once, however many of the causes above occurred in the read — and schedules a reconnection (§3.2).

3.2 Reconnection and Backoff

Peios / Peios technical reference / resolvd / The Netd Channel

Failure and reconnection are covered by PSPU §6.9. resolvd's backoff starts at 0.5 seconds and doubles to a ceiling of 10 seconds.

3.2.1 The backoff sequence #

The first attempt is made during startup, before readiness (§2.2). Each failed attempt schedules the next one the current backoff later, then doubles the backoff up to 10 seconds. Measured from a first attempt that fails, attempts are made at:

AttemptTime after the first
20.5 s
31.5 s
43.5 s
57.5 s
615.5 s
7 and laterevery 10 s

3.2.2 The unreachable warning #

A failed attempt made before any connection has succeeded is logged as netd not reachable (<error>); retrying when it is the first failure, and not logged otherwise. Together with the rule after a loss (below), the warning is written at most once in the life of the process: for the first failure, when that failure comes before the first successful connection. A resolvd whose first attempt succeeds never writes it.

3.2.3 A good snapshot resets the backoff #

A successfully decoded snapshot resets the backoff to 0.5 seconds. Connecting, writing the subscription, or receiving another reply kind does not reset it. A partial snapshot does not reset it until complete.

3.2.4 After a loss #

When an established channel is dropped (§3.1):

  • the next attempt is due after the current backoff, then the delay doubles up to 10 seconds. After a channel that delivered a snapshot, successive gaps are 0.5, 1, 2, 4, 8, then 10 seconds. A connection lost before its first snapshot continues the existing sequence;
  • the netd not reachable warning is not logged for the failures that follow, however many there are; the lost the netd channel; reconnecting line that preceded them is the record.

3.2.4.1 A refused subscription also backs off #

A netd that accepts the connection and then answers the subscription with an error has not delivered a snapshot. Its retries follow the same growing delay, up to ten seconds, as other failures. Each connection still logs subscribed to netd at info level and two warnings (§9.4). An accept-then-close or malformed reply likewise preserves the growing delay until a good snapshot arrives.

3.2.5 Disconnected from netd #

Losing netd changes nothing in the engine. The scopes from the last snapshot, the hostname taken from it, and every cached answer stay in use until the next snapshot replaces them; questions are answered exactly as before, and the servers in those scopes are still asked. status reports netd as false (§5.3).

3.2.6 Before the first snapshot #

When resolvd has never received a snapshot it has no scopes. It answers synthetic and static names, uses the fallback servers if any are configured, and answers everything else unavailable (§4.4).

3.3 Applying a Snapshot

Peios / Peios technical reference / resolvd / The Netd Channel

Each snapshot replaces resolvd's scopes whole (PSPU §6.9). Applying one has three parts: the hostname, the scope list, and the cache.

3.3.1 The hostname #

  • The snapshot's hostname is used when it is not empty. When it is empty, resolvd reads the kernel's hostname at that moment, into a 256-byte buffer; a kernel hostname that is empty or (none) counts as none.
  • The result is parsed as a domain name. A name that does not parse, or the root, leaves resolvd with no hostname: no synthetic answer is given for it, and status reports an empty hostname.
  • The kernel's hostname is otherwise read only at startup. A change to it is not seen until the next snapshot with an empty hostname.

3.3.2 The scope list #

Each DnsScope in the snapshot becomes one scope, in snapshot order:

Snapshot fieldBecomes
ifidThe scope key
nameThe interface name reported in answers and in status
serversServer addresses, in order. Malformed addresses are logged and dropped. Link-local IPv6 addresses use the containing interface; an optional %name or %index must match it (§4.7). There is no port; servers are asked on port 53.
domainsSearch domains, in order. A string that does not parse as a name, and the root, are dropped.
addresses(address, prefix length) pairs. A string that is not address/prefix is dropped.
default_route, exclusive, metric, levelCarried as they are
ntpIgnored

The ifid is used as it arrives. netd's interface ids are UUIDs, so none is fallback, the key of the fallback scope (§4.4); a scope that did arrive with that key would share the fallback scope's cache entries, would be asked through the fallback servers, and would lend its interface name to the fallback scope's answers.

3.3.2.1 Every scope is kept #

Dropped entries are dropped silently. Every scope in the snapshot is kept, whatever its level or server count; which of them take part in routing is decided per question (§4.4).

3.3.2.2 The snapshot summary line #

resolvd then logs one line at info level summarising the snapshot:

netd: eth0: 1 server(s), 1 domain(s), default; wg0: 1 server(s), 0 domain(s)

with , default on each scope that claims the default route, or netd: no scopes for an empty snapshot. The counts are of the servers and domains left after parsing.

3.3.3 The cache #

Comparing the new scope list with the old one, by scope key:

  • a scope whose key is new loses any cached answers under that key;
  • a scope whose server list differs from before — in content or in order — loses its cached answers;
  • a scope missing from the new snapshot loses its cached answers;
  • a change to a scope's domains, addresses, flags, metric or level leaves its cache alone.

Demotion is kept per server address, not per scope, and survives every snapshot (§4.6).

3.3.4 Questions already in flight #

  • A candidate is routed when it is first asked (§4.4), and its retries stay with the scope key it was routed to. When that scope's servers change, retries use the new list; when the scope has gone, the next retry finds no servers and the question ends unavailable.
  • A transaction already sent is not cancelled by a snapshot, and its reply is cached under the scope key it was routed to.

4.1 How a Question Is Answered

Peios / Peios technical reference / resolvd / The Engine

Every door ends here. The native socket's resolve, lookup and reverse, and every stub query, become one or more tasks, and each task takes the same path.

4.1.1 The path #

  1. Parse. The name is parsed from presentation form. A trailing dot makes no difference. Escapes are not interpreted; a label is the bytes between dots. A name with an empty label, a label over 63 bytes, or more than 255 bytes on the wire does not parse, and the task is answered notfound at once.
  2. Synthetic names. The name as asked is checked against the synthetic names (§4.2). A match answers the task.
  3. Candidates. The name becomes its list of candidates (§4.3). An empty list — a single label with no applicable search domain — answers the task notfound.
  4. Routing. Each candidate in turn is routed to one scope (§4.4). When no scope can take it, the task is answered unavailable.
  5. The cache. Unless the request asked to bypass the cache, the cache is consulted under that scope (§4.5).
  6. The network. Otherwise the candidate is sent upstream (§4.6 to §4.8).
  7. The next candidate. A notfound for a candidate — from the cache or from a server — moves to the next candidate when there is one. Any other result answers the task.
  8. The last candidate. When the last candidate is notfound, the task is answered notfound.

4.1.2 The empty name is the root #

An empty name, or . alone, parses as the root. The root is not a single label, so it is not expanded (§4.3): it is its own one candidate, routed and asked like any other name.

4.1.3 Each candidate is routed separately #

Each candidate is routed on its own, so the expansions of one single label can go to different scopes. Each has its own attempt budget (§4.6).

4.1.4 What each way out reports #

Ends atOutcomesourceserverinterfacercode
Name does not parsenotfoundlocalnilnil0
Synthetic nameper §4.2synthetic or hostsnilnil0
No candidatesnotfoundlocalnilnil0
No scope can take the candidateunavailablelocalnilnil0
Cache hitas cachedcachethe server whose reply was cachedthe scope's interfacethe cached reply's response code
A server's replyfound or notfounddnsthe serverthe scope's interfacethe server's response code
Every candidate notfoundnotfoundas the last candidate's cache hit or server's reply, in the two rows aboveas that rowas that rowas that row
Attempts exhaustedunavailablelocalnilthe scope's interface0
No servers left in the scopeunavailablelocalnilnil0
In-flight ceiling reachedunavailablelocalnilnil0

A single label whose every expansion came back NXDOMAIN from a server therefore reports dns, the server that answered the last candidate, that candidate's scope's interface, and response code 3; when the last candidate's NXDOMAIN came from the cache, it reports cache with the cached server and code.

4.1.4.1 Answers from the fallback scope #

interface is nil for every answer from the fallback scope, which has no interface: its cache hits, its servers' replies and its exhausted attempts alike.

4.1.4.2 Validation #

validation is unvalidated in every case: resolvd performs no DNSSEC validation (PSPU §6.6).

4.1.5 What an answer carries #

An answer's records are the answer-section records of the reply that decided it, after the filtering of §4.8, or the synthetic records of §4.2. The engine also carries the candidate the answer was found at; the stub door uses it to add a CNAME (§6.2) and lookup uses it as the start of its CNAME chase (§4.9).

4.1.6 An asker that leaves #

While a native or stub TCP question is waiting for the engine, its stream is polled for hangup and errors. A definite hangup, error or invalid socket abandons the question before ready upstream replies are processed: all of its transactions are cancelled, including both families of an any lookup. Other questions continue normally.

Replies to cancelled transactions are ignored; they do not populate the cache. Answers already cached before cancellation remain cached.

An abandoned question is removed from the waiting clients and no answer write is attempted for it. Cancellation does not count as an upstream failure, refusal or answer.

A write-half-close is supported: the client may stop sending while still waiting for its answer. Readable end of file alone therefore does not cancel a question. In particular, a TCP FIN cannot reliably distinguish a complete departure from a write-half-close. Without a definite hangup or error, the request retains its admission slot and runs through the usual engine attempts and deadlines. UDP has no disconnect notification.

4.2 Synthetic Names

Peios / Peios technical reference / resolvd / The Engine

PSPU §6.7 lists the names a resolver answers before any network. resolvd checks them in a fixed order, on the name as asked and before any search expansion, and the first match answers the task.

4.2.1 The order #

  1. localhost, or any name under it. 127.0.0.1 for A, ::1 for AAAA, both for ANY. Source synthetic.
  2. A static name. An exact, case-insensitive match against the Hosts\ values (§2.3). The name's addresses of the asked family — IPv4 for A, IPv6 for AAAA, both for ANY. Source hosts.
  3. The machine's hostname. An exact, case-insensitive match against the hostname (§3.3), answered with the machine's own addresses (below). Source synthetic.
  4. .local. The name local or any name under it is notfound, whatever the type asked. Source synthetic. No query is sent.
  5. A reverse-mapping name, for PTR or ANY only. See below.

A name that matches none of these goes on to search expansion and the network.

4.2.1.1 Static names beat the hostname and .local #

Because the order is fixed, a static name beats the hostname and .local: a Hosts\ value named printer.local is answered with its addresses, and one named after the machine overrides the machine's own addresses.

4.2.1.2 localhost beats a static name #

localhost is checked first, so a Hosts\ value named localhost, or a name under it such as printer.localhost, never answers a forward question: the synthetic loopback answer is given instead. Its addresses still answer reverse questions through the static-name row below, except loopback addresses, which the loopback row takes first.

4.2.1.3 Record types #

For the three address-bearing kinds, the answer is found whatever type was asked; a type other than A, AAAA or ANY, or a family the name has no address in, gives found with no records. localhost asked for MX is an empty found, not a notfound, and is not forwarded.

4.2.1.4 The machine's own addresses #

  • They are every address of every scope at addressed or better, whether or not the scope has servers, in snapshot order, link-local addresses included and loopback addresses excluded.
  • Repeated addresses are removed only when they are adjacent in that list, so an address on two interfaces that are not consecutive in the snapshot appears twice.
  • When there are none, the hostname is answered with 127.0.0.1 and ::1.

4.2.2 Reverse names #

4.2.2.1 Reverse-mapping names #

A reverse-mapping name is one under in-addr.arpa with exactly four labels before the suffix, or one under ip6.arpa with exactly 32. The suffix is matched case-insensitively.

  • Each in-addr.arpa label parses as a decimal number from 0 to 255. Leading zeros and a leading + are accepted, so 1.0.0.0127.in-addr.arpa and +1.0.0.127.in-addr.arpa both name 127.0.0.1.
  • Each ip6.arpa label is a single hexadecimal digit, in either case.

A name of either shape whose labels do not parse is not a reverse-mapping name.

4.2.2.2 Synthetic reverse answers #

Asked for PTR or ANY, a reverse-mapping name is answered synthetically when its address is:

AddressAnswerSource
A loopback address — anything in 127.0.0.0/8, or ::1PTR localhostsynthetic
Listed under a static namePTR that namehosts
One of the machine's own addresses, when there is a hostnamePTR the hostnamesynthetic

The rows are tried in that order.

4.2.2.3 An address shared by static names #

When several static names list the same address, resolvd returns one PTR: the name whose ASCII-lowercased presentation form sorts first in byte order. For example, alpha.home wins over beta.home. The answer is independent of registry insertion order, hash-table order, reloads and restarts while the set of matching names stays the same. It changes when that set changes and another name sorts first. The selected name retains its configured spelling in the reply. This rule applies to IPv4 and IPv6, for both PTR and ANY questions.

4.2.2.4 Other reverse questions #

A reverse name asked for any other type, and a reverse name whose address matches none of the rows, goes to the network.

4.2.3 Properties of every synthetic answer #

  • Every synthetic record has TTL 0.
  • Synthetic answers are never cached, and the no_cache flag has no effect on them.
  • Each one adds one to the synthetic counter (§4.10).

4.2.4 Expanded candidates are not checked #

Only the name as asked is checked. An expanded candidate is never compared with the synthetic names, so with a static name printer.lan and the search domain lan, a question for printer is expanded and sent to the network as printer.lan.

4.3 Single-Label Expansion

Peios / Peios technical reference / resolvd / The Engine

PSPU §6.7 sets the search expansion rules. This article is how resolvd builds and asks the list of candidates.

4.3.1 What counts as a single label #

  • A name is single-label when it parses to exactly one label. The trailing dot plays no part: printer. is single-label and is expanded exactly as printer is.
  • A top-level domain such as com is a single label too, and cannot be asked for directly: it is asked only as its expansions.
  • Any other name — a multi-label name, or the root — has one candidate, itself.

4.3.2 The applicable domains #

The table and serverless exclusion below describe source b4f7085.

WhenDomains, in order
Routing's step 1 applies: a routable exclusive scope is at addressed or better (§4.4)That scope's search domains, and nothing else
OtherwiseThe search domains of every routable scope (§1.3), scopes ordered by metric, then ExtraSearchDomains

The exclusive scope is the one routing would choose (§4.4).

  • An up scope that has search domains and no servers is not routable, and contributes none of them.
  • Scopes with equal metrics keep their snapshot order, and each scope's domains keep their own order.
  • ExtraSearchDomains is not applied while routing's step 1 applies.

4.3.2.1 Proposed serverless-scope correction #

The proposed source correction uses all scopes at link or better, including scopes with no DNS servers. Without a qualifying exclusive scope, their domains are ordered by metric and then followed by ExtraSearchDomains; equal metrics retain snapshot order and each scope keeps its domain order.

An exclusive scope at addressed or better supplies only its own domains, even without servers: other scopes' domains and ExtraSearchDomains are not used. For a name not already answered synthetically, if that scope has no domains, a bare single-label name has no candidates and remains notfound without a query. If expansion produces a candidate that selects a serverless scope and needs upstream work, it is unavailable; later candidates are not tried. Synthetic-answer and cache handling remain unchanged.

This describes proposed source behavior, not a released package or an installed resolver. The source-revision qualification in §4.4 applies.

4.3.3 Building the candidates #

Each domain in turn is appended to the label.

  • A candidate already in the list, compared case-insensitively, is not added again.
  • A candidate longer than 255 bytes on the wire is left out.

With no candidates left the name is notfound without a query (§4.1).

4.3.4 The case of a candidate #

A candidate keeps the case of the label as asked and of the domain as configured; that is the case its records are reported at (§4.8).

4.3.5 Asking the candidates #

  • Candidates are asked one at a time, in order. Each is routed on its own (§4.4) and has its own attempt budget (§4.6).
  • A notfound — from a server or from the cache — moves to the next candidate (§4.1).
  • found, with records or without, is the answer, and later candidates are not asked.
  • unavailable is the answer, and later candidates are not asked.
  • When the last candidate is notfound, the question is notfound (§4.1).

4.4 Scope Routing

Peios / Peios technical reference / resolvd / The Engine

PSPU §6.7 gives the routing order. resolvd applies it to each candidate separately, when the candidate is first asked, and the candidate's retries stay with the scope it chose.

The server-availability exclusions below describe source b4f7085. They differ from PSPU's definition of up. The proposed correction is summarised after the historical exclusive-scope case.

4.4.1 Which scopes take part #

Only routable scopes (§1.3) take part: those that are up — at level link or better (PSPU §6.2) — and have at least one server. An up scope with no servers is skipped at every step: it is never chosen, it never wins a tie, and its flags have no effect on routing. The same set decides which search domains apply (§4.3).

4.4.2 The steps #

  1. Exclusive. The routable scopes that are exclusive and at level addressed or better. The one with the lowest metric takes the candidate; among equal metrics, the first in snapshot order.
  2. Search domain. Among the routable scopes, every search domain the candidate equals or ends with, compared label by label and case-insensitively. The scope holding the matching domain with the most labels takes the candidate.
  3. Reverse subnet. When the candidate is a reverse-mapping name (§4.2), the routable scopes that hold an address of the same family whose prefix contains the named address; a prefix length beyond the family's width counts as the full width. The lowest metric wins, then the earliest.
  4. Default route. The routable scopes with default_route set. The lowest metric wins, then the earliest.
  5. Lowest metric. Otherwise, the routable scope with the lowest metric, then the earliest. This step is reached whenever no routable scope claims the default route — including when the only claimant has no servers.
  6. Fallback. When there are no routable scopes at all, the fallback scope, provided FallbackServers names at least one server. Its scope key is fallback.

When none of the steps yields a scope, the candidate is unavailable without a query (§4.1).

4.4.2.1 An exclusive scope that does not qualify #

An exclusive scope at level link, or one with no servers, is not exclusive for routing, and routing continues with step 2. The first takes part in the later steps as an ordinary scope; the second, not being routable, in none of them.

4.4.2.2 Proposed serverless-scope correction #

The proposed source correction treats level link or better as up, independently of DNS servers:

  • Steps 1–4 consider up scopes even when their server lists are empty. Exclusive selection still requires addressed or better. Domain, subnet, metric and snapshot-order rules are unchanged.
  • When one of those steps selects a scope without servers and upstream work is needed, the candidate is unavailable without a query. It does not fall through to another interface or the fallback scope.
  • Step 5 is reached only when earlier steps select no scope. This ordinary lowest-metric choice still requires an up scope with servers. A serverless default-route claimant therefore blocks step 5, while a serverless scope that matches no earlier rule does not block it.
  • Fallback is considered only if no earlier step selects a scope and no up scope has servers. A serverless winner at an earlier step blocks fallback too.

An exclusive scope at link alone still does not qualify for step 1; it can participate in later steps as an ordinary up scope. Synthetic answers and the selected scope's cache retain their existing precedence; this correction does not change cache invalidation or retry policy. These are proposed source semantics, not a released-package or installed-resolver guarantee. Check the installed source revision.

4.4.2.3 Ties between search domains #

Between matching domains with the same number of labels, the one in the scope with the lower metric wins. With equal metrics too, the first match found wins: the earlier scope in snapshot order, and within one scope the earlier domain.

4.4.3 Consequences #

4.4.3.1 Step 1 takes every candidate #

Step 1 is absolute while it applies: with an exclusive scope chosen, every candidate goes to it, whatever its name, and step 2 never runs.

4.4.3.2 Search domains beat the subnet rule #

Step 2 runs before steps 3 and 4, so a scope whose search domain is in-addr.arpa or 10.in-addr.arpa takes reverse names ahead of the subnet rule.

4.4.3.3 Fallback servers are a last resort #

Fallback servers are used only while no scope is routable. As soon as one scope at link or better has a server, the fallback scope takes nothing. Answers cached through it are then not consulted, and stay in the cache until they are flushed, overwritten or evicted (§4.5).

4.4.3.4 A candidate asks only its own scope's servers #

Every attempt for a candidate, and the TCP retry after a truncated reply, goes to a server of the one scope the candidate was routed to. When those attempts fail, the candidate is unavailable (§4.6); the servers of other scopes, and the fallback servers, are not asked for it.

4.4.3.5 Demotion does not affect routing #

Routing reads only the current scope list. Which servers are demoted has no effect on which scope a candidate goes to: a scope whose every server is demoted still takes the candidates routing gives it.

4.5 The Cache

Peios / Peios technical reference / resolvd / The Engine

The cache's key and lifetimes are set by PSPU §6.7. This article is how resolvd computes them and what it does at the edges.

4.5.1 The cache key #

  • An entry is keyed by the candidate in lower case, the record type, and the scope key (§1.3). The class is not part of the key; every question resolvd asks is class IN. The stub rejects other classes before consulting the cache (§6.1), even when a matching IN answer is held.
  • The same name asked through two scopes is two entries, and a single label's expansions are each cached under the expanded name.

4.5.2 What is stored #

Only replies a server sent with response code NOERROR or NXDOMAIN are stored, under the candidate and the scope the candidate was routed to:

ReplyStored asLifetime
NOERROR with relevant answer records of class IN (§4.8)found with those recordsThe least TTL among those records, capped at 86 400 s
NOERROR with no relevant answer records of class INfound with noneNegative lifetime
NXDOMAINnotfoundNegative lifetime

4.5.2.1 The negative lifetime #

  • The negative lifetime comes from the first IN SOA record in the reply's authority section whose owner is the effective name or an enclosing domain. The effective name is the candidate after following its relevant CNAME path (§4.8); suffix matching respects label boundaries. Use the lesser of the SOA's MINIMUM field and its own TTL, capped at 300 s. When an alias leads to NXDOMAIN, also cap the lifetime at the least TTL of that alias path.
  • A reply with no qualifying SOA in its authority section has a negative lifetime of zero.

4.5.2.2 A zero lifetime is not stored #

An entry whose lifetime is zero is not stored. A positive answer with a zero TTL, and a negative answer without an SOA, are therefore never cached.

4.5.2.3 What is never stored #

Never stored: synthetic answers, unavailable, transport failures, a UDP reply with TC set (the reply to its TCP retry is the one considered), and replies with any other response code.

4.5.2.4 The TTLs inside an entry #

The records in an entry are kept with the TTLs the server sent. Only the entry's lifetime is capped.

4.5.3 Hits #

  • An entry is live while its expiry is in the future. A live entry is returned with each record's TTL lowered to the whole number of seconds the entry has left, rounded down, when that is less than the record's own.
  • A notfound hit with further candidates waiting moves to the next candidate, as a notfound from a server would (§4.1).

4.5.4 Bypassing the cache #

  • A resolve request with no_cache set skips the lookup for each of its candidates. The reply it gets is still stored.
  • Stub queries, lookup and reverse always consult the cache.

4.5.5 Capacity and eviction #

  • The cache holds at most 8 192 entries.
  • Expiry alone does not remove an entry. Looking up an expired key removes it immediately, before any replacement answer arrives. Otherwise it stays until overwritten, flushed, or removed during admission at capacity. The cache_entries count in status includes expired entries that have not yet been removed.

4.5.5.1 Eviction #

Storing an entry under a key the cache does not already hold, when it already holds 8 192 entries, first evicts:

  1. every entry whose expiry is at or before the current time;
  2. then, if the cache still holds 8 192 entries, the one entry with the earliest expiry — any one of them, when several share it.

4.5.5.2 A long-lived answer preserves other live entries #

The sweep compares against the current time, independently of the new answer's lifetime. A day-long answer arriving at a full cache of live, shorter-lived answers replaces only the entry with the earliest expiry; the cache still holds 8 192 entries. If removing expired entries makes room, no live entry is evicted.

Older builds compared against the incoming answer's expiry and could empty a cache of live answers.

4.5.5.3 Overwriting a held key #

Storing under a key the cache already holds replaces that entry, live or expired, and evicts nothing else.

4.5.6 Flushes #

EventDiscards
A snapshot in which a scope is new, has a different server list, or is missing (§3.3)That scope's entries
A registry change to FallbackServers in content or order (§2.3)The fallback scope's entries
A flush request (§5.3)Everything
A restartEverything

Nothing else discards entries. Changes to static names, search domains, the hostname, ControlSecurity, scope flags and metrics leave the cache as it is.

4.5.6.1 A reply after its scope was flushed #

In source b4f7085, a reply that arrives after its scope was flushed is still stored, under the same scope key.

4.5.6.2 Proposed admission rule for invalidated tasks #

The proposed source correction also invalidates pending tasks' permission to store answers. A global flush affects every pending task. Removing an interface scope or changing its server list affects tasks currently associated with that scope; changing the fallback-server list affects tasks associated with the fallback scope.

An invalidated task still completes its original requester normally, but cannot store any further answers. This restriction persists through UDP-to-TCP fallback, timeout retries and later search candidates, even if a later attempt uses a new server list. Removing and re-adding a scope does not restore the task's permission. Fresh requests can cache normally.

Changes only to metrics, search domains or other scope properties with unchanged server lists do not invalidate admission; unrelated scopes' pending tasks are unaffected. This changes cache admission, not request cancellation or routing. Cache keys, TTLs and eviction policy are unchanged. It describes proposed source behavior, not a released package or an installed resolver; check the installed source revision before relying on it.

4.6 Servers, Attempts and Timeouts

Peios / Peios technical reference / resolvd / The Engine

Server order, demotion and the attempt limit are set by PSPU §6.7, and the contract's mainline values are in PSPU §6.B. resolvd's values are:

BoundValue
Transaction timeout2 s
Attempts per question (all candidates and transports)3
Demotion period30 s
Upstream transactions in flight4 096

4.6.1 Choosing the server #

  • For each attempt, the scope's servers — or the fallback servers — are put in order: the servers not currently demoted, in their configured order, followed by the demoted ones, in their configured order.
  • The attempt goes to the first server in that order not yet asked for this candidate. When every server has been asked, it goes to the server at position n mod count in that order, where n is the number of attempts already made for the candidate.
  • With one server, all three attempts go to it.
  • The order is computed afresh for each attempt, so a server demoted by the previous attempt is already behind its peers for the next.
  • Attempts for one candidate are made one at a time; a transaction is sent only after the previous one for the same candidate has ended.

With two healthy servers A and B, the attempts go to A, B, then whichever of them is first in the order at that moment — A if both have since been demoted, since demotion keeps configured order within the demoted group.

4.6.2 What fails an attempt #

Each of the following ends the transaction, adds one to upstream_failed, demotes the server, and moves to the next attempt:

  • no matching reply within 2 seconds of the transaction being sent;
  • an error opening, binding, connecting or sending on the transaction's socket, except the local resource and permission errors below — an unreachable network is known at once, not after the timeout;
  • an error receiving on a UDP socket, such as the refusal that follows an ICMP port-unreachable;
  • for TCP, the connection failing to complete: an error or hang-up while it is being established;
  • for TCP, an error, a hang-up or a failed write before the whole query has been sent;
  • for TCP, the connection closing, or a read error, before a whole matching reply has arrived;
  • a matching reply whose response code is neither NOERROR nor NXDOMAIN — SERVFAIL, REFUSED, FORMERR, NOTIMP, and every other code, including EDNS extended codes.

A synchronous failure to open or send a transaction caused by exhausted local descriptors (EMFILE, ENFILE), memory or buffers (ENOMEM, ENOBUFS), local address allocation (EADDRNOTAVAIL, EADDRINUSE), or permission (EACCES, EPERM) still adds one upstream_failed and advances the bounded retry sequence, but does not demote the server. A different configured address or address family may still be usable. If all attempts fail, the result is unavailable and is not cached.

A reply that does not match the transaction is ignored while the socket remains open for a matching reply until the original deadline (§4.8).

4.6.3 Demotion #

  • Demoting a server marks its address until 30 seconds from now; a further failure moves that mark forward.
  • A matching reply with response code NOERROR or NXDOMAIN that resolvd uses — a UDP reply without TC, or a TCP reply — removes the server's mark at once. A UDP reply with TC set does not remove it, whatever its response code; the reply to the TCP retry decides.
  • The mark belongs to the address, so a server listed by two scopes is demoted in both.
  • A demoted server is still asked, after the healthy ones.
  • status lists, for each scope, which of its servers are demoted at that moment (§5.3); demotion of the fallback servers is not reported.

4.6.4 The attempt limit #

  • A question gets three upstream attempts across all search candidates and transports. Failures spend them: a timeout, a failure response code, or a transport failure. When its last attempt fails, the question is unavailable (§4.1).
  • The budget is not reset when the question moves to another candidate, but a candidate's NXDOMAIN that moves it on is an answer, not a failure, and does not spend an attempt. Every search candidate is therefore asked, however many there are. Cached answers spend no attempt.
  • The TCP retry following a truncated UDP reply (§4.7) spends one attempt and gets its own two-second deadline. A truncated reply on the final attempt cannot start TCP: the question is unavailable.

The three attempts allow at most six seconds of upstream timeout waiting in total, including TCP and search-domain expansion. Each NXDOMAIN that moves to another candidate adds one round trip. Loop scheduling and local I/O can add elapsed time; the attempt limit does not give those operations a separate deadline.

4.6.5 The in-flight ceiling #

  • When 4 096 or more transactions are outstanding, a question about to allocate an upstream transaction is answered unavailable at once, and the refused counter goes up by one. A question whose scope has no servers left is answered unavailable before the ceiling is looked at, and is not counted as refused.
  • Every send checks this ceiling, including later attempts and TCP retries. A retry is normally sent after its previous transaction has ended and been removed, so it can reuse that slot even at capacity. Exhausting the three-attempt budget is checked before allocating a transaction, and is not counted as a capacity refusal.
  • Synthetic names and cache hits are answered as usual whatever the count.

4.7 The Upstream Query

Peios / Peios technical reference / resolvd / The Engine

PSPU §6.7 sets the upstream behaviour. This article is what resolvd puts on the wire to meet it.

4.7.1 Sockets #

4.7.1.1 One socket per transaction #

Every transaction has its own socket, opened when the transaction is sent and closed when it ends.

4.7.1.2 The UDP socket #

  • A new socket is bound to the unspecified address of the server's family, port 0, so the kernel picks the source address and a fresh ephemeral source port.
  • The socket is connected to the server's port 53, so the kernel delivers only datagrams from that address and port.
  • Each datagram is read into a 4 096-byte buffer; a longer datagram is truncated by the read.

The query is sent with one send.

4.7.1.3 The TCP connection #

A new nonblocking socket is connected to the server's port 53. When the connection completes, the query is written with its two-byte length prefix; once it is all written, the socket is read until the two-byte length and that many bytes of reply have arrived. Bytes after the first message are ignored. One query is sent per connection, and the connection is closed when the transaction ends.

4.7.1.4 A transaction that cannot be sent #

A failure found while a transaction is being sent — opening or binding its socket, connecting it, or, for UDP, sending the query — fails the attempt at once (§4.6) and is logged at warn level as upstream <server>: <error>. A TCP failure found later, once the connection is under way, is not logged, and neither is a timeout or a UDP receive error.

4.7.2 Link-local servers #

A link-local IPv6 server from netd is reached through the interface named by its DNS scope. Both UDP and TCP upstream sockets carry that interface’s index in sin6_scope_id; a truncated UDP answer keeps it on the TCP retry. An address such as fe80::1 can therefore be advertised in RDNSS without a zone suffix. Failures of that address on one scope do not demote the same address on another scope.

A snapshot may also spell it fe80::1%eth0 or use a numeric interface index. An explicit zone must match the containing scope’s interface; invalid or mismatched zones are logged and ignored. Global addresses continue to use ordinary unscoped sockets.

FallbackServers has no containing interface scope and continues to accept plain IP addresses only: a %zone value is logged and skipped. Configure link-local DNS on the interface profile instead. A bare link-local fallback cannot be sent; it fails as a local address error, without marking a remote server unhealthy.

4.7.3 The message #

FieldValue
ID16 bits from the kernel CSPRNG, fresh for every transaction
FlagsRD set; CD clear; everything else clear
QuestionThe candidate, case-randomised; the record type asked; class IN
AdditionalOne OPT record: payload size 1 232, DO clear, extended response code 0, version 0, no options

Authority and answer sections are empty.

4.7.3.1 Query entropy #

Every transaction obtains 34 fresh bytes through getrandom with GRND_NONBLOCK: two bytes for the message ID, interpreted little-endian, and 32 bytes of case bits, enough for every letter in a DNS name. This uses the kernel CSPRNG without a predictable userspace generator or a clock fallback.

If entropy is unavailable, including before the kernel CSPRNG is ready, resolvd logs the error, increments refused, and settles the affected question as unavailable without sending it. The event loop does not wait for entropy readiness; later requests can try again. Synthetic and cached answers do not need upstream entropy.

4.7.3.2 Case randomisation #

  • Every ASCII letter in the candidate is flipped between upper and lower case when its random bit is 1. Digits, hyphens and other bytes are unchanged.
  • Case bytes follow the ID bytes. Bits are taken lowest first from each byte in order. A bit is used only for a letter.
  • Each transaction — a retry to the same server, and the TCP retry after truncation, included — gets a new pattern and a new ID.

4.7.4 No fallback without EDNS0 #

Every query carries the OPT record. resolvd does not fall back to a query without EDNS0: a server that answers an EDNS0 query with FORMERR has failed the attempt (§4.6).

4.7.5 The TCP retry #

  • A matching UDP reply with TC set is not used. The same candidate is sent to the same server over TCP as a new transaction, with a new ID, a new case pattern and its own two-second deadline, if an attempt remains in the question budget. The TCP transaction spends one attempt (§4.6); without budget the question is unavailable.
  • A TCP reply with TC set is used as it is.

4.8 Reply Validation

Peios / Peios technical reference / resolvd / The Engine

4.8.1 Matching #

A reply is matched against the transaction it arrived for. It matches when all of these hold:

  1. it decodes as a DNS message — bytes after its last section are tolerated;
  2. its ID equals the transaction's;
  3. its QR bit is set;
  4. it has exactly one question;
  5. that question's type and class equal those sent;
  6. that question's name is byte-for-byte identical to the name sent, case pattern included.

4.8.1.1 A non-matching reply is ignored #

A reply that fails any of these is ignored: the transaction is not ended by it, nothing is counted, and the server is not demoted for it.

4.8.1.2 A non-matching datagram on UDP #

A datagram that does not match is discarded while the transaction's socket stays open. A later matching datagram can answer the same transaction. Ignoring a datagram does not extend the original two-second deadline: if no matching reply arrives, the attempt times out and the server is demoted (§4.6).

4.8.1.3 An undecodable or cut-short datagram #

The same holds for a datagram that does not decode. A datagram longer than the 4 096-byte read buffer arrives cut short. If the cut falls inside the DNS message, it cannot decode and is discarded; the socket remains available for another reply until the original deadline.

4.8.1.4 A non-matching TCP reply #

An undecodable or non-matching TCP message is discarded while the connection remains open. Later complete frames on that connection are checked, including frames already buffered and a matching frame followed by EOF. Partial frames remain buffered for the next read. Ignoring a frame does not extend the original deadline. If the peer closes before a matching frame completes, the attempt fails; if it stays open without a matching reply, the attempt times out (§4.6).

4.8.2 A matching reply #

A matching reply adds one to upstream_answered and ends the transaction. Then:

ReplyResult
UDP, TC setTCP retry to the same server (§4.7)
Response code NOERRORfound
Response code NXDOMAINnotfound
Any other response codeThe attempt fails (§4.6)

A NOERROR or NXDOMAIN reply that is used removes the server's demotion; a UDP reply with TC set does not (§4.6).

4.8.2.1 Extended response codes #

The response code is the header's four bits combined with the extended bits of an OPT record in the reply, so an extended code is never mistaken for NOERROR or NXDOMAIN.

4.8.3 What is taken from it #

4.8.3.1 Records #

  • Relevant answer records of class IN, in the order the server sent them.
  • For NXDOMAIN none are taken, even if the server sent some.
  • Start at the candidate. Keep records of the requested type at that owner; otherwise follow the first IN CNAME there, retaining it, and repeat at its target. Stop after sixteen aliases, on a repeated name, or when no matching record remains. Records unrelated to that path or of an unrequested type are discarded before rendering or caching. Their TTLs cannot shorten the cache lifetime.
  • An explicit CNAME question returns CNAME records at the queried owner. ANY accepts all IN types at that owner and does not follow aliases. Owner comparisons are case-insensitive and use DNS label boundaries.
  • The authority and additional sections are not returned.

4.8.3.2 The case of returned records #

A record whose owner name equals the candidate, case-insensitively, is given the candidate's case — the case of the question as asked, not the randomised case sent. Other records keep the case the server used.

4.8.3.3 Lifetime #

The cache lifetime of §4.5, computed from the answer records or from the authority section's SOA.

4.8.3.4 Upstream flags #

The server's AA, AD, RA and CD bits are not used and are not carried into any answer.

4.8.3.5 An empty NOERROR #

A NOERROR reply with no relevant answer records of class IN — including a referral from a server that does not recurse — is found with no records.

A notfound with further candidates moves to the next one (§4.3); everything else answers the question.

4.9 Lookup and Reverse

Peios / Peios technical reference / resolvd / The Engine

4.9.1 lookup #

A lookup (PSPU §6.5) asks for A, AAAA, or both:

familyTasks
anyA and AAAA, started together
inetA
inet6AAAA

4.9.1.1 Each task is an ordinary question #

Each task is an ordinary question (§4.1): it has its own synthetic check, its own expansion, its own routing per candidate, its own cache lookups and its own attempts. The two tasks of an any lookup run at the same time and can be answered from different sources. The reply is built when the last of them is done.

4.9.1.2 The outcome #

Tasks' outcomesoutcome
At least one found, with or without recordsfound
None found, at least one unavailableunavailable
Every one notfoundnotfound

An A answer and an AAAA that is unavailable is found, with IPv4 addresses only.

4.9.1.3 Chasing CNAMEs #

For each found task, resolvd looks for addresses in that task's answer records, starting at the candidate the answer was found at. Names are compared case-insensitively.

  1. If the first record at the current name is a CNAME, and no record of the asked type is at the current name, the chase moves to the CNAME's target.
  2. This repeats at most 16 times; a loop stops there.
  3. The addresses are the A (for the A task) or AAAA (for the AAAA task) records at the name the chase ended at, in answer order, each with its record's TTL.

4.9.1.4 The chase asks nothing further #

Only the records in the task's own answer are used. A chain that leaves them — a CNAME whose target's addresses the server did not include — ends with no addresses, and no further question is asked.

4.9.1.5 The reply fields #

  • addresses lists each finished task's addresses in turn, in the order the tasks finished, so whether IPv4 or IPv6 addresses come first depends on which family was answered first.
  • canonical and source come from the same selected found task. Prefer a task that produced addresses; when both or neither did, prefer A over AAAA. Completion order does not affect this choice. canonical is the name that task's CNAME chase ended at, after search expansion. Without a found task it is the name asked, as parsed, in presentation form without a trailing dot (the root is .).
  • A found with no addresses still reports the expanded or chased name.
  • A name that does not parse is answered at once with outcome notfound, canonical ., no addresses and source local; no task is started.
  • source is the source of the selected task, and local when no task was found. It describes that task; the other family's records can have a different source.
  • validation is unvalidated.
  • A lookup adds one to queries however many tasks it makes.

4.9.2 reverse becomes a PTR question #

A reverse turns its address into the reverse-mapping name — the four octets reversed under in-addr.arpa for IPv4, the 32 nibbles reversed, in lower-case hexadecimal, under ip6.arpa for IPv6 — and resolves that name for PTR exactly as a resolve without no_cache would. The reply is an ordinary answer (§5.3).

4.10 Counters

Peios / Peios technical reference / resolvd / The Engine

The engine keeps seven counters, reported by status (§5.3). Each is an unsigned 64-bit count.

4.10.1 What each counter counts #

CounterGoes up by one for
queriesEvery resolve, lookup and reverse request that passes the access check, and every syntactically accepted stub query, including UDP budget refusals and requests whose name does not parse
syntheticEvery task answered by a synthetic name (§4.2), including .local's notfound; an any lookup of localhost adds two
cache_hitsEvery candidate found live in the cache (§4.5), including a notfound hit that moves on to the next candidate
upstream_sentEvery transaction started, UDP or TCP, including TCP retries. It is counted before the socket is opened, so a transaction whose socket cannot be opened, connected or sent on (§4.7) counts here as well as in upstream_failed, though nothing reached the wire
upstream_answeredEvery matching reply (§4.8), whatever its response code, including a truncated one
upstream_failedEvery failed transaction (§4.6): a timeout, a transport failure, or a reply with a response code other than NOERROR and NXDOMAIN
refusedEvery question refused at the in-flight ceiling (§4.6), the UDP pending budgets (§6.1), or because fresh query entropy is unavailable (§4.7)

4.10.2 What is not counted #

status and flush requests are not counted, and nothing on the netd channel is.

An unmatched reply changes neither upstream counter (§4.8). A later matching reply adds one upstream_answered; if the original deadline expires without one, the timeout adds one upstream_failed instead. A reply with SERVFAIL shows up in both. A transaction that could not be sent shows up in upstream_sent and upstream_failed, and never in upstream_answered.

4.10.3 Resetting the counters #

The counters start at zero when resolvd starts, and are reset only by a restart. flush does not reset them.

5.1 Framing and Limits

Peios / Peios technical reference / resolvd / The Native Socket

The framing, the one-request-per-connection rule and the requirement to answer errors with an error reply are PSPU §6.4's. This article is resolvd's reading of them.

5.1.1 Accepting connections #

At most 64 waiting connections are accepted per event-loop iteration. When 256 native connections are either still sending their requests or waiting for engine answers, a newly accepted connection is closed at once, with no reply; so is one that cannot be made nonblocking. Reading a request keeps its slot occupied until resolution completes or the asker is abandoned (§4.1). Reply writers use a separate bounded queue (§2.5).

5.1.2 Reading the request #

Each connection is read without blocking. Partial headers and payloads are retained until more bytes arrive or the delivery bound expires.

  1. Read exactly the four-byte little-endian payload length. A length above 65 536 gets the reply request too large and the connection closes, without reading any payload bytes.
  2. Read only that many payload bytes, in chunks of at most 4 096 bytes. The buffered frame never exceeds 65 540 bytes, including its header.
  3. Decode the complete payload immediately. Bytes after this first frame remain unread and do not count against its size ceiling; they cannot make a legal request too large.

5.1.2.1 The length check precedes the payload #

The declared length is checked before any payload is read, even when the client queues the header and a large payload together. A legal request followed by additional bytes is still answered once. This is one request per connection; extra frames are not processed. When extra bytes remain unread, the client may observe a connection reset after its framed reply instead of a clean end of file.

5.1.3 A connection that ends early #

  • A connection that closes, or fails to read, before its request is complete is dropped without a reply.
  • A client that sends its whole request and then shuts down its writing side is answered as usual, whether the end of file is already waiting behind the request or arrives later. The complete frame is decoded without reading beyond it, and the connection closes after its one reply. A partial frame followed by end of file is dropped immediately, without waiting for the delivery timeout.
  • A connection that has not delivered a complete request five seconds after it was accepted is closed without a reply.

5.1.4 Decoding #

The payload is a MessagePack map. The following describes the decoder in resolvd 0.1.6 at source b4f7085, before the proposed source correction described below:

  • Every key is a string, and a key repeated in the request's top-level map is an error. Keys inside a skipped value are not checked for repeats.
  • A key resolvd does not know is skipped when its value is nil, a boolean, an integer, a string, binary, an array or a map, with one exception among the integers: an integer above 9 223 372 036 854 775 807 (a uint 64 of 2^63 or more) anywhere inside the value, a map key included, fails the request with malformed message: unexpected value type.
  • A floating-point or extension value anywhere inside an unknown key's value fails the decode, and so does a string inside it that is not UTF-8: a skipped string is still checked.
  • An array or map nested more than 32 levels deep inside an unknown key's value, counting the value itself as the first level, fails the decode.
  • query names the request; a missing query is an error, and an unknown one is an error.
  • Every key resolvd knows — query, name, type, no_cache, family and address — is read and type-checked in every request, whether that request takes it or not, with the types of PSPU §6.5. A value of the wrong type is an error, so {query: status, name: 5} is refused; type is checked against 65 535 in every request, so {query: status, type: 70000} is refused too. family takes any string (§5.3), and address is parsed as an address only in a reverse.
  • Anything after the top-level map in the payload is ignored.

The map is read in order, and the first error met is the one reported. A missing field is looked for only after the whole map has been read.

5.1.4.1 Proposed unknown-field correction #

The proposed source correction makes libresolv skip floating-point values, fixed and variable-length extension values (including their type byte), and the entire unsigned 64-bit range in unknown fields. This applies to requests and replies, including unknown status counter keys and values nested inside skipped arrays and maps. It does not change the 0.1.6 source behavior described above.

Skipped strings must still be UTF-8, skipped containers retain the 32-level nesting limit, and truncated values still fail. The reserved MessagePack marker 0xc1 still produces unsupported value type. Known fields retain their existing type and range checks; in particular, this does not extend the range accepted by a known integer field.

5.1.5 Error replies #

Every error reply is ok: false with an error string and nothing else. The causes below describe the same source version before the proposed correction; after that change, unknown float, extension and large unsigned integer values no longer produce the two type errors:

errorCause
request too largeThe size checks above
malformed message: truncated messageThe payload ends inside a value, or an array or map claims more items than bytes remain, a map's keys and values counted separately
malformed message: unexpected value typeA known field, a key, or the payload itself has the wrong MessagePack type; this includes a type that is negative or above 9 223 372 036 854 775 807, and an integer above 9 223 372 036 854 775 807 anywhere inside an unknown key's value
malformed message: unsupported value typeA floating-point or extension value
malformed message: string is not UTF-8A string that is not UTF-8
malformed message: nested too deeplyNesting beyond 32 levels inside a skipped value
missing or malformed field <name>A field the request needs is absent; a type, in any request, from 65 536 to 9 223 372 036 854 775 807; or the address of a reverse that is not an IP address
duplicate field <name>A repeated key
unknown query "<name>"A query resolvd does not know
access deniedThe access check (§5.2)
reply too largeThe encoded reply would exceed the 65 536-byte payload ceiling; it is replaced by this error

5.1.5.1 The connection after an error reply #

After an error reply the connection is closed.

5.1.6 Writing the reply #

  • The reply is framed the same way — four-byte little-endian length, then the MessagePack map — and written without blocking. An unfinished write is queued with a fixed one-second deadline and a cap of 256 queued native replies (§2.5). A failed or expired write is logged as control: reply failed: <error>. The connection is then closed.
  • An encoded reply payload may be at most 65 536 bytes, excluding its four-byte length prefix. A larger reply is replaced by the error reply too large; no partial answer is sent. This applies to every native reply kind. Exactly 65 536 bytes is allowed.

5.2 The Access Check

Peios / Peios technical reference / resolvd / The Native Socket

The control object and its check are PSPU §6.4's. resolvd makes the check after the request has been decoded and before anything else is done with it.

5.2.1 The check #

  1. The connecting peer's token is opened from the socket. When it cannot be, resolvd logs control: no peer token: <error> and the request is denied.
  2. A KACS access check is made of the request's right against the control object's descriptor, with that token. A check that cannot be completed counts as a denial.
  3. A denied request is answered access denied (§5.1).
RequestRight
resolve, lookup, reverse, statusRESOLVER_QUERY (0x0001)
flushRESOLVER_CONTROL (0x0002)

5.2.1.1 The generic mapping #

The check uses this generic mapping:

Generic rightMaps to
GENERIC_READRESOLVER_QUERY, READ_CONTROL
GENERIC_WRITERESOLVER_CONTROL, READ_CONTROL
GENERIC_EXECUTERESOLVER_QUERY
GENERIC_ALLRESOLVER_ALL_ACCESS (0x000F0003)

5.2.1.2 Decoding comes before the check #

A request is decoded before it is checked, so a malformed request gets its decoding error whoever sent it, and an unknown query gets unknown query rather than access denied.

5.2.1.3 The answer does not depend on the caller #

The peer's identity is used for this check and for nothing else. Every caller allowed to ask gets the same answer to the same question.

5.2.2 The compiled default descriptor #

The descriptor is ControlSecurity when it holds a valid self-relative descriptor, else the compiled default (§2.3):

ComponentValue
OwnerSYSTEM
GroupSYSTEM
DACLSYSTEM: RESOLVER_ALL_ACCESS; Administrators: RESOLVER_ALL_ACCESS; Everyone: RESOLVER_QUERY and READ_CONTROL

5.2.3 A new descriptor applies to later requests #

The descriptor is rebuilt whenever the registry configuration changes (§2.3). A request decoded after the change is checked against the new one; a request already being answered is not checked again.

5.2.4 What the control object does not govern #

The stub listener is not checked against the control object (PSPU §6.8): anything on the machine that can reach 127.0.0.53 can ask. A program denied RESOLVER_QUERY on the native socket still resolves names through the stub listener. The NSS shim uses the native socket, so for such a program getaddrinfo fails (§7.1) while a resolver that speaks DNS directly succeeds.

5.3 Requests

Peios / Peios technical reference / resolvd / The Native Socket

The requests, their fields and their reply shapes are PSPU §6.5. This article covers what resolvd accepts and what it fills in.

5.3.1 Common to every request #

A request that passes the access check (§5.2) is handled at once:

  • status and flush are answered within the same loop iteration;
  • the other three become engine questions (§4.1), and the connection is held until the engine answers; the reply is written then.

5.3.2 resolve #

FieldAccepted
nameAny string: no name is refused at decoding. It is handed to the engine as it is, which parses it (§4.1).
typeAny value from 0 to 65 535, forwarded as it is — ANY (255), OPT (41) and types resolvd has no parser for included.
no_cacheBoolean; absent means false. Skips the cache lookup (§4.5).

The reply's outcome, source, server, interface and rcode are as §4.1 describes. Each record carries:

KeyContent
nameThe owner name in presentation form, without a trailing dot; the root is .. A dot or backslash inside a label is written \. or \\, and any byte outside ! to ~ as a backslash and three decimal digits.
typeThe record type number
ttlAs sent by the server, lowered on a cache hit (§4.5); 0 for a synthetic record
dataThe record data in uncompressed wire form
textThe record data in presentation form (below)
Typetext
A, AAAAThe address
CNAME, PTR, NSThe target name, written as name is
SOAmname rname serial refresh retry expire minimum
MXpreference exchange
SRVpriority weight port target
TXTEach string in double quotes, separated by spaces; inner quotes are escaped as \", backslashes are not escaped, and bytes that are not UTF-8 become U+FFFD
Anything else\# <length> <hex>, the hex in lower case

5.3.3 The lookup family #

family takes any, inet or inet6. Absent, or any other string, means any. The reply is built as §4.9 describes.

5.3.4 The reverse address #

address is an IPv4 or IPv6 address in textual form; anything else is the error missing or malformed field address. The question is built as §4.9 describes.

5.3.5 status #

KeyContent
hostnameThe hostname in use (§3.3), or an empty string when there is none
netdWhether the netd channel is connected at this moment
scopesEvery scope in the latest snapshot, in snapshot order — including scopes with no servers and scopes that take no part in routing
fallback_serversFallbackServers as parsed (§2.3)
cache_entriesThe number of entries held, expired ones included (§4.5)
counters§4.10

5.3.5.1 Each scope in status #

Each scope carries interface (the interface name), servers and domains as parsed from the snapshot (§3.3), default_route, exclusive, metric, subnets, and demoted:

  • subnets holds the interface's addresses as address/prefix — the address itself, not the network it is in;
  • demoted holds those of the scope's servers that are demoted at this moment (§4.6);
  • a scope's level is not reported.

5.3.6 flush #

  • Every cache entry, for every scope, is discarded, resolvd logs cache flushed at info level, and the reply is ok: true with no kind.
  • Demotions and counters are left as they are.

6.1 Receiving Queries

Peios / Peios technical reference / resolvd / The Stub Listener

The stub listener's address, its loopback-only rule and its rendering are PSPU §6.8. This article covers how resolvd reads queries; the replies are §6.2.

6.1.1 UDP #

When the UDP socket is readable, at most 64 datagrams are read per event-loop iteration, including malformed or dropped datagrams. For each:

  1. A datagram whose source address is not a loopback address — anything outside 127.0.0.0/8 — is dropped without a reply.
  2. It is checked as a query (below). A refused query gets its error reply, or nothing.
  3. A valid query is admitted if fewer than 16 UDP questions from its source address and port, and fewer than 256 UDP questions in total, are waiting for engine answers. Otherwise it gets SERVFAIL, with no upstream transaction, and increments both queries and refused. This admission check precedes synthetic and cache lookup.
  4. An admitted query becomes an engine question (§4.1). Its slot is released when the engine completes it, including timeout completion.

The endpoint is an accounting key, not an authenticated process identity. Changing source ports can consume the aggregate UDP allowance but cannot consume the separate native or TCP admission allowances. These bounds limit retained work; they do not promise fairness between local processes.

6.1.1.1 The UDP read buffer #

Each datagram is read into a 4 096-byte buffer; a longer datagram is truncated by the read to its first 4 096 bytes.

6.1.1.2 A UDP query longer than the buffer #

A query longer than 4 096 bytes therefore arrives cut short. When the cut falls inside the DNS message, the message no longer decodes and, being at least 12 bytes long, is answered FORMERR (below). Bytes after the end of the message are ignored by decoding, so a query whose message fits in 4 096 bytes and is only padded beyond them is answered as usual.

6.1.1.3 Where UDP replies go #

Replies are sent to the source address and port the query came from.

6.1.2 TCP #

When the TCP listener is readable, at most 64 waiting connections are accepted per event-loop iteration. A connection is closed at once, without a reply:

  • when 256 stub TCP connections are still sending queries or waiting for engine answers;
  • when its source is not a loopback address.

A connection that cannot be made nonblocking is closed the same way.

6.1.2.1 Reading a TCP query #

Each held connection is read as it becomes readable, everything available each time, in 4 096-byte chunks. A message is a two-byte big-endian length and that many bytes.

  • When more than 65 537 bytes are buffered, the connection is closed without a reply.
  • When a whole message has arrived, it is checked as a query, and the connection leaves the set of connections still sending, but continues to occupy its admission slot while the engine works.
  • A connection carries one query. Anything sent after the first message is ignored, and the connection is closed once the answer, or the error reply, has been written.
  • A connection that closes, or fails to read, before its message is whole is closed without a reply.
  • A client that sends a complete query and then shuts down its writing side is still answered. This also applies when the read finds end of file immediately after the buffered query. The connection closes after its one reply. A partial frame followed by end of file is dropped immediately, without waiting for the delivery timeout.
  • A connection that has not delivered a whole message ten seconds after it was accepted is closed without a reply.
  • Once its query has arrived the ten-second bound no longer applies; the connection is held until the engine answers or a definite hangup/error abandons its question (§4.1). Reply writers use a separate bounded queue (§2.5).

6.1.3 Checking a query #

The messageResult
Does not decode, and is at least 12 bytes longFORMERR: the query's ID, QR set, every other header bit clear, no sections
Does not decode, and is shorter than 12 bytesDropped without a reply
Has QR setDropped without a reply
Has an opcode other than QUERYNOTIMP
Has no question, or more than oneFORMERR
Has a question class other than INNOTIMP; the question is echoed unchanged and no upstream query is made
OtherwiseAccepted

6.1.3.1 The NOTIMP and question-count FORMERR replies #

These two replies are built from the query:

  • the header copies the query's ID, opcode, RD and CD bits, and sets QR and RA; every other bit is clear;
  • the question section is the query's, whatever it holds;
  • when the query carried an OPT record, the reply carries one whose payload size is the size the query advertised, with DO clear and no options; without one, the reply has none.

6.1.4 What an accepted query contributes #

  • Only IN questions are accepted. Their decoded name and type reach the engine; unsupported classes are rejected at the stub door over both UDP and TCP.
  • RD, CD, the DO bit and any EDNS options are not looked at.
  • Stub queries always consult the cache (§4.5).

6.1.4.1 Wire names #

The decoded name reaches the engine directly, without conversion to presentation text and reparsing. Label boundaries and bytes, including embedded dots, backslashes, NUL and other binary bytes, are preserved. The usual upstream ASCII case randomization still applies (§4.3).

Names are subject to DNS wire limits, not the length of a displayed escape sequence. A valid 63-byte binary label or a valid name whose display form would exceed 255 bytes can therefore be resolved normally.

Search expansion (§4.1) uses the number of wire labels: a single label containing a literal dot remains a single-label name.

6.1.5 Which stub queries are counted #

Every valid query counts in queries (§4.10), including a UDP query refused by the pending budgets (which also increments refused). Queries rejected by syntax, opcode, question count or class checks do not count.

6.2 Rendering Replies

Peios / Peios technical reference / resolvd / The Stub Listener

The outcome-to-response-code table and the rules for CNAME, RA, AA and AD are PSPU §6.8. This article is how resolvd builds each reply.

6.2.1 The header and question #

  • The reply copies the query's ID, opcode, RD and CD bits and its question section.
  • QR and RA are set. AA, AD and TC are clear, except that TC is set on a truncated UDP reply (below).
  • No bit from an upstream server's reply reaches the stub reply.

6.2.2 By outcome #

6.2.2.1 found replies #

  • The response code is NOERROR, and the answer section holds the engine's records in the order the engine has them (§4.8).
  • When the answer was found at a candidate other than the question's name — compared case-insensitively — the section begins with a CNAME from the question's name, in the query's case, to the candidate. Its TTL is the least TTL among the records when any are present.
  • A found with no records at an expanded name is NOERROR with only that leading CNAME. Its TTL is the negative cache lifetime: the lesser of the enclosing upstream SOA record's TTL and minimum field, capped at 300 seconds and at the TTL of any CNAME path the reply followed (§4.5). A cache hit uses the remaining lifetime, rounded down to whole seconds. With no enclosing SOA, the TTL is zero; no lifetime is invented. The authority section remains empty. An unexpanded empty found has no CNAME.

6.2.2.2 notfound replies #

NXDOMAIN, with empty answer and authority sections. No SOA is supplied, so a client has nothing from which to cache the absence.

6.2.2.3 unavailable replies #

SERVFAIL, with empty sections.

6.2.3 EDNS in stub replies #

When the query carried an OPT record, the reply carries one advertising a payload size of 1 232 bytes, with DO clear and no options. A query without OPT gets a reply without one.

6.2.4 Size and truncation #

  • Over UDP the reply's size limit is the payload size the query's OPT advertised, or 512 bytes when that is smaller or there is no OPT.
  • A reply over the limit is replaced by a truncated one, which keeps the same header with TC set, and the question.
  • The truncated reply keeps the OPT record when the reply has one.
  • The truncated reply carries no records: nothing is partially included.
  • A reply that cannot be encoded at all is not sent.
  • Over TCP the whole reply is sent, whatever its size up to the 65 535 bytes a DNS message can hold.
  • A TCP reply is written with its two-byte length, without blocking. Unfinished writes are queued with a fixed one-second deadline and a cap of 256 queued TCP replies (§2.5); a failed or expired write is not logged.

7.1 The Module

Peios / Peios technical reference / resolvd / The Nss Shim

The shim's obligations are PSPU §6.10. This chapter describes the module resolvd's source tree builds.

7.1.1 The object #

  • libnss_peios_net.so.2 is installed by dev.peios.resolvd-nss at /usr/lib/x86_64-linux-peios/libnss_peios_net.so.2.
  • It is built from the nss crate, with the native channel codec compiled in. Its only shared-object dependencies are libc.so.6 and the dynamic loader, ld-linux-x86-64.so.2. The GCC unwinder required by Rust is linked statically, with its symbols private to the module; name lookups do not load libgcc_s.so.1. The identity shim, libnss_peios.so.2, uses the same static-unwinder arrangement. Neither shim links libpeios.

7.1.1.1 The entry points #

The module exports six entry points:

Entry pointAsks
_nss_peios_net_gethostbyname4_rlookup, family any
_nss_peios_net_gethostbyname3_rlookup, family from af
_nss_peios_net_gethostbyname2_ras gethostbyname3_r, with no TTL or canonical name out
_nss_peios_net_gethostbyname_ras gethostbyname3_r, with af AF_INET
_nss_peios_net_gethostbyaddr2_rreverse
_nss_peios_net_gethostbyaddr_ras gethostbyaddr2_r, with no TTL out

7.1.2 One connection per call #

  • Every call that needs resolvd opens a new connection to /run/resolvd/resolv.sock, sends one request, reads one reply, and closes the connection.
  • Nothing is kept between calls: no connection, no cache, no configuration. The module reads no file.

7.1.2.1 The ten-second socket timeouts #

Each connection is given ten-second read and write timeouts, which apply to each read and write on the socket. Ten seconds is longer than the six seconds a question's three timed-out attempts take in resolvd, TCP retries included (§4.6), so a slow upstream normally reaches the caller as unavailable from resolvd rather than as a timeout here. A question can still reach the timeout when many search candidates each answer NXDOMAIN slowly, since those answers do not spend the attempt budget; that is reported as the table below shows.

7.1.3 Results #

ResultNSS statuserrnoh_errno
Addresses, or names, foundSUCCESS——
found, but no address of the asked familyNOTFOUNDENOENTHOST_NOT_FOUND
notfoundNOTFOUNDENOENTHOST_NOT_FOUND
unavailableTRYAGAINEAGAINTRY_AGAIN
No reply within ten secondsTRYAGAINEAGAINTRY_AGAIN
resolvd cannot be connected toUNAVAILENOENTNO_RECOVERY
An error reply, including access deniedUNAVAILENOENTNO_RECOVERY
A reply of the wrong kind, a reply that does not decode, or any other I/O errorUNAVAILENOENTNO_RECOVERY
The caller's buffer is too smallTRYAGAINERANGENETDB_INTERNAL (-1), required for retry; see version note below
gethostbyname3_r or 2_r with an af other than AF_INET or AF_INET6UNAVAILEAFNOSUPPORTNO_DATA
gethostbyaddr2_r or _r with an af and length other than AF_INET/4 or AF_INET6/16UNAVAILEAFNOSUPPORTNO_RECOVERY
A null name, a name that is not UTF-8, or a null addressNOTFOUNDENOENTHOST_NOT_FOUND

Note

The buffer-exhaustion row specifies the glibc-compatible retry contract. resolvd 0.1.6 source instead sets h_errno to 0; that implementation does not satisfy the retry contract. The corrected behavior requires the NSS buffer-retry fix. Check the installed module's source revision before relying on it.

The missing-family row above describes source b4f7085: a found name with no address of the family asked is reported exactly as a name that does not exist. This does not meet PSPU §6.10's requirement that found without an address of that family return NO_DATA.

The proposed source correction preserves NOTFOUND and ENOENT, but sets h_errno to NO_DATA when a found reply has no usable address of the requested family. A found reverse reply with no PTR record has the same result. Explicit notfound still returns HOST_NOT_FOUND; unavailable and transport failures keep their distinct results in the table. This describes proposed source behavior, not a released package or an installed module. Check the installed module's source revision before relying on this distinction.

7.1.3.1 AF_UNSPEC #

AF_UNSPEC is accepted only by gethostbyname4_r, which always asks for both families.

7.1.4 The caller's buffer #

Every string, pointer array and address the module returns is placed in the buffer glibc passes in; the module allocates nothing that outlives the call. When the buffer cannot hold the result, the call returns as in the table, and nothing written to the buffer is valid.

7.2 Forward Lookups

Peios / Peios technical reference / resolvd / The Nss Shim

7.2.1 localhost in the shim #

  • A name that, with trailing dots removed, is localhost or ends in .localhost, compared case-insensitively, is answered inside the calling process without contacting resolvd — whether resolvd is running or not.
  • The answer is 127.0.0.1 for IPv4, ::1 for IPv6, or both, IPv4 first, with canonical name localhost and TTL 0.

7.2.2 Other names #

  • Any other name is sent as a lookup (§4.9), with the family the entry point asks for. The addresses in the reply are filtered to that family, and an empty result is NOTFOUND (§7.1).
  • Addresses are returned in the order resolvd's reply lists them.

resolvd does not fix which family comes first (§4.9); glibc's own sorting of getaddrinfo results is what callers normally see.

7.2.3 gethostbyname4_r #

The result is a linked list of gaih_addrtuple, and the pointer glibc passes is overwritten with the head of the list:

FieldContent
nextThe next tuple: one tuple per address, in reply order
nameOne copy of the canonical name, shared by every tuple
familyAF_INET or AF_INET6, per address
addrThe address
scopeid0, so an IPv6 link-local address is returned without the interface it belongs to

7.2.3.1 The gethostbyname4_r TTL #

When ttlp is given it receives the least TTL among the addresses.

7.2.4 The forward hostent #

gethostbyname3_r, 2_r and _r return a hostent:

FieldContent
h_nameThe reply's canonical name
h_aliasesEmpty
h_addrtypeThe af asked for
h_length4 or 16
h_addr_listEvery address, in reply order

7.2.5 The gethostbyname3_r TTL and canonical name #

When ttlp is given it receives the least TTL among the addresses, and when canonp is given it is pointed at h_name.

7.3 Reverse Lookups

Peios / Peios technical reference / resolvd / The Nss Shim

7.3.1 Loopback addresses in the shim #

An address in 127.0.0.0/8, or ::1, is answered inside the calling process without contacting resolvd: h_name is localhost, with no aliases and TTL 0.

7.3.2 Other addresses #

  • Any other address is sent as a reverse (§4.9). A found reply with no records is NOTFOUND (§7.1).
  • In source b4f7085, a reply whose records include no PTR record — only CNAMEs, for instance — is NOTFOUND with HOST_NOT_FOUND.

The proposed source correction returns NOTFOUND / ENOENT / NO_DATA for a found reply with no records or no PTR record. Explicit notfound remains HOST_NOT_FOUND; this does not turn transient failures into negative answers. The source and installed-module qualification in §7.1 applies here too.

7.3.3 The reverse hostent #

Otherwise the reply's PTR records are taken in order, each record's text with any trailing dots removed:

FieldContent
h_nameThe first PTR record's name
h_aliasesThe remaining PTR records' names
h_addrtypeThe af asked for
h_length4 or 16
h_addr_listThe address asked about, alone

7.3.4 The reverse TTL #

When ttlp is given it receives the least TTL among all the reply's records, PTR or not.

8.1 The resolv Command

Peios / Peios technical reference / resolvd / The Resolv Command

resolv (/usr/bin/resolv, package dev.peios.resolv) owns no state.

8.1.1 One request per verb #

Every verb but version is one request on the native socket, on one connection, and its output is a rendering of the reply.

8.1.2 Verbs #

CommandRequest
resolv statusstatus
resolv query <name> [<type>] [--no-cache]resolve
resolv lookup <name>lookup, family any
resolv reverse <address>reverse
resolv flushflush
resolv version, resolv --versionnone: prints resolv <version>

8.1.2.1 The command line #

  • --no-cache is recognised anywhere on the command line and removed before the rest is matched; it affects only query.
  • Any other argument list prints a usage line to standard error and exits 64.

8.1.2.2 Arguments checked before contacting resolvd #

  • The <type> of query is A when omitted. It is one of A, NS, CNAME, SOA, PTR, MX, TXT, AAAA, SRV or ANY, in any case, or TYPE<n> for any type number from 0 to 65 535.
  • Any other <type> prints resolv: unknown record type "<type>" and exits 1 without contacting resolvd.
  • The <address> of reverse is checked in the same way: anything that is not an IPv4 or IPv6 address prints resolv: "<address>" is not an address and exits 1 without contacting resolvd.

8.1.2.3 No timeout in resolv #

resolv sets no timeout on the connection. It waits for as long as resolvd takes to answer.

8.1.3 Output #

8.1.3.1 query output #

query prints a summary line, then one line per record:

<outcome>  <source>[ via <server>][ on <interface>]  validation <validation>
<name>	<ttl>	<type>	<text>

with via present when the reply names a server and on when it names an interface, and fields on record lines separated by tabs. A line rcode <name> follows the summary when the outcome is found and the response code is not zero. resolvd answers found only with response code zero (§4.8), so against resolvd the line does not appear.

8.1.3.2 lookup output #

lookup prints <outcome> <source> canonical <name>, then <address> <ttl>, tab-separated, for each address.

8.1.3.3 reverse output #

reverse prints <outcome> <source>, then the text of every record in the answer, whatever its type.

8.1.3.4 status output #

status prints three lines:

hostname   <name, or (unset)>
netd       <connected | not connected>
cache      <n> entries

then scopes (none) when there are no scopes, or, for each scope, a blank line and

<interface>  metric <n>[  [default-route, exclusive]]
  server   <address>[  (demoted)]
  domain   <domain>
  subnet   <address/prefix>

with the bracketed flags present as they apply and one line per server, domain and subnet. When fallback servers are configured, a blank line and a fallback <servers> line follow, the servers separated by single spaces. Last come a blank line and the counters, on one line:

queries <n>  synthetic <n>  cache-hits <n>  upstream sent <n> answered <n> failed <n>  refused <n>

8.1.3.5 flush output #

flush prints nothing on success.

8.1.4 Exit status #

StatusMeaning
0found, or status, flush or version succeeded
1resolvd unreachable, a protocol failure, an error reply (including access denied), or a bad type or address argument; a reply of the wrong kind exits 1 with no message
2notfound
3unavailable
64A command line resolv does not recognise

8.1.4.1 The unreachable message #

Errors are printed to standard error prefixed resolv: . resolvd unreachable reads resolv: resolvd is not reachable at /run/resolvd/resolv.sock: <error>.

9.1 Reading resolvd's State

Peios / Peios technical reference / resolvd / Failure Modes

9.1.1 Where to look #

SignalTells you
resolv statusWhether netd is connected, the scopes in use, which servers are demoted, the fallback servers, cache size, counters (§5.3, §8.1)
resolv query <name> --no-cacheWhere a fresh answer comes from: source, server, interface, response code (§4.1)
evctl 'LOGS FROM resolvd SINCE 1h ago'Every log line (§2.1)
The exit status of resolv2 for notfound, 3 for unavailable, 1 when resolvd could not be asked (§8.1)
The door's own errorNXDOMAIN or SERVFAIL at the stub; HOST_NOT_FOUND, TRY_AGAIN or NO_RECOVERY from getaddrinfo (§6.2, §7.1)

Two readings of the counters are worth knowing. upstream_failed rising with upstream_answered flat means no matching reply is arriving: the servers are not answering, their replies do not match (§4.8), or the transactions cannot be sent at all (§4.7), which the log shows as upstream <server>: <error> lines. Both rising together means servers answer with failure codes. refused above zero means the in-flight ceiling has been reached (§4.6).

9.1.2 Log lines #

Every line resolvd writes, with the level it is written at:

LineLevelMeaning
native socket: <error>errorStartup failed at the native socket; resolvd exits (§2.2)
stub listener on 127.0.0.53:53: <error>errorStartup failed at port 53; resolvd exits (§2.2)
poll: <error>errorThe loop failed; resolvd exits (§2.2)
could not set a descriptor on <path> (<error>); …errorThe native socket is reachable only by SYSTEM and administrators (§2.4)
removed a stale /run/resolvd/resolv.sockwarnA previous instance left its socket behind and this one removed it; expected on every restart of the service (§2.4)
could not build a descriptor: <error>warnAs could not set a descriptor …, but before writing was attempted (§2.4)
registry watch unavailable (<error>); configuration is read oncewarnConfiguration changes will not be seen (§2.3)
registry watch: <error>; re-armingwarnThe watch failed and is being replaced (§2.3)
Dns FallbackServers: ignoring malformed address "<s>"warn§2.3
Dns ExtraSearchDomains: ignoring malformed domain "<s>"warn§2.3
Dns Hosts: ignoring malformed name "<s>"warn§2.3
Dns Hosts: ignoring malformed address "<s>"warn§2.3
ControlSecurity is not a valid descriptor (<error>); using the defaultwarn§2.3
readiness notify: <error>warnpeinit was not told resolvd is ready (§2.1)
netd not reachable (<error>); retryingwarnFirst failure to reach netd (§3.2)
netd refused the subscription: <message>warnnetd answered subscribe with an error (§3.1)
netd sent something unreadable: <error>warn§3.1
lost the netd channel; reconnectingwarn§3.1
upstream <server>: <error>warnA transaction could not be sent (§4.7)
control: no peer token: <error>warnA native request was denied because its token could not be read (§5.2)
control: reply failed: <error>warnA native reply could not be written (§5.1)
stub udp: <error>, stub tcp accept: <error>, native accept: <error>warnA listener read or accept failed. Accept errors pause that listener for 250 ms before retrying; other descriptors continue to be serviced
listening on /run/resolvd/resolv.sock and 127.0.0.53:53infoStartup reached readiness (§2.2)
subscribed to netdinfo§3.1
netd: <summary>infoA snapshot was applied (§3.3)
configuration changedinfoA registry change was applied (§2.3)
cache flushedinfoA flush request was carried out (§5.3)
/dev/kmsg mirror unavailable (<error>): log lines go to stdout/stderr onlywarnThe kernel log mirror could not be opened, which for resolvd is always; written once, directly after the first line logged (§2.1)

9.1.3 What a question logs #

Answering a question writes no log line, however it is answered — synthetic, from the cache, by a server, or unavailable after timeouts — and a timeout or a demotion writes none either. The only lines a single question or request can cause are upstream <server>: <error>, for a transaction that could not be sent (§4.7), and, for a native request, control: no peer token: <error> (§5.2) and control: reply failed: <error> (§5.1).

9.2 Names Do Not Resolve

Peios / Peios technical reference / resolvd / Failure Modes

9.2.1 Everything needing the network is unavailable #

Looks like: resolv query example.com exits 3; the stub answers SERVFAIL; getaddrinfo returns EAI_AGAIN. localhost, the hostname and static names still answer.

Check resolv status:

  • netd not connected and scopes (none). resolvd has never had a snapshot. Without fallback servers nothing can be asked (§4.4). Start netd; resolvd reconnects within ten seconds of netd's socket appearing (§3.2) and the first snapshot brings the scopes.
  • Scopes listed, but none has a server. Interfaces with no servers take no part in routing (§4.4). Either the profiles and leases supply no servers, or the offered addresses or explicit zones were invalid (§4.7). Configure FallbackServers (§2.3), or give the profile servers.
  • Servers listed and marked (demoted). Every server's last failure was less than 30 seconds ago (§4.6). upstream_failed rising shows it. Questions are still sent to demoted servers, so service returns as soon as one answers.
  • The servers listed are link-local (fe80::…). Check that the scope names the intended interface and that it still exists. resolvd sends through that interface for UDP and TCP (§4.7). Link-local servers belong in an interface profile, not the unscoped fallback list.

9.2.2 A single label is notfound at once #

Looks like: resolv query printer exits 2 without delay, with source local.

No search domain applies (§4.3). Check the domain lines in status. A scope's domains count only while it has a server; while an exclusive scope takes every question (§4.4), only its domains apply and ExtraSearchDomains is ignored. A single label is never sent bare, so with no domain it is answered locally.

9.2.3 Names meant for the VPN go elsewhere #

An exclusive scope takes every question only while it is at addressed or better and has at least one server (§4.4). A VPN interface whose servers have not arrived yet, or are missing from its profile, is not exclusive for routing, and names go to the other interfaces' servers in the meantime. status shows the VPN scope with exclusive and no server lines.

9.2.4 An answer is wrong or stale #

Cached answers live up to a day (§4.5). They are discarded for a scope when its server list changes, and for everything by resolv flush (§5.3), which needs RESOLVER_CONTROL. A static name in Hosts\ overrides DNS at once, with no flush (§4.2).

To see what the network says now, use resolv query <name> --no-cache. It skips the cache lookup, but the answer it gets is still stored, replacing the cached one (§4.5).

9.2.5 Answers are slow #

Each candidate can take up to six seconds of timeouts before it is unavailable, longer with truncated replies retried over TCP (§4.6). A dead first server costs two seconds per question until it is demoted; after that it is asked last, for 30 seconds, and the cycle repeats if it is still dead.

A UDP reply that does not match its query — a server that does not echo the question's case exactly, for instance — is ignored (§4.8). A later matching reply can still answer the transaction. Only if none arrives by the original two-second deadline does the attempt time out, demote the server, and add to upstream_failed without adding to upstream_answered.

9.2.6 refused is rising #

At least 4 096 transactions are outstanding (§4.6), and new questions that need the network are answered unavailable at once. Cached and synthetic answers are unaffected. The ceiling clears as transactions complete, within two seconds of each being sent.

9.2.7 Getting back to a known state #

A new resolvd discards the cache, the demotions and the counters, re-reads the registry, and takes a fresh snapshot from netd; nothing is persisted. svctl restart resolvd is enough: the new process replaces the socket its predecessor left (§2.4).

The exception is the first start after an upgrade from a resolvd whose DACL did not name its own account (§2.4). That older resolvd's directory and socket shut the new one out, and it exits with native socket: Permission denied (os error 13) until they are gone. Remove them once as SYSTEM, or reboot:

svctl stop resolvd; rm -rf /run/resolvd; svctl start resolvd

9.3 resolvd Cannot Be Reached

Peios / Peios technical reference / resolvd / Failure Modes

9.3.1 resolvd is not running #

DoorWhat a client sees
NSS shimlocalhost and loopback reverses still answer; everything else is UNAVAIL with NO_RECOVERY (§7.1)
Stub listenerNothing listens on 127.0.0.53; UDP queries go unanswered or are refused by the kernel, TCP connections are refused
resolvExit 1, resolv: resolvd is not reachable at /run/resolvd/resolv.sock: <error>

peinit restarts resolvd whenever it exits (§2.1). The new process removes the socket the old one left and binds a fresh one (§2.4), so an outage after a crash lasts only until the restart completes. If resolvd keeps exiting, the cause is one of the startup failures below.

9.3.2 resolvd will not start #

The startup failures are fatal and logged before the exit (§2.2):

  • stub listener on 127.0.0.53:53: <error>: either the port reservation for tcp,udp:53 is not in the registry — its seed is inert until the image applies it (§2.1) — or something else already holds 127.0.0.53:53. The error text says which.
  • native socket: <error>: /run/resolvd could not be created or given its mode, a stale socket file could not be removed, or the socket could not be bound or set up.

Because the restart policy is Always, a failure that persists is a restart loop: the same line in the log each time.

9.3.3 The native door is closed to ordinary programs #

Looks like: getaddrinfo fails with NO_RECOVERY for every name but localhost, for every user but SYSTEM and administrators, while programs that send DNS to 127.0.0.53 themselves work.

Two causes:

  • The socket's descriptor could not be written at startup (§2.4), and the log has the could not set a descriptor line. Ordinary programs cannot reach the socket at all.
  • ControlSecurity holds a valid descriptor that does not grant RESOLVER_QUERY to them (§5.2). They reach the socket and are denied: resolv query prints resolv: access denied. Deleting the value restores the compiled default without a restart (§2.3).

The stub listener is not governed by either, which is why direct DNS clients are unaffected.

9.4 Stalls and Loops

Peios / Peios technical reference / resolvd / Failure Modes

9.4.1 A client that does not read #

Native and stub TCP replies are written without blocking (§2.5). A client that does not read can retain its own queued reply until the fixed one-second write deadline, then its connection closes. It does not stop other lookups. Each door holds at most 256 queued replies; if full, a new reply that cannot be written immediately is closed too. Stub TCP write failures are silent; native failures log control: reply failed.

9.4.2 netd refuses the subscription #

When netd accepts the connection and answers subscribe with an error — resolvd's account lacks NETWORK_QUERY, for instance — resolvd drops the channel and retries with exponential backoff (§3.2): 0.5 seconds, then 1, 2, 4, 8, and ten seconds between attempts until a good snapshot resets the sequence. The log shows subscribed to netd, then netd refused the subscription, then lost the netd channel; reconnecting for each refusal. resolv status shows netd as not connected except in the moment between each reconnection and its refusal, and the scopes are whatever the last accepted snapshot held, or none.

9.4.3 The registry watch is lost #

When the watch cannot be armed at startup, or fails and cannot be re-armed, configuration changes are no longer seen (§2.3). resolv status still shows the fallback servers in use, so a change to FallbackServers that does not appear there is the sign. A restart reads the key again and re-arms the watch.

9.4.4 Cache capacity pressure #

At 8 192 entries, storing a new key removes expired entries first. If that creates room, it keeps every live entry; otherwise it replaces only the entry with the earliest expiry (§4.5). A long answer lifetime does not cause a sweep of shorter-lived answers that are still valid.

cache_entries can fall sharply when admission removes many expired entries. Expiry alone does not remove them, so reaching 8 192 does not mean 8 192 live answers. Looking up an expired key also removes it, before its replacement arrives.

Older builds compared the sweep against the incoming answer's expiry (§4.5). On those builds a day-long answer could empty a full cache of live answers, causing a burst of upstream requests.

Appendix A Constants and Limits

Peios / Peios technical reference / resolvd

The articles cited are authoritative; this appendix collects their values in one place.

A.1 Limits and timers #

BoundValueDescribed in
Native request size ceiling (payload)65 536 bytes§5.1
Maximum buffered native frame65 540 bytes§5.1
Native connection read chunk4 096 bytes§5.1
Native request delivery bound5 s§5.1
Native connections still sending a request256§5.1
Queued native replies256§2.5
Queued stub TCP replies256§2.5
Native reply write deadline1 s total§5.1
Stub TCP connections still sending a query256§6.1
Stub TCP query delivery bound10 s§6.1
Stub TCP message buffer65 537 bytes§6.1
Stub TCP read chunk4 096 bytes§6.1
Stub UDP read buffer4 096 bytes§6.1
Stub TCP reply write deadline1 s total§6.2
Classic UDP reply limit, no OPT512 bytes§6.2
EDNS0 payload size advertised, upstream and stub1 232 bytes§4.7, §6.2
Transaction timeout2 s§4.6
Attempts per question (all candidates and transports)3§4.6
Demotion period30 s§4.6
Service descriptor limit16 384§2.1
Listener retry delay after accept error250 ms§2.5, §9.1
Upstream transactions in flight4 096§4.6
Upstream UDP read buffer4 096 bytes§4.7
Cache entries8 192§4.5
Positive lifetime cap86 400 s§4.5
Negative lifetime cap300 s§4.5
CNAME chase depth in lookup16§4.9
netd reconnect backoff0.5 s doubling to 10 s§3.2
netd channel write timeout2 s§3.1
netd frame ceiling65 536 bytes§3.1
netd channel read chunk8 192 bytes§3.1
Registry watch event buffer16 384 bytes§2.3
Kernel hostname read buffer256 bytes§3.3
Unknown-value nesting depth, native requests32§5.1
NSS shim read and write timeout10 s§7.1
DNS label63 bytes§4.1
DNS name on the wire255 bytes§4.1

A.2 Rights #

RightValueDescribed in
RESOLVER_QUERY0x00000001§5.2
RESOLVER_CONTROL0x00000002§5.2
RESOLVER_ALL_ACCESS0x000F0003§5.2

A.3 Paths, addresses and identities #

ItemValueDescribed in
Daemon/usr/sbin/resolvd§2.1
Operator command/usr/bin/resolv§8.1
NSS shim/usr/lib/x86_64-linux-peios/libnss_peios_net.so.2§7.1
Runtime directory/run/resolvd, mode 0755§2.4
Native socket/run/resolvd/resolv.sock, mode 0666§2.4
Stub listener127.0.0.53:53, UDP and TCP§2.4
netd control socket/run/netd/control.sock§3.1
Upstream port53§4.7
Constant resolver configuration/usr/etc/resolv.conf§2.1
Service definition seed/usr/share/regim/resolvd-service.reg§2.1
Port reservation seed/usr/share/regim/resolvd-port.reg§2.1
Registry reference/usr/share/regman/resolvd.regman§2.1
Service SIDS-1-5-80-3864064249-1823296737-2008945602-1354971773-2894779966§2.1
Fallback scope keyfallback§4.4

Appendix B Registry Keys

Peios / Peios technical reference / resolvd

The articles cited are authoritative; this appendix collects the keys in one place.

B.1 Read by resolvd #

KeyValueTypeDefaultDescribed in
Machine\System\Network\DnsFallbackServersREG_MULTI_SZ (or one REG_SZ or REG_EXPAND_SZ)none§2.3
Machine\System\Network\DnsExtraSearchDomainsREG_MULTI_SZ (or one REG_SZ or REG_EXPAND_SZ)none§2.3
Machine\System\Network\DnsControlSecurityREG_BINARYthe compiled default of §5.2§2.3, §5.2
Machine\System\Network\Dns\Hostsany name: a static nameREG_SZ, REG_EXPAND_SZ or REG_MULTI_SZnone§2.3, §4.2

resolvd watches Machine\System\Network and everything under it, and re-reads Machine\System\Network\Dns whole on any change (§2.3). It reads no other key.

B.2 Seeded by the package #

Both seeds are installed under /usr/share/regim/ and are inert until an image applies them (§2.1).

SeedKeyValues
resolvd-service.regMachine\System\Services\resolvdImagePath, Triggers, Identity, Readiness, RuntimeDirectories, RestartPolicy, ErrorControl, DisplayName, Description
resolvd-port.regMachine\System\Network\TcpIp\PortReservationstcp,udp:53

Constants and catalogs

Peios / Peios technical reference / Constants and Catalogs

Numeric constants — right bits, type values, enum members, limits — are catalogued in exactly one place each. This topic is either that place or a pointer to it.

What lives here #

PageHolds
Access mask bitsPer-object-type rights for files, processes, tokens, registry keys and services; the GenericMapping tables; the *_ALL_ACCESS and STANDARD_RIGHTS_* aggregates.
Other constantsImpersonation levels, integrity levels, logon types, elevation types, PIP tiers, audit policy flags, create dispositions, SECURITY_INFORMATION flags, and the kernel's size limits.

What lives elsewhere #

Three catalogues are owned by documents that also define their semantics, and are not duplicated here.

CatalogueCanonical home
ACE types, ACE flags, and their numeric valuesPCDS §5.4 — see ACE types and flags
Well-known SIDsPCDS §4.4 — see Well-known SIDs
Every privilege, with its LUID bitPeios Kernel TRM §3.4.2 — see Privilege catalog

The three pages above are signposts. Following one gets you to the table.

Note

A constant with two spellings is listed under the name you would meet it by. Where a header and a specification disagree — and they do, for impersonation levels, create dispositions and the group attribute flags — the page says so and names both. The full mapping is the Peios Kernel TRM §3.A.

Related catalogues #

  • Every event type the system emits: the Peios Events Index.
  • Every audit event's payload schema: the same book, chapters 3 to 8.

Well-known SIDs

Peios / Peios technical reference / Constants and Catalogs

The well-known SID catalogue is PCDS §4.4, in the Peios Core Data Structures specification. It is normative there, and is not duplicated here.

It covers the universal SIDs (S-1-1-0 Everyone, S-1-3-0 CREATOR OWNER, and the rest), the NT authority SIDs under S-1-5, the BUILTIN groups under S-1-5-32, domain SID structure, integrity label SIDs under S-1-16, capability SIDs under S-1-15, service SIDs under S-1-5-80, and the PIP trust labels under S-1-19.

For the conceptual treatment — which principals matter and why — read Well-known principals.

A note on the BUILTIN range #

PCDS lists the BUILTIN groups KACS assigns meaning to. It deliberately does not enumerate S-1-5-32-547 through S-1-5-32-583, which Active Directory defines: KACS gives them no special semantics, and they participate in ACE matching like any other group SID.

An older revision of this reference tabulated them. That table was removed on purpose, not lost — enumerating names the system does not act on implies a behaviour that does not exist.

PIP trust labels #

The S-1-19-T-L ladder encodes two dimensions: T is the PIP type axis, L the trust axis. Dominance requires both to be greater than or equal.

The numeric tiers are in Other constants, and the full SID list is in PCDS §4.4. The mechanism is Process integrity protection.

Privilege catalog

Peios / Peios technical reference / Constants and Catalogs

The per-privilege catalogue — every name, its LUID bit position, and what it does — is in the Peios Kernel TRM §3.4.2, "Catalogue". It is not duplicated here.

The conceptual treatment is Privileges, and the four-category model is Categories.

Five privileges influence an access check #

Only five can contribute bits to a granted mask, and therefore only five can appear as the privilege.name of a kacs.audit.privilege.used event:

  • SeSecurityPrivilege
  • SeTakeOwnershipPrivilege
  • SeBackupPrivilege
  • SeRestorePrivilege
  • SeRelabelPrivilege

Any other bit fails the audit encoder closed rather than emitting an unnamed privilege. See kacs.audit.privilege.used in the Events Index.

Two are enforced but not nameable #

SeTakeOwnershipPrivilege and SeRelabelPrivilege are enforced by KACS but absent from the published privilege table, so they cannot be named in a service's RequiredPrivileges. A service needing either declares nothing and takes its source token's defaults, or fails to start if it tries to name one.

That asymmetry is peinit's, not the kernel's — see the peinit TRM §4.5.

ACE types and flags

Peios / Peios technical reference / Constants and Catalogs

The ACE catalogue is PCDS §5.4, in the Peios Core Data Structures specification. It is normative there, and is not duplicated here.

It covers every AceType value from 0x00 to 0x15 — the body layout of each family, the AceFlags bits, and the ACL revision rules that constrain which types may appear.

Twenty of those values have behaviour. Two do not: 0x04 (ACCESS_ALLOWED_COMPOUND_ACE, never implemented anywhere) and 0x15 (SYSTEM_ACCESS_FILTER_ACE, an MS-DTYP type Peios has not implemented). Both are named by the kernel ABI so that a decoder can label the byte, but neither affects an access decision: they are skipped when the descriptor is evaluated and preserved unchanged when it is written back. sd has no name for either and prints them as OTHER(0x04) and OTHER(0x15).

Two names per type #

PCDS names both the ACE structure and the AceType constant that selects it, because a reader may arrive with either — one from a declaration, the other from a hex dump. ACCESS_ALLOWED_ACE is the structure; ACCESS_ALLOWED_ACE_TYPE is the constant whose value is 0x00.

The headers use a third spelling, moving the qualifier to the front: KACS_ACE_TYPE_ACCESS_ALLOWED. The Peios Kernel TRM §3.A maps the two vocabularies.

Inheritance flags #

The four propagation flags and the INHERITED_ACE provenance flag are catalogued with the inheritance algorithm in PCDS §5.6, rather than with the type values.

MIC policy bits #

The mask of a SYSTEM_MANDATORY_LABEL_ACE carries policy bits saying what a non-dominant caller may not do. Those bits are in PCDS §5.4 alongside the ACE type; the mechanism is Mandatory integrity control.

Access mask bits

Peios / Peios technical reference / Constants and Catalogs

The 32-bit access mask has the same shape for every object type. Bits 0–15 are object-specific, 16–20 are standard rights, 24–25 are special, 28–31 are generic. The standard, special and generic regions are uniform; the object-specific bits carry different meanings for different object types.

This page is the catalogue of right values. What the mask means — how it is evaluated, how generic bits expand — is PCDS §5.3, which is normative. The bit layout of the mask within a descriptor is PCDS §5.1.

Bit rangeRegionExamples
0–15Object-specificFILE_READ_DATA, PROCESS_TERMINATE, TOKEN_QUERY
16–20Standard rightsDELETE, READ_CONTROL, WRITE_DAC, WRITE_OWNER, SYNCHRONIZE
21–23ReservedRejected at parse. PCDS §5.3.
24–25SpecialACCESS_SYSTEM_SECURITY, MAXIMUM_ALLOWED
26–27ReservedRejected at parse.
28–31GenericGENERIC_ALL, GENERIC_EXECUTE, GENERIC_WRITE, GENERIC_READ

File access rights #

RightValueFor filesFor directories (alias)
FILE_READ_DATA0x0001Read file contentFILE_LIST_DIRECTORY — list entries
FILE_WRITE_DATA0x0002Write file contentFILE_ADD_FILE — create files
FILE_APPEND_DATA0x0004Append-only writeFILE_ADD_SUBDIRECTORY — create subdirectory
FILE_READ_EA0x0008Read extended attributesSame
FILE_WRITE_EA0x0010Write extended attributesSame
FILE_EXECUTE0x0020Execute fileFILE_TRAVERSE — traverse through
FILE_DELETE_CHILD0x0040(not applicable)Delete children regardless of their permissions
FILE_READ_ATTRIBUTES0x0080Read attributesSame
FILE_WRITE_ATTRIBUTES0x0100Write attributesSame

The directory names are aliases: identical bit values, different naming convention depending on whether the object is a file or a directory.

File GenericMapping #

Generic rightMaps to
GENERIC_READFILE_READ_DATA | FILE_READ_ATTRIBUTES | FILE_READ_EA | READ_CONTROL | SYNCHRONIZE
GENERIC_WRITEFILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_ATTRIBUTES | FILE_WRITE_EA | READ_CONTROL | SYNCHRONIZE
GENERIC_EXECUTEFILE_EXECUTE | FILE_READ_ATTRIBUTES | READ_CONTROL | SYNCHRONIZE
GENERIC_ALLEvery file-specific bit, plus DELETE, READ_CONTROL, WRITE_DAC, WRITE_OWNER, SYNCHRONIZE

FILE_ALL_ACCESS = STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE | 0x1FF = 0x001F01FF.

Process access rights #

RightValueMeaning
PROCESS_TERMINATE0x0001Send terminating signals.
PROCESS_SIGNAL0x0002Send non-terminating informational signals — SIGCHLD, SIGURG, SIGWINCH.
PROCESS_VM_READ0x0010Read process memory — ptrace(PTRACE_PEEK*), process_vm_readv, /proc/<pid>/mem reads.
PROCESS_VM_WRITE0x0020Write process memory — ptrace(PTRACE_POKE*, ATTACH), process_vm_writev.
PROCESS_DUP_HANDLE0x0040Duplicate file descriptors out via pidfd_getfd.
PROCESS_SET_INFORMATION0x0200Change process attributes — priority, affinity, rlimits, /proc/<pid>/* writes.
PROCESS_QUERY_INFORMATION0x0400Detailed process info — token, full /proc/<pid>/* reads.
PROCESS_SUSPEND_RESUME0x0800Send stop and continue signals.
PROCESS_QUERY_LIMITED0x1000Basic info — PID, image name, state. Required by pidfd_open.

Bits 0x0004, 0x0008, 0x0080 and 0x0100 are unused.

Process GenericMapping #

Generic rightMaps to
GENERIC_READPROCESS_QUERY_INFORMATION | PROCESS_VM_READ | READ_CONTROL
GENERIC_WRITEPROCESS_SET_INFORMATION | PROCESS_VM_WRITE | WRITE_DAC
GENERIC_EXECUTEPROCESS_TERMINATE | PROCESS_SUSPEND_RESUME | PROCESS_QUERY_LIMITED
GENERIC_ALLAll process-specific bits, plus STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE

PROCESS_ALL_ACCESS = STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE | 0x1FFF = 0x001F1FFF.

Token access rights #

RightValueMeaning
TOKEN_ASSIGN_PRIMARY0x0001Install as a process's primary token.
TOKEN_DUPLICATE0x0002Create a copy.
TOKEN_IMPERSONATE0x0004Install as a thread's impersonation token.
TOKEN_QUERY0x0008Read token information.
TOKEN_QUERY_SOURCE0x0010Subsumed by TOKEN_QUERY. Reserved for format compatibility.
TOKEN_ADJUST_PRIVILEGES0x0020Enable, disable or remove privileges.
TOKEN_ADJUST_GROUPS0x0040Enable or disable groups.
TOKEN_ADJUST_DEFAULT0x0080Change default DACL, owner index, primary group index.
TOKEN_ADJUST_INTERACTIVITY_SCOPE0x0100Change interactivity_scope. Additionally requires SeTcbPrivilege; does not change the LogonSession auth_id.

TOKEN_QUERY_SOURCE is a documented bit position rather than an enforced right: a token fd granting TOKEN_QUERY suffices for everything, and TOKEN_QUERY_SOURCE is not separately checked.

Token GenericMapping #

Generic rightMaps to
GENERIC_READTOKEN_QUERY | READ_CONTROL
GENERIC_WRITETOKEN_ADJUST_PRIVILEGES | TOKEN_ADJUST_GROUPS | TOKEN_ADJUST_DEFAULT | WRITE_DAC
GENERIC_EXECUTETOKEN_IMPERSONATE
GENERIC_ALLTOKEN_ALL_ACCESS

TOKEN_ALL_ACCESS = STANDARD_RIGHTS_REQUIRED | 0x01FF = 0x000F01FF. Note the absence of SYNCHRONIZE, unlike the file and process aggregates.

Registry-key access rights #

RightValueMeaning
KEY_QUERY_VALUE0x0001Read a value.
KEY_SET_VALUE0x0002Write a value.
KEY_CREATE_SUB_KEY0x0004Create a subkey.
KEY_ENUMERATE_SUB_KEYS0x0008Enumerate subkeys.
KEY_NOTIFY0x0010Watch for changes.
KEY_CREATE_LINK0x0020Create a symbolic link to another key.

Bits from 0x0040 upward are reserved.

Registry GenericMapping #

Generic rightMaps to
GENERIC_READKEY_QUERY_VALUE | KEY_ENUMERATE_SUB_KEYS | KEY_NOTIFY | READ_CONTROL
GENERIC_WRITEKEY_SET_VALUE | KEY_CREATE_SUB_KEY | READ_CONTROL
GENERIC_EXECUTEREAD_CONTROL
GENERIC_ALLAll key-specific bits, plus STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE

Service access rights #

RightValueMeaning
SERVICE_QUERY_CONFIG0x0001Read configuration.
SERVICE_CHANGE_CONFIG0x0002Modify configuration.
SERVICE_QUERY_STATUS0x0004Read runtime status.
SERVICE_ENUMERATE_DEPENDENTS0x0008List dependent services.
SERVICE_START0x0010Start the service.
SERVICE_STOP0x0020Stop the service.
SERVICE_PAUSE_CONTINUE0x0040Pause and resume.
SERVICE_INTERROGATE0x0080Request a status update.
SERVICE_USER_DEFINED_CONTROL0x0100Send service-specific control codes.

Standard, special and generic rights #

RightValue
DELETE0x00010000
READ_CONTROL0x00020000
WRITE_DAC0x00040000
WRITE_OWNER0x00080000
SYNCHRONIZE0x00100000
ACCESS_SYSTEM_SECURITY0x01000000
MAXIMUM_ALLOWED0x02000000
GENERIC_ALL0x10000000
GENERIC_EXECUTE0x20000000
GENERIC_WRITE0x40000000
GENERIC_READ0x80000000

The STANDARD_RIGHTS aggregates #

ConstantValueComposition
STANDARD_RIGHTS_REQUIRED0x000F0000DELETE | READ_CONTROL | WRITE_DAC | WRITE_OWNER
STANDARD_RIGHTS_READ0x00020000Alias of READ_CONTROL
STANDARD_RIGHTS_WRITE0x00020000Alias of READ_CONTROL
STANDARD_RIGHTS_EXECUTE0x00020000Alias of READ_CONTROL
STANDARD_RIGHTS_ALL0x001F0000All five standard rights

Three of these are the same value. STANDARD_RIGHTS_READ, _WRITE and _EXECUTE are all aliases of READ_CONTROL, which surprises people reading a mask and expecting them to differ. Only STANDARD_RIGHTS_REQUIRED and STANDARD_RIGHTS_ALL name distinct values.

STANDARD_RIGHTS_REQUIRED is the conventional minimum included in every *_ALL_ACCESS aggregate.

Other constants

Peios / Peios technical reference / Constants and Catalogs

Enumerated values that are not access rights. Access rights are catalogued separately in Access mask bits.

Note

Names here are the ones uapi/pkm/ declares, because that is where a reader meets them. Several were published under different spellings in older design documents; where that is so, this page says which. The Peios Kernel TRM §3.A carries the full mapping.

Impersonation levels #

A token's impersonation level. The conceptual model is Impersonation levels.

ConstantValueMeaning
KACS_IMLEVEL_ANONYMOUS0No identity.
KACS_IMLEVEL_IDENTIFICATION1Inspect only. Cannot be used for an access check.
KACS_IMLEVEL_IMPERSONATION2Act as the client locally. The default.
KACS_IMLEVEL_DELEGATION3Act as the client locally, and forward credentials to remote machines.

Older documents spell these KACS_LEVEL_*. That spelling does not exist in any header.

A primary token reports level 0.

Integrity levels #

The RIDs used in S-1-16-* SIDs and in a token's integrity_level field. The model is Mandatory integrity control.

ConstantRIDName
INTEGRITY_LEVEL_UNTRUSTED0Untrusted
INTEGRITY_LEVEL_LOW4096Low
INTEGRITY_LEVEL_MEDIUM8192Medium
INTEGRITY_LEVEL_HIGH12288High
INTEGRITY_LEVEL_SYSTEM16384System

Spaced by 4096, so future levels can be inserted between existing ones.

These are named levels, not a closed enum. The integrity level is the S-1-16 SID's single sub-authority as an unsigned integer, compared numerically. Any such value is valid, and non-standard ones appear in Windows-interop descriptors — medium-plus at 8448, protected at 20480. Code that switches on the five names will mishandle those.

Mandatory policy flags #

A token's mandatory_policy field. Both are immutable after token creation.

FlagValueMeaning
NO_WRITE_UP0x01MIC blocks write-category access from lower integrity.
NEW_PROCESS_MIN0x02At exec, lower the token's integrity to match the binary if the binary is lower.

Logon types #

A session's logon_type field. The model is Logon types.

ConstantValueMeaning
LOGON_TYPE_INTERACTIVE2Console, SSH, terminal services.
LOGON_TYPE_NETWORK3Network resource access — SMB, RPC, federated.
LOGON_TYPE_BATCH4Scheduled job.
LOGON_TYPE_SERVICE5Service running under a specific principal.
LOGON_TYPE_NETWORK_CLEARTEXT8Network logon with a cleartext credential.
LOGON_TYPE_NEW_CREDENTIALS9Keep the local identity; use alternative credentials outbound.
LOGON_TYPE_REMOTE_INTERACTIVE10A graphical desktop on this machine, driven from somewhere else.

Values 1, 6, 7 and 11 upward are reserved.

Audit events name the type rather than numbering it: the object.session.logon-type of a kacs.session.destroyed event is the constant's suffix in kebab case — interactive, network, batch, service, network-cleartext, new-credentials or remote-interactive.

Elevation types #

A token's elevation_type field.

ConstantValueMeaning
KACS_ELEVATION_DEFAULT1Not part of a linked pair.
KACS_ELEVATION_FULL2The elevated half of a linked pair.
KACS_ELEVATION_LIMITED3The non-elevated half.

PIP tiers #

A process's PSB pip_type field.

ValueMeaning
0None. Unprotected, the default for unsigned binaries.
512Protected. Standard PIP protection.
1024Isolated. Reserved; no signing key targets it.

There are no public constants for these. Nothing in uapi/pkm/ names the tiers, and PIP_TYPE_NONE / _PROTECTED / _ISOLATED — which older documents use — exist nowhere in the tree. Protected survives only as the kernel-private PKM_KACS_PIP_TYPE_PROTECTED.

A program reasoning about tiers compares the numbers. The Peios Kernel TRM §3.7 says the same.

Token audit policy flags #

A token's audit_policy field. These are what force the audit events in the Events Index.

FlagValueMeaning
OBJECT_ACCESS_SUCCESS0x01Force a kacs.audit.access.checked event, with trigger.kind policy, on every successful access.
OBJECT_ACCESS_FAILURE0x02Force a kacs.audit.access.checked event, with trigger.kind policy, on every failed access.
PRIVILEGE_USE_SUCCESS0x04Emit a kacs.audit.privilege.used event when a privilege's bits survive.
PRIVILEGE_USE_FAILURE0x08Emit a kacs.audit.privilege.used event when its bits are stripped.

Create dispositions #

For kacs_open.

ConstantValueBehaviour
KACS_DISPOSITION_SUPERSEDE0If it exists, delete and recreate; otherwise create.
KACS_DISPOSITION_OPEN1If it exists, open; otherwise fail with ENOENT.
KACS_DISPOSITION_CREATE2If it exists, fail with EEXIST; otherwise create.
KACS_DISPOSITION_OPEN_IF3If it exists, open; otherwise create.
KACS_DISPOSITION_OVERWRITE4If it exists, truncate and open; otherwise fail with ENOENT.
KACS_DISPOSITION_OVERWRITE_IF5If it exists, truncate and open; otherwise create.

Older documents spell these KACS_FILE_SUPERSEDE, KACS_FILE_OPEN and so on. That spelling does not exist in any header.

SECURITY_INFORMATION flags #

For kacs_get_sd and kacs_set_sd. Declared as KACS_SECINFO_*; the names below are the MS-DTYP spellings PCDS uses.

FlagValueRight to readRight to write
OWNER_SECURITY_INFORMATION0x01READ_CONTROLWRITE_OWNER
GROUP_SECURITY_INFORMATION0x02READ_CONTROLWRITE_OWNER
DACL_SECURITY_INFORMATION0x04READ_CONTROLWRITE_DAC
SACL_SECURITY_INFORMATION0x08ACCESS_SYSTEM_SECURITYACCESS_SYSTEM_SECURITY
LABEL_SECURITY_INFORMATION0x10READ_CONTROLWRITE_OWNER, plus the integrity rules

SACL_SECURITY_INFORMATION and LABEL_SECURITY_INFORMATION are mutually exclusive in one call.

Process mitigation flags #

The KACS_MIT_* flags on the PSB. The catalogue of what each one gates is Process mitigations.

Size and count limits #

LimitValueContext
Max SD size65,535 bytesAny SD blob. Normative in PCDS §5.1.
Max ACL size64 KBAny ACL within an SD
Max single ACE size64 KBBounded by the ACL size
Min SID size8 bytesRevision, count and authority, no sub-authorities
Max SID size68 bytes15 sub-authorities
Max token wire spec64 KBkacs_create_token input
Max session wire spec4096 byteskacs_create_logon_session input
Max CAAP wire spec256 KBkacs_set_caap input
Max CAAP rules per policy256
Max applies-to expression64 KBPer CAAP rule
Max conditional stack depth1024Evaluator limit
Max TLP cache entries64Trusted Library Path prefixes
Max TLP path length4096 bytesPer prefix
Max mount template SD64 KBkacs_set_mount_policy template

Wire formats reference

Peios / Peios technical reference / Wire Formats Reference

Byte-level layouts are specified normatively in the PCSA books and in the kernel manual's ABI appendices. This page is the map.

Security descriptors and their parts #

StructureWhere
Security descriptor header, self-relativePCDS §5.1
ACL header and ACE arrayPCDS §5.2
ACE header and per-type body layoutsPCDS §5.4
Access mask layoutPCDS §5.3
Claim entry — CLAIM_SECURITY_ATTRIBUTE_RELATIVE_V1PCDS §5.9
Conditional-ACE bytecode, with the full opcode tablePCDS §5.11
Resource attribute ACEsPCDS §5.10

See also Security descriptors.

Identifiers #

StructureWhere
SID binary formatPCDS §4.1
SID string formatPCDS §4.2
GUIDPCDS §2
LUIDPCDS §3

Kernel interfaces #

StructureWhere
Token and session wire specsPeios Kernel TRM §3.A — see Token and session specs
CAAP wire formatSee CAAP format
Registry ABI, including watch record layoutPeios Kernel TRM §5.A
Token query class payloadsPeios Kernel TRM §3.A

Event stream #

StructureWhere
KMES event header, field by fieldPSPK §2
Event envelope, in summaryEvents Index §1.2
Per-event payload schemasEvents Index, chapters 3 to 15

Packages and repositories #

The package container, manifest, signature and repository index formats are PSPU §5.

Security descriptors (wire format)

Peios / Peios technical reference / Wire Formats Reference

The byte-level layout of a security descriptor and everything inside it is specified in PCDS, the Peios Core Data Structures specification. It is normative there and is not duplicated here.

StructureSection
Descriptor header, self-relative form — Revision, Sbz1, Control, and the four offsetsPCDS §5.1
Control flagsPCDS §5.1
ACL header — AclRevision, AclSize, AceCountPCDS §5.2
ACE header — AceType, AceFlags, AceSizePCDS §5.4
Per-type ACE body layoutsPCDS §5.4
Access mask bit layoutPCDS §5.3
SID binary formatPCDS §4.1
Claim entry formatPCDS §5.9
Conditional-ACE bytecodePCDS §5.11

Peios uses the MS-DTYP §2.4.6 self-relative format without translation, so a descriptor written by a Windows domain controller and replicated through Samba is evaluated as-is. Where KACS's evaluator departs from MS-DTYP — a separate question from the format — the Peios Kernel TRM §3.B has the list.

The right values carried in an access mask are catalogued in Access mask bits.

CAAP format

Peios / Peios technical reference / Wire Formats Reference

The central access policy wire format — the structure kacs_set_caap accepts — is specified in the Peios Kernel TRM §3.A, the KACS ABI reference.

The conceptual model, and what the structures mean, is Policies and rules.

Shape, in summary #

Fields are length-prefixed: each part begins with a 32-bit byte count followed by that many bytes, and an absent field has length zero. That convention runs through the policy blob, its rules, and each rule's applies-to expression and SACL.

The limits that bound it — 256 KB per wire spec, 256 rules per policy, 64 KB per applies-to expression — are in Other constants.

The applies-to expression and the rule SACLs use the same conditional-ACE bytecode as a conditional ACE, specified in PCDS §5.11.

Token and session specs

Peios / Peios technical reference / Wire Formats Reference

The binary specs that kacs_create_token and kacs_create_logon_session accept, and the payload format of every token query class, are laid out in the Peios Kernel TRM §3.A, the KACS ABI reference. All 46 header offsets and all 24 query classes are there.

The conceptual field list — what a token holds and what each field does — is Token types.

Shape, in summary #

Both specs are length-prefixed throughout: a field is a 32-bit byte count followed by that many bytes, and an absent field has a count of zero. Arrays are a 32-bit element count followed by that many records.

Size limits are 64 KB for a token spec and 4096 bytes for a session spec; both are in Other constants.

The enumerated values a spec carries — impersonation level, elevation type, logon type, mandatory policy, audit policy — are catalogued in Other constants too, under the names the headers declare.

For building a session spec from the command line rather than by hand, logonse types the surface for you; the underlying format is unchanged.

Kernel ABI reference

Peios / Peios technical reference / Kernel ABI Reference

The kernel ABI is documented in the Peios Kernel TRM, in four appendices generated from uapi/pkm/:

AppendixCovers
§2.A, KMES ABI ReferenceEvent syscalls, ring and event header layouts, configuration structures, and trace controls
§3.A, KACS ABI ReferenceSyscall numbers, ioctls, structure layouts, token query classes, and the KACS constants
§5.A, LCS ABI ReferenceThe registry ABI — syscalls, ioctls, structure layouts, and the watch record header offsets
§6.A, NTFE ABI ReferenceThe packet engine's /dev/peios-ntfe ABI — ioctls, the verdict event and status layouts, the counter record, and the seat, layer, verdict and flag constants

Two more appendices sit alongside them: §3.B lists where the KACS evaluator departs from MS-DTYP, and §3.C lists the audit events KACS emits.

Names #

Those appendices use the names uapi/pkm/ declares. A reader arriving with the name PCDS uses — which is MS-DTYP's — will find the mapping in §3.A's own divergence table. Impersonation levels, create dispositions, group attribute flags and SECURITY_INFORMATION flags all differ between the two vocabularies. See Conventions §6.5 for why neither is being renamed.

Checking generated appendices #

The PKM repository's tools/gen-kmes-abi.py, gen-kacs-abi.py, gen-lcs-abi.py and gen-ntfe-abi.py derive these appendices from that checkout's sources. gen-stratafs-constants.py checks the StrataFS constants appendix in the same way. Run a generator with --check to detect stale output without writing it; omit --check to regenerate.

By default, the companion documentation is a sibling learn/ checkout. For a named documentation worktree, select it explicitly:

PKM_LEARN_ROOT=/path/to/docs-worktree python3 tools/gen-kacs-abi.py --check
PKM_LEARN_ROOT=/path/to/docs-worktree git push

All five generators and the PKM pre-push appendix checks use this setting. Kernel inputs always come from the checkout containing the generator, regardless of its directory name or the command's working directory. An invalid explicit documentation path fails; a standalone kernel checkout without a sibling learn/ or an override skips documentation checks. Missing or stale appendices in a selected documentation checkout fail --check.

Related #

  • Byte-level layouts by structure: Wire formats reference.
  • Numeric constants by kind: Constants and catalogs.
  • The tracepoints the kernel exposes: Kernel tracepoints.

Inspecting sessions

Peios / Peios technical reference / Security diagnostics reference

Logon sessions are the kernel's records of authentication events. Every token belongs to one session; every running thread is acting under a token; therefore every running thread is associated with a session. Inspecting the system's active sessions tells you who is currently signed in, in what mode, when, and which authentication mechanism brought them in.

The primary inspection surface is /sys/kernel/security/kacs/sessions — a text-format pseudo-file that lists every active session. This page covers the listing format, the standard pattern for finding a session from a token, and how to track session lifecycle through the audit stream.

The sessions pseudo-file #

/sys/kernel/security/kacs/sessions is a text file produced by the kernel on each read. Each line describes one active session:

logon_session_id=<decimal-u64> user_sid=<lowercase-hex-sid> logon_type=<decimal-u32> auth_package=<lowercase-hex-utf8> created_at=<decimal-u64>

Fields are space-separated, in key=value form. The format is stable for the listed fields; consumers should ignore unknown additional fields, which future versions may append.

FieldTypeMeaning
logon_session_iddecimal u64The session's LUID. Same as the auth_id recorded on every token belonging to this session.
user_sidlowercase hexThe SID of the principal who signed in.
logon_typedecimal u32The logon type. See Logon types.
auth_packagelowercase hex of UTF-8The auth-package name (e.g. "Kerberos", "NTLM", "local") encoded as lowercase hex of the UTF-8 bytes.
created_atdecimal u64When the session was made, in seconds since the Unix epoch (wall-clock time).

The user_sid and auth_package fields are hex-encoded for parser stability — the SID is a binary structure, and the auth-package name could in principle contain characters that complicate text parsing. Hex encoding is uniform.

Access rule #

The kernel checks every read of the file against a fixed descriptor that grants read to BUILTIN\Administrators and SYSTEM only. Anyone signed in may open it; a non-administrative caller gets EACCES when it reads. This is intentional: the listing reveals every active session on the machine, including their identities and timestamps, which is information you do not want a low-privileged process to read.

The descriptor is built into the kernel, not stored on the file, so it cannot be widened. A service that needs to list sessions runs as SYSTEM or in Administrators; anything else can still find the sessions of the processes it may inspect, through their tokens (below).

securityfs itself cannot store descriptors, so peinit gives the mount a synthesised one at boot that lets Authenticated Users open and traverse it. Before that the file cannot be opened at all.

Bootstrap sessions #

Two sessions exist before authd is up:

Session IDUse
999The SYSTEM session. Attached to init, inherited by every early-boot process. Stays present for the lifetime of the system.
998The Anonymous session. Backs the singleton Anonymous token.

Both appear in the listing. They are not bugs; their presence is the normal state of any running system.

Finding a session from a token #

The standard pattern: given a thread or process, find its session.

  1. Open the token. For a thread, use /proc/<pid>/task/<tid>/token. For a process's primary, use /proc/<pid>/token. For yourself, use kacs_open_self_token or /sys/kernel/security/kacs/self.
  2. Query TokenStatistics via KACS_IOC_QUERY. The response includes auth_id (the session's LUID).
  3. Look up auth_id in /sys/kernel/security/kacs/sessions to find the matching logon_session_id. The line gives you the session's full details.

This is the standard "which session is this process in" query. The session ID is the key; the listing has the rest.

For programmatic enumeration ("for each running process, which session is it in"):

for each pid in /proc/*:
  for each tid in /proc/<pid>/task/*:
    open /proc/<pid>/task/<tid>/token
    query TokenStatistics, get auth_id
    cross-reference with the sessions listing

This produces a complete picture of which thread is in which session. The logonse command uses exactly this pattern — it walks the running processes to render which processes belong to which session.

Tracking session lifecycle #

Sessions are created and destroyed dynamically. The listing always reflects the current state; to track changes over time you need to either poll the listing or subscribe to session lifecycle events.

Session creation #

The kernel does not emit a kernel-level event when a session is created — there is no logon-session-created event in v0.20 audit. Tracking creations requires either:

  • Periodic polling of /sys/kernel/security/kacs/sessions and comparing against the previous snapshot.
  • Hooking into authd, which is the only thing that creates sessions and could in principle emit a higher-level event. (This is an authd integration concern, not a kernel one.)
  • Listening for the audit events that fire on successful authentications — these are emitted by authd and consumed via KMES.

For most monitoring purposes, the audit stream from authd is the right source. The kernel-level sessions listing tells you "what is right now"; the audit stream tells you "what happened recently".

Session destruction #

The kernel does emit a kacs.session.destroyed event when a session loses its last token reference. The event includes the session ID, the user SID, the logon type, the auth-package name, and the creation time — enough to reconstruct what the session was. The session ID is the same value every audit record made under the session carries as subject.token.auth-id, so the two join.

The event is documented in Events and transport. Tools that want to track session lifecycle subscribe to this event via KMES and write a record on each occurrence.

The pattern: at session creation (detected via authd or via polling), record the start; at the kacs.session.destroyed event, record the end. The two together give you a complete session log.

Inspecting an individual session #

A session ID is just a u64, but the listing line gives you everything currently knowable about the session from the kernel's perspective. There is no separate "session detail" query that returns more than what the listing provides.

If you need detail beyond what the listing offers (the privileges granted at sign-in, the policy that applied, the auth-package's specific authentication flow), the source is authd's own state, accessible via authd's APIs. The kernel records the session existence and minimal metadata; authd records the rest.

This separation is the standard kernel/userspace split. The kernel knows the session exists, who it is for, when it was created, and what auth-package created it. authd knows what happened during authentication.

Counting tokens per session #

A session can have many tokens. The number of tokens belonging to a session is the count of:

  • Primary tokens attached to processes whose auth_id matches.
  • Impersonation tokens currently installed on threads whose auth_id matches.
  • Token fds open against tokens with that auth_id.

Counting these from the outside is awkward — there is no "tokens per session" query. The standard way is to walk /proc/*/token and /proc/*/task/*/token, query TokenStatistics on each, and count matches.

authd's forced sign-out walks only the first half of that: it looks at each process's primary token and ends the processes whose auth_id matches. It deliberately does not count a thread impersonating a token of the session, which is usually a service answering one request for the person, and ending it would end the service for everybody.

The reason for the awkward enumeration: each token is a separate kernel object, and there is no per-session index. The kernel knows tokens reference sessions (via auth_id); it does not maintain a reverse index of which tokens reference which session. Walking the running processes is the way to find tokens that exist.

A token held only by a file descriptor with no associated running process — e.g., a token fd passed via SCM_RIGHTS to a recipient that hasn't installed it — is not enumerable by walking /proc. Such tokens still keep the session alive (refcount), but their existence is not visible to a session-enumeration tool. They will reveal themselves only when the holding process tries to use them.

Session expiry #

A session's created_at is set at creation; there is no expires_at in the listing. Sessions do not expire on a kernel timer. A session lives as long as its tokens have references; it ends when the last token reference drops.

Warning

Token expiration (a field on each token) is set by authd but not enforced by the kernel in v0.20. A token can have an expiration time in the past and still be valid for AccessCheck.

The token's session continues to exist regardless of any token's expiration value.

If a deployment needs strict session timeouts, the enforcement is in userspace, and nothing ships it today: authd does not watch session ages. What authd does provide is the means: a program with the right to sign people out (by default SYSTEM and Administrators) can compare created_at against its own maximum and ask authd to end a session that has run too long, with a SessionEnd request on /run/logon.sock (PGSS §2.22). authd then ends every process whose primary token belongs to the session — SIGTERM, a grace period, SIGKILL — as described in Session lifecycle.

The lack of kernel-side timer enforcement is a deliberate simplification. Adding kernel timers for session expiry would push expiry policy into the kernel; keeping it in userspace lets administrators define their own rules.

Where to go next #

For the rest of a process's inspectable state — its PSB and its process SD — read Inspecting processes.

For what sessions are, how they are created, and how revocation actually works, read Session lifecycle.

Inspecting processes

Peios / Peios technical reference / Security diagnostics reference

A process's inspectable state spans three things: its token (its identity), its PSB (its PIP labels and mitigation flags), and its process SD (the policy on the process as an object). Each is read through its own surface. Your own state is always readable; another process's needs a right on its process SD, and for everything but the PSB, PIP dominance too.

This page covers the per-process inspection surfaces beyond the token. Tokens are covered in Inspecting tokens; this page is about the PSB and the process SD.

What the PSB holds #

The Process Security Block is the per-process kernel structure with:

FieldMeaning
pip_typeThe process's PIP type: 0 for none, 512 for Protected.
pip_trustThe PIP trust level within the type: 8192 for PeiosTcb.
Mitigation flagsThe bitfield of enabled mitigations (WXP, LSV, TLP, CFIF, CFIB, PIE, SML, NO_CHILD, etc.).
process_guidThe process's identity for its whole life, unlike its PID, which is reused. Events carry it.

The process SD sits beside the PSB and is read separately (below). Internal fields (refcounts, lock state) are not exposed.

Reading a PSB #

/proc/<pid>/psb is one line of text:

pip_type=512 pip_trust=8192 mitigations=0x105 process_guid=3f2c9a1e-6b0d-4c8e-9a41-2d7e5f10b6c3

mitigations is the bitfield below, in hexadecimal.

  • Your own is always readable.
  • Another process's needs PROCESS_QUERY_LIMITED on its process SD — the right ps needs for a process's name and CPU use, which the default process SD gives Everyone.

PIP dominance is not required here, and this is the only inspection surface where it is not. That a process is protected, and at what trust, is not a secret the kernel keeps: every other refusal already reveals it. Reading it is what lets a tool such as Task Manager say why the rest of a process is closed — "protected: signed Peios TCB" rather than an unexplained "access denied".

Inspecting another process #

Everything beyond the PSB needs two things:

  • The right on the target's process SD: PROCESS_QUERY_INFORMATION for its token and detailed /proc entries, READ_CONTROL for its process SD.
  • PIP dominance over the target (the caller's PIP must dominate the target's, per the two-check rule).

Both requirements apply. A principal granted PROCESS_QUERY_INFORMATION cannot inspect a higher-PIP process even with the SD grant — the PIP check is independent.

Once both checks pass, open the target's primary token (via /proc/<pid>/token or kacs_open_process_token), query through KACS_IOC_QUERY, and read the process SD via kacs_get_sd. Reading the token needs TOKEN_QUERY on the token's own descriptor as well, which by default its user, its creator, SYSTEM and Administrators have.

A SeDebugPrivilege-holder can bypass the process SD check (PROCESS_QUERY_INFORMATION becomes trivially granted) but does not bypass PIP — a privileged debugger still cannot inspect TCB processes.

In practice, only peinit and processes signed at the same PIP level as the target can inspect TCB processes. Ordinary administrators with SeDebugPrivilege are blocked at the PIP layer, and see such a process's PSB and nothing else.

Reading the process SD #

A process's SD is read via kacs_get_sd with the appropriate process-targeted flags. The call:

kacs_get_sd(target_pidfd, security_information, buf, buf_len, flags)

Returns the SD components requested (per the security_information mask). Self-targeted queries are always allowed; cross-process queries require READ_CONTROL on the target's process SD plus PIP dominance.

READ_CONTROL is one of the standard rights every SD-bearing object exposes; it appears in the DACL like any other right. By default the owner of an object has it implicitly (see Ownership).

For inspecting the SACL specifically — to see audit ACEs, mandatory labels, PIP trust labels, scoped policy references — ACCESS_SYSTEM_SECURITY is the right needed, not READ_CONTROL. That right is gated by SeSecurityPrivilege. So:

  • DACL: needs READ_CONTROL (typically held by the owner).
  • SACL: needs ACCESS_SYSTEM_SECURITY (typically held only by administrators with SeSecurityPrivilege).
  • Owner / primary group SIDs: need READ_CONTROL.

A non-privileged caller can read a process's DACL (if granted) but not its SACL. For administrative inspection of the full SD including SACL, SeSecurityPrivilege is the lever.

Reading mitigation flags #

The mitigation bitfield is the mitigations value in /proc/<pid>/psb, under the rule in Reading a PSB. It is the same one the kernel uses internally:

FlagBitMeaning
WXP0x001Write-XOR-Execute enabled
TLP0x002Trusted Library Paths enabled
LSV0x004Library Signature Verification enabled
CFI (legacy)0x008CFIF + CFIB combined alias
UI_ACCESS0x010Reserved
NO_CHILD0x020Forbid fork/clone-new-process
CFIF0x040Forward CFI
CFIB0x080Backward CFI
PIE0x100PIE-only exec
SML0x200Speculation mitigation lock

A process's mitigation flags tell you what hardening it has enabled. Comparing this against the process's binary lets you reason about which exploitation paths are closed — a TCB-signed binary running with WXP, LSV, TLP, CFIF, CFIB, and PIE is comprehensively hardened; one with only PIE has minimal hardening.

The flags are one-way — once set, they cannot be cleared. So the snapshot you read now is also the snapshot for the rest of the process's life (except that new flags may be set). Re-reading produces the same or stricter result.

Cross-referencing process and token #

A common diagnostic pattern: given a process, know which session it is in, which user it acts as, what its PIP is, and what mitigations are active.

The sequence:

  1. Open the process's primary token via /proc/<pid>/token or kacs_open_process_token.
  2. Query TokenUser for the user SID.
  3. Query TokenStatistics for auth_id. Cross-reference with /sys/kernel/security/kacs/sessions for session details.
  4. Read /proc/<pid>/psb for PIP and mitigations.
  5. Read the process SD for who can act on this process.

Each step requires the appropriate access, and each fails closed if the caller lacks authority over the target. For self-targeted queries everything succeeds.

For a debugger or monitoring tool, this is the standard "tell me everything about this process" workflow. The pieces are independent (each query is its own ioctl), but they combine to give a complete picture.

Live vs static state #

A process's state changes over time. The current state is what the inspection surfaces return; previous state is not retrievable.

The changing parts of a process's state:

  • Threads come and go. A process's set of threads is dynamic. Re-running per-thread inspection picks up the current set.
  • The thread's effective token may change (impersonation install/revert). Re-querying gets the current value.
  • The primary token's adjustable fields (privileges enabled state, groups enabled state, default DACL) can change. The modified_id counter on the token tracks how many changes have happened.
  • The process SD can be modified by anyone with WRITE_DAC on the process. New ACEs appear; old ACEs disappear.

The immutable parts of a process's state (once set):

  • PIP fields. Set at exec; never change for the lifetime of the process.
  • Mitigation flags (including NO_CHILD). One-way; can be tightened but never relaxed.
  • Token identity fields (user_sid, groups[].sid, restricted_sids, logon_sid). Set at token creation; the token can be replaced but never have its identity adjusted.

Knowing which fields are immutable helps with monitoring. A monitor that has already read the PIP fields once does not need to re-read them; they will not change. A monitor watching for privilege state changes needs to poll or subscribe to events — they can change at any time.

What inspection cannot tell you #

A few clarifications:

  • It cannot tell you what access a process has. Inspection gives you the inputs to AccessCheck (the token, the object's SD); it does not compute the access. To know what a process can do to a specific object, call AccessCheck.
  • It cannot give you a tamper-evident snapshot. The kernel may modify state between two reads; there is no "atomic snapshot" surface. Tools that need consistency should use the modified_id counter to detect changes.
  • It cannot reveal the contents of the target process's memory. Inspecting the PSB and the process SD shows you the kernel's metadata about the process. To read the process's memory you need PROCESS_VM_READ on the process SD plus PIP dominance plus a ptrace-like syscall. That is a different topic.
  • It cannot show you removed history. A token whose privileges were once enabled but have since been removed shows the current state, not the history. The used bit on a privilege is a sticky record of "this privilege has been exercised at some point", but specific timestamps are an audit-log concern, not an inspection concern.

The inspection surfaces are for the present moment. For historical questions, the audit log is the right source.

Where to go next #

For inspecting the live flow of audit events rather than current state, read The event stream.

For the identity half of a process's state — obtaining and querying token fds — read Inspecting tokens.

For the dominance rule that gates every cross-process inspection, read The two-check rule.

The event stream

Peios / Peios technical reference / Security diagnostics reference

The other pages in this topic inspect state — what a token holds, which session a thread belongs to, what a process is authorised for. This page inspects flow: the live stream of audit events the kernel produces as access checks fire. State tells you what is true right now; the event stream tells you what is happening, event by event, as it happens.

The tool for reading that stream raw is revstrm. It attaches directly to the KMES per-CPU ring buffers and prints every event it drains. It is deliberately oblivious to the rest of the audit pipeline: it knows nothing about eventd, applies no persistence, and makes no attempt at reliable delivery. It is a debugging probe for the transport layer itself.

What revstrm is (and is not) #

revstrm is not the normal way to look at audit events. The normal way is to query eventd, the userspace audit daemon that subscribes to KMES, persists events, and serves them to consumers. eventd is the durable, deployment-facing surface. revstrm sits underneath it, tapping the same kernel stream directly.

Two things make it worth having:

  • It is a diagnostic. When events are not reaching eventd, or you suspect the kernel is not emitting what you expect, revstrm lets you see the raw KMES stream with no daemon in the path. It is the "is the wire live?" probe for the audit pipeline.
  • It is the reference consumer. revstrm exercises the full KMES consumption protocol of PSPK §2.4 — one drain thread per CPU ring, the futex notification wait, generation-change re-attach, and lapping/gap detection. It is the worked example that proves the consumption path before a production consumer like eventd relies on it.

Because it taps KMES directly rather than through eventd, revstrm is subject to the raw transport's limits: it is its own KMES subscriber, it can fall behind, and when it does its ring laps and it loses events — visibly (see Lapping and gaps). It is not a lossless audit sink and must never be relied on as one. For durable audit, that is eventd's job.

Access requirement #

Attaching to the KMES ring buffers requires SeSecurityPrivilege. Without it, the attach fails at the first ring with a clear hint rather than a silent empty stream:

revstrm: cannot attach to the KMES ring buffers (SeSecurityPrivilege required)

This is the same privilege class that gates the rest of the security-sensitive surfaces: reading the audit stream reveals every access decision on the machine, so it is not something a low-privileged process can do. An administrator (whose token holds SeSecurityPrivilege) can run it; an ordinary user cannot.

Synopsis #

revstrm [OPTION]...

With no options, revstrm follows the live stream: it attaches to every per-CPU ring and prints each event as it arrives, oldest surviving event first, until you interrupt it (Ctrl-C) or the output pipe closes. There is no target to name and no subscription to configure — it drains whatever the kernel is currently writing to KMES.

Options #

The option surface is small and entirely about what to show and how to show it — there is nothing to configure about the subscription itself.

OptionDescription
-t, --type GLOBOnly show events whose event-type string matches GLOB. Repeatable; an event is shown if it matches any supplied pattern (OR). Uses shell-glob syntax (e.g. --type 'access-*').
-o, --origin CLASSOnly show events from origin CLASS, one of userspace, kmes, kacs, or lcs (case-insensitive; user/usr alias userspace). Repeatable; an event is shown if its origin matches any supplied class.
-p, --prettyExpand the msgpack payload across multiple indented lines instead of the compact one-line form.
-s, --snapshotDrain the events currently buffered across all rings and exit, instead of following the live stream. Single-threaded, in CPU order; there is nothing to wait for.
--helpPrint usage and exit.
--versionPrint version and exit.

Long options may be abbreviated as long as the prefix is unambiguous (e.g. --sn for --snapshot).

The --type and --origin filters are applied by revstrm after draining, purely to reduce what is printed — they are display filters, not a kernel-side subscription. The kernel still writes every event into the ring, and revstrm still drains every event; filtered-out events are simply not printed. This matters for lapping: filtering does not reduce the drain load, so it does not make revstrm less likely to fall behind.

Output format #

Each event prints as a single header line (followed, under --pretty, by an indented payload block):

TIME  cpuN  #SEQUENCE  ORIGIN  event.type  payload
FieldMeaning
TIMEUTC time-of-day with microsecond precision, HH:MM:SS.uuuuuu. The calendar date is dropped — a live tail cares about wall-clock time of day, not the day.
cpuNThe per-CPU ring the event was drained from. Events are sharded per CPU all the way down; revstrm prints the CPU rather than merging into a single ordered stream.
#SEQUENCEThe event's per-ring sequence number. Gaps in the sequence on a given CPU indicate lost events.
ORIGINThe origin class: one of USR, KMES, KACS, LCS (or the catalogue's name for the class, e.g. NTFE, else cN).
event.typeThe event-type string (e.g. kacs.audit.access.checked, kacs.session.destroyed).
payloadThe msgpack payload, rendered as described below.

Payload rendering #

By default the payload is rendered compactly on the header line, truncated if long. revstrm decodes the msgpack and formats each value by the type the event catalogue declares for it.

Payloads are nested maps, so revstrm follows the nesting and looks up each value by its flattened dotted path: the sid inside subject → token is subject.token.sid. It reads the catalogue from /usr/share/evman (or the directory named by EVMAN_DIR), the same one evman shows. The nesting itself is kept in the output — compact form shows nested braces, --pretty shows indented blocks. The declared type picks the rendering:

Declared typeRendered as
bin.sid, bin.sid[]Canonical string SIDs (S-1-5-18), one or an array of them.
bin.guidThe canonical GUID string.
uint.timeA UTC time with nanosecond precision (2026-10-06T12:34:56.123456789Z).
uint.duration, uint.bytesThe number with a scaled reading (1500000ns (1.5ms), 8192 (8KiB)).
uint.mask|-joined access-right names, with any unrecognised bits shown as a hex remainder so nothing is hidden. The names come from the table for the record's object.kind; for a kind revstrm has no table for, only the standard and generic rights are named and the object-specific bits are left as hex.
uint.enum, uint.flagsThe name or names from the field's values: line (UDP (17), PROT_READ|PROT_WRITE). A value the catalogue does not list is shown as a number.
uint.luid, uint.integrityHexadecimal; the integrity level by name (high (0x3000)).
int.errnoThe negative error number with its message (-13 (Permission denied)).

A value of the wrong shape for its declared type is shown as it is on the wire rather than guessed at. The header's origin class is named from the catalogue's emitter.class enumeration when it lists a class revstrm does not know.

If no catalogue is installed, or a path is not in it (a third-party or older event), revstrm falls back to field-name conventions: keys ending in sid or sids render as string SIDs, and keys ending in access decode as file access rights.

A payload that will not decode as msgpack falls back to a hex preview rather than being dropped. With --pretty, the same payload is expanded into an aligned, indented block — one key per line, nested maps and arrays expanded beneath their key. Use --pretty when you are reading individual events closely; leave it off when tailing a busy stream.

The event schemas themselves — kacs.audit.access.checked, kacs.audit.handle.used, kacs.audit.privilege.used, kacs.session.destroyed and the rest — are documented in Events and transport, and evman <event-type> describes any of them. revstrm does not interpret them beyond formatting values by their declared types; it is a stream printer, not an event analyser.

Example #

A short follow session might look like:

14:22:07.481923  cpu0  #10432    KACS  kacs.audit.access.checked  {subject: {token: {sid: S-1-5-21-…, integrity: 12288, …}, …}, access: {requested: 129, granted: 129}, outcome: {success: true}, …}
14:22:07.492010  cpu3  #8871     KACS  kacs.audit.privilege.used  {privilege: {name: "SeBackupPrivilege", contributed: 1, surviving: 1}, outcome: {success: true}, …}
14:22:08.003114  cpu0  #10433    KACS  kacs.session.destroyed  {object: {session: {id: 4051, user: {sid: S-1-5-21-…}, logon-type: "interactive", …}}}

Lapping and gaps #

revstrm is one KMES subscriber among possibly several, with its own per-CPU rings. If it cannot drain a ring fast enough — a burst of audit events, a slow terminal, a --pretty render on a busy stream — that ring laps: the kernel overwrites the oldest un-drained events with new ones. This is the flow-control behaviour of KMES, not a bug in revstrm.

When a ring laps, revstrm does not hide it. It prints a visible marker naming the CPU and the count lost:

--- cpu2: lost 37 event(s) (ring lapped) ---

A dropped event a debugger cannot see is the worst possible outcome, so lapping is always surfaced. If you see these markers, revstrm is not keeping up — narrowing the output with --type/--origin won't help (the drain still happens), but redirecting to a file, dropping --pretty, or reducing the event rate will. Sustained lapping is also a signal in its own right: the kernel is producing events faster than a single un-buffered consumer can drain them.

Gaps are also visible directly in the #SEQUENCE column: a jump in the per-CPU sequence number is a run of events that this subscriber never saw.

Exit behaviour #

  • In follow mode (the default), revstrm runs until interrupted or until stdout closes. A downstream head (or any reader) closing the pipe shuts revstrm down cleanly — it treats the broken pipe as "nothing left to print to" and exits, rather than being killed by SIGPIPE. If every per-CPU ring hits a fatal error, all drain threads exit and the process ends.
  • In snapshot mode (--snapshot), it drains what is currently buffered across all rings and exits immediately.

When to use it #

Use revstrm when you are debugging the audit pipeline — "are events being emitted at all?", "is eventd's problem upstream or downstream of KMES?", "what exactly is the kernel putting on the wire?". Use eventd (or whatever consumes it in your deployment) for everything else: durable audit, historical queries, and any consumption that must not lose events.

See also #

  • Inspecting security state — the state-inspection counterparts: tokens, sessions, and processes.
  • Events and transport — the schemas of the events revstrm prints.
  • Auditing — where the events come from.

The administrative socket

Peios / Peios technical reference / Local principal interfaces

lpsd listens on:

/run/lpsd/admin.sock

lps and Principals Manager are its clients. If you are writing tooling against it, or diagnosing why a command was refused, this page is what you need.

Who may use it #

Two ways to satisfy the check, and lpsd reads the connecting process's token to decide:

  • membership of BUILTIN\Administrators, enabled; or
  • being LocalSystem.

LocalSystem is admitted deliberately rather than as a convenience. At first boot there is no administrator yet — that is the problem the bootstrap service exists to solve, and it runs as LocalSystem.

The word enabled is load-bearing. A group that is present in a token but marked deny-only contributes to denials and grants nothing, so lpsd requires the enabled attribute. A deliberately restricted token that merely mentions Administrators does not qualify.

What decides, and what does not #

The peer's token decides. lpsd asks the kernel who is on the other end of the connection. Nothing in any message contributes to that answer, and nothing a client sends could.

The socket's KACS descriptor admits the same callers. lpsd stamps one on the socket when it creates it, admitting LocalSystem and BUILTIN\Administrators, so anyone else is refused at connect rather than after sending a request. Its runtime directory, /run/lpsd, is peinit's, provisioned before lpsd starts.

That descriptor is load-bearing, not decoration: /run is seeded with a descriptor admitting LocalSystem alone, and everything created under it inherits that. Without lpsd stamping its own, an administrator running lps would be refused by KACS before a byte was exchanged.

The Unix mode decides nothing. KACS grants every managed process the capabilities that override the DAC check, so file modes do not gate anything on Peios. The socket's mode is permissive to say so rather than to imply a control that is not operating.

Tip

If you see permission denied from lps, it is a KACS refusal, not a Unix one. Check the descriptor on /run/lpsd/admin.sock with sd show, and check your token's groups with token.

Shape of the protocol #

One request per connection: lps connects, sends one request, reads one answer, and disconnects. There is no session and no state carried between commands.

That is partly the shape of the tool — a command does one thing and exits — and partly a bound on a real hazard. lpsd serves administrative requests on the same thread as logons, so a client that connected and then said nothing would stall every logon behind it. One request under a deadline bounds that.

The authorization check happens before anything is read, so an unauthorised connection costs a token read and a refusal rather than the full timeout. An open socket cannot be used to stall logons.

Durability #

A change is applied in memory, written to disk, and only then reported as done. If the write fails the change is rolled back and the command reports failure.

So a successful reply from lpsd means the change has persisted. A refused request leaves the store unchanged, and a change that cannot be saved is rolled back. These guarantees concern the daemon's reply, not every error a client can report.

If the connection is lost before the reply arrives, the outcome may be unknown: the change could have been saved before the reply was lost. Read back any inspectable account state before retrying; do not treat a transport error as proof that nothing changed. The protocol does not promise that retrying reverses or safely repeats the first request.

What is recorded #

Every request that changes the store is recorded as an event, after the change is durable, or as the failure lps was told:

RequestEvent
addlpsd.account.created
removelpsd.account.deleted
Any other change to an account: enable, disable, password, set, claim set, claim remove, rename, logon-types, key add, key remove, policylpsd.account.modified, with operation.name saying which
group create / group deletelpsd.group.created / lpsd.group.deleted
group add / group remove (membership)lpsd.group.member.added / lpsd.group.member.removed

Each record names the administrator who asked as subject.token.sid, and the account and group as object.account.sid and object.group.sid, never by name. A deleted account or group is named by the SID it had, read before it went. A failure carries outcome.reason: not-found, exists, invalid, internal, or not-saved for a change that could not be written and so was rolled back. A request that changes nothing, such as enabling an account that is already enabled, is not recorded. All of these events are essential, so the emission policy cannot switch them off.

Two changes have no event yet: renaming a group and changing its description.

A caller refused because it is not an administrator is not recorded. The admin socket decides that from the peer's token, as above, not by a KACS access check, so no kacs.audit.access.checked record is written either. The socket's own descriptor is a KACS check, at connect, and is audited only if it has a SACL.

The protocol itself #

The wire protocol is PLPS, sharing its codec and header layout with PGSS Logon (PGSS §2) and PSI (PSPU §2). It is specified in PSPU §10: every request, its reply, and how a request is refused.

If you are writing tooling in Rust, the libauthd-client crate in the authd repository speaks it, as lps and Principals Manager do, and reports a refusal with lpsd's reason. A program that only needs to read principals should ask the identity socket instead, which anyone may use: see Resolving names. A program reading or changing the account of the person running it uses the self socket, below.

The self socket #

lpsd listens on a second socket:

/run/lpsd/self.sock

It is for every principal rather than for administrators. Any authenticated principal may connect, and it answers two requests, both about the caller and nobody else: show me my account (name, display name, credential policy, whether a password is set, and SSH keys), and set my display name. There is no field in either request for naming another account. lpsd reads whose account it is from the connecting process's token, as it does on the admin socket, and a caller whose account lpsd does not hold, such as LocalSystem, is told so.

Its descriptor admits LocalSystem and BUILTIN\Administrators fully, and Authenticated Users (every principal that signed in) with only what a connection needs.

Because everyone can reach it, lpsd never waits on a connection there. Each one gets five seconds from connecting to the end of its answer, a request may be at most 4 KiB, and lpsd holds at most 32 such connections at once and 4 from any one user. A program that connects and says nothing holds up nobody else's sign-in.

A changed display name is saved before the answer, and lpsd tells authd at once, so name lookups show the new name straight away. The change is recorded as lpsd.account.modified, with operation.name set-display-name, naming the principal as both who asked and the account changed.

Changing a password or an SSH key is not done here, because it asks for the current password: see Changing your own keys. In Rust, libauthd-client's own module speaks the self socket and its credential module holds the password and key conversations. The protocol is PSPU §10.11.

See also #

  • The lps command — the client this socket serves.
  • Authorities and principal sources — the authority/source split behind the design.

Peios Learn — documentation for the Peios project.

Built with Trail.