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>/statusshows 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 API | What Peios does |
|---|---|
getuid, getgid, getgroups | Returns the projection from the token. |
setuid, setgid | No-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, capset | Returns 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 xattrs | Unconditionally denied — KACS replaces POSIX ACLs. |
fchmod, chmod | Gated 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, chown | Same, 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_CREDENTIALS | Returns 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_security | Denied; use kacs_get_sd / kacs_set_sd. |
auditd and the Linux audit subsystem | Replaced 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 seegetuid()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 butgetuid()does not return 0. - The
uid0utility lets a process withSeAssignPrimaryTokenPrivilegeand the right authority cosmetically set its UID to 0 without changing the underlying token. Useful for legacy applications that hard-codegeteuid() == 0checks 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:
| Field | Meaning |
|---|---|
projected_uid | The Linux UID corresponding to the token's user SID. 65534 if the SID has no mapping. |
projected_gid | The Linux GID corresponding to the token's primary group SID. Same fallback. |
projected_supplementary_gids | An 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:
| Syscall | Returns |
|---|---|
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>/status | The primary and effective token's projected fields. |
/proc/<pid>/loginuid | The 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 (withoutSeAssignPrimaryTokenPrivilege) or trigger a full identity swap through authd (with the privilege). See setuid and uid0.- The setuid-on-exec bit (
S_ISUIDin 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:
- The file has an SD with
owner_sid = S-1-5-21-...-1001andgroup_sid = S-1-5-21-...-513. - Each SID is run through the SID-to-UID mapping.
stat()returnsst_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'spid,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_uidandi_gid; Peios keeps these consistent with the SD's owner-projection, but the SD is authoritative. A file whosei_uidsomehow 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:
- The syscall (
open,read, etc.) is called. - The kernel does its DAC check — file mode bits, owner UID, group GID. If DAC refuses, the syscall fails with
EACCES. - The kernel does capability checks for operations that require specific capabilities.
- 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:
| Class | Meaning |
|---|---|
| ALLOW | Always present in the process's effective set. Cannot be cleared. Used to neutralise the DAC and capability checks that need to defer to LSM. |
| PRIVILEGE | Mapped to a KACS privilege. cap_capable() returns "granted" iff the calling token holds the corresponding KACS privilege. |
| DENY | Permanently 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:
| Capability | What it would normally gate | Why ALLOW |
|---|---|---|
CAP_DAC_OVERRIDE | DAC mode-bit checks | KACS replaces DAC entirely; the bit must be on so DAC always defers. |
CAP_DAC_READ_SEARCH | DAC read/search on directories | Same. |
CAP_FOWNER | Owner-based bypasses (chmod own files, etc.) | Same. |
CAP_CHOWN | Restriction on chown() | Linux normally requires CAP_CHOWN to chown; Peios redirects chown through KACS anyway. |
CAP_SETUID | Restriction on setuid() | setuid() is itself reinterpreted by Peios; the cap must be present for the syscall to even get to the LSM hook. |
CAP_SETGID | Same for setgid() | Same. |
CAP_NET_BIND_SERVICE | The 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:
| Capability | Maps to KACS privilege |
|---|---|
CAP_NET_ADMIN | (administrative network operations, gated by appropriate KACS privileges in v0.20) |
CAP_SYS_TIME | SeSystemtimePrivilege |
CAP_SYS_BOOT | SeShutdownPrivilege |
CAP_SYS_NICE | SeIncreaseBasePriorityPrivilege |
CAP_IPC_LOCK | SeLockMemoryPrivilege |
CAP_SYS_RESOURCE | SeIncreaseQuotaPrivilege |
CAP_AUDIT_CONTROL, CAP_AUDIT_READ, CAP_MAC_ADMIN | SeSecurityPrivilege |
CAP_PERFMON | SeSystemProfilePrivilege 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:
| Capability | Why DENY |
|---|---|
CAP_SETFCAP | Linux 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 capabilities | Caps 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_OVERRIDEetc. silently leave them set. The process cannot opt out of DAC neutralisation. - It cannot grant DENY bits. Attempts to add
CAP_SETFCAPetc. 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.
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 (unlessSeAssignPrimaryTokenPrivilegetriggers 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.capabilityxattr is unconditionally denied. - Existing
security.capabilityxattrs 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 holdsSeSystemtimePrivilegeon its token seesCAP_SYS_TIMEas granted; one that doesn't sees it as denied. capset()does not grant or remove ALLOW/DENY capabilities. The mask returned bycapget()is informational.- File capabilities don't work. Binaries that depended on
security.capabilityneed 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 touid.seteuid(uid_t euid)— set effective UID toeuid.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,setresgidfor 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 SeAssignPrimaryTokenPrivilege | Behaviour |
|---|---|
| No | The syscall returns 0 but nothing changes. The token is unchanged. The projection is unchanged. getuid() returns the same value before and after. |
| Yes | The 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:
- Looks up the SID corresponding to UID N (via the directory's reverse mapping).
- Asks authd to construct a token for that principal.
- Replaces the calling process's primary token with the new one.
- 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 SeAssignPrimaryTokenPrivilege | Setuid-bit behaviour |
|---|---|
| No | The 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. |
| Yes | A 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:
- Runs as a user with
SeAssignPrimaryTokenPrivilege(or chains through one). - Sets the calling credentials'
cred->uid,cred->euid, andcred->suidall to 0. - 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:
| Mechanism | Caller needs | Effect on token | Effect on getuid() / geteuid() |
|---|---|---|---|
setuid(N) without privilege | (none) | Unchanged | Unchanged (call returns 0 but doesn't actually set anything) |
setuid(N) with privilege | SeAssignPrimaryTokenPrivilege | Full swap via authd | Reflects the new identity |
exec of setuid-bit binary without privilege | (none) | Unchanged | euid/suid cosmetically updated to binary owner; uid unchanged |
exec of setuid-bit binary with privilege | SeAssignPrimaryTokenPrivilege | Full swap to binary owner | Reflects new identity |
uid0 wrapper | The wrapper itself runs with the privilege | Unchanged | Cosmetic 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. Thesetuid()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_PEERCREDfor 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_PEERCREDwould 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_IMPERSONATEon the fd, or the fusedpeios_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 to | Use |
|---|---|
| Log who connected for diagnostic purposes | SO_PEERCRED or projected UID-style API |
| Display a "connected as user X" indicator | Same |
| Make an access decision based on peer identity | KACS_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 use | KACS_SO_PEER_TOKEN (store the fd) |
| Send an identity along a message | SCM_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_PEERCREDstill 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.
One call, one round trip #
The module holds itself to a rule: each libc call costs exactly one request.
getpwuidasks for the six fields apasswdrecord needs, together.getgrnamasks for the group's members with their names already resolved, so fillinggr_memcosts nothing further. A reply of bare identifiers would have turned one call into one per member.initgroupsasks 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 #
| Result | glibc status | Effect |
|---|---|---|
| The principal exists | SUCCESS | The record is returned. |
| No such principal | NOTFOUND | The caller sees no such user. |
| A source did not answer | TRYAGAIN (EAGAIN) | The caller retries. Not an absence. |
authd is not reachable | UNAVAIL | Nothing 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.
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_VSOCKspans 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_MESGQanducred-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:
| Mechanism | What protects it |
|---|---|
| Socket file | The directory's descriptor for bind, the socket file's for connect (write-class right) |
| Abstract socket | A descriptor stamped at bind, checked on connect and datagram send |
| Pipe | Descriptor possession, nothing else |
| FIFO | The file's descriptor at open; the directory's for creation |
| System V queue, segment, semaphore array | A descriptor stamped at creation, addressed by kind and id (System V IPC) |
| POSIX shm, named semaphore, message queue | They are files — tmpfs under /dev/shm, mqueuefs inodes — with file descriptors |
| memfd, eventfd, pidfd, io_uring ring | Descriptor possession; pidfd operations are process-access checks |
| Shared mappings | Rights 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:
| Package | Covers |
|---|---|
org.kernel.linux-firmware-iwlwifi | Intel Wi-Fi |
org.kernel.linux-firmware-atheros | Qualcomm Atheros Wi-Fi and Bluetooth (ath9k, ath10k/11k/12k, btqca) |
org.kernel.linux-firmware-mediatek | MediaTek Wi-Fi and Bluetooth (mt76, mt79xx) |
org.kernel.linux-firmware-realtek | Realtek Wi-Fi, Bluetooth, USB/PCIe Ethernet |
org.kernel.linux-firmware-broadcom | Broadcom and Cypress Wi-Fi, Bluetooth, tg3/bnx2 Ethernet |
org.kernel.linux-firmware-marvell | Marvell and NXP Wi-Fi and Bluetooth (mwifiex, libertas) |
org.kernel.linux-firmware-ralink | Ralink rt2x00 Wi-Fi |
org.kernel.linux-firmware-amd-graphics | AMD Radeon GPUs (amdgpu, radeon) |
org.kernel.linux-firmware-amd-platform | AMD PSP/SEV, Platform Management Framework, XDNA NPU |
org.kernel.linux-firmware-intel-graphics | Intel GPUs (i915, xe: GuC, HuC, DMC) |
org.kernel.linux-firmware-intel-platform | Intel Bluetooth, sensor hub, IPU cameras, NPU, QAT, E800 NICs |
org.kernel.linux-firmware-intel-sound | Intel audio DSP firmware for pre-2019 parts (AVS, catpt, Skylake SST) |
org.sofproject.sof-firmware | Intel 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-graphics | NVIDIA GPUs for nouveau/nova (GSP) |
org.kernel.linux-firmware-audio-codecs | Cirrus, TI and Creative laptop codecs and amplifiers |
org.kernel.linux-firmware-nic | Wired NICs: Chelsio, QLogic qed, Myricom, Tehuti, 3Com, legacy USB Ethernet |
org.kernel.linux-firmware-storage | FC/SCSI HBAs (qla2xxx, QLogic BR, AdvanSys) and the ENE card reader |
org.kernel.linux-firmware-misc | USB 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:
| Keyword | Meaning |
|---|---|
| MUST, MUST NOT | An absolute requirement or prohibition |
| SHALL, SHALL NOT | Synonyms for MUST and MUST NOT |
| SHOULD, SHOULD NOT | A requirement that may be set aside for a stated reason, the consequences of which are understood |
| MAY | Genuinely optional |
| REQUIRED, OPTIONAL | Synonyms 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.
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:
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.
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.
| Symbol | Meaning |
|---|---|
& (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).
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:
| Article | Purpose |
|---|---|
| Scope | What the book covers, and what it does not — with each exclusion naming where the excluded thing is covered |
| Conventions | The 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:
| Article | Purpose |
|---|---|
| Scope and Roles | What the chapter specifies, and the roles that speak it (§3.2) |
| Terminology | Terms 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:
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:
| Role | Obligation |
|---|---|
| Producer | Builds the artifact. Everything the artifact contains is a producer obligation. |
| Consumer | Validates 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).
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.
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.
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 this | This |
|---|---|
| The daemon MUST reject a malformed request | The daemon rejects a malformed request |
| A client SHOULD retry after a timeout | A client that times out can retry |
| The cache MAY be discarded | The cache can be discarded at any time |
| Callers must hold the lock | Callers 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.
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:
| Article | Purpose |
|---|---|
| Overview | What the component is, and what is unusual about it |
| What This Manual Covers | And what is covered elsewhere, naming where |
| Terminology | Terms specific to the component, delegating the rest |
| Compatibility | Versions, 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.
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.
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.
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:
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.
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.
| Form | Means | Example |
|---|---|---|
§N | Chapter N | PSPU §5 |
§N.M | Article M of chapter N | PSPU §5.23 |
§N.A | Appendix A of chapter N | PSPU §5.A |
§A | Appendix A of the book | the 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:
5.2.2 Between books #
A reference to another book leads with that book's short name:
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 #
5.2.4 Prose and the reference are different things #
A citation MAY be accompanied by prose naming what is at the other end:
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
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:
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:
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:
- Build the affected books and extract the actual chapter and article numbering from the rendered index.
- Collect every
§reference in the corpus. - 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.
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.
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.
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.
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 #
| Form | Means |
|---|---|
RFC 2119 | The whole document |
RFC 8259 §7 | A specific section |
Unicode 16.0 | A version of a standard |
MS-DTYP §2.4.2 | A section of a Microsoft Open Specification |
A.3 IETF #
| Reference | Title | Used for |
|---|---|---|
| RFC 2119 | Key words for use in RFCs | Normative keywords (§2.1) |
| RFC 3339 | Date and Time on the Internet | Timestamps (§3.3) |
| RFC 3629 | UTF-8 | String encoding (§3.3) |
| RFC 3986 | Uniform Resource Identifier | URL syntax |
| RFC 4648 | Base16, Base32, Base64 Encodings | Base64, §4 alphabet |
| RFC 7468 | Textual Encodings of PKIX Structures | PEM key files |
| RFC 8032 | EdDSA | Ed25519 signing and verification |
| RFC 8259 | JSON | JSON documents |
| RFC 8478 | Zstandard | Compression |
| RFC 8915 | Network Time Security | Authenticated 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.
| Identifier | Title |
|---|---|
| MS-DTYP | Windows Data Types |
| MS-ERREF | Windows Error Codes |
| MS-LSAD | Local Security Authority (Domain Policy) Remote Protocol |
| MS-SAMR | Security Account Manager Remote Protocol |
| MS-ADTS | Active Directory Technical Specification |
| MS-GPOL | Group Policy: Core Protocol |
| MS-GPREG | Group Policy: Registry Extension Encoding |
A.5 Unicode #
| Reference | Used for |
|---|---|
| Unicode 16.0 | Normalization forms, case folding |
CaseFolding.txt, status S and C entries | Case-insensitive comparison |
A.6 POSIX and other standards #
| Reference | Used for |
|---|---|
| IEEE Std 1003.1-2017, Chapter 14 | The pax interchange format |
| MessagePack specification | Event payload encoding |
| SPDX License List | License 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:
| Offset | Size | Field | Type |
|---|---|---|---|
| 0 | 4 | Data1 | uint32, little-endian |
| 4 | 2 | Data2 | uint16, little-endian |
| 6 | 2 | Data3 | uint16, little-endian |
| 8 | 8 | Data4 | uint8[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.
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:
| Group | Digits | Source |
|---|---|---|
| 1 | 8 | Data1, most significant nibble first |
| 2 | 4 | Data2, most significant nibble first |
| 3 | 4 | Data3, most significant nibble first |
| 4 | 4 | Data4[0] and Data4[1], in byte order |
| 5 | 12 | Data4[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.
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.
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:
- Fill all 16 bytes with cryptographically random data.
- Set the four most significant bits of Data3 to
0100(version 4). - 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.
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:
| Offset | Size | Field | Type |
|---|---|---|---|
| 0 | 4 | LowPart | uint32, little-endian |
| 4 | 4 | HighPart | uint32, 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.
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.
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:
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 1 | Revision | MUST be 1. |
| 1 | 1 | SubAuthorityCount | Number of sub-authorities. MUST be between 0 and 15 inclusive. |
| 2 | 6 | IdentifierAuthority | A 6-byte big-endian value identifying the authority that issued the SID. |
| 8 | 4 × SubAuthorityCount | SubAuthority[] | 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.
4.2 String Format
Peios / Peios technical reference / PCDS / SID
SIDs are represented in string form as:
S-1-{authority}-{sub1}-{sub2}-...-{subN}
Where:
Sis a literal prefix.1is 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 with0x.- Each
{subN}is the decimal representation of the corresponding 32-bit sub-authority.
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 #
| SID | Name | Description |
|---|---|---|
| S-1-0-0 | Nobody | The null SID. No principal. |
| S-1-1-0 | Everyone | Matches all principals, including anonymous. |
| S-1-2-0 | Local | Principals that log on locally (physically). |
| S-1-2-1 | Console Logon | Principals that log on via the physical console. |
4.4.2 Creator authorities #
| SID | Name | Description |
|---|---|---|
| S-1-3-0 | Creator Owner | Placeholder in inheritable ACEs. Replaced with the creating principal's SID during inheritance. |
| S-1-3-1 | Creator Group | Placeholder in inheritable ACEs. Replaced with the creating principal's primary group SID during inheritance. |
| S-1-3-4 | Owner Rights | When 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) #
| SID | Name | Description |
|---|---|---|
| S-1-5-2 | Network | Principals that authenticated over the network. |
| S-1-5-3 | Batch | Principals that logged on to run a scheduled job. |
| S-1-5-4 | Interactive | Principals that logged on interactively. |
| S-1-5-6 | Service | Principals that authenticated as a service. |
| S-1-5-7 | Anonymous | The anonymous identity. Carried by tokens at Anonymous impersonation level. |
| S-1-5-10 | Principal Self | Placeholder 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-11 | Authenticated Users | All principals that have been authenticated (excludes Anonymous). |
| S-1-5-14 | Remote Interactive | Principals that logged on interactively to a remote graphical desktop. Carried in addition to Interactive, never in place of it. |
| S-1-5-18 | Local System (SYSTEM) | The operating system's own identity. Highest privilege level. |
| S-1-5-19 | Local Service | A built-in service account with reduced privileges. |
| S-1-5-20 | Network Service | A built-in service account that can authenticate to remote services. |
4.4.4 Logon SIDs #
| SID | Name | Description |
|---|---|---|
| S-1-5-5-X-Y | Logon SID | A 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) #
| SID | Name | Description |
|---|---|---|
| S-1-5-32-544 | BUILTIN\Administrators | The built-in administrators group. |
| S-1-5-32-545 | BUILTIN\Users | The built-in users group. |
| S-1-5-32-546 | BUILTIN\Guests | The built-in guests group. |
| S-1-5-32-551 | BUILTIN\Backup Operators | Members can bypass file security for backup and restore. |
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.
| RID | Name | Description |
|---|---|---|
| 500 | Domain Administrator | The built-in administrator account. |
| 501 | Domain Guest | The built-in guest account. |
| 512 | Domain Admins | The domain administrators group. |
| 513 | Domain Users | The domain users group. |
| 514 | Domain Guests | The domain guests group. |
| 515 | Domain Computers | Computer accounts in the domain. |
4.4.7 Mandatory integrity labels (S-1-16) #
| SID | Name | Numeric level | Description |
|---|---|---|---|
| S-1-16-0 | Untrusted | 0 | Lowest trust. Sandboxed or experimental code. |
| S-1-16-4096 | Low | 4096 | Reduced trust. Services handling untrusted input. |
| S-1-16-8192 | Medium | 8192 | Standard trust. Default for interactive logons and most services. |
| S-1-16-12288 | High | 12288 | Elevated administrative logons. |
| S-1-16-16384 | System | 16384 | The 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) #
| SID | Name | Description |
|---|---|---|
| S-1-19-0-0 | None / No trust | Default for unsigned processes. |
| S-1-19-512-1024 | Protected, Authenticode | Third-party signed binaries. |
| S-1-19-512-1536 | Protected, AntiMalware | Security tooling. |
| S-1-19-512-2048 | Protected, App | Peios-distributed applications. |
| S-1-19-512-4096 | Protected, Peios | Core Peios components. |
| S-1-19-512-8192 | Protected, PeiosTcb | Peios Trusted Computing Base. |
| S-1-19-1024-8192 | Isolated, PeiosTcb | Maximum 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= None512= Protected1024= 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) #
| SID | Name | Description |
|---|---|---|
| S-1-15-2-hash | Confinement SID | Identifies a confined application. The sub-authorities are derived from the application identity. |
| S-1-15-2-1 | ALL_APPLICATION_PACKAGES | Matches all confined applications in normal confinement mode. |
| S-1-15-2-2 | ALL_RESTRICTED_APPLICATION_PACKAGES | Matches 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) #
| SID | Name | Description |
|---|---|---|
| S-1-15-3-1 | internetClient | Outbound internet access. |
| S-1-15-3-2 | internetClientServer | Inbound and outbound internet access. |
| S-1-15-3-3 | privateNetworkClientServer | LAN/private network access. |
| S-1-15-3-8 | enterpriseAuthentication | Domain credential access. |
| S-1-15-3-9 | sharedUserCertificates | Certificate store access. |
| S-1-15-3-10 | removableStorage | Removable 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_sdcases 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.
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 1 | Revision | MUST be 1. |
| 1 | 1 | Sbz1 | Reserved. 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. |
| 2 | 2 | Control | Control flags, little-endian. |
| 4 | 4 | OffsetOwner | Offset to the owner SID, little-endian. 0 if absent. |
| 8 | 4 | OffsetGroup | Offset to the group SID, little-endian. 0 if absent. |
| 12 | 4 | OffsetSacl | Offset to the SACL, little-endian. 0 if absent. |
| 16 | 4 | OffsetDacl | Offset 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:
| Flag | Bit | Value | Description |
|---|---|---|---|
| SE_OWNER_DEFAULTED (OD) | 0 | 0x0001 | The owner was established by default means. |
| SE_GROUP_DEFAULTED (GD) | 1 | 0x0002 | The group was established by default means. |
| SE_DACL_PRESENT (DP) | 2 | 0x0004 | A DACL is present. If clear, AccessCheck treats the DACL as null (grants all access). |
| SE_DACL_DEFAULTED (DD) | 3 | 0x0008 | The DACL was established by default means. |
| SE_SACL_PRESENT (SP) | 4 | 0x0010 | A SACL is present. |
| SE_SACL_DEFAULTED (SD) | 5 | 0x0020 | The SACL was established by default means. |
| SE_DACL_TRUSTED (DT) | 6 | 0x0040 | Reserved metadata. Preserved during round-trip serialisation. No operational semantics. |
| SE_SERVER_SECURITY (SS) | 7 | 0x0080 | Create a server ACL based on the input ACL. |
| SE_DACL_AUTO_INHERIT_REQ (AR) | 8 | 0x0100 | Requests 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_REQ | 9 | 0x0200 | Requests that the SACL be auto-inherited from the parent. Same semantics as bit 8 for the SACL. |
| SE_DACL_AUTO_INHERITED (DI) | 10 | 0x0400 | The DACL was created through automatic inheritance. |
| SE_SACL_AUTO_INHERITED (SI) | 11 | 0x0800 | The SACL was created through automatic inheritance. |
| SE_DACL_PROTECTED (PD) | 12 | 0x1000 | The DACL is protected from inheritance. Inheritable ACEs from parent objects MUST NOT be merged. |
| SE_SACL_PROTECTED (PS) | 13 | 0x2000 | The SACL is protected from inheritance. |
| SE_RM_CONTROL_VALID (RM) | 14 | 0x4000 | The Sbz1 byte is interpreted as resource manager control bits. |
| SE_SELF_RELATIVE (SR) | 15 | 0x8000 | The 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:
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 1 | AclRevision | ACL revision number. Determines which ACE families the ACL formally permits. |
| 1 | 1 | Sbz1 | Reserved. Preserved for compatibility but not interpreted by KACS. |
| 2 | 2 | AclSize | Total size of the ACL in bytes, including the 8-byte header. Little-endian. |
| 4 | 2 | AceCount | Number of ACEs in the ACL. Little-endian. |
| 6 | 2 | Sbz2 | Reserved. 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 #
AclSizeMUST be at least 8 bytes.AclSizeMUST NOT exceed the containing buffer.- The ACL body MUST contain exactly
AceCountACEs within the declaredAclSize. - Truncated ACEs, ACE overruns, or leftover bytes within
AclSizeare malformed. - The architectural maximum ACL size is 64 KB because
AclSizeis 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:
| Bit | Name | Value | Meaning |
|---|---|---|---|
| 16 | DELETE | 0x00010000 | Delete the object. |
| 17 | READ_CONTROL | 0x00020000 | Read the object's SD (excluding SACL). |
| 18 | WRITE_DAC | 0x00040000 | Modify the object's DACL. |
| 19 | WRITE_OWNER | 0x00080000 | Change the object's owner. |
| 20 | SYNCHRONIZE | 0x00100000 | Wait on the object. |
5.3.1.3 Special rights (bits 24–25) #
| Bit | Name | Value | Meaning |
|---|---|---|---|
| 24 | ACCESS_SYSTEM_SECURITY | 0x01000000 | Read or write the SACL. Requires SeSecurityPrivilege. |
| 25 | MAXIMUM_ALLOWED | 0x02000000 | Not 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:
| Bit | Name | Value |
|---|---|---|
| 28 | GENERIC_ALL | 0x10000000 |
| 29 | GENERIC_EXECUTE | 0x20000000 |
| 30 | GENERIC_WRITE | 0x40000000 |
| 31 | GENERIC_READ | 0x80000000 |
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:
| Field | Description |
|---|---|
read | Specific + standard bits that GENERIC_READ maps to. |
write | Specific + standard bits that GENERIC_WRITE maps to. |
execute | Specific + standard bits that GENERIC_EXECUTE maps to. |
all | Specific + 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.
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:
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 1 | AceType | Identifies the ACE type. |
| 1 | 1 | AceFlags | Inheritance and audit flags. |
| 2 | 2 | AceSize | Total 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_ACEACCESS_DENIED_ACESYSTEM_AUDIT_ACESYSTEM_ALARM_ACESYSTEM_MANDATORY_LABEL_ACESYSTEM_SCOPED_POLICY_ID_ACESYSTEM_PROCESS_TRUST_LABEL_ACE
Layout:
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 4 | AceHeader | Standard ACE header. |
| 4 | 4 | Mask | Access mask. |
| 8 | variable | Sid | Principal SID. Consumes the remainder of the ACE. |
Parsing rules:
AceSizeMUST 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_ACEACCESS_DENIED_OBJECT_ACESYSTEM_AUDIT_OBJECT_ACESYSTEM_ALARM_OBJECT_ACE
Layout:
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 4 | AceHeader | Standard ACE header. |
| 4 | 4 | Mask | Access mask. |
| 8 | 4 | Flags | Bitfield describing which GUIDs are present. |
| 12 | 0 or 16 | ObjectType | Present when ACE_OBJECT_TYPE_PRESENT is set. |
| 12 or 28 | 0 or 16 | InheritedObjectType | Present when ACE_INHERITED_OBJECT_TYPE_PRESENT is set. |
| variable | variable | Sid | Principal SID. Begins immediately after the optional GUID fields and consumes the remainder of the ACE. |
Object ACE flags:
| Flag | Value | Description |
|---|---|---|
ACE_OBJECT_TYPE_PRESENT | 0x00000001 | ObjectType GUID is present. |
ACE_INHERITED_OBJECT_TYPE_PRESENT | 0x00000002 | InheritedObjectType GUID is present. |
Parsing rules:
AceSizeMUST be large enough to contain the header, mask, flags, all GUIDs selected byFlags, and a complete SID.- Unknown bits in
FlagsMUST 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_ACEACCESS_DENIED_CALLBACK_ACESYSTEM_AUDIT_CALLBACK_ACESYSTEM_ALARM_CALLBACK_ACEACCESS_ALLOWED_CALLBACK_OBJECT_ACEACCESS_DENIED_CALLBACK_OBJECT_ACESYSTEM_AUDIT_CALLBACK_OBJECT_ACESYSTEM_ALARM_CALLBACK_OBJECT_ACE
For non-object callback ACEs, the body layout is:
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 4 | AceHeader | Standard ACE header. |
| 4 | 4 | Mask | Access mask. |
| 8 | variable | Sid | Principal SID. |
| variable | variable | ApplicationData | Trailing type-specific bytes. Consumes the remainder of the ACE. |
For callback object ACEs, the body layout is:
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 4 | AceHeader | Standard ACE header. |
| 4 | 4 | Mask | Access mask. |
| 8 | 4 | Flags | Object ACE flags. |
| 12 | 0 or 16 | ObjectType | Present when ACE_OBJECT_TYPE_PRESENT is set. |
| 12 or 28 | 0 or 16 | InheritedObjectType | Present when ACE_INHERITED_OBJECT_TYPE_PRESENT is set. |
| variable | variable | Sid | Principal SID. |
| variable | variable | ApplicationData | Trailing 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.
ApplicationDataMAY be empty. Semantics for empty or malformed callback payloads are defined by the relevant subsystem.- For conditional ACEs,
ApplicationDatacarries 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:
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 4 | AceHeader | Standard ACE header. |
| 4 | 4 | Mask | Reserved for compatibility. Not used for access decisions. |
| 8 | variable | Sid | MUST be Everyone (S-1-1-0). |
| variable | variable | ApplicationData | One claim entry using §5.9. Consumes the remainder of the ACE. |
Parsing rules:
- The SID MUST be Everyone.
ApplicationDataMUST contain exactly one claim entry using §5.9.
5.4.3 DACL ACE types #
5.4.3.1 Basic ACEs #
| Structure | Value | Effect |
|---|---|---|
| ACCESS_ALLOWED_ACE | 0x00 | Grants the specified rights to the SID. |
| ACCESS_DENIED_ACE | 0x01 | Denies 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.
| Structure | Value | Effect |
|---|---|---|
| ACCESS_ALLOWED_OBJECT_ACE | 0x05 | Grants rights scoped to a property/class GUID. |
| ACCESS_DENIED_OBJECT_ACE | 0x06 | Denies 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.
| Structure | Value | Effect |
|---|---|---|
| ACCESS_ALLOWED_CALLBACK_ACE | 0x09 | Conditional allow. |
| ACCESS_DENIED_CALLBACK_ACE | 0x0A | Conditional deny. |
| ACCESS_ALLOWED_CALLBACK_OBJECT_ACE | 0x0B | Conditional allow, scoped to GUID. |
| ACCESS_DENIED_CALLBACK_OBJECT_ACE | 0x0C | Conditional deny, scoped to GUID. |
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).
| Structure | Value | Effect |
|---|---|---|
| SYSTEM_AUDIT_ACE | 0x02 | Audit access matching the SID and mask. |
| SYSTEM_AUDIT_OBJECT_ACE | 0x07 | Audit access scoped to a GUID. |
| SYSTEM_AUDIT_CALLBACK_ACE | 0x0D | Conditional audit. |
| SYSTEM_AUDIT_CALLBACK_OBJECT_ACE | 0x0F | Conditional audit, scoped to GUID. |
5.4.4.2 Alarm ACEs (continuous auditing) #
| Structure | Value | Effect |
|---|---|---|
| SYSTEM_ALARM_ACE | 0x03 | Continuous audit for matching SID and mask. |
| SYSTEM_ALARM_OBJECT_ACE | 0x08 | Continuous audit scoped to a GUID. |
| SYSTEM_ALARM_CALLBACK_ACE | 0x0E | Conditional continuous audit. |
| SYSTEM_ALARM_CALLBACK_OBJECT_ACE | 0x10 | Conditional 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).
| Structure | Value | Effect |
|---|---|---|
| SYSTEM_MANDATORY_LABEL_ACE | 0x11 | Sets 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).
| Structure | Value | Effect |
|---|---|---|
| SYSTEM_RESOURCE_ATTRIBUTE_ACE | 0x12 | Defines 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.
| Structure | Value | Effect |
|---|---|---|
| SYSTEM_SCOPED_POLICY_ID_ACE | 0x13 | References 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.
| Structure | Value | Effect |
|---|---|---|
| SYSTEM_PROCESS_TRUST_LABEL_ACE | 0x14 | Sets 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.
| Structure | Value | Notes |
|---|---|---|
| ACCESS_ALLOWED_COMPOUND_ACE | 0x04 | Never implemented. Reserved. |
| SYSTEM_ACCESS_FILTER_ACE | 0x15 | Defined 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:
| Value | AceType constant | Structure |
|---|---|---|
| 0x00 | ACCESS_ALLOWED_ACE_TYPE | ACCESS_ALLOWED_ACE |
| 0x01 | ACCESS_DENIED_ACE_TYPE | ACCESS_DENIED_ACE |
| 0x02 | SYSTEM_AUDIT_ACE_TYPE | SYSTEM_AUDIT_ACE |
| 0x03 | SYSTEM_ALARM_ACE_TYPE | SYSTEM_ALARM_ACE |
| 0x04 | ACCESS_ALLOWED_COMPOUND_ACE_TYPE | ACCESS_ALLOWED_COMPOUND_ACE |
| 0x05 | ACCESS_ALLOWED_OBJECT_ACE_TYPE | ACCESS_ALLOWED_OBJECT_ACE |
| 0x06 | ACCESS_DENIED_OBJECT_ACE_TYPE | ACCESS_DENIED_OBJECT_ACE |
| 0x07 | SYSTEM_AUDIT_OBJECT_ACE_TYPE | SYSTEM_AUDIT_OBJECT_ACE |
| 0x08 | SYSTEM_ALARM_OBJECT_ACE_TYPE | SYSTEM_ALARM_OBJECT_ACE |
| 0x09 | ACCESS_ALLOWED_CALLBACK_ACE_TYPE | ACCESS_ALLOWED_CALLBACK_ACE |
| 0x0A | ACCESS_DENIED_CALLBACK_ACE_TYPE | ACCESS_DENIED_CALLBACK_ACE |
| 0x0B | ACCESS_ALLOWED_CALLBACK_OBJECT_ACE_TYPE | ACCESS_ALLOWED_CALLBACK_OBJECT_ACE |
| 0x0C | ACCESS_DENIED_CALLBACK_OBJECT_ACE_TYPE | ACCESS_DENIED_CALLBACK_OBJECT_ACE |
| 0x0D | SYSTEM_AUDIT_CALLBACK_ACE_TYPE | SYSTEM_AUDIT_CALLBACK_ACE |
| 0x0E | SYSTEM_ALARM_CALLBACK_ACE_TYPE | SYSTEM_ALARM_CALLBACK_ACE |
| 0x0F | SYSTEM_AUDIT_CALLBACK_OBJECT_ACE_TYPE | SYSTEM_AUDIT_CALLBACK_OBJECT_ACE |
| 0x10 | SYSTEM_ALARM_CALLBACK_OBJECT_ACE_TYPE | SYSTEM_ALARM_CALLBACK_OBJECT_ACE |
| 0x11 | SYSTEM_MANDATORY_LABEL_ACE_TYPE | SYSTEM_MANDATORY_LABEL_ACE |
| 0x12 | SYSTEM_RESOURCE_ATTRIBUTE_ACE_TYPE | SYSTEM_RESOURCE_ATTRIBUTE_ACE |
| 0x13 | SYSTEM_SCOPED_POLICY_ID_ACE_TYPE | SYSTEM_SCOPED_POLICY_ID_ACE |
| 0x14 | SYSTEM_PROCESS_TRUST_LABEL_ACE_TYPE | SYSTEM_PROCESS_TRUST_LABEL_ACE |
| 0x15 | SYSTEM_ACCESS_FILTER_ACE_TYPE | SYSTEM_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.
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:
- Explicit deny ACEs — deny rules placed directly on this object (not inherited).
- Explicit allow ACEs — allow rules placed directly on this object.
- Inherited deny ACEs — deny rules inherited from parent objects (nearest parent first).
- 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:
| Flag | Value | Description |
|---|---|---|
| OBJECT_INHERIT_ACE (OI) | 0x01 | Inherited by non-container children (files). For container children (subdirectories), inherited as inherit-only unless NP is also set. |
| CONTAINER_INHERIT_ACE (CI) | 0x02 | Inherited by container children (subdirectories). The inherited ACE remains inheritable (propagates to grandchildren) unless NP is also set. |
| NO_PROPAGATE_INHERIT_ACE (NP) | 0x04 | When inherited, OI and CI flags are cleared on the copy. One-level inheritance. |
| INHERIT_ONLY_ACE (IO) | 0x08 | Does not apply to the object it is attached to. Exists only to be inherited by children. |
A fifth flag records provenance:
| Flag | Value | Description |
|---|---|---|
| INHERITED_ACE | 0x10 | Set on ACEs created through inheritance (not explicitly placed). Determines ordering in canonical form. |
5.6.2 Common flag combinations #
| Flags | Meaning |
|---|---|
| CI | OI | Inherit to everything — containers and non-containers, recursively. |
| CI | Inherit to containers only, recursively. |
| OI | Inherit to non-containers only. Containers receive it as inherit-only. |
| CI | OI | IO | Inherit to everything, but do not apply to this object. |
| CI | OI | NP | Inherit to immediate children only. |
| CI | NP | Inherit 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.
-
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.
-
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.
-
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).
-
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:
- Parent SD — provides inheritable ACEs.
- Creator SD — an explicit SD provided by the caller (if any).
- 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):
- If the child protects that ACL (SE_DACL_PROTECTED or SE_SACL_PROTECTED), it is left exactly as it is.
- 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.
- 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 type | TRUE | FALSE | UNKNOWN |
|---|---|---|---|
| Allow | ACE takes effect | ACE skipped | ACE skipped |
| Deny | ACE takes effect | ACE skipped | ACE takes effect |
| Audit | Event emitted | Event skipped | Event 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 #
| AND | TRUE | FALSE | UNKNOWN |
|---|---|---|---|
| TRUE | TRUE | FALSE | UNKNOWN |
| FALSE | FALSE | FALSE | FALSE |
| UNKNOWN | UNKNOWN | FALSE | UNKNOWN |
| OR | TRUE | FALSE | UNKNOWN |
|---|---|---|---|
| TRUE | TRUE | TRUE | TRUE |
| FALSE | TRUE | FALSE | UNKNOWN |
| UNKNOWN | TRUE | UNKNOWN | UNKNOWN |
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:
| Opcode | Prefix | Source | Description |
|---|---|---|---|
| 0xf9 | @User. | token.user_claims | Token-level claims set by authd at creation. |
| 0xfb | @Device. | token.device_claims | Device-level claims from the device token. |
| 0xfa | @Resource. | SD's SACL resource attribute ACEs | Per-object attributes, extracted in Pre-SACL walk. |
| 0xf8 | @Local. | local_claims parameter to AccessCheck | Per-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.
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_claimspassed 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:
| Type | Value | Notes |
|---|---|---|
INT64 | 0x0001 | Signed 64-bit integer. |
UINT64 | 0x0002 | Unsigned 64-bit integer. |
STRING | 0x0003 | UTF-16LE string. |
SID | 0x0005 | Binary SID. |
BOOLEAN | 0x0006 | Stored as u64; normalised to true/false at resolution time. |
OCTET | 0x0010 | Byte 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.
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 4 | NameOffset | Offset to the UTF-16LE null-terminated attribute name. |
| 4 | 2 | ValueType | One of the supported claim value types above. |
| 6 | 2 | Reserved | Reserved. Ignored by AccessCheck. Producers SHOULD set to 0. |
| 8 | 4 | Flags | Claim flags. |
| 12 | 4 | ValueCount | Number of values. May be 0. |
| 16 | 4 * ValueCount | ValueOffsets[] | One relative offset per value. Interpretation depends on ValueType. |
Claim flags use the same meanings everywhere this format appears:
| Flag | Value | Meaning |
|---|---|---|
CLAIM_SECURITY_ATTRIBUTE_VALUE_CASE_SENSITIVE | 0x0002 | String/octet comparisons using this attribute are case-sensitive. |
CLAIM_SECURITY_ATTRIBUTE_USE_FOR_DENY_ONLY | 0x0004 | The attribute is visible only to deny-side conditional evaluation: deny ACE conditions and audit/alarm ACE conditions. |
CLAIM_SECURITY_ATTRIBUTE_DISABLED | 0x0010 | The attribute is invisible to conditional evaluation. |
CLAIM_SECURITY_ATTRIBUTE_MANDATORY | 0x0020 | The 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 integerUINT64: unsigned 64-bit integerBOOLEAN: 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:
| Offset | Size | Field | Description |
|---|---|---|---|
| 0 | 4 | Length | Byte length of the octet string. |
| 4 | Length | Data | Raw 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_lenMUST be non-zero.entry_lenMUST fit entirely within the containing buffer.entry_bytesis 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_claimsbuffer -> 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 #
| Type | Value | Description |
|---|---|---|
| INT64 | 0x0001 | Signed 64-bit integer. |
| UINT64 | 0x0002 | Unsigned 64-bit integer. |
| STRING | 0x0003 | Unicode string. |
| FQBN | 0x0004 | Fully Qualified Binary Name. Reserved — not supported in KACS v0.20. |
| SID | 0x0005 | Security identifier. |
| BOOLEAN | 0x0006 | Boolean value. |
| OCTET | 0x0010 | Byte 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 type | Byte-code | Token data encoding |
|---|---|---|
| Padding | 0x00 | No data. Used for DWORD alignment padding at end of expression. |
| Signed int8 | 0x01 | 1 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 int16 | 0x02 | 1 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 int32 | 0x03 | 1 QWORD (8 bytes LE) for the value (2's complement). 1 byte for sign. 1 byte for base. Total: 10 bytes. |
| Signed int64 | 0x04 | 1 QWORD (8 bytes LE) for the value (2's complement). 1 byte for sign. 1 byte for base. Total: 10 bytes. |
| Unicode string | 0x10 | 1 DWORD (4 bytes LE) for length in bytes. Then UTF-16LE code units (2 bytes each, LSB first). Not null-terminated. |
| Octet string | 0x18 | 1 DWORD (4 bytes LE) for length in bytes. Then raw bytes. |
| Composite | 0x50 | 1 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. |
| SID | 0x51 | 1 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:
| Sign | Code | Description |
|---|---|---|
| + | 0x01 | Explicit positive sign. |
| - | 0x02 | Negative. |
| None | 0x03 | No 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:
| Base | Code | Description |
|---|---|---|
| Octal | 0x01 | Display as octal. |
| Decimal | 0x02 | Display as decimal. |
| Hexadecimal | 0x03 | Display 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 type | Byte-code | Processing |
|---|---|---|
| == | 0x80 | TRUE if RHS equals LHS (single or set value); FALSE otherwise. |
| != | 0x81 | FALSE if RHS equals LHS; TRUE otherwise. |
| < | 0x82 | TRUE if LHS < RHS; FALSE otherwise. |
| <= | 0x83 | TRUE if LHS <= RHS; FALSE otherwise. |
| > | 0x84 | TRUE if LHS > RHS; FALSE otherwise. |
| >= | 0x85 | TRUE if LHS >= RHS; FALSE otherwise. |
| Contains | 0x86 | TRUE if LHS value(s) include all of RHS value(s); FALSE otherwise. |
| Any_of | 0x88 | TRUE if RHS includes any of LHS value(s); FALSE otherwise. |
| Not_Contains | 0x8e | Logical inverse of Contains. |
| Not_Any_of | 0x8f | Logical 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 type | Byte-code | Processing |
|---|---|---|
| Member_of | 0x89 | TRUE if the token's group SIDs contain all SIDs in the operand. |
| Device_Member_of | 0x8a | TRUE if the token's device group SIDs contain all SIDs in the operand. |
| Member_of_Any | 0x8b | TRUE if the token's group SIDs contain any SID in the operand. |
| Device_Member_of_Any | 0x8c | TRUE if the token's device group SIDs contain any SID in the operand. |
| Not_Member_of | 0x90 | Logical inverse of Member_of. |
| Not_Device_Member_of | 0x91 | Logical inverse of Device_Member_of. |
| Not_Member_of_Any | 0x92 | Logical inverse of Member_of_Any. |
| Not_Device_Member_of_Any | 0x93 | Logical inverse of Device_Member_of_Any. |
For an empty SID operand set:
Member_of({})andDevice_Member_of({})return TRUE (vacuous truth).Member_of_Any({})andDevice_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 type | Byte-code | Processing |
|---|---|---|
| Exists | 0x87 | TRUE 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_Exists | 0x8d | Logical inverse of Exists. |
| NOT (!) | 0xa2 | TRUE→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 type | Byte-code | Processing |
|---|---|---|
| AND (&&) | 0xa0 | If either operand is FALSE, return FALSE. Else if either is UNKNOWN, return UNKNOWN. Else return TRUE. |
| OR (||) | 0xa1 | If 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 type | Byte-code | Namespace |
|---|---|---|
| @Local. | 0xf8 | Local claims (passed as AccessCheck parameter). |
| @User. | 0xf9 | User claims (from token.user_claims). |
| @Resource. | 0xfa | Resource attributes (from SACL resource attribute ACEs). |
| @Device. | 0xfb | Device claims (from token.device_claims). |
5.11.7 Complete byte-code summary #
For quick reference, all byte-codes in numeric order:
| Byte-code | Token |
|---|---|
| 0x00 | Padding |
| 0x01 | Signed int8 literal |
| 0x02 | Signed int16 literal |
| 0x03 | Signed int32 literal |
| 0x04 | Signed int64 literal |
| 0x10 | Unicode string literal |
| 0x18 | Octet string literal |
| 0x50 | Composite literal |
| 0x51 | SID literal |
| 0x80 | == |
| 0x81 | != |
| 0x82 | < |
| 0x83 | <= |
| 0x84 | > |
| 0x85 | >= |
| 0x86 | Contains |
| 0x87 | Exists |
| 0x88 | Any_of |
| 0x89 | Member_of |
| 0x8a | Device_Member_of |
| 0x8b | Member_of_Any |
| 0x8c | Device_Member_of_Any |
| 0x8d | Not_Exists |
| 0x8e | Not_Contains |
| 0x8f | Not_Any_of |
| 0x90 | Not_Member_of |
| 0x91 | Not_Device_Member_of |
| 0x92 | Not_Member_of_Any |
| 0x93 | Not_Device_Member_of_Any |
| 0xa0 | AND (&&) |
| 0xa1 | OR (||) |
| 0xa2 | NOT (!) |
| 0xf8 | @Local. attribute |
| 0xf9 | @User. attribute |
| 0xfa | @Resource. attribute |
| 0xfb | @Device. attribute |
KACS implementations MUST be byte-compatible with these encodings.
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.
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.
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.
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.
- The client sends exactly one opening message:
LogonStart,CredentialChangeStartto change its own credential (§2.20),CredentialEnrollStartto add or remove one of its own (§2.23), orSessionEndorSessionEndQueryabout a logon session (§2.22). It MUST be the first message. An authority MUST reject any conversation that opens with something else. - The authority sends zero or more
CredentialRequestmessages. Each MUST be answered by exactly oneCredentialResponsebefore the authority sends anything further. - The authority sends exactly one terminal message:
AccessGrantedorAccessDeniedfor a logon,CredentialChangedorAccessDeniedfor a credential change or an enrolment,SessionEndedorAccessDeniedfor aSessionEnd, andSessionEndAllowedorAccessDeniedfor aSessionEndQuery. - 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.
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.
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:
| Offset | Size | Field | Value |
|---|---|---|---|
| 0 | 4 | magic | PGSL (50 47 53 4c) |
| 4 | 2 | version | 1 |
| 6 | 2 | msg_type | See §2.A |
| 8 | 4 | total_len | Header 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
u32byte count, and the bytes follow immediately. - Structures and array elements are prefixed with a
u32byte 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 #
| Field | Encoding | Limit |
|---|---|---|
logon_type | u8 | §2.B |
identifier_type | u8 | §2.B |
identifier | length-framed bytes | 1024 |
tty | string | 128 |
remote_host | string | 256 |
supported_credential_types | length-framed bytes, one u8 per type | 32 |
required_credential_type | u8; 0 none, 1 Password, 2 SshPublicKey | §2.D |
has_ssh_binding | u8, exactly 0 or 1 | |
ssh_binding | structure, 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.
| Field | Encoding | Limit |
|---|---|---|
messages | array of Message | 8 |
prompts | array of Prompt | 16 |
Message:
| Field | Encoding | Limit |
|---|---|---|
severity | u8 | §2.B |
text | string | 512 |
Prompt:
| Field | Encoding | Limit |
|---|---|---|
credential_ref | u32 | — |
credential_type | u8 | §2.B |
credential_name | string | 128 |
parameters | length-framed bytes | 4096 |
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.
| Field | Encoding | Limit |
|---|---|---|
answers | array of Answer | 16 |
Answer:
| Field | Encoding | Limit |
|---|---|---|
credential_ref | u32 | — |
data | length-framed bytes | 32768 |
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.
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.
| Field | Encoding |
|---|---|
session_id | u64 |
profile | length-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.
| Field | Encoding | Limit |
|---|---|---|
home | string | 4096 |
shell | string | 4096 |
display_name | string | 256 |
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.
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.
| Field | Encoding | Limit |
|---|---|---|
denial | u32 | §2.B |
reason | string | 512 |
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.
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.
| Value | Name | Collection |
|---|---|---|
| 1 | Password | A line of text, not echoed |
| 2 | SshPublicKey | Bound 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.
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.
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).
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).
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.
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.
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:
| Character | Reserved 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.
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.
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.
| Field | Encoding | Limit |
|---|---|---|
tag | u32 | §2.14 |
key_type | u8 | §2.B |
name | string | 256 bytes |
sid | length-framed bytes (SID) | 68 bytes |
unix_id | u32 | |
kind | u8 | §2.B |
fields | u32 | §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 #
| Value | Name | Answers |
|---|---|---|
| 1 | Name | getpwnam, getgrnam |
| 2 | Sid | Rendering a security descriptor |
| 3 | UnixId | getpwuid, 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 #
| Value | Name |
|---|---|
| 0 | Any |
| 1 | Principal |
| 2 | Group |
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.
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.
| Bit | Name | Value encoding |
|---|---|---|
| 0 | UNIX_ID | u32 |
| 1 | PRIMARY_GROUP | reference |
| 2 | HOME | string, 4096 bytes |
| 3 | SHELL | string, 4096 bytes |
| 4 | DISPLAY_NAME | string, 256 bytes |
| 5 | GROUPS | array of references, 128 |
| 6 | MEMBERS | array of references, 256 |
| 7 | CLAIMS | array of claim entries, 64 |
| 8 | ENABLED | u8 |
| 9 | LOGON_TYPES | u32 |
| 10 | DESCRIPTION | string, 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.
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.
| Field | Encoding | Limit |
|---|---|---|
tag | u32 | §2.14 |
outcome | u8 | §2.B |
sid | length-framed bytes (SID) | 68 bytes |
qualified_name | string | 512 bytes |
kind_found | u8 | §2.B |
present | u32 | §2.B |
withheld | array of withheld entries | 32 |
values | array of length-framed values | 32 |
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 invalues; - 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:
| Field | Encoding | Limit |
|---|---|---|
field | u32 | one bit, §2.B |
reason | u8 | §2.B |
| Reason | Meaning |
|---|---|
Absent | The field has no value. |
Restricted | The caller may not have this field. |
Declined | The source will not produce it. |
TooLarge | It 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:
| Field | Encoding | Limit |
|---|---|---|
sid | length-framed bytes (SID) | 68 bytes |
name | string | 512 bytes |
unix_id | u32 |
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:
Declinedwhere the source will not enumerate the group's members.TooLargewhere the membership exceeds what one reply can carry.Absentwhere 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,
MEMBERSis withheld asTooLarge, and the members are walked withEnumerate(§2.17). - Where a source in the order could not be reached, or would not
enumerate,
MEMBERSis withheld asDeclined. A list missing one source's members would look complete.
2.16.4 One round trip #
An authority MUST be able to answer each of the following in a single request:
| Caller wants | Request |
|---|---|
A passwd record | Lookup{key_type: UnixId or Name, kind: Principal, fields: UNIX_ID | PRIMARY_GROUP | HOME | SHELL | DISPLAY_NAME} |
A group record | Lookup{key_type: UnixId or Name, kind: Group, fields: UNIX_ID | MEMBERS} |
| A principal's groups | Lookup{key_type: Name, kind: Principal, fields: GROUPS} |
| A name for a SID | Lookup{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.
| Field | Encoding | Limit |
|---|---|---|
tag | u32 | §2.14 |
kind | u8 | §2.B |
fields | u32 | §2.B |
of_key_type | u8 | §2.B |
of_name | string | 256 bytes |
of_sid | length-framed bytes (SID) | 68 bytes |
of_unix_id | u32 | |
cursor | length-framed bytes | 256 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.
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.
| Field | Encoding | Limit |
|---|---|---|
tag | u32 | §2.14 |
outcome | u8 | §2.B |
entries | array of entries | 256 |
next | length-framed bytes | 256 bytes |
incomplete | array of strings | 32 |
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.
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.
| Value | Name | Meaning |
|---|---|---|
| 1 | Found | The request was answered. |
| 2 | NotFound | No such object, and every source that could have said so was asked. |
| 3 | Unavailable | A source that could have answered did not. |
| 4 | Refused | The caller may not make this request. |
| 5 | Malformed | The 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.
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.
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.
| Field | Encoding | Limit |
|---|---|---|
identity | string | 128 |
service | string | 256 |
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:
- 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.
- 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.
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.
| Field | Encoding | Limit |
|---|---|---|
supported_credential_types | length-framed bytes, one u8 per type | 32 |
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 #
- Listen on
/run/logon.sockas aSOCK_STREAMUnix domain socket (§2.5). - Control access to it with a security descriptor, not with process integrity and not with POSIX permission bits (§2.4).
- Serve conversations concurrently, so that one stalled logon does not block others (§2.5).
- Support
SCM_RIGHTSon the channel (§2.9).
2.21.1.2 Framing #
- Reject any message whose magic is not
PGSL, as a whole-connection failure (§2.6). - Reject any message whose version it does not implement, answering
UnsupportedVersionwhere it can still encode a denial (§2.6). - Reject any message declaring more than 65536 bytes, or fewer than a header, without reading the body (§2.6).
- 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 #
- Require
LogonStart,CredentialChangeStart,CredentialEnrollStart,SessionEndorSessionEndQueryas the first message, and reject a conversation opening otherwise (§2.3). - Send exactly one terminal message, nothing after it, and close the connection (§2.3).
- Bound the number of rounds, the time spent awaiting an answer, and
the time a conversation may remain open, and terminate with
ConversationLimiton 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. - Reject a
CredentialResponseit did not solicit (§2.3). - 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 #
- Establish peer identity from the connected socket's peer token,
never from a message body, and never via
SO_PEERCRED(§2.4). - Treat
logon_typeas a proposal and constrain it against the verified peer (§2.4). - Treat
identifier,ttyandremote_hostas unverified claims (§2.4).
2.21.1.5 Derivation #
- Perform derivation itself, and never accept a token, privilege set or integrity level from another party (§2.1).
- Never fork or exec on the client's behalf; never learn about terminals, environments or session leadership (§2.1).
2.21.1.6 Prompting #
- 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). - Ensure
credential_refis unique among the prompts of one request (§2.8).
2.21.1.7 Result #
- Transfer the token as a file descriptor in the ancillary data of
AccessGranted, never by name (§2.9). - Never distinguish an unknown principal from a bad credential — by denial code, by reason text, or by timing (§2.10, §2.12).
- Send
homeandshellas 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 #
- 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).
- Never write credential material to a log, audit record, error message or diagnostic (§2.12).
2.21.1.9 Identity lookup #
- Listen on
/run/ident.sockas well as/run/logon.sock, as aSOCK_STREAMUnix domain socket (§2.14). - Grant connect access to that socket, by security descriptor, to every principal that runs ordinary programs (§2.14).
- Refuse a message of one socket's range received on the other (§2.14).
- Echo each request's
tag, and never interpret it otherwise (§2.14). - Serve requests concurrently, and never let a slow answer on one connection delay another (§2.14).
- Bound the time spent answering a request, and answer
Unavailablerather than waiting indefinitely (§2.14). - Ignore ancillary data received on the identity socket (§2.14).
2.21.1.10 Resolution #
- Interpret names itself, and never require a client to parse, qualify or case-fold one (§2.15).
- Compare names ASCII case-insensitively, establishing the comparison itself rather than delegating it to a source (§2.15).
- 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). - Resolve bare names in a configured order, never in registration order, stopping at the first source that answers (§2.15).
- 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 #
- Never answer a
Principalrequest with a group, or aGrouprequest with a principal, and establish the kind itself rather than relaying a source's (§2.16). - Ignore a field bit it does not implement, and never report it as present (§2.16).
- Report each requested field it implements as present or as withheld with a reason, never as neither (§2.16).
- Withhold
MEMBERSwith a reason rather than returning a partial or empty list, where it will not or cannot produce it (§2.16). - Report
Absentrather thanDeclinedfor a group whose membership is a rule rather than a record, and gather aBUILTINgroup's recorded members from the sources rather than reporting itAbsent(§2.16). - Encode "no POSIX identifier" as zero, and never substitute a number a caller could mistake for a real one (§2.16).
- Answer a
passwdrecord, agrouprecord, a principal's memberships, or a name for a SID, each in one request (§2.16). - Never report
NotFoundunless every source in the search order that could have answered was consulted and answered (§2.18). - Report
Unavailablewhere a source earlier in the search order could not be reached (§2.18). - Never report
NotFoundin place ofRefusedor of aRestrictedfield, and never reportRefusedfor anything other than a caller that may not make the request (§2.18). - List every source that did not contribute to an enumeration, and
never report
Foundon a page it could not produce (§2.17, §2.18). - 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). - Serve member enumeration where it withholds
MEMBERSasTooLarge(§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.
- Never hold, store, or accept a credential for a service identity, and never offer a credential exchange for one (§2.19).
- Refuse a
ServiceAttestunless 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). - Answer a peer that satisfies neither condition, or only one, with
PermissionDenied(§2.19). - Never mint a token under this message for a principal that has not
been designated as usable for a service logon, and refuse with
AccountRestrictedwhere it cannot determine whether a principal carries that designation (§2.19). - Never let a refusal distinguish "no such principal" from "not designated for service logon" (§2.19, §2.10).
- Reject an empty or unusable
servicewithMalformedRequest(§2.19). - Record the logon type as
Service, and leave everyprofilefield empty rather than inventing a value (§2.19, §2.9). - 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).
- Record that the logon was attested rather than authenticated, wherever it records what vouched for a credentialled logon (§2.19).
- Reject a
LogonStartthat would complete with no credential exchange for an identity it accepts underServiceAttest, and reject aServiceAttestnaming an identity it accepts underLogonStart(§2.19).
2.21.1.13 Logon type restrictions #
- 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). - Read an unstated
LOGON_TYPESas its own default rather than as "nothing permitted", and never includeServicein that default (§2.16, §2.19). - Never widen what a principal's holder permits. Local policy may narrow it further; nothing may add to it (§2.16).
- Refuse a
logon_typeit does not recognise, withMalformedRequest(§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.
- 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).
- Establish, within the conversation, that the principal can present
their current credential before changing it, and refuse with
AuthenticationFailedwhere they cannot (§2.20). - Refuse with
AccountRestricteda principal it holds no changeable credential for, including every service identity (§2.20, §2.19). - Send
CredentialChangedonly once the new credential is the one a subsequent logon will be tested against (§2.20). - Mint no token and create no session for a change, and send no
descriptor with
CredentialChanged(§2.20). - End with
ConversationLimita change it gives up on for want of an acceptable new credential (§2.20). - Refuse a
CredentialChangeStartthat ends beforesupported_credential_typeswithMalformedRequest(§2.20). - 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.
- Never accept
SessionEndQueryasSessionEndor the reverse, and change nothing in answeringSessionEndQuery(§2.22). - Answer
SessionEndQuerywithSessionEndAllowedexactly where aSessionEndfrom the same peer would be permitted, and otherwise with the denial it would draw (§2.22). - Refuse, with
PermissionDenied, a peer whose token is restricted, the SYSTEM and Anonymous sessions the kernel creates, and every session of logon typeService, whoever asks (§2.22). - 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).
- 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).
- Answer
NoSuchSessiononly to a caller who may end other principals' sessions, andPermissionDeniedto any other where the session does not exist (§2.22, §2.10). - 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).
- Address each signal by a reference that cannot come to name a different process, never by a process identifier looked up again (§2.22).
- Look again after each round, bound the number of rounds, and answer within 30 seconds (§2.22).
- Count in
remainingevery process it could not examine or end, and never fail the request for one (§2.22). - 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).
- Where the session is the one the connected peer's token belongs to,
send
SessionEndedbefore signalling anything, carrying what it found to end and aremainingof zero (§2.22). - 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.
- 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).
- For both
AddandRemove, establish within the conversation that the principal can present their current credential before changing anything, and refuse withAuthenticationFailedwhere they cannot (§2.23). - Refuse with
AccountRestricteda principal it holds no credential for that it can ask for as proof, including every service identity (§2.23, §2.19). - Refuse an
actionorcredential_typeit does not recognise withMalformedRequest, acredential_typeit does not enrol withPermissionDenied, and aCredentialEnrollStartthat ends beforematerialwithMalformedRequest(§2.23). - Refuse material it will not accept with
CredentialRejected, check it again when applying the change, and never sendCredentialRejectedfor a failed proof or in answer to any other message (§2.23, §2.B). - 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 #
- Send exactly one
LogonStart, as the first message (§2.3). - Answer each
CredentialRequestwith exactly oneCredentialResponse(§2.3). - Never send a
CredentialResponsethat was not solicited (§2.3). - Treat a connection that closes without a terminal message as a failed logon, and not retry automatically (§2.3).
2.21.2.2 Framing #
- Reject any message whose magic is not
PGSL(§2.6). - Reject any message whose version it does not implement (§2.6).
- Skip to a structure's declared end after reading known fields (§2.6).
2.21.2.3 Capabilities #
- Never declare in
supported_credential_typesa 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. - Fail the conversation, rather than guess, if it receives a prompt it cannot render (§2.8).
2.21.2.4 Rendering #
- Display received messages, in order, before the prompts of the same request (§2.8).
- Not interpret or branch on message text,
credential_name, orreason(§2.8, §2.10). - Echo
credential_refback unchanged (§2.8).
2.21.2.5 Result #
- Read the token descriptor from the ancillary data of
AccessGranted, and treat anAccessGrantedwithout one as a failed logon (§2.9). - Close the descriptor if it does not install the token (§2.9).
- Have a fallback for every
profilefield, and treat an empty field, and an absentprofile, identically and not as an error (§2.9). - Never execute a relative
shell, nor resolve a relativehomeagainst its own working directory (§2.9). Ashellcontaining no separator is relative, and MUST NOT be resolved against a search path. - Never treat
profileas 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 #
- Collect a credential only where it can establish that its collection
method meets the type's requirements, and never collect a
Passwordwith echo enabled (§2.12). - Never silently truncate an answer to fit its own buffer (§2.12).
- 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 #
- Forward a name unchanged, and never parse, split, qualify or case-fold one (§2.15).
- Never reuse a
tagwhile a request bearing it is outstanding, and never assume replies arrive in request order (§2.14). - Treat a cursor as opaque: never construct, parse or modify one, nor present one to a different authority or across a reconnection (§2.17).
- Continue an enumeration until
nextis 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). - Surface an enumeration's
incompletelist rather than discarding it (§2.17). - Distinguish
NotFoundfromUnavailable, and never cache the second as though it were the first (§2.18). - 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 #
- Send exactly one
CredentialChangeStart, as the first message, and name no principal in it — there is no field for one (§2.20). - Treat
CredentialChangedas 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 #
- Send exactly one
SessionEndorSessionEndQuery, as the only message, and useSessionEndQuery— neverSessionEnd— to learn whether ending a session would be permitted (§2.22). - Wait up to 60 seconds for the answer to
SessionEndbefore treating it as unanswered, and then not assume either outcome (§2.22). - Treat a non-zero
remainingas a session that may still exist, and never read the counts as an outcome where the session was the caller's own (§2.22). - Treat a denial code it does not recognise as a refusal, not as an unreadable answer (§2.B).
2.21.2.10 Credential enrolment #
- Send exactly one
CredentialEnrollStart, as the first message, with every field, and name no principal in it (§2.23). - Tell
CredentialRejected(the material) apart fromAuthenticationFailed(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.
| Field | Encoding | Limit |
|---|---|---|
logon_session_id | u64 | — |
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.
| Field | Encoding | Limit |
|---|---|---|
logon_session_id | u64 | — |
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.
- 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 isService— 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. - 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. - 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.
- No such session. Where the session does not exist, an authority
MUST answer
NoSuchSessionto a caller rule 3 permits, andPermissionDeniedto 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).
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.
2.22.7 SessionEnded #
msg_type = 0x8040. Authority to client. The successful terminal
message of SessionEnd; nothing follows it.
| Field | Encoding | Limit |
|---|---|---|
ended | u32 | — |
remaining | u32 | — |
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.
| Field | Encoding | Limit |
|---|---|---|
supported_credential_types | length-framed bytes, one u8 per type | 32 |
action | u8 | §2.B |
credential_type | u8 | §2.B |
material | string | 16384 |
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_type | Message | Direction | Defined in |
|---|---|---|---|
0x0001 | LogonStart | client → authority | §2.7 |
0x0002 | CredentialResponse | client → authority | §2.8 |
0x8001 | CredentialRequest | authority → client | §2.8 |
0x8002 | AccessGranted | authority → client | §2.9 |
0x8003 | AccessDenied | authority → client | §2.10 |
0x0020 | ServiceAttest | client → authority | §2.19 |
0x0030 | CredentialChangeStart | client → authority | §2.20 |
0x8030 | CredentialChanged | authority → client | §2.20, §2.23 |
0x0031 | CredentialEnrollStart | client → authority | §2.23 |
0x0040 | SessionEnd | client → authority | §2.22 |
0x0041 | SessionEndQuery | client → authority | §2.22 |
0x8040 | SessionEnded | authority → client | §2.22 |
0x8041 | SessionEndAllowed | authority → client | §2.22 |
2.A.1.2 On /run/ident.sock #
msg_type | Message | Direction | Defined in |
|---|---|---|---|
0x0010 | Lookup | client → authority | §2.16 |
0x0011 | Enumerate | client → authority | §2.17 |
0x8010 | LookupReply | authority → client | §2.16 |
0x8011 | EnumerateReply | authority → 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 #
| Constant | Value | Defined in |
|---|---|---|
| Logon socket path | /run/logon.sock | §2.5 |
| Identity socket path | /run/ident.sock | §2.14 |
| Magic | PGSL (50 47 53 4c) | §2.6 |
| Version | 1 | §2.6 |
| Header size | 12 bytes | §2.6 |
| Maximum message size | 65536 bytes | §2.6 |
| Longest grace before a forced end | 10 seconds | §2.22 |
Longest a SessionEnd may take to answer | 30 seconds | §2.22 |
| Least a client waits for that answer | 60 seconds | §2.21 |
2.A.3 Field limits #
| Field | Maximum | Defined in |
|---|---|---|
identifier | 1024 bytes | §2.7 |
tty | 128 bytes | §2.7 |
remote_host | 256 bytes | §2.7 |
supported_credential_types | 32 entries | §2.7 |
messages | 8 entries | §2.8 |
prompts | 16 entries | §2.8 |
answers | 16 entries | §2.8 |
text | 512 bytes | §2.8 |
credential_name | 128 bytes | §2.8 |
data | 32768 bytes | §2.8 |
home | 4096 bytes | §2.9 |
shell | 4096 bytes | §2.9 |
display_name | 256 bytes | §2.9 |
reason | 512 bytes | §2.10 |
material | 16384 bytes | §2.23 |
identity | 128 bytes | §2.19 |
service | 256 bytes | §2.19 |
name (lookup key) | 256 bytes | §2.16 |
sid | 68 bytes | §2.16 |
qualified_name | 512 bytes | §2.16 |
withheld | 32 entries | §2.16 |
values | 32 entries | §2.16 |
reference name | 512 bytes | §2.16 |
HOME, SHELL | 4096 bytes | §2.16 |
DISPLAY_NAME | 256 bytes | §2.16 |
GROUPS | 128 entries | §2.16 |
MEMBERS | 256 entries | §2.16 |
CLAIMS | 64 entries | §2.16 |
DESCRIPTION | 1024 bytes | §2.16 |
of_name | 256 bytes | §2.17 |
cursor, next | 256 bytes | §2.17 |
entries | 256 entries | §2.17 |
incomplete | 32 entries | §2.17 |
incomplete source name | 32 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.
| Value | Name |
|---|---|
| 2 | Interactive |
| 3 | Network |
| 4 | Batch |
| 5 | Service |
| 8 | NetworkCleartext |
| 9 | NewCredentials |
| 10 | RemoteInteractive |
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.
| Value | Name | identifier holds |
|---|---|---|
| 1 | Username | A 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.
| Value | Name | Collection |
|---|---|---|
| 1 | Password | A line of text, not echoed |
| 2 | SshPublicKey | Bound SSH candidate or signature proof; §2.D |
2.B.4 Message severities #
Carried in Message.severity (§2.8) as a u8.
| Value | Name |
|---|---|
| 0 | Info |
| 1 | Error |
2.B.5 Enrolment actions #
Carried in CredentialEnrollStart.action (§2.23) as a u8.
| Value | Name | material holds |
|---|---|---|
| 1 | Add | The credential to enrol: for SshPublicKey, one line of an OpenSSH public key file |
| 2 | Remove | Which 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.
| Value | Name | Meaning |
|---|---|---|
| 1 | MalformedRequest | The message could not be understood. |
| 2 | UnsupportedVersion | The protocol version is not implemented. |
| 3 | PermissionDenied | The peer may not make this request at all. |
| 4 | AuthenticationFailed | The principal is unknown, or the credential is wrong. Deliberately one code — see §2.10. |
| 5 | LogonTypeNotPermitted | The peer may not request this kind of session. |
| 6 | AccountRestricted | The 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). |
| 7 | AuthorityUnavailable | The authority cannot reach what it needs to decide. |
| 8 | ConversationLimit | Too many rounds, or too long without an answer. |
| 9 | Internal | The authority failed for a reason it will not describe. |
| 10 | NoSuchSession | There is no live logon session by that identifier. Answers only SessionEnd and SessionEndQuery (§2.22). |
| 11 | CredentialRejected | The 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.
| Value | Name | Key is in |
|---|---|---|
| 1 | Name | name |
| 2 | Sid | sid |
| 3 | UnixId | unix_id |
2.B.8 Object kinds #
Carried in Lookup.kind, LookupReply.kind_found and Enumerate.kind
(§2.16, §2.17) as a u8.
| Value | Name |
|---|---|
| 0 | Any |
| 1 | Principal |
| 2 | Group |
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.
| Bit | Name | Value encoding |
|---|---|---|
| 0 | UNIX_ID | u32 |
| 1 | PRIMARY_GROUP | reference |
| 2 | HOME | string |
| 3 | SHELL | string |
| 4 | DISPLAY_NAME | string |
| 5 | GROUPS | array of references |
| 6 | MEMBERS | array of references |
| 7 | CLAIMS | array of claim entries |
| 8 | ENABLED | u8 |
| 9 | LOGON_TYPES | u32 |
| 10 | DESCRIPTION | string |
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.
| Value | Name |
|---|---|
| 1 | Found |
| 2 | NotFound |
| 3 | Unavailable |
| 4 | Refused |
| 5 | Malformed |
2.B.11 Withheld reasons #
Carried in a withheld entry's reason (§2.16) as a u8.
| Value | Name | Meaning |
|---|---|---|
| 1 | Absent | The field has no value. |
| 2 | Restricted | The caller may not have this field. |
| 3 | Declined | The source will not produce it. |
| 4 | TooLarge | It 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 #
| Component | Responsibility |
|---|---|
| SSH client | Produces the standard SSH authentication signature using its private key |
| SSH transport and monitor | Establishes and retains the live transport binding; mediates credential collection |
| authd | Verifies the local originator, relays the credential exchange, enforces logon policy, and grants a token |
| lpsd | Owns 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:
| Field | Representation |
|---|---|
| Record ID | Random 128-bit identifier, stable until deletion |
| Public key | Validated, canonical SSH public-key blob; at most 8192 bytes |
| Label | UTF-8 text, at most 128 bytes; no control characters |
| Created time | UTC 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, initiallyPasswordand/orSshPublicKey. 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
keyornone, or a password policy with no password set — is refusedAccountRestricted, 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 addandlps key removeuse — 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 isCredentialRejected, with the store's reason; - names a key for removal by its
SHA256:fingerprint rather than the record IDlps key removetakes, because the fingerprint is what a person can read from their own.pubfile. 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:
| Field | Encoding and limit |
|---|---|
| Operation | u8: 0 Candidate, 1 Proof |
| Signature algorithm | UTF-8 string, 1–64 bytes |
| Public key | Byte string, 1–8192 bytes |
| Signature | Byte 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:
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:
| Offset | Size | Field | Value |
|---|---|---|---|
| 0 | 4 | magic | MSIP (4d 53 49 50) |
| 4 | 2 | version | 1 |
| 6 | 2 | msg_type | See §3.A |
| 8 | 4 | total_len | Header 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:
| Field | Meaning |
|---|---|
element_types | The element types this surface can render. |
surface | Optional 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 repliesListing: 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:
reason | Meaning |
|---|---|
access_denied | The access check of §3.4 failed. |
unknown_kind | Start named a kind this daemon does not offer, or Attach named a conversation that does not exist or has ended. |
unsupported_elements | The conversation needs element types the surface did not declare. |
unavailable | The 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:
| Field | Meaning |
|---|---|
seq | This turn's sequence number. |
id | Optional template identity (§3.2). |
name | Optional display title. |
class | Optional presentation hints. |
elements | The 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:
| Field | Default | Meaning |
|---|---|---|
ref | — | Semantic identity (§3.2). Unique within the turn. |
type | — | An element type from the negotiated set. |
name | absent | Display label. |
class | [] | Presentation hints. |
help | absent | Longer explanatory text. |
required | false | An answer that validates must supply a value. |
default | absent | Value assumed when the answer omits this ref. |
secret | false | §3.4's handling rules apply. |
enabled | true | Disabled elements render but cannot be answered. |
error | absent | Validation 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 fromrequired, 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. actionis 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:
| Field | Meaning |
|---|---|
seq | The turn being answered. |
action | The action pressed, by ref. At most one. |
values | Input 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
defaultis 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 —
seqis 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
erroron 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:
| Field | Meaning |
|---|---|
seq | The outstanding turn. |
full | Replacement or patch. |
elements | See 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.
| Field | Meaning |
|---|---|
seq | The conversation's final seq. |
outcome | complete, cancelled, or failed. |
message | Optional 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.
code | Violation |
|---|---|
malformed | Undecodable body, or a field breaking this chapter's rules. |
unbound | A message requiring a binding, on an unbound connection — or a second binding attempt on a bound one. |
unknown_ref | An answer naming a ref the turn does not have. |
bad_value | A value on an action/disabled/output element, a wrongly-typed value, or an action rule of §3.9 broken. |
unexpected_type | A 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
Erroron 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,secretand 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).
| Message | Direction | msg_type |
|---|---|---|
| Hello | surface → daemon | 0x0001 |
| List | surface → daemon | 0x0002 |
| Start | surface → daemon | 0x0003 |
| Attach | surface → daemon | 0x0004 |
| Answer | surface → daemon | 0x0005 |
| Welcome | daemon → surface | 0x8001 |
| Listing | daemon → surface | 0x8002 |
| Bound | daemon → surface | 0x8003 |
| Refused | daemon → surface | 0x8004 |
| Turn | daemon → surface | 0x8005 |
| Update | daemon → surface | 0x8006 |
| Error | daemon → surface | 0x8007 |
| End | daemon → surface | 0x8008 |
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.
| State | Meaning |
|---|---|
text | The content. |
detail | What 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.
| State | Meaning |
|---|---|
min, max | Length bounds, in characters. |
pattern | Anchored regular expression the value must match. |
placeholder | Ghost 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.
| State | Meaning |
|---|---|
choices | Array of {value, name, help?}, in display order. |
multiple | Zero 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.
| State | Meaning |
|---|---|
columns | Array of {key, name, align?}, in display order. align is left (default) or right; numbers and sizes read better right-aligned. |
rows | Array 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. |
empty | Text 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.
| State | Meaning |
|---|---|
value | Progress so far. |
max | The whole; absent means indeterminate. |
3.B.7 log — output #
Accumulating lines of detail.
| State | Meaning |
|---|---|
lines | The lines so far. |
append | In 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 #
| State | Meaning |
|---|---|
validate | Default true; false skips §3.9 validation. |
primary | The action an unattended answerer presses. At most one per turn. |
destructive | Pressing 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:
| Form | Meaning | Defined in |
|---|---|---|
generic | The 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.
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.
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:
| Key | Value | Type | Meaning |
|---|---|---|---|
Machine\Generic\Icons | Themes | REG_MULTI_SZ | Theme 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.
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.
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:
- The whole type,
<type>/<subtype>, with/replaced by-. - 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 is | Type |
|---|---|
| A directory | inode/directory |
| A regular file that may be executed, and nothing better is known | application/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.
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
genericwhere 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\IconsThemes, 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, orgeneric(§4.5). - Finds
.pgss.iconby 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>.svgunder/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 inuser.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 #
| Identifier | Drawn for |
|---|---|
generic | Anything 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.
| Identifier | Drawn for |
|---|---|
inode-directory | A directory |
application-x-executable | A program |
application | A file of any other application/ type |
text | A file of any text/ type |
image | A file of any image/ type |
audio | A file of any audio/ type |
video | A 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).
| Identifier | Drawn for |
|---|---|
directory-documents | A directory called Documents |
directory-downloads | A directory called Downloads |
directory-pictures | A directory called Pictures |
directory-music | A directory called Music |
directory-videos | A 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.
5.3.2 The file #
The file is a TOML document whose top-level table has these keys:
| Key | Type | Meaning |
|---|---|---|
title | string | What the application is called. One line, not empty. Required. |
program | string | The file to run: an absolute path. Required. |
arguments | array of strings | What the program is always started with, in order, before anything else. Empty when absent. |
icon | string | The identifier of its icon (PGSS Icons §4.3). The id when absent. |
description | string | One line on what it is for. None when absent. |
opens | array of strings | The media type patterns it opens. Empty when absent. |
listed | boolean | Whether a launcher lists it. true when absent. |
An example, complete for a file explorer:
= "Gexora"
= "Browse the files on this system"
= "/usr/bin/gexora"
= ["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
titleis absent, is not a string, is empty or is more than one lineprogramis 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
iconthat is not in the form of an identifier is treated as absent, and the icon is the id - an entry in
opensthat is not a media type pattern is left out - leading and trailing whitespace on
title,descriptionandiconis 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.
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.
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.
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:
| Key | Whose choices |
|---|---|
CurrentUser\Generic\Applications\Defaults | The person's. The kernel routes CurrentUser\ to the caller's own Users\<SID>\, so each person has their own. |
Machine\Generic\Applications\Defaults | The machine's: everyone's on it, for whatever they have not chosen themselves. |
Each value of either key is one choice:
| Part | Is |
|---|---|
| Name | A media type pattern (§5.2): the type the choice is for, image/png, or a top-level type and *, image/*. |
| Type | REG_SZ. |
| Data | The 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:
- the person's choice named
type/subtype - the person's choice named
type/* - the machine's choice named
type/subtype - 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.
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.
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
Defaultsunder eitherApplicationskey. 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>.tomlwith 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
iconas 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
titleand an absoluteprogram, 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\Defaultswhere 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.
| Role | Obligation |
|---|---|
| Emitter | Writes 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). |
| Consumer | Reads 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:
| Notation | Meaning |
|---|---|
and, or, not | Boolean operators |
for each x in list | Iteration in list order |
absent | No value: a key or registry value that does not exist |
return | Ends 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.
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.
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.
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.
| Role | Participant |
|---|---|
subject | Whose authority the action used: the principal that acted |
object | What the action was done to |
source | Where something came from: the origin of a copy, of traffic, of data a backend supplied |
destination | Where it went |
emitter | Who 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):
| Root | Holds |
|---|---|
subject, object, source, destination, emitter | The participants, and their common things: token, process, file, key, session |
event | The record's own header values (below) |
access | Rights asked for, granted and audited |
outcome | Whether the action succeeded, and why not (§6.6) |
trigger | Whatever caused the record to exist: an audit ACE, a blocking layer, a causing file |
operation | What was attempted and how |
config | A configuration value reported on |
policy | A rule set in force |
transaction | A transaction an operation belonged to |
fields | Properties 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 path | Type |
|---|---|---|
type | event.type | str |
timestamp | event.time | uint.time |
sequence | event.sequence | uint |
cpu_id | event.cpu | uint |
| — | event.boot.guid | bin.guid |
origin_class | emitter.class | uint.enum |
effective_token_guid | emitter.token.guid | bin.guid |
true_token_guid | emitter.true-token.guid | bin.guid |
process_guid | emitter.process.guid | bin.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:
| Path | Value |
|---|---|
policy.generation | The generation now in force |
policy.generation-previous | The generation it replaced |
object.key.path | The key's resolved path |
object.key.path-requested | The 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).
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 #
| Value | Wire form | Type |
|---|---|---|
| Principal | A binary SID (PCDS) | bin.sid |
| GUID | The 16-byte binary GUID (PCDS) | bin.guid |
| LUID | Unsigned integer | uint.luid |
| ACE | The binary ACE (PCDS) | bin.ace |
| Point in time | Unsigned integer, nanoseconds since the Unix epoch | uint.time |
| Duration | Unsigned integer, nanoseconds | uint.duration |
| Size in bytes | Unsigned integer | uint.bytes |
| Access mask | Unsigned integer | uint.mask |
| Flags | Unsigned integer | uint.flags |
| Integrity level | Unsigned integer, the integrity RID | uint.integrity |
| Error number | Signed integer, negative | int.errno |
| Boolean | MessagePack boolean | bool |
| Enumeration | String, kebab-case | str.enum |
| Numeric enumeration | Unsigned integer | uint.enum |
| Path | String | str.path |
| IP address | String, in the textual form for its family | str.ip |
| Digest | Binary | bin |
| Text | String | str |
| Other integer | Unsigned or signed integer | uint, 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, neveroperation.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.
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 #
| Field | Type | Carries |
|---|---|---|
outcome.success | bool | Whether the action did what was asked |
outcome.reason | str.enum | Why it failed, or why a success was unusual |
outcome.errno | int.errno | The error number it failed with |
outcome.detail | str | Text 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.
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:
| Key | Value |
|---|---|
kind | The 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.
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:
- 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.
- 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.
6.8.2 Tiers #
Every event type declares one tier in its fragment (§6.10). The tiers are a closed set.
| Tier | Meaning | On unless the policy says otherwise |
|---|---|---|
essential | Extremely important and relatively rare, or governed by its own configuration | Always; cannot be switched off |
standard | What an administrator reviews: lifecycle, configuration, failures | Yes |
verbose | Detail for an active investigation | No |
debug | For the component's developers | No |
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.
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
Enabledvalue on the deepest key that has one decides. A value onEventsitself 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
Enabledvalue on the path, astandardtype is on, and averboseordebugtype 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 type | Tier | Decided by | On |
|---|---|---|---|
lcs.config.value.rejected | standard | its tier | Yes |
kacs.caap.staging.diverged | standard | Events\kacs | No |
kacs.audit.handle.used | standard | Events\kacs\audit | Yes |
kacs.audit.access.checked | essential | its tier, always | Yes |
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.
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:
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 #
| Key | Required | Value |
|---|---|---|
type | Yes | The field's type (§6.5) |
values | For an enumeration | The values, separated by |, or the name of the reason-code family they are drawn from |
closed | For an enumeration | true if no value may be added without a new version of the field |
asserted | No | true if the value may be supplied by userspace on a kernel-originated record (§6.7) |
carried | No | header 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 #
| Key | Required | Value |
|---|---|---|
tier | Yes | The event type's tier (§6.8) |
gating | If any | What decides which occurrences are recorded |
cardinality | No | How 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:
- Every path a
field:orinclude:line names is defined. - No path is defined by two fragments, and no path is defined twice in one.
- No path is defined both as a value and as a map (§6.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.
- 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).
- 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.
- Every enumeration declares its values or names its reason-code family, and says whether it is closed.
- Every event type declares a tier, and an event type with gating declares it.
- An event type that carries a
uint.maskfield carriesobject.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.
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 carriesoutcome.successwherever 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.kindwherever 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
.evmanfile 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.
| Root | Component | Package |
|---|---|---|
kacs | Kernel Access Control Subsystem | dev.peios.kernel |
lcs | The kernel's registry (configuration store) | dev.peios.kernel |
kmes | Kernel Mediated Event Subsystem | dev.peios.kernel |
stratafs | StrataFS | dev.peios.kernel |
ntfe | The kernel's network-policy engine | dev.peios.kernel |
peinit | The service manager | dev.peios.peinit |
peipkg | The package manager | dev.peios.peipkg |
eventd | The event store | dev.peios.eventd |
authd | The authentication daemon | dev.peios.authd |
lpsd | The local principal source | dev.peios.authd-lpsd |
timed | The time daemon | dev.peios.timed |
netd | The network daemon | dev.peios.netd |
trustd | The trust daemon | dev.peios.trustd |
These roots are reserved for components that write no events yet, and MUST NOT be used by anything else:
| Root | Component |
|---|---|
resolvd | The name-resolution daemon |
ud | The user daemon |
loregd | The log store |
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.
| Offset | Size | Type | Field | Description |
|---|---|---|---|---|
| 0 | 4 | u32 | event_size | Total size of the event, header plus payload, in bytes. |
| 4 | 4 | u32 | header_size | Size of the header in bytes. |
| 8 | 8 | u64 | timestamp | Wall clock time at emission, in nanoseconds since the Unix epoch. |
| 16 | 8 | u64 | sequence | Per-CPU, per-boot monotonic sequence number. |
| 24 | 2 | u16 | cpu_id | The CPU on which the event was emitted, identifying the ring buffer that carries it. |
| 26 | 1 | u8 | origin_class | The emission path that produced the event. |
| 27 | 16 | GUID | effective_token_guid | GUID of the effective token of the emitting thread. Null GUID if unavailable. |
| 43 | 16 | GUID | true_token_guid | GUID of the emitting process's primary token. Null GUID if unavailable. |
| 59 | 16 | GUID | process_guid | GUID of the emitting process. Null GUID if unavailable. |
| 75 | 2 | u16 | type_len | Length of the event type string in bytes. |
| 77 | type_len | [u8] | type | Event 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 #
| Value | Origin |
|---|---|
| 0 | Userspace, via system call |
| 1 | KMES |
| 2 | KACS |
| 3 | LCS |
| 4 | NTFE |
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_guidis the token governing the emitting thread's access rights. If the thread was impersonating, this is the impersonation token; otherwise it equalstrue_token_guid.true_token_guidis the emitting process's primary token, regardless of impersonation.process_guididentifies the emitting process. It is assigned when the process is created and does not change acrossexec.
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:
| Offset | Size | Region | Consumer access |
|---|---|---|---|
| 0 | 4096 | Producer metadata page | Read-only |
| 4096 | 4096 | Consumer metadata page | Read-write |
| 8192 | 2 × capacity | Data region | Read-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 #
| Offset | Size | Type | Field | Description |
|---|---|---|---|---|
| 0 | 8 | [u8; 8] | magic | 4B 4D 45 53 52 49 4E 47, KMESRING in ASCII. Compared byte by byte, not as an integer. |
| 8 | 4 | u32 | version | Ring buffer format version. This version is 1. |
| 12 | 2 | u16 | cpu_id | The CPU this buffer belongs to. |
| 14 | 2 | u16 | reserved0 | Reserved, zero. |
| 16 | 8 | u64 | capacity | Data region capacity in bytes. A power of two. |
| 24 | 8 | u64 | data_offset | Offset from the start of the mapping to the data region. 8192. |
| 32 | 8 | u64 | generation | Buffer generation. Starts at 1 for the first buffer on each CPU and increases by one each time the buffer is replaced. |
| 40 | 24 | -- | reserved1 | Reserved, 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 #
| Offset | Size | Type | Field | Description |
|---|---|---|---|---|
| 64 | 8 | u64 | write_pos | Monotonically increasing byte offset at which the next event will be written. Never wraps. |
| 72 | 8 | u64 | tail_pos | Byte offset of the oldest surviving event. Advanced by KMES as events are overwritten. |
| 80 | 48 | -- | reserved2 | Reserved, 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 #
| Offset | Size | Type | Field | Description |
|---|---|---|---|---|
| 128 | 4 | u32 | futex_counter | Incremented by KMES when it wakes sleeping consumers. |
| 132 | 60 | -- | reserved3 | Reserved, 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 #
| Offset | Size | Type | Field | Description |
|---|---|---|---|---|
| 4096 | 1 | u8 | need_wake | Set by a consumer that is about to sleep. Read by KMES after writing an event; any nonzero value counts as set. |
| 4097 | 4095 | -- | reserved4 | Reserved. |
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 #
- Load
write_poswith acquire ordering. Ifwrite_pos == read_pos, no events are available: go to the notification wait. - Load
tail_poswith acquire ordering. Ifread_pos < tail_pos, the events at the read position have been overwritten and the consumer has been lapped: setread_pos = tail_pos. The skipped span is lost, and will show up as a sequence gap. - Save the current
tail_posassaved_tail. - Read the event at data region offset
read_pos & (capacity - 1). - Re-read
tail_pos. If it has advanced pastsaved_tailandread_pos < tail_pos, the event was overwritten while it was being read. The bytes just read MUST be discarded: go to step 2. - Check that
event_size > 0andevent_size >= header_size. If either fails, the bytes are not a valid event; the consumer SHOULD setread_pos = tail_posand go to step 2. A consumer MUST perform this check: anevent_sizeof zero would otherwise make the drain loop spin forever. - Process the event. Advance
read_posby the event'sevent_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:
- Store 1 to
need_wakewith release ordering. - Re-load
write_poswith acquire ordering. If events arrived between the drain loop finding the buffer empty and this store, clearneed_waketo 0 and return to the drain loop. This re-check is REQUIRED: without it, an event written in that window would findneed_wakestill clear, and the consumer would sleep with events waiting. - Read
futex_counter. - Optionally spin, re-checking
write_pos. If events arrive during the spin, clearneed_waketo 0 and return to the drain loop. The spin duration is the consumer's choice, and a consumer MAY omit this step entirely. - Call
futex_wait(&futex_counter, last_seen_value), wherelast_seen_valueis the value read in step 3. The kernel puts the thread to sleep only iffutex_counterstill holds that value, so a wake that arrived in the meantime is not missed. - On waking, clear
need_waketo 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:
- Record the sequence number of the last event successfully processed from this buffer.
- 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. - Call
kmes_attachfor the same CPU to obtain a descriptor for the replacement buffer, and map it. - Read the new buffer's
capacity,write_pos, andtail_pos. - Scan the new buffer for the first event whose sequence number is
greater than the recorded one, and set
read_posto that event's position. A consumer MUST locate its position by sequence number and MUST NOT carryread_posacross. - Close the old descriptor and unmap the old buffer.
- 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 #
| Operation | Ordering | Purpose |
|---|---|---|
Producer stores tail_pos | release | The advanced tail is visible before the data that replaces the events it skipped past. |
Producer stores write_pos | release | Complete event data is visible before the position that makes it reachable. |
Producer stores futex_counter | release | A consumer waking from the futex observes all prior writes. |
Consumer stores need_wake = 1 | release | The producer observes the flag before the consumer waits. |
Consumer stores need_wake = 0 | relaxed | A stale read causes only a spurious wake. |
Consumer loads write_pos in the drain loop | acquire | Pairs with the producer's release. |
Consumer loads write_pos after setting need_wake | acquire | Closes the window between finding the buffer empty and announcing the sleep. |
Consumer loads tail_pos | acquire | Pairs 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:
| Offset | Size | Field |
|---|---|---|
| 0 | 1 | Version. MUST be 0x01. |
| 1 | 3309 | Raw 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.
- 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. - 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 ise_shnum == 0, which does not commit. - Read
security.peios.sig. If present and exactly 3310 bytes, use it. - 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:
| Field | Type | Description |
|---|---|---|
hive_count | u32 | Number of hive entries. MUST be non-zero. |
_pad | u32 | Reserved. MUST be zero. |
max_sequence | u64 | The highest sequence number persisted anywhere in this source's storage. A single value for the whole source, not per hive. |
hives_ptr | u64 | Userspace address of an array of hive_count reg_src_hive_entry structures. |
Each reg_src_hive_entry:
| Field | Type | Description |
|---|---|---|
name_len | u32 | Length of the hive name in UTF-8 bytes. |
_pad0 | u32 | Reserved. MUST be zero. |
name_ptr | u64 | Userspace address of the hive name. Not null-terminated. |
root_guid | u8[16] | The GUID of this hive's root key. MUST NOT be all-zero. |
flags | u32 | RSI_HIVE_PRIVATE (0x01). All other bits reserved and MUST be zero. |
_pad1 | u32 | Reserved. MUST be zero. |
scope_guid | u8[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
CurrentUserin 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_PRIVATEcarries an all-zeroscope_guid; hive_countis withinMaxHivesPerSourceand the registered source count is withinMaxRegisteredSources(ENOSPC);max_sequenceis notU64_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 overESTALEwhen 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):
| Field | Type | Description |
|---|---|---|
version | u32 | REG_SRC_LIFETIME_VERSION, which is 1. |
flags | u32 | Reserved. MUST be zero. |
session | u64 | MUST 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):
| Field | Type | Description |
|---|---|---|
session | u64 | The session returned by negotiation. |
request_id | u64 | The id of a request the source has read and not yet answered. |
request_deadline_ns | u64 | Output. When the kernel-side caller stops waiting, counting from when the request was queued. |
lifetime_deadline_ns | u64 | Output. The transaction's lifetime ceiling; zero for a transaction owned by one kernel operation, which has no separate ceiling. |
lease_deadline_ns | u64 | Output. 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.
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | total_len |
| 4 | 8 | request_id |
| 12 | 2 | op_code |
| 14 | 8 | txn_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.
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | total_len |
| 4 | 8 | request_id |
| 12 | 2 | op_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_codethat 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.
| Operation | Code | Response |
|---|---|---|
RSI_LOOKUP | 0x0001 | 0x8001 |
RSI_CREATE_ENTRY | 0x0002 | 0x8002 |
RSI_HIDE_ENTRY | 0x0003 | 0x8003 |
RSI_DELETE_ENTRY | 0x0004 | 0x8004 |
RSI_ENUM_CHILDREN | 0x0005 | 0x8005 |
RSI_CREATE_KEY | 0x0010 | 0x8010 |
RSI_READ_KEY | 0x0011 | 0x8011 |
RSI_WRITE_KEY | 0x0012 | 0x8012 |
RSI_DROP_KEY | 0x0013 | 0x8013 |
RSI_QUERY_VALUES | 0x0020 | 0x8020 |
RSI_SET_VALUE | 0x0021 | 0x8021 |
RSI_DELETE_VALUE_ENTRY | 0x0022 | 0x8022 |
RSI_SET_BLANKET_TOMBSTONE | 0x0023 | 0x8023 |
RSI_BEGIN_TRANSACTION | 0x0030 | 0x8030 |
RSI_COMMIT_TRANSACTION | 0x0031 | 0x8031 |
RSI_ABORT_TRANSACTION | 0x0032 | 0x8032 |
RSI_FLUSH | 0x0040 | 0x8040 |
RSI_DELETE_LAYER | 0x0050 | 0x8050 |
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:
| Size | Field |
|---|---|
| 4+n | layer_name |
| 1 | target_type: 0 = GUID, 1 = HIDDEN |
| 16 | target_guid |
| 8 | sequence |
then metadata_count (u32), then that many key metadata entries:
| Size | Field |
|---|---|
| 16 | guid |
| 4+n | sd, binary |
| 1 | volatile |
| 1 | symlink |
| 8 | last_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_guidand 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:
| Size | Field |
|---|---|
| 4+n | child_name |
| 4 | entry_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.
| Bit | Field | Encoding |
|---|---|---|
| 0 | sd | length-prefixed |
| 1 | last_write_time | i64 |
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:
| Size | Field |
|---|---|
| 4+n | value_name |
| 4+n | layer_name |
| 4 | type |
| 4+n | data, binary |
| 8 | sequence |
then blanket_count (u32), then per blanket tombstone:
| Size | Field |
|---|---|
| 4+n | layer_name |
| 8 | sequence |
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.
| Mode | Value | Meaning |
|---|---|---|
RSI_TXN_READ_WRITE | 0 | An ordinary transaction. |
RSI_TXN_READ_ONLY | 1 | A 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.
| Status | Code | Kernel maps to | Meaning |
|---|---|---|---|
RSI_OK | 0 | success | The operation completed. |
RSI_NOT_FOUND | 1 | ENOENT | A key, value or path entry does not exist. |
RSI_ALREADY_EXISTS | 2 | EEXIST | A path entry or key already exists. |
RSI_STORAGE_ERROR | 3 | EIO | A failure in the backing store. |
RSI_NOT_EMPTY | 4 | ENOTEMPTY | A key still has children or values. |
RSI_TOO_LARGE | 5 | ENOSPC | Value data exceeds the maximum size. |
RSI_TXN_BUSY | 6 | EBUSY | A transaction could not take the write lock. |
RSI_INVALID | 7 | EINVAL | A malformed request or an invalid field value. |
RSI_CAS_FAILED | 8 | EAGAIN | A conditional write's sequence did not match. |
RSI_TXN_NOT_SUPPORTED | 9 | ENOTSUP | This 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.
| Offset | Size | Field |
|---|---|---|
| 0 | 2 | record_type |
| 2 | 4 | record_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.
| Record | Code |
|---|---|
HEADER | 0x01 |
LAYER | 0x02 |
KEY | 0x03 |
PATH_ENTRY | 0x04 |
VALUE | 0x05 |
BLANKET_TOMBSTONE | 0x06 |
TRAILER | 0xFF |
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:
HEADERMUST be the first record and MUST appear exactly once.- Every
LAYERrecord MUST precede all key data. ALAYERrecord after key data has begun is invalid. - The root
KEYrecord — the one whose GUID equalsHEADER.RootGUID— MUST be the firstKEYrecord in the stream, and MUST appear exactly once. - Within a key's section, records MUST appear in the order
PATH_ENTRY, thenVALUE, thenBLANKET_TOMBSTONE. APATH_ENTRYafter aVALUEorBLANKET_TOMBSTONEin the same section is invalid, as is aVALUEafter aBLANKET_TOMBSTONE. - No
PATH_ENTRY,VALUEorBLANKET_TOMBSTONEmay appear before the firstKEYrecord. TRAILERMUST 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.
| Size | Field | Description |
|---|---|---|
| 8 | Magic | The ASCII bytes PEIOSREG — 50 45 49 4F 53 52 45 47. |
| 4 | FormatVersion | u32. |
| 4 | MinReaderVersion | u32. |
| 8 | Timestamp | i64, Unix nanoseconds. |
| 16 | RootGUID | The GUID of the key at the root of this backup. |
| 4+n | HiveName | The 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.
| Size | Field | Description |
|---|---|---|
| 4+n | Name | The layer name. |
| 4 | Precedence | u32, as observed at backup time. |
| 1 | Enabled | u8. MUST be 0 or 1. |
| 4+n | Owner | Binary 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.
| Size | Field | Description |
|---|---|---|
| 16 | GUID | The key's identity. MUST NOT be nil. |
| 4 | Flags | u32. Bit 0 volatile, bit 1 symlink. |
| 4 | SDLength | u32. |
| n | SD | The full Security Descriptor. |
| 8 | LastWriteTime | i64, 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.
| Size | Field | Description |
|---|---|---|
| 16 | ParentGUID | The parent key. MUST NOT be nil. |
| 4+n | ChildName | The name under that parent. |
| 16 | ChildGUID | The key being named, or an all-zero GUID meaning HIDDEN. |
| 4+n | LayerName | The layer this entry belongs to. |
| 8 | Sequence | u64. |
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.
| Size | Field | Description |
|---|---|---|
| 16 | KeyGUID | The key this value belongs to. MUST NOT be nil. |
| 4+n | Name | The value name; empty for the default value. |
| 4 | Type | u32. |
| 4 | DataLength | u32. |
| n | Data | The value's bytes. |
| 4+n | LayerName | The layer this entry belongs to. |
| 8 | Sequence | u64. |
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.
| Size | Field | Description |
|---|---|---|
| 16 | KeyGUID | The key this blanket belongs to. MUST NOT be nil. |
| 4+n | LayerName | The layer. |
| 8 | Sequence | u64. |
5.3.7 TRAILER — 0xFF #
Exactly one, last. Payload 40 bytes, so the whole record is 46.
| Size | Field | Description |
|---|---|---|
| 8 | RecordCount | u64. Every record in the stream, HEADER and TRAILER included. |
| 32 | Checksum | SHA-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
KEYrecord whose GUID equalsHEADER.RootGUID, and it MUST be the firstKEYrecord. - 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
KEYrecord's GUID, the restore MUST fail. - If any GUID-bearing
PATH_ENTRYin 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.
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.
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.
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:
| Offset | Size | Field | Value |
|---|---|---|---|
| 0 | 4 | magic | PPSI (50 50 53 49) |
| 4 | 2 | version | 1 |
| 6 | 2 | msg_type | See §2.A |
| 8 | 4 | total_len | Header plus body, in bytes |
| 12 | 8 | conversation | See 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
0is reserved for connection-level messages:Register,RegisteredandChanged(§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.
| Field | Encoding | Limit |
|---|---|---|
source_name | string | 32 bytes |
domain | length-framed bytes (SID) | 68 bytes |
capabilities | u32 | §2.B |
entry_ttl | u32 | seconds |
max_batch | u32 | 64 |
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.
| Bit | Name | Meaning |
|---|---|---|
| 0 | QUERIES | Answers Query (§2.15). |
| 1 | ENUMERATES | Answers EnumerateSource (§2.16). |
| 2 | MEMBERS | Can produce a group's membership. |
| 3 | PUSHES_CHANGES | Sends Changed (§2.17). |
| 4 | CHANGES_CREDENTIALS | Answers ChangeCredential (§2.21). |
| 5 | ENROLLS_CREDENTIALS | Answers 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.
| Field | Encoding |
|---|---|
unix_id_base | u32 |
unix_id_count | u32 |
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 #
- A connection MUST open with
Registeron conversation0. An authority MUST refuse any connection that opens otherwise. - 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. - An authority MUST NOT admit two sources under one name at the same time.
- An authority MUST send
Registeredonly after the source is routable, so that a logon racing the acknowledgement cannot find a source that is registered but not yet reachable. - A source MUST NOT send any other message before receiving
Registered. - 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:
- take its list of permitted source names from its own configuration;
- derive the service SID each of those names implies;
- 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.
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.
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.
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.
| Field | Encoding | Limit |
|---|---|---|
start | nested LogonStart, length-framed | PGSS §2.7 |
originator | length-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.
| Field | Encoding | Limit |
|---|---|---|
user_sid | length-framed bytes (SID) | 68 bytes |
canonical_name | string | 256 bytes |
groups | array of group entries | 128 |
unix_id | u32 | §2.20 |
primary_group | length-framed bytes (SID) | 68 bytes |
profile | length-framed structure (PGSS §2.9) | |
claims | array of claim entries | 64 |
permitted_logon_types | u32 | PGSS §2.16 |
authenticated_credential_type | u8; 0 none, 1 Password, 2 SshPublicKey | PGSS §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:
| Field | Encoding | Limit |
|---|---|---|
sid | length-framed bytes (SID) | 68 bytes |
unix_id | u32 | §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.
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:
| Field | Encoding | Limit |
|---|---|---|
name | string | 255 bytes |
flags | u32 | PCDS §5.9 |
value_type | u32 | PCDS §5.9 |
values | array of length-framed values | 64 |
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 #
- 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.
- Enforce identity scope (§2.18), membership scope (§2.19) —
including over
primary_group— and numeric scope (§2.20). - Fail the logon on a malformed group SID, an unusable claim, an
invalid
canonical_name, or aunix_idoutside 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. - 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.
- 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.
| Field | Encoding | Limit |
|---|---|---|
denial | u32 | PGSS §2.B |
reason | string | 512 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.
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 #
- An authority MUST send
Abandonfor any conversation it opened that will not reach a terminal state, unless the connection itself is being torn down. - A source MUST discard all state for the conversation on receipt, and MUST NOT reply.
Abandonis not a terminal message from the source, and noAssertionorRefusalfollows 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.- A source that receives
Abandonfor a conversation it does not know MUST ignore it. It has already cleaned up, which is the outcome the message wanted.
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.
| Field | Encoding | Limit |
|---|---|---|
fields | u32 | PGSS §2.B |
keys | array of key entries | 64 |
A key entry is a length-framed structure:
| Field | Encoding | Limit |
|---|---|---|
key_type | u8 | §2.B |
name | string | 256 bytes |
sid | length-framed bytes (SID) | 68 bytes |
relative_id | u32 | |
kind | u8 | PGSS §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 #
| Value | Name |
|---|---|
| 1 | Name |
| 2 | Sid |
| 3 | RelativeId |
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.
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.
| Field | Encoding | Limit |
|---|---|---|
results | array of result entries | 64 |
One result per key, in the order the keys were sent.
A result entry is a length-framed structure:
| Field | Encoding | Limit |
|---|---|---|
outcome | u8 | §2.B |
sid | length-framed bytes (SID) | 68 bytes |
canonical_name | string | 256 bytes |
kind | u8 | PGSS §2.B |
present | u32 | PGSS §2.B |
withheld | array of withheld entries | 32 |
values | array of length-framed values | 32 |
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:
| Value | Name | Meaning |
|---|---|---|
| 1 | Found | The source holds this object. |
| 2 | NotFound | It does not. |
| 4 | Refused | It 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).
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.
| Field | Encoding | Limit |
|---|---|---|
kind | u8 | PGSS §2.B |
fields | u32 | PGSS §2.B |
of | length-framed key entry (§2.15) | |
cursor | length-framed bytes | 256 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.
| Field | Encoding | Limit |
|---|---|---|
outcome | u8 | §2.B |
entries | array of result entries (§2.15) | 256 |
next | length-framed bytes | 256 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.
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.
| Field | Encoding | Limit |
|---|---|---|
scope | u8 | §2.B |
sid | length-framed bytes (SID) | 68 bytes |
scope | Name | Meaning |
|---|---|---|
| 1 | All | Everything this source holds may have changed. |
| 2 | Object | The 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.
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.
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.
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).
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:
ris 0. Zero is not an identifier; it is how a source says it has no number for something. It MUST NOT becomebase.ris at or pastcount. 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:
- uid 0. It belongs to the authority's own table and is not reachable by adding a base to anything a source can send.
- 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.
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.
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.
| Field | Encoding | Limit |
|---|---|---|
start | nested CredentialChangeStart, length-framed | PGSS §2.20 |
principal | length-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 #
- Listen; never dial out to a source (§2.3).
- Never rely on the socket's descriptor as the access control, and bound unregistered connections and registration time independently of it (§2.6).
- Bound registered sources, and conversations per source (§2.6).
- Tear down the connection on a framing error or a failed write, rather than attempting resynchronisation (§2.6).
2.22.1.2 Registration #
- Require
Registeron conversation0as the first message (§2.8). - 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). - Accept only configured sources, treating an empty configuration as no source may register (§2.9).
- Refuse a source that declares no domain (§2.10).
- Refuse a domain that is not a well-formed locally-issued domain SID (§2.10).
- Refuse a domain another registered source claims (§2.10).
- Refuse a source declaring a different domain from the one it declared before (§2.10).
- Refuse a source whose declared domain contradicts a configured pin, and treat an unparseable pin as refusing rather than as absent (§2.10).
- Send
Registeredonly once the source is routable (§2.8). - 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 #
- Allocate conversation identifiers, never accepting one from a
source, never opening one with an identifier already live, and
reserve
0for registration (§2.7). - Route each logon to exactly one source, resolved before any credential is collected (§2.11).
- Never fall back to another source when the owning source is unreachable (§2.11).
- Relay the verified
originator, taken from the client's socket (§2.11). - Refuse to relay a prompt for a credential type the client did not advertise (§2.12).
- Enforce its own round and time limits on the relayed exchange (§2.12).
- Send
Abandonfor any conversation that will not reach a terminal state (§2.14).
2.22.1.4 Assertions #
- Validate every SID structurally before treating it as identity, including SIDs carried inside claim values (§2.13).
- Drop an asserted logon SID, and drop duplicates first-mention-wins, before applying any scope test (§2.13).
- Enforce identity confinement, with no configuration lifting it, on
an
Assertion, on aQueryResult, and on thesidof aChanged(§2.18). - Enforce membership scope, subject only to a per-source permission
covering memberships alone, and apply it to a source-asserted
primary_groupas well as to listed groups — promoting an unlisted one into the membership set before the test (§2.19, §2.13). - Never apply membership scope to a
primary_groupit substituted itself for an empty field (§2.13, §2.19). - Fail the logon on a malformed group SID, an unusable claim, a
canonical_namePGSS §2.15 forbids, or an out-of-range identifier (§2.13). - Place
primary_groupon the token even when the source did not list it amonggroups(§2.13). - Perform derivation itself, and never accept privileges, integrity, or a token from a source (§2.4).
- Never relay a refusal reason revealing a distinction the source was required not to make (§2.13).
2.22.1.5 Identifiers #
- Apply the source's base to every relative identifier it accepts, and never accept one already rebased (§2.20).
- Refuse a relative identifier of
0or one at or past the source's count, rather than clamping or wrapping it (§2.20). - 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 #
- 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).
- Never send more keys in one
Querythan the source'smax_batch, nor more than 64 whatever it declared (§2.8). - Resolve an absolute POSIX identifier to a source and a relative identifier itself, and never send an absolute one to a source (§2.15).
- Apply identity confinement, membership scope and numeric scope to a
QueryResultexactly as to anAssertion, and validate every SID in one structurally — including those of references inside a value (§2.15). - Rebase every identifier in a result, under the rules of §2.20 (§2.15).
- Qualify a source's
canonical_nameitself; never require a source to (§2.15). - Consult no further source on a
Refusedresult, treat onlyNotFoundas leave to continue, and never relay a source'sRefusedoutward as PGSS Logon'sRefusedoutcome (§2.15). - Never record a source it may not ask — one that declared no
QUERIES— as having answeredNotFound(§2.15). - Relay cursors opaquely, never construct or modify a source's, and never restart an enumeration on a source's behalf (§2.16).
- 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).
- Never infer from a source declining to enumerate that it holds nothing (§2.16).
2.22.1.7 Caching #
- Not cache a source's answers at all unless it declared
PUSHES_CHANGESor a non-zeroentry_ttl(§2.8, §2.17). - Not hold an answer beyond a declared
entry_ttl(§2.8). - Accept
Changedat any time afterRegistered, and never reply to it (§2.17). - Re-read through
Queryafter an invalidation, and never take a new value fromChanged(§2.17). - Treat the loss of a source's connection as
Allfor 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.
- Take
principalfrom the client's connected socket, never from a message body (§2.21). - Send
ChangeCredentialonly to the source authoritative forprincipal's domain, never offer the change to another source, and never send it to a source that did not declareCHANGES_CREDENTIALS(§2.21, §2.8). - Answer the client
AuthorityUnavailable, rather than refusing the account, where no registered source is authoritative forprincipaland a configured source is absent (§2.21). - End the client's conversation with
Internalon anAssertionin a change conversation or aCredentialChangedin 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.
- Never send
EnrollCredentialto a source that did not declareENROLLS_CREDENTIALS, and answer the clientPermissionDeniedwhere the source holding the principal did not (§2.23, §2.8). - Relay
CredentialRejectedonly from an enrolment conversation, and end any other conversation carrying it withInternal(§2.23, §2.13).
2.22.2 Source obligations #
A principal source MUST satisfy all of the following.
2.22.2.1 Connection #
- Connect to the authority; never listen for it (§2.3).
- Open with
Registeron conversation0, carrying its name and its domain (§2.8). - Send nothing else before receiving
Registered(§2.8). - Report itself ready — to an init system or equivalent — only after
Registered(§2.3). - Declare the same domain on every registration, for the life of the machine's configuration (§2.10).
- 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 #
- Reply on the conversation identifier it was given, and never invent one (§2.7).
- 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).
- Refuse an
Authenticate,ChangeCredential,QueryorEnumerateSourcearriving on conversation0(§2.7). - Bound the conversations it tracks itself, rather than relying on the authority's limit (§2.6).
- Refuse beyond that bound with
AuthorityUnavailable, rather than dropping silently (§2.11). - Discard conversation state on
Abandon, and not reply (§2.14).
2.22.2.3 Answering #
- Send exactly one terminal message per conversation:
AssertionorRefusalfor an authentication,CredentialChangedorRefusalfor a credential change (§2.13, §2.21). - Assert only principals within its declared domain (§2.18).
- Assert group SIDs and identifiers only, never attributes (§2.13).
- 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. - Never distinguish an unknown principal from a bad credential — by denial code, by reason, or by timing (§2.13).
2.22.2.4 Identifiers #
- Assert relative identifiers only, and never apply its own base (§2.20).
- Allocate from a single counter across every kind of object it holds, so that no number is issued twice (§2.20).
- Send
0for any object it does not number, including every group it does not own (§2.20). - 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.
- Declare only capabilities it implements, and answer every message type it declared (§2.8).
- Answer every key of a
Query, in order, without reordering, merging or omitting — or refuse the conversation whole (§2.15). - Send
Found,NotFoundorRefusedin a result, and neverUnavailableorMalformed(§2.15). - Return its own canonical spelling of a name, unqualified (§2.15).
- Return relative identifiers in a result, exactly as in an
Assertion(§2.15, §2.20). - Answer only for principals within its declared domain, on a query as on a logon (§2.18).
- Answer
Refused, never an emptyFound, 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). - Refuse a cursor it can no longer honour, rather than restarting or answering from a changed store (§2.16).
- Hold no per-cursor state it is unwilling to discard unasked (§2.16).
- 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).
- Send
Changedbefore the altered answer becomes observable, if it declaredPUSHES_CHANGES(§2.17). - Declare a non-zero
entry_ttlif 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 #
- Store verifiers that are not usable as credential material — nothing a challenge could be recomputed from (PGSS §2.11).
- Erase credential material, and every buffer it was decoded through, before that memory is released (PGSS §2.12).
- Never write credential material to a log, audit record, or diagnostic (PGSS §2.12).
2.22.2.7 Logon type restrictions #
- State a principal's
permitted_logon_typeswhere it holds the property, and send zero where it does not (§2.13). - 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).
- Never refuse an authentication on the strength of the restrictions it states. A source states; the authority decides (§2.4, §2.13).
- Answer
LOGON_TYPESfrom aQueryas 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.
- Change only the credential of
principal, and never take a principal from anything the client supplied (§2.21). - Establish that the principal can present their current credential
before changing it, and refuse with
AuthenticationFailedwhere they cannot (§2.21). - Never change a credential on the strength of a current credential that has changed since it was proved (§2.21).
- Refuse with
AccountRestricteda principal it holds no changeable credential for (§2.21). - Send
CredentialChangedonly once the new credential is the one its nextAuthenticatefor that principal will verify (§2.21).
2.22.2.9 Credential enrolment #
A source that declares ENROLLS_CREDENTIALS MUST also satisfy the
following.
- Add or remove credentials only of
principal, and never take a principal from anything the client supplied (§2.23). - For both adding and removing, establish that the principal can
present a credential they already have before changing anything,
refuse with
AuthenticationFailedwhere they cannot, and refuse withAccountRestricted, before asking, a principal it can ask for no such credential (§2.23). - 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). - Send
CredentialChangedonly once the change is durable, and, where it declaresPUSHES_CHANGES, only afterChanged(§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.
| Field | Encoding | Limit |
|---|---|---|
start | nested CredentialEnrollStart, length-framed | PGSS §2.23 |
principal | length-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_type | Message | Direction | Conversation | Defined in |
|---|---|---|---|---|
0x8001 | Register | source → authority | 0 | §2.8 |
0x0001 | Registered | authority → source | 0 | §2.8 |
0x0002 | Authenticate | authority → source | 1+ (opens) | §2.11 |
0x8002 | CredentialRequest | source → authority | 1+ | §2.12 |
0x0003 | CredentialResponse | authority → source | 1+ | §2.12 |
0x8003 | Assertion | source → authority | 1+ (terminal) | §2.13 |
0x8004 | Refusal | source → authority | 1+ (terminal) | §2.13 |
0x0004 | Abandon | authority → source | 1+ (terminal) | §2.14 |
0x0005 | Query | authority → source | 1+ (opens) | §2.15 |
0x8005 | QueryResult | source → authority | 1+ (terminal) | §2.15 |
0x0006 | EnumerateSource | authority → source | 1+ (opens) | §2.16 |
0x8006 | EnumerateResult | source → authority | 1+ (terminal) | §2.16 |
0x8007 | Changed | source → authority | 0 | §2.17 |
0x0007 | ChangeCredential | authority → source | 1+ (opens) | §2.21 |
0x8008 | CredentialChanged | source → authority | 1+ (terminal) | §2.21, §2.23 |
0x0008 | EnrollCredential | authority → source | 1+ (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 #
| Constant | Value | Defined in |
|---|---|---|
| Socket path | the implementation's choice | §2.6 |
| Magic | PPSI (50 50 53 49) | §2.7 |
| Version | 1 | §2.7 |
| Header size | 20 bytes | §2.7 |
| Maximum message size | 81920 bytes | §2.7 |
| Reserved conversation | 0 | §2.7 |
2.A.3 Field limits #
| Field | Maximum | Defined in |
|---|---|---|
source_name | 32 bytes | §2.8 |
domain | 68 bytes | §2.8 |
max_batch | 64 | §2.8 |
originator | 68 bytes | §2.11 |
principal | 68 bytes | §2.21, §2.23 |
user_sid | 68 bytes | §2.13 |
canonical_name | 256 bytes | §2.13 |
groups | 128 entries, each SID 68 bytes | §2.13 |
primary_group | 68 bytes | §2.13 |
claims | 64 entries | §2.13 |
claim name | 255 bytes | §2.13 |
claim values | 64 per claim | §2.13 |
| claim string value | 1024 bytes | §2.13 |
| claim octet value | 1024 bytes | §2.13 |
| claim SID value | 68 bytes | §2.13 |
reason | 512 bytes | §2.13 |
keys | 64 entries | §2.15 |
key name | 256 bytes | §2.15 |
results | 64 entries | §2.15 |
withheld | 32 entries | §2.15 |
values | 32 entries | §2.15 |
entries | 256 entries | §2.16 |
cursor, next | 256 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.
| Value | Name | Key is in |
|---|---|---|
| 0 | none | The key entry is absent |
| 1 | Name | name |
| 2 | Sid | sid |
| 3 | RelativeId | relative_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:
| Value | Name |
|---|---|
| 1 | Found |
| 2 | NotFound |
| 4 | Refused |
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.
| Value | Name |
|---|---|
| 1 | All |
| 2 | Object |
2.B.4 Capabilities #
Carried in Register.capabilities (§2.8) as a u32 bitmask.
| Bit | Name |
|---|---|
| 0 | QUERIES |
| 1 | ENUMERATES |
| 2 | MEMBERS |
| 3 | PUSHES_CHANGES |
| 4 | CHANGES_CREDENTIALS |
| 5 | ENROLLS_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 #
| Element | PGSS | Note |
|---|---|---|
LogonStart body | §2.7 | Nested whole inside Authenticate, never inlined (§2.7) |
CredentialRequest body | §2.8 | Byte-for-byte identical |
CredentialResponse body | §2.8 | Byte-for-byte identical |
profile body | §2.9 | Nested inside Assertion, relayed onward unchanged (§2.13) |
| Denial codes | §2.B | Reused by Refusal (§2.13) |
Lookup result body, present onward | §2.16 | Nested inside a QueryResult entry (§2.15) |
| Field bitmask | §2.B | Carried unchanged by Query (§2.15) |
| Object kinds | §2.B | Carried unchanged by a key entry (§2.15) |
| Lookup outcomes | §2.B | A source may send three of the five (§2.B) |
| Header layout, first 12 bytes | §2.6 | Same fields at the same offsets |
| Byte order, string encoding, length framing | §2.6 | See §2.7 |
| Extensibility rules | §2.6 | Append-only; new enum value is breaking |
| Credential-handling obligations | §2.12 | Bind sources too (§2.22) |
| Name rules | §2.15 | Bind 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 #
| Element | Defined 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 Logon | PSI | |
|---|---|---|
| Magic | PGSL | PPSI |
| Header | 12 bytes | 20 bytes |
| Maximum message | 65536 | 81920 |
| Conversations per connection | one | many |
| Connection lifetime | one logon | the source's lifetime |
High bit of msg_type | authority → client | source → authority |
| Success terminal | AccessGranted + token fd | Assertion — no session, no token |
| Socket path | normative | the implementation's choice |
Key type 3 | absolute POSIX identifier | relative 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.
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.
| Channel | Socket type | Direction | Section |
|---|---|---|---|
| Log ingestion | SOCK_DGRAM | producer to collector | §3.6 |
| Metric ingestion | SOCK_DGRAM | producer to collector | §3.9 |
| Query | SOCK_STREAM | request 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.
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,uint32oruint64; negative values use negative fixint,int8,int16,int32orint64. - 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 pattern0x7ff8000000000000. - 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.
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.
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 #
| Field | Type | Required | Meaning |
|---|---|---|---|
origin | string | yes | Non-empty name of the program that produced the line. |
is_error | bool | yes | True if the line came from standard error, or the producer marked it an error. False otherwise. |
message | string | yes | The log text — one line of output. MAY be empty, which is a blank line. |
timestamp | integer | no | When the line was produced, in the timestamp domain (§3.5). Absent means the collector uses its own clock at receipt. |
job_id | binary, 16 bytes | no | A 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 —
originan integer, say originis 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:
timestampis not an integer, is negative, or is outside the timestamp domain (§3.5)job_idis 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).
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.
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).
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.
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 #
| Field | Type | Required | Meaning |
|---|---|---|---|
name | string | yes | The metric name (§3.10). |
labels | map | no | Key-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. |
type | string | yes | Exactly "counter", "gauge" or "histogram", lowercase. |
timestamp | integer | no | When the measurement was taken, in the timestamp domain (§3.5). Absent means the collector uses its own clock at receipt. |
value | varies | yes | The 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:
| Field | Type | Meaning |
|---|---|---|
boundaries | array of number | Non-empty, finite bucket upper bounds, strictly increasing after conversion to binary64, in the order given. |
counts | array of integer | Cumulative count per boundary. Same length as boundaries. Non-decreasing, each no greater than total_count. |
total_count | integer | Number of observations. |
sum | number | Finite 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
typeis not exactly"counter","gauge"or"histogram"
Name and labels (§3.10)
nameis 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
timestampis 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
boundariesis empty- a boundary or
sumconverts to a non-finite binary64 countsandboundariesdiffer 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_countis zero and any count orsumis 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_PUBLISHforname(§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.
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.
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).
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:
| Offset | Size | Field | Value |
|---|---|---|---|
| 0 | 4 | length | Length of the payload in bytes |
| 4 | length | payload | The 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.
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:
| Field | Type | Required | Meaning |
|---|---|---|---|
query | string | yes | The 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.
status | Carries | Meaning |
|---|---|---|
"ok" | records | A chunk of result records. |
"end" | — | The non-streaming query is complete. |
"watch" | — | The streaming query's initial result set is complete. |
"error" | error | The 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.
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.
3.17 Value Encoding
Peios / Peios technical reference / PSPU / Observability Interfaces
Every value in a result record is encoded as follows.
| Value | Encoding |
|---|---|
| Integer | MessagePack integer |
| Float | MessagePack float64 |
| Timestamp | MessagePack integer, nanoseconds since the Unix epoch (§3.5) |
| String | MessagePack string |
| GUID | MessagePack string, PCDS canonical form |
| Binary | MessagePack bin |
| Boolean | MessagePack boolean |
| Array | MessagePack array |
| Absent or null | MessagePack 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.
EVENTSsearches structured event records, primarily by event type (§3.23).LOGSsearches log output, primarily by origin (§3.24).METRICevaluates 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:
| Clause | Meaning |
|---|---|
SINCE t | Lower time bound, inclusive. |
UNTIL t | Upper time bound, exclusive. Defaults to the evaluation time. |
WHERE p | Filter 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 n | Return at most n. |
SKIP n | Discard the first n after ordering. |
STREAM | Deliver 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 | |
|---|---|
| 1 | Cross-type conditions, producing time ranges (§3.26) |
| 2 | The primary selector |
| 3 | Access control on the primary and cross-type sources (§3.28) |
| 4 | SINCE and UNTIL |
| 5 | WHERE, including the ranges from phase 1 |
| 6 | ERROR ONLY and CONTAINING, as WHERE predicates (§3.24) |
| 7 | Metric transforms (§3.25) |
| 8 | GROUP |
| 9 | COUNT BY, TOP N BY, DISTINCT, and the aggregation functions |
| 10 | Metric window aggregations (§3.25) |
| 11 | SORT (§3.21) |
| 12 | SKIP and TAKE |
| 13 | SELECT (§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:
WHEREis repeatable. MultipleWHEREclauses are combined withAND, each treated as a parenthesised group:WHERE a == 1 OR b == 2followed byWHERE c == 3means(a == 1 OR b == 2) AND c == 3.SELECTis repeatable where it is valid at all, and is additive:SELECT event.time SELECT event.typenames 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.
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.
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.
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 #
| Literal | Meaning |
|---|---|
<duration> ago | That duration before the evaluation time. |
<duration> hence | That duration after it. |
today | Midnight of the current day, UTC. |
yesterday | Midnight of the previous day, UTC. |
YYYY-MM-DD | Midnight of that date, UTC. |
YYYY-MM-DDTHH:MM:SS | That 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 #
| Operator | Meaning | Operand types |
|---|---|---|
== | Equal | any |
!= | Not equal | any |
> >= < <= | Ordering | integer, float, timestamp |
STARTS_WITH | Prefix | string |
ENDS_WITH | Suffix | string |
CONTAINS | Substring | string |
HAS | Array contains an element | array |
IN | Member of a set | any |
NOT_IN | Not a member | any |
IS NULL | Absent or null | any |
IS NOT NULL | Present and not null | any |
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.
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:
- Null
- Boolean,
falsebeforetrue - Numeric, integers and floats compared mathematically (§3.20)
- String and GUID, ASCII-folded
- Binary, unsigned lexicographic
- 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:
| Group | Representative |
|---|---|
| Null | nil |
| Boolean | the boolean |
| Numeric | an integer if every contributing value was an integer; otherwise a float64 |
| String or GUID | the smallest original UTF-8 byte sequence among the members |
| Binary | the exact value |
| Array | the 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).
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:
| Alias | Value |
|---|---|
userspace | 0 |
kmes | 1 |
kacs | 2 |
lcs | 3 |
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:
| Query | Record |
|---|---|
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 #
COUNTreturns an unsigned integer.SUMover 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 afloat64. If that would be non-finite, the query MUST fail with an error rather than returning an infinity.AVGreturns afloat64whenever at least one numeric value contributed.MINandMAXreturn 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.
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:
- Transform —
RATE,DELTA,P50,P95orP99. Operates within each series independently and produces scalars. At most one per query. - 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
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 — aRATEwith 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
| Form | Available in |
|---|---|
WHERE METRIC … | events, logs |
WHERE EVENT … EXISTS | logs, metrics |
WHERE LOG … EXISTS | events, 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.
3.26.4 The lookback limit #
A collector MUST bound how far back a cross-type filter may scan.
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.
3.27.1 The shape of a streaming query #
- The collector executes the query normally and sends the initial
result set as
"ok"messages. - It sends
"watch"(§3.16). The query is established at this point and not before. - It stays open. As records are committed, it evaluates them against the query and sends those that match.
- 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.
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.
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.
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,DISTINCTand 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.
TAKEandSKIPpage 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.
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 #
| Bound | Mainline value | Mainline range | Key | Section |
|---|---|---|---|---|
| Log datagram ceiling | 262144 B | 262144 – 1048576 | MaxLogDatagramBytes | §3.6 |
| Metric datagram ceiling | 262144 B | 262144 – 1048576 | MaxMetricDatagramBytes | §3.9 |
| Receive queue, either socket | ≤ 4 × the ceiling | — | — | §3.6 |
3.A.2 Queries #
| Bound | Mainline value | Mainline range | Key | Section |
|---|---|---|---|---|
| Query request ceiling | 65536 B | 1024 – 16777216 | MaxQueryRequestBytes | §3.15 |
| Query response target | 65536 B | 1024 – 16777216 | QueryResponseTargetBytes | §3.15 |
| Query timeout | 30000 ms | 1000 – 300000 | QueryTimeoutMs | §3.16 |
| Concurrent queries | 128 | 1 – 4096 | MaxConcurrentQueries | §3.14 |
| Concurrent streaming queries | 64 | 1 – 1024 | MaxStreamingQueries | §3.14 |
| Concurrent queries per caller | 16 | 1 – 4096 | MaxQueriesPerUser | §3.14 |
| Memory all running queries hold together | 268435456 B | 16777216 – 4294967295 | MaxQueryHeldBytes | §3.16 |
| Values per DISTINCT stream | 100000 | 1000 – 10000000 | MaxDistinctStreamValues | §3.27 |
3.A.3 Cross-type filtering #
| Bound | Mainline value | Mainline range | Key | Section |
|---|---|---|---|---|
Existence window W | 15000 ms | 1000 – 300000 | CrossTypeWindowMs | §3.26 |
| Maximum lookback | 604800 s | 3600 – 2592000 | CrossTypeMaxLookbackSeconds | §3.26 |
3.A.4 Fixed by this chapter #
These are not configuration and a collector MUST NOT vary them.
| Quantity | Value | Section |
|---|---|---|
| Timestamp domain | 0 – 9223372036854775807 ns | §3.5 |
| GUID field width | 16 bytes | §3.7, §3.11 |
| Message length prefix | 4 bytes, little-endian | §3.15 |
| Maximum response payload | 2^32 - 1 bytes | §3.15 |
| Transforms per query | at most 1 | §3.25 |
| Terminal aggregations per query | at most 1 | §3.25 |
| Queries per connection | exactly 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 #
| Clause | EVENTS | LOGS | METRIC | Section |
|---|---|---|---|---|
SINCE / UNTIL | yes | yes | yes | §3.19 |
WHERE | yes | yes | yes | §3.20 |
WHERE METRIC | yes | yes | no | §3.26 |
WHERE EVENT … EXISTS | no | yes | yes | §3.26 |
WHERE LOG … EXISTS | yes | no | yes | §3.26 |
SORT | yes | yes | yes | §3.21 |
TAKE / SKIP | yes | yes | yes | §3.18 |
SELECT | non-aggregating only | non-aggregating only | no | §3.22 |
COUNT BY / TOP N BY | yes | yes | no | §3.23 |
DISTINCT | yes | yes | no | §3.23 |
GROUP + function | yes | yes | no | §3.23 |
STREAM | yes | yes | no | §3.27 |
ERROR ONLY / CONTAINING | no | yes | no | §3.24 |
INDEX | yes | no | no | §3.23 |
WHERE and SELECT are the only repeatable clauses (§3.18).
3.B.3 Combinations that are rejected #
| Combination | Rejected at | Section |
|---|---|---|
SELECT with COUNT BY, TOP N BY, DISTINCT or GROUP | parse | §3.22 |
SELECT in metric mode | parse | §3.22 |
STREAM with COUNT BY, TOP N BY or GROUP | parse | §3.27 |
STREAM with UNTIL | parse | §3.27 |
DISTINCT … STREAM with SORT, TAKE or SKIP | parse | §3.27 |
Window aggregation without SINCE | parse | §3.25 |
| Scalar and window aggregation together | parse | §3.25 |
| Two transforms | parse | §3.25 |
Cross-type filter without SINCE | parse | §3.26 |
= outside a label selector | parse | §3.20 |
== NULL or != NULL | parse | §3.19 |
| Ordering operator on a binary literal | parse | §3.20 |
| Ordering operator on a fixed field that cannot be ordered | parse or planning | §3.20 |
| Unknown log field name | parse | §3.24 |
| Effective range beyond the lookback limit | planning | §3.26 |
| Selected metric series spanning more than one type | execution | §3.25 |
RATE or DELTA on a gauge or histogram | execution | §3.25 |
| Percentile on a counter or gauge | execution | §3.25 |
| Histogram series with no percentile function | execution | §3.25 |
Unbracketed metric query with SINCE resolving to several series | execution | §3.25 |
| Cross-type metric selector resolving to several series | execution | §3.26 |
| Aggregation producing a non-finite value | execution | §3.23, §3.25 |
| A sort or aggregation needing more memory than the collector allows queries | execution | §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 #
| Keyword | Stage | Valid on | Produces |
|---|---|---|---|
RATE | transform | counter | per-second change |
DELTA | transform | counter | absolute change |
P50 P95 P99 | transform | histogram | one value per sample |
AVG MIN MAX SUM | scalar aggregation | counter, gauge | one value |
AVG_OVER MIN_OVER MAX_OVER SUM_OVER | window aggregation | counter, gauge | one 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 #
| Kind | Form | Section |
|---|---|---|
| Identifier | [A-Za-z_][A-Za-z0-9_.-]* | §3.19 |
| String | "…" with \" \\ \n \r \t \uXXXX | §3.19 |
| Binary | x"0a1b…", even digit count | §3.19 |
| Integer | decimal or 0x… | §3.19 |
| Float | finite, with a fraction or exponent | §3.19 |
| Boolean | true, false | §3.19 |
| Null | NULL, 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 |
| GUID | 8-4-4-4-12, braced or not | §3.19 |
3.B.7 Fields #
| Mode | Fixed fields | Everything else |
|---|---|---|
| EVENTS | event.time event.sequence event.cpu event.type event.boot.guid emitter.class emitter.token.guid emitter.true-token.guid emitter.process.guid | a flattened payload path, or null |
| LOGS | timestamp origin is_error message boot_id job_id | a parse error |
| METRIC | timestamp boot_id name type value overflow | a 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 #
| Mode | Without SORT |
|---|---|
| EVENTS | event.time descending |
| LOGS | timestamp descending |
| METRIC | timestamp ascending |
COUNT BY, TOP N BY, GROUP … COUNT | count descending |
GROUP … SUM, AVG, MIN, MAX | group keys ascending in the order named |
DISTINCT | by 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 #
| Concern | Where it is specified |
|---|---|
| Event emission and the ring-buffer transport | PSPK |
| Event types and payload schemas | the emitting subsystem's own documentation |
| Tokens, SIDs and Security Descriptors | PCDS, and the Peios Kernel TRM |
| Forwarding a service's output | the peinit TRM |
| Storage, indexing, retention, query planning | the 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.
| Control | Notification | |
|---|---|---|
| Socket type | SOCK_STREAM | SOCK_DGRAM |
| Who connects | The client | Nobody; a service sends |
| Addressing | A fixed path | A path given to each service |
| Direction | Request and response | One-way |
| Framing | Newline-delimited JSON | KEY=VALUE lines |
| Identity | The peer's token, at connect | The sender's kernel-attested PID |
| Authorisation | An access check per command | Membership: is the sender this service? |
| Loss | None. A stream, or an error | Possible. A datagram may be dropped |
| Ordering | Guaranteed within a connection | Not 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.
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,falseandnullare 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.
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:
| Right | Bit | Grants |
|---|---|---|
SERVICE_QUERY_STATUS | 0x0001 | Query the service's state and detail. |
SERVICE_START | 0x0002 | Start the service. |
SERVICE_STOP | 0x0004 | Stop the service. |
SERVICE_INTERROGATE | 0x0008 | Reload the service. |
SERVICE_ALL_ACCESS | 0x000F | All 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:
| Right | Bit | Grants |
|---|---|---|
SYSTEM_SHUTDOWN | 0x0001 | Initiate a shutdown. |
SYSTEM_RELOAD_CONFIG | 0x0002 | Re-read the configuration. |
SYSTEM_QUERY_STATUS | 0x0004 | Ask 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 right | Maps to |
|---|---|
GENERIC_READ | SERVICE_QUERY_STATUS |
GENERIC_WRITE | SERVICE_START | SERVICE_STOP | SERVICE_INTERROGATE |
GENERIC_EXECUTE | SERVICE_START | SERVICE_STOP | SERVICE_INTERROGATE |
GENERIC_ALL | SERVICE_ALL_ACCESS |
For the manager's descriptor:
| Generic right | Maps to |
|---|---|
GENERIC_READ | SYSTEM_QUERY_STATUS |
GENERIC_WRITE | SYSTEM_RELOAD_CONFIG |
GENERIC_EXECUTE | SYSTEM_SHUTDOWN |
GENERIC_ALL | SYSTEM_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 #
| Command | Right required |
|---|---|
start | SERVICE_START |
stop | SERVICE_STOP |
restart | SERVICE_START and SERVICE_STOP |
reload | SERVICE_INTERROGATE |
reset | SERVICE_STOP |
status | SERVICE_QUERY_STATUS |
list | Evaluated per service; see below |
operation-status | SERVICE_QUERY_STATUS on the operation's target |
job-status | JOB_QUERY on the job |
job-list | Evaluated per job; see below |
job-stop | JOB_STOP on the job |
shutdown | SYSTEM_SHUTDOWN |
reload-config | SYSTEM_RELOAD_CONFIG |
boot | SYSTEM_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 #
- 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_STATEreceived during shutdown. - 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. - 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).
- 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. - 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.
4.8.1 Fields #
| Field | Type | Required | Meaning |
|---|---|---|---|
command | string | always | The command to run. §4.11, §4.14, §4.15 |
service | string | for service commands | The target service's name. |
wait | bool | no | Whether to block until the operation resolves. §4.13 |
type | string | for shutdown | poweroff, reboot or halt. |
operation_id | string | for operation-status | The operation to report on. |
job_id | string | for job-status, job-stop | The job. |
submitter | string | no | For job-list: only jobs with this submitter SID. |
identity | string | no | For job-list: only jobs whose identity is this SID. |
logon_session | integer | no | For job-list: only jobs whose identity is in this logon session. |
state | string | no | For 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.
| Field | Type | Meaning |
|---|---|---|
operation_id | string | The operation to observe. |
service | string | The target. |
state | string | The service's state when the response was formed. §4.B |
cause | string or null | Why the service last transitioned. §4.B |
warnings | array of strings | Human-readable warnings. Often empty. |
mode | string | For 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:
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 #
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).
| Code | Meaning | Closes? |
|---|---|---|
MALFORMED_REQUEST | The frame is not a single well-formed JSON object. §4.5 | On an empty frame |
REQUEST_TOO_LARGE | The request exceeds the configured maximum. §4.4 | Yes |
INVALID_COMMAND | command is absent, not a string, or names no known command. | No |
INVALID_ARGUMENTS | A field the command requires is absent or malformed. | No |
UNKNOWN_SERVICE | The named service has no definition the manager can act on. | No |
UNKNOWN_OPERATION | The operation identifier names nothing the manager holds — it never existed, or its retention has elapsed. §4.14 | No |
UNKNOWN_JOB | The job identifier names nothing the manager holds — it never existed, or its retention has elapsed. §7.7 | No |
ACCESS_DENIED | The access check denied the requested right. §4.7 | No |
INVALID_STATE | The command is not valid for the service's current state (§4.12), or the manager is shutting down (§4.15). | No |
OPERATION_TIMEOUT | A wait=true request's operation did not reach a terminal state in time. §4.13 | No |
INTERNAL_ERROR | The 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.
| Command | Effect | Default wait |
|---|---|---|
start | Start the service. | true |
stop | Stop the service, escalating if it does not exit. | true |
restart | Stop then start, under one operation. | true |
reload | Tell the service to re-read its configuration. | false |
reset | Clear 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 #
| Command | The operation completes when |
|---|---|
start | The service reaches a dependent-satisfying state, or a state indicating its start-time conditions did not apply. |
stop | The service is no longer running. |
restart | The service reaches its normal successful start target after the restart. |
reload | The reload resolves, whatever its mode. |
reset | Immediately. |
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.
| inactive | starting | active | reloading | stopping | completed | backoff | failed | abandoned | skipped | |
|---|---|---|---|---|---|---|---|---|---|---|
start | act | merge | already | already | queue | act | defer | act | invalid | act |
stop | noop | cancel + act | act | act | merge | clear | cancel | noop | invalid | noop |
restart | act | queue | act | act | queue | act | act | act | invalid | act |
reload | invalid | invalid | act | merge | invalid | invalid | invalid | invalid | invalid | invalid |
reset | noop | invalid | invalid | invalid | invalid | invalid | invalid | clear | clear | clear |
status | ok | ok | ok | ok | ok | ok | ok | ok | ok | ok |
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:
startdefers, as above, and honours the remaining delay.stopcancels both the pending restart and any deferred start, and the service becomes inactive.restartcancels the automatic restart and performs a caller-initiated one.reloadandresetare 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 #
| Ending | Response |
|---|---|
| The operation reaches a terminal state | The acknowledgement shape. |
| The operation's lifetime expires | OPERATION_TIMEOUT. |
| The operation is no longer held by the manager | UNKNOWN_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:
| Value | Meaning |
|---|---|
confirmed | The service acknowledged the reload by signalling readiness. The reload demonstrably happened. |
advisory | The manager issued the reload and the service did not acknowledge it. The reload probably happened; nothing confirms it. |
failed | The 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.
| Field | Type | Meaning |
|---|---|---|
display_name | string or null | The definition's human-readable name. |
description | string or null | The definition's human-readable description. |
state | string | §4.B. |
cause | string or null | Why the service last transitioned. §4.B. |
status_text | string or null | The most recent status string the service sent (§4.19). |
progress | object or null | The most recent progress the service sent (§4.19), in the form §7.7 gives. |
current_job | object or null | The current main job, or null if none. |
current_operation | object or null | The current operation, or null if none. |
health | string or null | healthy, unhealthy, unknown, or null when the service has no health check configured. |
uptime_seconds | integer or null | Whole seconds since the current job started. Null when nothing is running. |
definition_removed | bool | True while the service's definition has been withdrawn and an instance is still draining (§4.12). |
warnings | array of objects | Conditions worth an operator's attention. |
timers | array of objects | The service's calendar timer triggers, as the manager has them armed. Empty when it has none. |
granted | array of strings | The 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:
| Field | Type | Meaning |
|---|---|---|
path | string | What the warning is about. |
type | string | The kind of warning. §4.B. |
detected_at | string | When 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:
| Field | Type | Meaning |
|---|---|---|
schedule | string | The calendar expression, as the trigger gives it. |
scheduled_at | string or null | The schedule's next occurrence. §4.5. |
fires_at | string or null | When the timer will fire: that occurrence, delayed by the jitter drawn for it. Equal to scheduled_at without jitter. §4.5. |
last_fired_at | string or null | When it last fired. Null when it has not fired since the manager started and no earlier firing is recorded. §4.5. |
not_armed | string or null | Why 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.
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.
| Field | Meaning | Present when |
|---|---|---|
id | The operation's identifier. | Always. |
type | §4.B. | Always. |
service | The target. | Always. |
source | Why the manager created it. §4.B. | Always. |
state | §4.B. | Always. |
result | The resulting service state. | completed. |
error | Why it did not complete. | failed, cancelled, aborted. |
merged_into | The surviving operation's identifier. | merged. |
requested_at | When it was created. | Always. |
started_at | When it began executing. | Once running. |
completed_at | When 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.
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:
| Field | Type | Meaning |
|---|---|---|
granted | array of strings | The job rights the caller holds on this job. Never null. |
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.
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.
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.
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 #
type MUST be one of:
| Value | Meaning |
|---|---|
poweroff | Stop everything and remove power. |
reboot | Stop everything and restart the machine. |
halt | Stop 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.
| Field | Type | Meaning |
|---|---|---|
added | array of strings | Services that did not exist before. |
updated | array of strings | Services whose definition changed. |
restored | array of strings | Services whose withdrawal was reversed. |
marked_removed | array of strings | Services whose definition is gone but which are still running. |
discarded | array of strings | Services removed outright. |
warnings | array of strings | Human-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.
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.
| Field | Type | Meaning |
|---|---|---|
mode | string | The mode the machine booted in, after any downgrade. §4.B |
reason | string | Why it is in that mode. §4.B |
downgrade | array of strings | What forced a downgrade, one human-readable finding each. Empty unless reason is safe_mode_downgrade. |
attempts | integer | The boot attempt count as the manager checked it against the threshold at the start of this boot. |
max_attempts | integer | The threshold. 0 means the check is off and the count never sends the machine to recovery. |
confirmed | bool | This boot has been confirmed, and the count has been put back to 0. |
grace_seconds | integer | The grace period, in seconds. |
waiting_on | array of strings | The critical services not yet holding a dependent-satisfying state. Empty once the grace period has ended. |
confirms_at | string or null | When 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_error | string or null | Why 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:
| Situation | Effect on the datagram | Effect on the line |
|---|---|---|
| A malformed line | Rejected entirely | — |
| An unrecognised key | Applied normally | Ignored |
| A recognised key with an unexpected value | Applied normally | Ignored |
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:
- 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.
- That job has exec'd and is running. A job still in setup has not become the service yet.
- 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.
- The handle still refers to the attested PID. The manager MUST verify the PID against the handle rather than trusting the PID alone.
- 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 #
| Field | Value | Meaning |
|---|---|---|
READY | 1 | Startup is complete and the service is serving. |
RELOADING | 1 | Configuration reload has begun. |
STOPPING | 1 | Graceful shutdown has begun. |
LEVEL | free text | A 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 #
| Field | Value | Meaning |
|---|---|---|
WATCHDOG | 1 | A keepalive. |
WATCHDOG_USEC | unsigned integer | Change the expected keepalive interval, in microseconds. |
EXTEND_TIMEOUT_USEC | unsigned integer | Extend 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 #
| Field | Value | Meaning |
|---|---|---|
STATUS | free text | A human-readable statement of what the service is doing. |
PROGRESS | see below | How far the service has got. |
PROGRESS_UNIT | bytes, items or percent | What PROGRESS counts. |
ERRNO | free text | An errno-style error number. |
EXIT_STATUS | free text | An 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 /:
| Form | Meaning | A viewer shows |
|---|---|---|
N | Counting, 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/T | N 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 #
| Field | Value | Meaning |
|---|---|---|
FDSTORE | 1 | Store the descriptors attached to this datagram. |
FDNAME | free text | The name to store or remove them under. |
FDSTOREREMOVE | 1 | Remove the descriptors stored under FDNAME. |
FDPOLL | 0 | Do not monitor the stored descriptors for error conditions. |
§4.20.
4.19.5 Fields that are not supported #
| Field | Why |
|---|---|
MAINPID | A 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. |
BUSERROR | Peios 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:
- If the store is disabled for this service, close the descriptors and record the rejection.
- 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.
- Store them under the value of
FDNAMEif present and non-empty, and under the namestoredotherwise. - Note
FDPOLL=0if 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:
- Placed consecutively, starting at descriptor 3, with close-on-exec cleared.
LISTEN_FDSset to the number of descriptors passed.LISTEN_FDNAMESset to the names, colon-separated, in the same order as the descriptor numbers.LISTEN_PIDset to the new process's own PID.- 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.
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 #
| Bound | Value | Configurable | Defined in |
|---|---|---|---|
| Socket path | /run/services/peinit/control.sock | No | §4.4 |
| Concurrent connections per caller | 16; SYSTEM exempt | Yes | §4.4 |
| Concurrent connections in all | 256 | Yes | §4.4 |
| Request size | 65536 bytes, excluding the terminating newline | Yes | §4.4 |
| Idle timeout | 30 seconds | Yes | §4.4 |
| Listen backlog | 32 | No | §4.4 |
4.A.2 Operations #
| Bound | Value | Defined in |
|---|---|---|
| Terminal record retention | 60 seconds | §4.14 |
| Operation lifetime | The target service's own start or stop timeout | §4.11 |
4.A.3 Notification channel #
| Bound | Value | Defined in |
|---|---|---|
| Maximum datagram | 65536 bytes | §4.16 |
| Descriptors per datagram | 64 | §4.16 |
| First returned descriptor | 3 | §4.20 |
| Descriptor store maximum | Per service; 0 disables | §4.20 |
| Timeout extension cap | 4 × the phase's base timeout | §4.19 |
| Progress event rate | At 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 #
| Value | Process? | Satisfies dependents? |
|---|---|---|
inactive | No | No |
starting | Maybe | No |
active | Yes | Yes |
reloading | Yes | Yes |
stopping | Briefly | No |
completed | No | Yes |
backoff | No | No |
failed | No | No |
abandoned | Yes, unkillably | No |
skipped | No | Yes |
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 #
| Value | Terminal? | Meaning |
|---|---|---|
pending | No | Queued, not yet executing. |
running | No | Executing. |
completed | Yes | Reached its goal. |
failed | Yes | Did not reach its goal, or expired while queued. |
merged | Yes | Merged into another operation. |
cancelled | Yes | Terminated while pending. Never executed. |
aborted | Yes | Terminated 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 #
| Value | Meaning |
|---|---|
normal | A full boot, which is what a boot is unless something says otherwise. |
requested | The boot was asked for in this mode, on the kernel command line or its equivalent. |
safe_mode_downgrade | A 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.
| Role | Obligation |
|---|---|
| Producer | Builds package files. Everything a .peipkg contains is a producer obligation. |
| Repository | Publishes a descriptor, two indexes, and package files over static HTTP, and signs the metadata. |
| Consumer | Fetches, 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.jsoninside 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.jsoncarrying 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.
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:
| Suffix | Content |
|---|---|
-doc | Documentation, man pages, examples |
-debug | Debug symbols |
-dev | Headers, static libraries, build-time dependencies |
-source | Corresponding 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.
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.
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:
- 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.
- Split the remainder at the last hyphen: what follows is the peios revision, what precedes is the upstream version.
- The peios revision MUST parse as a positive integer.
- 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:
- Compare epochs as integers. If they differ, the higher epoch is greater.
- If equal, compare upstream versions by the algorithm below.
- If equal, compare peios revisions as integers. The higher revision is greater.
- 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:
- The non-alphanumeric characters
.,+,-, and~are separators and belong to no segment. - A maximal run of digits forms a numeric segment.
- A maximal run of letters forms an alphabetic segment.
- 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.
| Upstream | Segments |
|---|---|
1.26.2 | 1, 26, 2 |
1.0.0-rc.1 | 1, 0, 0, rc (pre), 1 (pre) |
1.0~rc1 | 1, 0, rc (pre), 1 (pre) |
16beta1 | 16, beta (pre), 1 (pre) |
5.6.3 Pre-release rank #
| Token | Rank |
|---|---|
dev | 0 |
alpha | 1 |
a | 1 |
beta | 2 |
b | 2 |
pre | 3 |
rc | 4 |
| any other alphabetic token | 5 |
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:
- Both numeric — compare as integers. Leading zeros are insignificant.
- 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.
- 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.)
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 longer | Result |
|---|---|
| a pre-release segment | the shorter is greater |
| anything else | the shorter is less |
| Example tail | Result | |
|---|---|---|
~1 | numeric, pre-release | the shorter is greater |
~rc | alphabetic, pre-release | the shorter is greater |
.1 | numeric | the shorter is less |
-foo | alphabetic, rank 5 | the shorter is less |
5.6.6 Worked examples #
| A | B | Result | Why |
|---|---|---|---|
1.0~2 | 1.0-2 | A < B | the pre-release flag decides before the kinds |
1.0~foo | 1.0-foo | A < B | the same, for two rank-5 tokens |
1.0 | 1.0 | A = B | identical |
1.0 | 2.0 | A < B | numeric segment differs |
1.10 | 1.9 | A > B | numeric, not lexical |
1.0 | 1.0.1 | A < B | longer continues numerically |
1.0 | 1.0-rc.1 | A > B | longer continues with a pre-release |
1.0-rc.1 | 1.0-rc.2 | A < B | numeric segment within the tail |
1.0-alpha | 1.0-beta | A < B | rank 1 < rank 2 |
1.0-rc | 1.0-pre | A > B | rank 4 > rank 3 |
1.0a1 | 1.0a2 | A < B | numeric within a concatenated tail |
1.0a1 | 1.0b1 | A < B | rank 1 < rank 2 |
1.0~rc1 | 1.0 | A < B | the tilde forces a pre-release |
1.0~1 | 1.0 | A < B | the tilde forces a pre-release, numeric or not |
5.2~20240101 | 5.2 | A < B | a dated snapshot precedes its release |
0:1.0 | 1:0.5 | A < B | epoch dominates |
1.0-1 | 1.0-2 | A < B | peios revision differs |
1.0-foo-1 | 1.0-1 | A > B | foo 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 #
| Operator | Meaning |
|---|---|
= | 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.
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 #
| Identifier | Meaning |
|---|---|
x86_64 | 64-bit x86 (AMD64, Intel 64) |
aarch64 | 64-bit ARM (ARMv8-A or later) |
noarch | architecture-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
| Identifier | Triplet |
|---|---|
x86_64 | x86_64-linux-peios |
aarch64 | aarch64-linux-peios |
noarch has no triplet form, and architecture-independent payload MUST
NOT be installed under an arch-namespaced path.
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
noarchMAY be installed on any system. - A package whose architecture is neither MUST NOT be installed.
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.
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).
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.
- 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. - Every entry's modification time MUST equal the value of
build.timestampin the manifest (§5.18), which MUST NOT carry sub-second precision. - Every entry's owner numeric ID and group numeric ID MUST be 0.
- Every entry's owner name and group name MUST be the string
root. - 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.
- Entry permission bits MUST be
0777for every entry, with the setuid and setgid bits cleared (§5.16). - PAX extended header records, when present, MUST appear in a fixed
canonical order:
pathfirst, thenlinkpathif present, then any other record sorted by record name lexicographically. - The tar header magic MUST be
ustar\0and the version MUST be00. - The
devmajoranddevminorheader fields MUST be 0 for every entry type this specification permits — none of which is a device entry. - Header padding bytes MUST be NUL (
0x00). - PAX global header records (typeflag
g) MUST NOT appear. - PAX extended header records (typeflag
x) MUST appear only when an entry'spathexceeds the ustar 100-byte limit, in which case apathrecord is emitted, or itslinknameexceeds that limit, in which case alinkpathrecord is emitted. A record with any other key MUST NOT be emitted. - A path exceeding the ustar 100-byte limit MUST be carried by a
pathrecord. The ustarprefixfield MUST NOT be used to split such a path acrossprefixandname. - 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.
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 path | Purpose | Section |
|---|---|---|
.peipkg/manifest.json | Authoritative package metadata | §5.18 |
.peipkg/files.json | Per-file integrity manifest | §5.25 |
.peipkg/signature | Inline 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:
.peipkg/manifest.json.peipkg/files.json- Any optional metadata entries, sorted lexicographically by path
- All payload entries, sorted lexicographically by path (§5.11 rule 1)
.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.
5.12.5 Permitted entry types #
A payload entry MUST be one of:
- a regular file (typeflag
0or\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.
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.
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).
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 #
| Path | Purpose |
|---|---|
/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-release | The 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).
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.
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:
| Category | Path | Addressed 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.
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.
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:
- has no target — the path without the suffix is not a payload entry of the same package, or is a directory or symlink entry;
- is not exactly 3310 bytes, or its first byte is not
0x01; or - has a target whose first four bytes are
\x7fELF. An ELF file carries its signature in its.peios.sigsection; 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.
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.
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).
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.
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 #
5.18.2 Required fields #
| Field | Type | Description |
|---|---|---|
schema_version | integer | MUST be 1 in this version. |
name | string | Package name conforming to §5.3. |
version | string | Version conforming to §5.5. |
architecture | string | Architecture identifier conforming to §5.8. |
dependencies | array | Required dependencies. MAY be empty; MUST be present. |
conflicts | array | Conflicting packages. MAY be empty; MUST be present. |
size_installed | integer | Total size in bytes of the installed payload. |
build | object | Build 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 #
| Field | Type | Description | Absent means |
|---|---|---|---|
description | string | One-line human-readable description. | empty string |
license | string | SPDX identifier or expression. | empty string |
license_class | string | Which of the closed set of licence classes (§5.a2) the package's terms fall into: unknown, free, firmware or proprietary. | unknown |
homepage | string | URL of the upstream project. | empty string |
default_root | string | The root a top-level install of this package lands in when the operator names none (§5.19). | the operator's current root |
special_system_package | boolean | Declares the package exempt from the §5.14 layout rules at production time (§5.14). | false |
alternate_upgrade | object | Declares 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_dependencies | array | Dependencies that enhance but are not required. | empty array |
provides | array | Virtual names this package satisfies. | empty array |
replaces | array | Packages this one supersedes. | empty array |
side_effects | array | Maintenance operations to invoke (§5.24). | empty array |
sd_overrides | array | Per-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 #
| Field | Required | Description |
|---|---|---|
message | yes | Text 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:
-
A request that names the package —
installorupgradewith 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, itsmessageverbatim, and this warning:Alternate upgrade paths may bypass normal peipkg protections; ensure you fully trust the authors of the package before running.
-
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.
-
A consumer MUST provide a deliberate, per-invocation override (
--bypass-alternate-upgradein 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.
5.18.5 The build object #
| Field | Required | Description |
|---|---|---|
timestamp | yes | RFC 3339 timestamp of the build. MUST be UTC, MUST end with Z, and MUST NOT carry sub-second precision. |
farm_id | yes | Identifier of the build farm that produced this package. |
source_ref | yes | Reference to the build inputs, sufficient to reproduce the build. |
source_package | no | Name of the corresponding-source package produced from the same recipe and inputs (§5.15). |
recipe_ref | no | VCS identity of the recipe tree the build ran from — for example git:<commit>, suffixed +dirty when the work tree held uncommitted changes. |
builder | no | Identity 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
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.
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.
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.
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.
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.
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:
| Field | Description |
|---|---|
path | Payload-relative path, matching a tar entry exactly. |
sd | Base64-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:
- 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.
- 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.
- 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.
- 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.
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 functionoptional_dependencies— packages that enhance functionality but are not requiredconflicts— packages that must not be installed alongside this oneprovides— virtual names this package satisfies on behalf of dependencies declared elsewherereplaces— packages this one supersedes
5.21.1 Dependency entries #
An entry in dependencies or optional_dependencies:
| Field | Required | Description |
|---|---|---|
name | yes | The depended-on package name (§5.3) or virtual name (§5.4). |
constraint | no | A version constraint per §5.7. Absent means any version satisfies. |
arch | no | An architecture qualifier. Default any. |
root | no | A root reference (§5.19) naming the root this dependency is placed and satisfied in. Absent means the same root as the depending package. |
claims | no | Claim 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.
5.21.4 Provides entries #
| Field | Required | Description |
|---|---|---|
name | yes | The virtual name provided, conforming to §5.4. |
version | no | The 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. |
claims | no | Filesystem 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.
The provides relation does not flow transitively: providing
smtp-server does not provide whatever smtp-server itself provides.
5.21.5 Replaces entries #
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.
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:
- The candidate's name equals the dependency's
name, or the candidate has aprovidesentry whose name equals it. - If the dependency carries a
constraint, the version satisfies it — the candidate's own version when matched by name, and the matchingprovidesentry's version when matched throughprovides. - The candidate's architecture satisfies the
archqualifier. - 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.
| Capability | Virtual name | Version |
|---|---|---|
| Shared library | The ELF soname, verbatim — libssl.so.3. | None by default. |
| ELF symbol-version node | elfver(<soname>:<token>), for example elfver(libstdc++.so.6:GLIBCXX_3.4.30). | None: the whole name is an exact capability. |
| pkg-config module | pkgconfig(<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.
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
nameof aprovidesor dependency entry carrying aclaimsfield. - 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.
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":
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
pathand MUST NOT containtarget. A consumer declares only where it expects the name; it supplies no implementation. - On a
providesentry — the provider side — each slot descriptor MUST containtargetand MAY containpath. 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":
// the provider's manifest
"provides":
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.
/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
pathdeclared for that slot by an installed consumer, and - the
pathdeclared for that slot by the holder's ownprovidesentry, 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.
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":
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.
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/.
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.
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.
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 #
| Field | Description |
|---|---|
schema_version | MUST be 1 in this version. |
algorithm | MUST be sha256 in this version. |
entries | One entry per regular-file payload entry. |
| Entry field | Description |
|---|---|
path | Payload-relative path, identical to the corresponding tar entry path. |
size | Size in bytes of the file's content. |
hash | Lowercase 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.
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.
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:
- Compute the SHA-256 of the downloaded
.peipkgfile. - Compare it against the hash recorded in the repository index (§5.33). If they differ, the package is corrupted or substituted; abort.
- Verify the inline signature (§5.30). If verification fails, the package's authenticity is unproven; abort.
- 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.
- Read
.peipkg/manifest.jsonand verify it against §5.18. - Read
.peipkg/files.jsonand verify it against §5.25, including the two-way coverage check and thesize_installedequality of §5.18. - For each payload entry, compute its content hash and compare it against the files manifest. If any file's hash does not match, abort.
- 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.
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.
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_compressedby more than the lesser of 1% or 16 MiB; and - the cumulative decompressed bytes produced do not exceed
size_installedplus 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.
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.
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/signatureitself; - the two trailing zero blocks that terminate a tar archive;
- any compression artifact — signing operates on the uncompressed tar bytes.
5.28.3 The envelope #
| Field | Description |
|---|---|
schema_version | MUST be 1 in this version. |
algorithm | MUST be ed25519 in this version. |
key_fingerprint | Fingerprint of the public key (§5.29). Lowercase hex, 64 characters. |
signature | The 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.
5.28.5 Signing procedure #
To sign a package, a producer:
- Constructs every tar entry except
.peipkg/signature. - Serialises them as an uncompressed tar byte stream in archive order.
- Computes the SHA-256 of that stream.
- Signs the resulting 32-byte hash with its Ed25519 private key, per RFC 8032.
- Constructs the envelope with the signature value and key fingerprint.
- Appends the
.peipkg/signatureentry — header, JSON content, and padding — to the tar byte stream. - Compresses the complete stream to produce the
.peipkgfile.
Note that the Ed25519 message is the 32-byte SHA-256 digest, not the tar stream itself. A verifier MUST do the same.
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.
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.
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.
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.
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:
- Decompress the
.peipkgfile to the uncompressed tar bytes. - 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. - Stop at that entry. What has been accumulated is the signed byte range of §5.28.
- Parse the content of
.peipkg/signatureas the signature envelope. - Validate the envelope's
schema_versionandalgorithm. Reject if either is unrecognised, naming the version mismatch where that is the cause. - Look up the public key by
key_fingerprintin the trust set scoped to the originating repository (§5.29). If no matching key is in that trust set, reject. - 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.
- Compute the SHA-256 of the signed bytes.
- 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.
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:
- The package contains no
.peipkg/signatureentry and the trust policy for its originating repository requires signed packages. - The
.peipkg/signatureentry is not the last named entry in the archive. - The envelope does not parse, or carries an unknown or duplicate field.
- The envelope's
schema_versionis not 1. - The envelope's
algorithmis not recognised. - The envelope's
key_fingerprintmatches no key in the trust set scoped to the originating repository. - The matching key's status does not permit verification.
- The cryptographic verification fails.
A rejected package MUST NOT be installed, and the consumer MUST report which condition triggered the rejection.
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.
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 #
| Field | Description |
|---|---|
schema_version | MUST be 1 in this version. |
repo.name | A short identifier for the repository. MUST be non-empty. SHOULD be kebab-case. |
repo.description | OPTIONAL. A human-readable one-line description. |
repo.signing | Signing key information. |
indexes.active | Pointer to the active index (§5.33). |
indexes.archive | Pointer 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 #
| Field | Description |
|---|---|
algorithm | MUST be ed25519 in this version. |
keys | One or more keys. MUST contain at least one with status active. |
| Key field | Description |
|---|---|
fingerprint | The key's fingerprint (§5.29): lowercase hex, 64 characters. |
url | Where the public key file is published. MAY be relative to <repo-base>. |
status | One of active, transitioning, revoked (§5.32). |
valid_until | RFC 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 #
| Field | Description |
|---|---|
url | Where the index is published. MAY be relative to <repo-base>. |
signature_url | Where 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.
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.
| Status | Used for new signatures | Accepted for verification |
|---|---|---|
active | yes | yes |
transitioning | no | yes, until valid_until |
revoked | no | never, 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 itsvalid_untiltimestamp, but no longer produces new signatures. A consumer MUST accept its signatures while the current time is at or beforevalid_until, and MUST reject them afterwards. Atransitioningkey entry MUST carry avalid_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.
5.32.3 Rotation #
A repository rotates a signing key by:
- Generating a new key pair.
- Adding the new public key to the descriptor's key list alongside the existing one.
- Beginning to sign new content with the new key.
- After a transition period during which both are advertised, marking
the old key
transitioningwith avalid_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).
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.
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.
5.33.2 Schema #
| Field | Description |
|---|---|
schema_version | MUST be 1 in this version. |
repo | The repository's name, matching repo.name in the descriptor. |
kind | MUST be active. |
index_version | A monotonically increasing positive integer identifying this index revision (§5.34). |
generated_at | RFC 3339 UTC timestamp of generation. |
packages | One 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 #
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).
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.
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.
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.
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.
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.
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.
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.
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:
kindMUST bearchive;- the
packagesarray MAY contain several entries with the samename, 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.
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.
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 #
| Path | Content |
|---|---|
<repo-base>/repo.json | Repository descriptor (§5.31) |
<repo-base>/repo.json.sig | Detached signature on the descriptor |
<repo-base>/index/active.json | Active index (§5.33) |
<repo-base>/index/active.json.sig | Detached signature on the active index |
<repo-base>/index/archive.json | Archive index (§5.35) |
<repo-base>/index/archive.json.sig | Detached signature on the archive index |
<repo-base>/keys/<fingerprint>.pub | Public 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.
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.
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:
- Fetches
<repo-base>/repo.jsonand<repo-base>/repo.json.sig. - 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).
- Verifies the descriptor's signature against those anchor keys and only those.
- On success, records the descriptor's contents — including every signing key and status — as the repository's initial trust state.
- 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.
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 #
| Policy | Meaning |
|---|---|
required | Every package and index MUST be signed and verify. Unsigned content from this repository is rejected. |
optional | Signed 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.
5.37.4 Refresh #
A consumer SHOULD refresh its cached repository state periodically. A refresh MUST:
- Fetch the current descriptor and its signature.
- Verify the signature against any key whose status was
activeortransitioningin the previously trusted descriptor. - On success, record the new descriptor as the current trust state, replacing the previous key set with the new one.
- Fetch the active index and verify it against the new descriptor's keys, applying §5.34.
- 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.
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.
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.
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
replacesdeclared 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
conflictsdeclared 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
algorithmfields of the files manifest and the index hash object reserve room for a further hash algorithm. - The
archqualifier 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_installedequal 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_versionstrictly 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 #
| Limit | Maximum |
|---|---|
| Payload entries | 100,000 |
.peipkg/manifest.json size | 16 MiB |
.peipkg/files.json size | 64 MiB |
.peipkg/signature size | 64 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 #
| Limit | Maximum |
|---|---|
dependencies | 10,000 |
optional_dependencies | 10,000 |
conflicts | 10,000 |
provides | 10,000 |
replaces | 1,000 |
sd_overrides | 100,000 |
Single sd_override decoded sd length | 64 KiB |
Slots per claims field | 64 |
| Claim paths materialised per role | 256 |
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 #
| Limit | Value |
|---|---|
| Package name length | 2 to 64 characters |
| Virtual name length | 2 to 128 characters |
| Architecture identifier length | at most 16 characters |
5.A.4 Documents #
| Limit | Value |
|---|---|
| JSON nesting depth | 64 |
| Integer field range | unsigned 64-bit |
5.A.5 Decompression #
| Bound | Value |
|---|---|
Compressed overrun allowance over size_compressed | the lesser of 1% or 16 MiB |
Decompressed overhead allowance over size_installed | 320 MiB |
| Absolute decompressed cap | 4 GiB (default; operator-tunable) |
5.A.6 Repository defaults #
| Default | Value |
|---|---|
| Maximum trusted age | 30 days |
| Maximum trusted age producing a warning | above 180 days |
| Maximum index staleness | 90 days |
| Maximum index staleness producing a warning | above 365 days |
| Revoked key retention | at least 1 year |
| Repository priority | positive integer; lower is higher priority |
| Default signature policy for a new repository | required |
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 #
| Identifier | Triplet | Notes |
|---|---|---|
x86_64 | x86_64-linux-peios | primary target |
aarch64 | aarch64-linux-peios | secondary target |
noarch | none | architecture-independent |
Defined in §5.8.
5.B.2 Pre-release rank tokens #
| Token | Rank |
|---|---|
dev | 0 |
alpha | 1 |
a | 1 |
beta | 2 |
b | 2 |
pre | 3 |
rc | 4 |
| any other alphabetic segment | 5 |
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 #
| Operator | Meaning |
|---|---|
= | 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 #
| Identifier | Declared when | Invoked as |
|---|---|---|
depmod | the payload contains kernel modules (MUST) | once per affected kernel release, naming it |
man-db | the payload contains man pages (SHOULD) | the tool, in quiet mode |
Defined in §5.24.
5.B.5 Licence classes #
| Class | Meaning |
|---|---|
unknown | Nothing asserted. The meaning of an absent license_class. |
free | Free and open-source software. |
firmware | Redistributable device firmware: no source, executes on a device rather than the CPU, needed to use hardware the user owns. |
proprietary | Non-free software that runs on the CPU. |
Defined in §5.18.
5.B.6 Hash algorithms #
| Algorithm | Identifier | Status |
|---|---|---|
| SHA-256 | sha256 | REQUIRED; the only valid value |
| BLAKE3 | blake3 | RESERVED for a future version |
Defined in §5.25.
5.B.7 Signature algorithms #
| Algorithm | Identifier | Status |
|---|---|---|
| Ed25519 | ed25519 | REQUIRED; the only valid value |
Defined in §5.29.
5.B.8 Signing key statuses #
| Status | Signs new content | Accepted for verification |
|---|---|---|
active | yes | yes |
transitioning | no | until valid_until |
revoked | no | never, regardless of cryptographic validity |
Defined in §5.32.
5.B.9 Index kinds #
| Kind | Content |
|---|---|
active | the current version of each package |
archive | every version ever shipped |
Defined in §5.33 and §5.35.
5.B.10 Signature policies #
| Policy | Unsigned content |
|---|---|
required | rejected |
optional | accepted with a per-operation warning |
There is no silently-accept-unsigned policy. Defined in §5.37.
5.B.11 Reserved metadata paths #
| Path | Required |
|---|---|
.peipkg/manifest.json | yes |
.peipkg/files.json | yes |
.peipkg/signature | in 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 #
| Type | Typeflag |
|---|---|
| Regular file | 0 or \0 |
| Directory | 5 |
| Symbolic link | 2 |
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.
| Door | Callers | Carries |
|---|---|---|
| Native socket (§6.4) | Peios tools, the network manager, the NSS shim | Records with TTL, source, validation state; the caller's identity |
| Stub listener (§6.8) | Anything that speaks DNS itself — Go's netgo, musl, hickory | DNS; no caller identity |
| NSS shim (§6.10) | Everything via glibc getaddrinfo and gethostbyname | struct 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):
| Offset | Size | Field | Value |
|---|---|---|---|
| 0 | 4 | length | Length of the payload in bytes, little-endian |
| 4 | length | payload | A 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.
| Right | Value | Governs |
|---|---|---|
RESOLVER_QUERY | 0x0001 | resolve, lookup, reverse, status |
RESOLVER_CONTROL | 0x0002 | flush |
RESOLVER_ALL_ACCESS | 0x000F0003 | Both, 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.
| Key | Type | Meaning |
|---|---|---|
name | string | The name |
type | uint | The record type number (1 A, 28 AAAA, 12 PTR, …) |
no_cache | bool | Bypass the cache for this question; default false |
Reply kind: answer:
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.
Reply kind: addresses:
6.5.3 reverse — RESOLVER_QUERY #
| Key | Type | Meaning |
|---|---|---|
address | string | An 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:
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 #
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:
| Value | Meaning |
|---|---|
unvalidated | The resolver did not attempt DNSSEC validation. |
secure | Validated, with a chain of trust to a configured anchor. |
insecure | Validated as provably unsigned. |
bogus | Validation 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:
- An exclusive scope, if any interface at
addressedor 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. - The longest matching search domain. The scope with the longest domain the name ends with; ties by metric.
- The subnet, for a reverse-mapping name: the scope whose interface has an address in the same subnet as the address named.
- The default-route claimant with the lowest metric.
- The lowest-metric scope with servers, when no interface claims the default route.
- 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:
localhostand any name under it:127.0.0.1and::1;- the machine's hostname: every unicast address of every interface at
addressedor 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.localquestion 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:
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:
| Key | Type | Meaning |
|---|---|---|
hostname | string | The machine's name as set by the manager; empty when unset |
scopes | array of map | One per managed interface with a link, in metric order |
Each scope:
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:
- Forward every question to the native channel (§6.4) as
lookuporreverse, one connection per libc call, and render the reply. It MUST NOT hold a connection across calls. - 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. - Answer
NSS_STATUS_UNAVAILfor everything else when the resolver cannot be reached. There is nothing behind it. - Report an over-full caller buffer as
NSS_STATUS_TRYAGAIN, with*errnop = ERANGEand*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 #
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.
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 #
- Offer the native socket, the stub listener and answer the shim, and run one resolution function behind all three (§6.3).
- Offer no other policy path: no files, no in-shim DNS, no fallback (§6.3).
6.11.1.2 Native channel #
- Frame as §6.4; refuse an oversized request without reading it, with an error reply (§6.4).
- Ignore unknown keys; reject duplicate keys (§6.4).
- One request per connection; bound the time a request may take to arrive (§6.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). - Answer unknown requests and denied requests with error replies (§6.4, §6.5).
6.11.1.3 Outcomes #
- Never report
unavailableasnotfound; never cacheunavailable(§6.6). - Report
unvalidateduntil validating; never forward an upstream'sAD(§6.6).
6.11.1.4 Resolution #
- Route every question to exactly one scope, in the order of §6.7; never fan out.
- Honour an exclusive scope absolutely while it is up (§6.7).
- Expand single labels with applicable domains only; never send one
bare; never expand a multi-label name; no
ndots(§6.7). - Answer the synthetic names of §6.7 before any network; never
forward
.local; never speak LLMNR. - Key the cache by scope; flush a scope whose servers change or that goes away; cap TTLs as §6.B (§6.7).
- Fresh source port, random identifier, 0x20 case with exact echo; ignore mismatched replies (§6.7).
- EDNS0 with the §6.B buffer; TCP on truncation; demote failing servers; bounded attempts (§6.7).
6.11.1.5 Stub door #
- Listen on
127.0.0.53only, UDP and TCP; ignore non-loopback sources (§6.8). - Render outcomes as §6.8; CNAME from a bare question to its expanded
name;
NOTIMPandFORMERRas §6.8; truncate over the buffer. - Bind without privilege (§6.8).
6.11.1.6 Network manager channel #
- Subscribe; replace scopes on every snapshot; reconnect with backoff; keep answering while disconnected (§6.9).
- Read only
Machine\System\Network\Dnsfrom the registry (§6.9).
6.11.2 Network manager obligations #
- Answer
subscribewith a snapshot and stream one on every change on the same connection (§6.9). - Send whole snapshots, never deltas; merge profile and lease facts itself (§6.9).
6.11.3 Client obligations #
- Treat an error reply as "not answered", never as
notfound(§6.5). - Be written against
validation(§6.6).
6.11.4 Shim obligations #
- One connection per libc call; answer
localhostalone;UNAVAILwhen the resolver is unreachable;ERANGEretry (§6.10). - No files, no DNS, no cache (§6.10).
- Map outcomes as §6.10; never render
unavailableasNOTFOUND.
Appendix 6.A Message Reference
Peios / Peios technical reference / PSPU / Name Resolution Interface
6.A.1 Native channel requests #
query | Right | Reply kind | Defined in |
|---|---|---|---|
resolve | RESOLVER_QUERY | answer | §6.5 |
lookup | RESOLVER_QUERY | addresses | §6.5 |
reverse | RESOLVER_QUERY | answer | §6.5 |
status | RESOLVER_QUERY | status | §6.5 |
flush | RESOLVER_CONTROL | (none) | §6.5 |
6.A.2 Network manager channel #
query | Right | Reply kind | Defined in |
|---|---|---|---|
subscribe | NETWORK_QUERY | snapshot, streamed | §6.9 |
6.A.3 Rights #
| Constant | Value | Defined in |
|---|---|---|
RESOLVER_QUERY | 0x00000001 | §6.4 |
RESOLVER_CONTROL | 0x00000002 | §6.4 |
RESOLVER_ALL_ACCESS | 0x000F0003 | §6.4 |
6.A.4 Enumerations #
| Field | Values | Defined in |
|---|---|---|
outcome | found, notfound, unavailable | §6.6 |
validation | unvalidated, secure, insecure, bogus | §6.6 |
source | local, synthetic, hosts, cache, dns | §6.2 |
family | any, inet, inet6 | §6.5 |
level | link, addressed, routed; never absent in a snapshot, since an absent interface contributes no scope | §6.9 |
6.A.5 Paths and addresses (Peios) #
| Item | Value |
|---|---|
| Native socket | /run/resolvd/resolv.sock |
| Stub listener | 127.0.0.53:53, UDP and TCP |
| Network manager socket | /run/netd/control.sock |
| Registry key | Machine\System\Network\Dns |
| Shim | libnss_peios_net.so.2 |
| Compat file | /usr/etc/resolv.conf, constant |
Appendix 6.B Limits
Peios / Peios technical reference / PSPU / Name Resolution Interface
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-statusandjob-stopcommands 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:
| Level | Type | Meaning | Applies to |
|---|---|---|---|
SOL_KACS | KACS_SCM_TOKEN | The token the job is to run as. At most one. | submit |
SOL_SOCKET | SCM_RIGHTS | Descriptors 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:
| Level | Type | Meaning | Carried by |
|---|---|---|---|
SOL_SOCKET | SCM_RIGHTS | The 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.
| Field | Type | Required | Meaning |
|---|---|---|---|
command | string | always | submit, status, wait, stop or signal. |
job_id | string | all but submit | The job. |
wait | bool | no | For stop: block until the job is terminal. §7.8 |
for | string | no | For wait: ready or terminal. §7.8 |
signal | integer | for signal | The 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):
The error shape is §4.9's:
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).
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 #
| Field | Type | Required | Default | Meaning |
|---|---|---|---|---|
image_path | string | yes | — | The program to execute. An absolute path. |
arguments | array of strings | no | [] | Its arguments, after the program name. |
environment | object of string → string | no | {} | Variables to set. §7.6 below. |
working_directory | string | no | / | The working directory. An absolute path. |
description | string | no | "" | A human-readable label, for views and records. |
timeout | integer | no | 0 | Seconds the job may run before it is stopped. 0 is no limit. |
stop_timeout | integer | no | §7.A | Seconds between the termination signal and the kill. |
readiness | string | no | none | none, or notify to wait for READY=1. |
readiness_timeout | integer | no | §7.A | Seconds a notify job has to become ready. |
success_exit_codes | array of integers | no | [] | Non-zero exit codes that count as success. |
descriptors | array of strings | no | [] | Names for the descriptors to inject. §7.9 |
output | bool | no | false | Whether the last attached descriptor is an output sink. §7.9 |
security_descriptor | string | no | §7.8 | The 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_pathis absent, not a string, empty, not absolute, or contains a NUL byte;working_directoryis present and not an absolute string, or contains a NUL byte;- any element of
argumentsis not a string or contains a NUL byte; - any key of
environmentis empty, contains=, or contains a NUL byte; or any value contains a NUL byte; - any of
timeout,stop_timeout,readiness_timeoutis present and not a non-negative integer, orstop_timeoutis0; readinessis present and not one of the two values;- any element of
success_exit_codesis not an integer in 0–255; descriptorsdoes not match the attached descriptors (§7.9);security_descriptoris present and is not a valid descriptor with an owner and a DACL (§7.8);- the total size of the resulting
argvand environment exceeds what the manager can pass toexecve.
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:
- Establish the job identity (§7.5) and refuse with
BAD_TOKENif it cannot. - Check the submitter's quota and refuse with
QUOTA_EXCEEDEDif the job would exceed it. - Create the job record, with its identifier, in the
createdstate, owned by the submitter with the descriptor of §7.8. - Start the process — install the identity, set up its streams, inject descriptors, execute — as it starts a service's main process.
- 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.
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.
| Field | Type | Meaning |
|---|---|---|
id | string | The job's identifier. |
type | string | Always submitted on this channel. §4.B |
state | string | §7.B. |
cause | string or null | Why the job ended, where the manager decided it. §7.B |
submitter | string | The submitter's user SID. |
identity | string | The job identity's user SID. |
logon_session | integer | The job identity's logon session identifier. |
description | string | As submitted. |
image_path | string | As submitted. |
pid | integer or null | The process identifier, while there is a process. |
ready | bool or null | For a notify job: whether READY=1 has arrived. Null for a none job. |
exit_code | integer or null | The exit code, when the process exited. |
exit_signal | integer or null | The signal, when the process was killed. |
status_text | string or null | The most recent STATUS the job sent (§4.19). |
progress | object or null | The most recent progress the job sent (§4.19). |
created_at | string | When the record was created. §4.5 |
started_at | string or null | When exec was confirmed. |
ended_at | string or null | When 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 #
| State | Process? | Terminal? | Meaning |
|---|---|---|---|
created | Not yet | No | The record exists; exec has not been confirmed. Never visible on this channel — a submit is answered only after leaving it. |
running | Yes | No | Exec succeeded and the process is alive. |
completed | No | Yes | The process exited with 0 or a code in success_exit_codes. |
failed | No | Yes | The process exited otherwise, was killed by a signal, or never ran because setup failed. |
abandoned | Yes, unkillably | Yes | The 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:
| State | pid | started_at | ended_at | exit_code / exit_signal |
|---|---|---|---|---|
running | set | set | null | null |
completed | null | set | set | exactly one set |
failed, process ran | null | set | set | exactly one set |
failed, setup failed | null | null | set | both null |
abandoned | set | set | set | both 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.
| Value | State | Meaning |
|---|---|---|
parent_setup_failure | failed | The manager could not prepare the launch: no token, no cgroup, no fork. No process existed. |
pre_exec_failure | failed | The child's setup between fork and exec failed, or exec itself did. |
readiness_timeout | failed | A notify job did not send READY=1 within readiness_timeout. The manager stopped it. |
timeout | failed | The job ran longer than timeout. The manager stopped it. |
explicit_stop | failed | A stop or job-stop was issued and the process died to it. |
shutdown | failed | The system was shutting down and the manager stopped it. |
process_unkillable | abandoned | The 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 #
| Right | Bit | Grants |
|---|---|---|
JOB_QUERY | 0x0001 | Read the job view; wait on the job. |
JOB_STOP | 0x0002 | Stop the job. |
JOB_SIGNAL | 0x0004 | Send a signal to the job's process. |
JOB_ALL_ACCESS | 0x0007 | All three. |
| Generic right | Maps to |
|---|---|
GENERIC_READ | JOB_QUERY |
GENERIC_WRITE | JOB_STOP | JOB_SIGNAL |
GENERIC_EXECUTE | JOB_STOP | JOB_SIGNAL |
GENERIC_ALL | JOB_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_ACCESSto 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) overJOB_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.
for | Returns when |
|---|---|
terminal (default) | The job reaches a terminal state. |
ready | The 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:
- Placed consecutively from descriptor 3, with close-on-exec cleared.
LISTEN_FDSset to the count.LISTEN_FDNAMESset to the names fromdescriptors, colon-separated, in the same order.LISTEN_PIDset 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.
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).
| Code | Meaning | Closes? |
|---|---|---|
MALFORMED_REQUEST | The message is not a single well-formed JSON object. §7.4 | No |
REQUEST_TOO_LARGE | The message exceeds the configured maximum, or was truncated. §7.3 | Yes |
INVALID_COMMAND | command is absent, not a string, or names no command. | No |
INVALID_ARGUMENTS | A field is absent, malformed, or inconsistent with the attachments. §7.6, §7.9 | No |
UNKNOWN_JOB | job_id names nothing the manager holds — it never existed, or its retention has elapsed. §7.7 | No |
ACCESS_DENIED | The access check against the job's descriptor denied the right. §7.8 | No |
INVALID_STATE | The command is not valid for the job's state, or the manager is shutting down. | No |
QUOTA_EXCEEDED | The submitter already holds its maximum of live jobs. §7.3 | No |
BAD_TOKEN | The attached token cannot be a job identity. §7.5 | No |
INTERNAL_ERROR | The 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
submitMUST 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
submitMUST 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 #
| Bound | Value | Configurable | Defined in |
|---|---|---|---|
| Socket path | /run/services/peinit/jobs.sock | No | §7.3 |
| Socket descriptor, default | Connect for Authenticated Users (S-1-5-11); full control for SYSTEM and Administrators | Yes | §7.3 |
| Concurrent connections | 64 | Yes | §7.3 |
| Message size | 65536 bytes | Yes | §7.3 |
| Idle timeout | 30 seconds | Yes | §7.3 |
| Listen backlog | 32 | No | §7.3 |
| Live jobs per submitter | 64; SYSTEM exempt | Yes | §7.3 |
7.A.2 Attachments #
| Bound | Value | Defined in |
|---|---|---|
| Tokens per message | 1 | §7.4 |
| Descriptors per message | 64, including an output sink | §7.4 |
| First injected descriptor | 3 | §7.9 |
7.A.3 The definition #
| Field | Default | Defined in |
|---|---|---|
stop_timeout | 10 seconds | §7.6 |
readiness_timeout | 30 seconds | §7.6 |
timeout | 0, no limit | §7.6 |
working_directory | / | §7.6 |
7.A.4 Records #
| Bound | Value | Defined in |
|---|---|---|
| Terminal record retention | 60 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 #
| Value | Process? | Terminal? |
|---|---|---|
created | Not yet | No |
running | Yes | No |
completed | No | Yes |
failed | No | Yes |
abandoned | Yes, unkillably | Yes |
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.
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), andraise(below) whenever the person asks for the object again. - Editor to requester, on the editor's standard output:
applymessages (§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:
| Member | Type | Required | Meaning |
|---|---|---|---|
object | object | Yes | What the descriptor is the descriptor of. |
object.name | string | Yes | What the object is called where the person found it: notes.txt, not a full path, unless a path is how they know it. |
object.kind | string | Yes | What kind of thing it is, in words for the person: File, Folder, Service. |
object.container | boolean | No, false | Whether other objects inherit from it (PCDS §5.6). |
object.children | string | No, "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.parent | object | No | What the object is in, which its inherited entries come from (see The parent). |
object.parent.name | string | Yes, in parent | What the parent is called where the person knows it: /srv. |
object.parent.sd | string | No | The parent's descriptor, self-relative and in base64 as sd is. |
object.parts | array | No, empty | The object's parts that rules can be made for one at a time: object types (see Parts and kinds). |
object.parts[].guid | string | Yes | The object type, as a GUID in its usual text form: bf967aba-0de6-11d0-a285-00aa003049e2. |
object.parts[].name | string | Yes | What the part is called, for the person. |
object.parts[].kind | string | Yes | set, a set of properties; property; or right, an action. |
object.parts[].set | string | No | For a property, the GUID of the set it is in. |
object.kinds | array | No, empty | On a container of typed things, the kinds of thing it holds: inherited object types. |
object.kinds[].guid, .name | string | Yes | The inherited object type, and what that kind of thing is called. |
object.part_rights | array | No, empty | The rights a rule for a part can give, as rights gives the object's (see Parts and kinds). |
object.part_rights[].name, .mask | string, number | Yes | What the right is called, and its access mask. |
object.naming | object | No | How a part not listed is named (see Parts named by the person). |
object.naming.namespace | string | Yes, in naming | The UUID v5 namespace a part's object type is derived in, as a GUID in its usual text form. |
object.naming.noun | string | Yes, in naming | What one such part is called, for the person: field. |
object.naming.example | string | No | A name the person might give, to show how one is written: source.name. |
sd | string | Yes | The descriptor as it is now: a self-relative Security Descriptor (PCDS §5.1), in base64 with padding (RFC 4648 §4). |
read | array of strings | No | The 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. |
rights | array | Yes | The object's rights by name, in the order a person should see them. |
rights[].name | string | Yes | What the right is called. |
rights[].mask | number | Yes | The access mask the right stands for, as an unsigned 32-bit integer. |
rights[].general | boolean | No, false | Whether it is a general right (§8.2). |
generic | object | Yes | What each generic right stands for on this kind of object (PCDS §5.3). |
generic.read, .write, .execute, .all | number | Yes | The access mask each generic right maps to. |
can | object | No | Which components the requester is able to change. |
can.dacl | boolean | No, true | Whether it can change the DACL. It is true when left out because, before this member, a requester always could. |
can.owner | boolean | No, false | Whether it can change the owner. |
can.audit | boolean | No, false | Whether it can change the SACL. |
can.label | boolean | No, false | Whether it can apply the label by itself (§8.5). |
can.propagate | boolean | No, false | Whether, on a container, it can push what was applied into what is already inside (§8.5, Pushing into what is inside). |
can.why | string | No | Why 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:
| Value | Holds | Flags of an entry made here |
|---|---|---|
all | Containers and other objects, as a folder holds folders and files. | Object inherit and container inherit. |
containers | Containers 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:
| Member | Type | Meaning |
|---|---|---|
type | "apply" | |
sd | string | The whole descriptor as the person now has it, self-relative and in base64 as in the request (§8.4). |
parts | array of strings | The components of sd that changed since the request, or since the last apply the requester answered applied: some of owner, group, dacl, sacl, label. |
propagate | boolean | Optional, 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:
| Line | Meaning |
|---|---|
{"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:
| Line | Meaning |
|---|---|
{"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:
| Line | Meaning |
|---|---|
{"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:
| Member | Type | Meaning |
|---|---|---|
done | number | How many items inside were re-propagated, those that protect every list included. Left out, none. |
failed | array | The items that could not be, each {"name":"…","why":"…"}: what the person knows it as, and why, as text for the person. Left out, none. |
stopped | boolean | Whether 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
| Direction | Line | Defined in |
|---|---|---|
| Requester → editor | The 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.
| Member | Type | Required | Meaning |
|---|---|---|---|
mode | string | yes | open for an existing file to read; save for a place and a name to write to |
title | string | yes | What the dialog is called, for the person: "Export a key" |
purpose | string | no | What the file is for, in a sentence, for the person to read |
folder | string | no | An absolute path to the folder to start in |
name | string | no | The name to suggest, in save mode: "Services.json" |
filters | array | no | The kinds of file to offer, in order, each an object below |
Each filter is an object with:
| Member | Type | Meaning |
|---|---|---|
name | string | What the kind is called, for the person: "Registry documents" |
extensions | array of strings | The 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.
| Line | Meaning |
|---|---|
{"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
| Direction | Line | Defined in |
|---|---|---|
| Requester → chooser | The 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:
| Offset | Size | Field | Value |
|---|---|---|---|
| 0 | 4 | magic | PLPS (50 4c 50 53) |
| 4 | 2 | version | 1 |
| 6 | 2 | msg_type | See §10.A |
| 8 | 4 | total_len | Header 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:
| Field | Encoding | Default if absent |
|---|---|---|
name | string, 256 bytes | |
rid | u32 | |
enabled | boolean | |
groups | u32: how many groups it is listed in | |
unix_id | u32: 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:
| Field | Encoding | Default if absent |
|---|---|---|
name | string, 256 bytes | |
rid | u32 | |
enabled | boolean | |
sid | SID | |
groups | array of at most 128 group references | |
unix_id | u32: effective | 0 |
primary_group | one length-framed group reference | an empty reference |
home | string, 4096 bytes | empty |
shell | string, 4096 bytes | empty |
display_name | string, 256 bytes | empty |
claims | array of at most 64 claims (§10.4) | none |
permitted_logon_types | u32, as on Add | 0, not stated |
credential_policy | u8 (§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:
| Field | Encoding | Default if absent |
|---|---|---|
sid | SID | |
name | string, 256 bytes: what this machine calls the group | empty |
unix_id | u32: its effective Unix ID, or zero | 0 |
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.
| Field | Encoding | Default if absent |
|---|---|---|
name | string, 256 bytes | |
credential_kind | u8: 0 none, 1 a password | |
secret | byte string, 4096 bytes | |
enabled | boolean | |
groups | array of at most 128 length-framed strings: groups as written (§10.2) | |
permitted_logon_types | u32 | 0, not stated |
primary_group | optional string (§10.4), 256 bytes: a group as written | absent |
home | optional string, 4096 bytes | absent |
shell | optional string, 4096 bytes | absent |
display_name | optional string, 256 bytes | absent |
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.
| Field | Encoding |
|---|---|
name | string, 256 bytes |
home | optional string (§10.4), 4096 bytes |
shell | optional string, 4096 bytes |
display_name | optional 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.
| Field | Encoding |
|---|---|
name | string, 256 bytes: the principal |
new_name | string, 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.
| Field | Encoding |
|---|---|
name | string, 256 bytes |
permitted_logon_types | u32, 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:
| Field | Encoding | Default if absent |
|---|---|---|
name | string, 256 bytes | |
rid | u32 | |
unix_id | u32: its effective Unix ID (§10.2) | |
sid | SID | |
members | u32: how many principals list it | |
description | string, 1024 bytes | empty |
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.
| Field | Encoding | Default if absent |
|---|---|---|
name | string, 256 bytes | |
description | string, 1024 bytes | empty |
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.
| Field | Encoding |
|---|---|
name | string, 256 bytes: a local group's |
description | string, 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.
| Field | Encoding |
|---|---|
name | string, 256 bytes: the principal |
group | string, 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.
| Field | Encoding |
|---|---|
name | string, 256 bytes: the principal |
claim | one 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.
| Field | Encoding |
|---|---|
name | string, 256 bytes: the principal |
claim_name | string, 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 #
| Value | Policy | Signs in with |
|---|---|---|
0 | Denied | nothing: every sign-in is refused |
1 | Password | its password |
2 | SshPublicKey | one of its SSH keys |
3 | PasswordOrKey | either |
4 | NoCredential | nothing 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:
| Field | Encoding |
|---|---|
policy | u8 |
keys | array of at most 32 length-framed keys |
A key:
| Field | Encoding |
|---|---|
id | byte string, 16 bytes: the store daemon's, never all zero |
fingerprint | string, 128 bytes: as ssh-keygen -l writes one |
label | string, 128 bytes |
created | u64: 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.
| Field | Encoding |
|---|---|
name | string, 256 bytes |
public_key | string, 16384 bytes: one line of an OpenSSH authorized_keys file |
label | string, 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.
| Field | Encoding |
|---|---|
failure | u32: why, from the table below |
reason | string, 512 bytes: why, in words |
| Value | Failure | Means |
|---|---|---|
1 | Denied | The caller may not administer the store (§10.3). |
2 | NotFound | Nothing in the store by the name, or the key, given. |
3 | Exists | The name asked for is already held. |
4 | Invalid | Well-formed, and asking for something the store will not do. |
5 | Internal | The 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.
| Field | Encoding |
|---|---|
name | string, 256 bytes |
sid | byte string, 68 bytes |
display_name | string, 256 bytes; empty when unset |
enabled | boolean |
policy | u8: the credential policy (§10.8) |
has_password | boolean: whether the account has a password at all |
keys | array of at most 32 length-framed keys |
A key:
| Field | Encoding |
|---|---|
id | byte string, 16 bytes: as Keys gives it (§10.8) |
fingerprint | string, 128 bytes: as ssh-keygen -l writes one |
algorithm | string, 64 bytes: the key's type, such as ssh-ed25519 |
label | string, 128 bytes |
created | u64: 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.
| Field | Encoding |
|---|---|
display_name | string, 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_type | Message | Direction | Answered with | Defined in |
|---|---|---|---|---|
0x0001 | List | client → store daemon | Principals | §10.5 |
0x0002 | Show | client → store daemon | Principal | §10.5 |
0x0003 | Domain | client → store daemon | DomainIs | §10.5 |
0x0004 | Add | client → store daemon | Created | §10.5 |
0x0005 | Remove | client → store daemon | Done | §10.5 |
0x0006 | SetEnabled | client → store daemon | Done | §10.5 |
0x0007 | SetPassword | client → store daemon | Done | §10.5 |
0x0008 | GroupAdd | client → store daemon | Done | §10.6 |
0x0009 | GroupRemove | client → store daemon | Done | §10.6 |
0x000a | GroupList | client → store daemon | Groups | §10.6 |
0x000b | GroupCreate | client → store daemon | Created | §10.6 |
0x000c | GroupDelete | client → store daemon | Done | §10.6 |
0x000d | SetProfile | client → store daemon | Done | §10.5 |
0x000e | SetPrimaryGroup | client → store daemon | Done | §10.6 |
0x000f | SetClaim | client → store daemon | Done | §10.7 |
0x0010 | RemoveClaim | client → store daemon | Done | §10.7 |
0x0011 | KeyList | client → store daemon | Keys | §10.8 |
0x0012 | KeyAdd | client → store daemon | Done | §10.8 |
0x0013 | KeyRemove | client → store daemon | Done | §10.8 |
0x0014 | CredentialPolicy | client → store daemon | Done | §10.8 |
0x0015 | Rename | client → store daemon | Done | §10.5 |
0x0016 | SetLogonTypes | client → store daemon | Done | §10.5 |
0x0017 | GroupRename | client → store daemon | Done | §10.6 |
0x0018 | GroupDescribe | client → store daemon | Done | §10.6 |
0x8001 | Principals | store daemon → client | §10.5 | |
0x8002 | Principal | store daemon → client | §10.5 | |
0x8003 | DomainIs | store daemon → client | §10.5 | |
0x8004 | Created | store daemon → client | §10.9 | |
0x8005 | Done | store daemon → client | §10.9 | |
0x8006 | Failed | store daemon → client | §10.9 | |
0x8007 | Groups | store daemon → client | §10.6 | |
0x8008 | Keys | store daemon → client | §10.8 |
Any request may be answered with Failed instead.
10.A.1.1 On the self socket #
msg_type | Message | Direction | Answered with | Defined in |
|---|---|---|---|---|
0x0040 | ShowSelf | client → store daemon | Self | §10.11 |
0x0041 | SetDisplayName | client → store daemon | Done | §10.11 |
0x8040 | Self | store 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 #
| Constant | Value | Defined 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 request | 4096 bytes | §10.11 |
| Magic | PLPS (50 4c 50 53) | §10.4 |
| Version | 1 | §10.4 |
| Header size | 12 bytes | §10.4 |
| Maximum message size | 2,097,152 bytes | §10.4 |
| Requests a connection | 1 | §10.3 |
10.A.3 Field limits #
| Field | Limit |
|---|---|
| A principal's or group's name | 256 bytes |
| A password | 4096 bytes, never empty |
home, shell | 4096 bytes |
display_name | 256 bytes |
A group's description | 1024 bytes |
| A principal's listed groups | 128 |
Principals in Principals | 4096 |
Groups in Groups | 4096 |
| A SID | 68 bytes |
| Claims on a principal | 64 |
| A claim's name | 255 bytes |
| A claim's values | 64 |
| A string or octet claim value | 1024 bytes |
| SSH keys on a principal | 32 |
| An SSH public key | 16384 bytes |
| A key's label | 128 bytes |
| A key's fingerprint | 128 bytes |
| A key's algorithm | 64 bytes |
A refusal's reason | 512 bytes |
10.A.4 Enumerations #
| Enumeration | Defined in |
|---|---|
| Failure | §10.9 |
| Credential policy | §10.8 |
| Logon types | PGSS §2.16, carried as PSI carries them (§2.13) |
| Claim value types and flags | PCDS §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
commoncapis implicit (Linux always includes it; not listed inCONFIG_LSMexplicitly).landlockis permitted — it's a process-local sandboxing LSM that processes opt into; it doesn't conflict with PKM.lockdownis permitted — it gates specific kernel-debugging interfaces; orthogonal to PKM.integrityis permitted — Linux's IMA/EVM mechanisms can run alongside PKM for the use cases they cover.pkmis 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/memis 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/kmemis 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:
peinitis signed at TCB level. If the binary peinit is exec'd from is not signed at TCB, the kernel's verification at exec setspip_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,lpsdare 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:
- Verify PKM is in the LSM stack and no incompatible LSM is present. PKM refuses to activate if violated.
- Verify
CONFIG_MODULE_SIG_FORCEis set. (This is a build-time flag; at runtime the kernel just behaves consistently with the build.) - Verify
CONFIG_STRICT_DEVMEMis set. (Same; build-time.) - Construct the SYSTEM and Anonymous tokens. As covered in Bootstrap tokens.
- Attach the SYSTEM token to init. Init becomes the first process holding it.
- 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 withSeLoadDriverPrivilegecan load arbitrary kernel modules and bypass everything. PIP is no longer meaningful.CONFIG_STRICT_DEVMEM=n./dev/memcan read physical memory. Any process withREAD_CONTROLon/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, viaktime_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 viaoverride_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 withCLONE_THREADshare 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
0xc1type 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 withevent_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
MaxNestingDepthrange; 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
u16type_lenfield and is nonzero. Syscall emitters cannot express an overlong type — the ABI length field is alreadyu16— but the kernel emission API takes asize_tand 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 withENOSPCwhen 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:
- One wall clock timestamp is captured; every event in the batch shares it.
- The three identity GUIDs are captured once and shared.
- 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. - 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.
- 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.
- The privilege gate and rate reservation, above.
event_type_lenis nonzero —EINVALotherwise.- 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 isEINVAL. - The declared size is within
MaxEventSize—ENOSPCotherwise. - The declared size is within 50% of the ring capacity —
ENOSPCotherwise. 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 surfaceENOSPCafter all other validation has passed. - The event type and payload are copied into a kernel staging buffer
(
EFAULTif a pointer is inaccessible,ENOMEMif 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. - The event type is validated as UTF-8 —
EINVALotherwise. - The payload is validated as msgpack within
MaxNestingDepth(§2.2) —EINVALotherwise.
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:
- The SeAuditPrivilege gate, as for
kmes_emit. countis within 1–256 —EINVALotherwise.counttokens are reserved from the rate bucket in one critical section, so concurrent threads cannot both pass the check —EAGAINif unavailable, and nothing is emitted. SeTcbPrivilege exempts as before.- Zero is stored to
*emitted_outbefore any per-entry work —EFAULTif unwritable, with nothing emitted. - The descriptor array is copied from userspace (
EFAULT/ENOMEM). - 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. - 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.
- Unused tokens (
countminus 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— alwaysMachine\System\KMES.config.name— the canonical value name.config.expected.type,config.expected.minandconfig.expected.max— the registry type code and range from the key's definition.config.received.kind— one ofmissing,wrong-typeorout-of-range. A malformed payload length reportswrong-type. AREG_DWORDand aREG_QWORDvalue out of range both reportout-of-range, sinceconfig.expected.typealready gives the width.config.received.type(the actual registry type code) is present exactly when the kind iswrong-type, andconfig.received.value(the number received) exactly when it isout-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 #
- PKM loads. KMES initialises with compiled-in defaults and creates per-CPU rings at the default capacity. They are live immediately.
- The first Machine-hive source registers, making LCS usable. KMES
enumerates every value under
Machine\System\KMES\. - Valid values are applied. A
BufferCapacitydiffering from the current one drives a swap; a matching or absent one changes nothing. - 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\KMESdo not trigger a re-read. - 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.
- 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.
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.
| Number | PKM constant | glibc alias | Signature |
|---|---|---|---|
| 1090 | SYS_KMES_EMIT | SYS_kmes_emit | kmes_emit(const char __user *event_type, u16 event_type_len, const void __user *payload, u32 payload_len) |
| 1091 | SYS_KMES_ATTACH | SYS_kmes_attach | kmes_attach(unsigned int cpu_id, u64 __user *capacity) |
| 1092 | SYS_KMES_EMIT_BATCH | SYS_kmes_emit_batch | kmes_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.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 8 | __u64 | event_type |
| 8 | 2 | __u16 | event_type_len |
| 10 | 6 | __u8[6] | _pad0 |
| 16 | 8 | __u64 | payload |
| 24 | 4 | __u32 | payload_len |
| 28 | 4 | __u8[4] | _pad1 |
2.A.3 Constants #
Grouped as the header groups them.
Event origin class — kmes_event_header.origin_class.
| Constant | Value | Notes |
|---|---|---|
KMES_ORIGIN_USERSPACE | 0 | |
KMES_ORIGIN_KMES | 1 | |
KMES_ORIGIN_KACS | 2 | |
KMES_ORIGIN_LCS | 3 | |
KMES_ORIGIN_NTFE | 4 | net/ntfe: ntfe.verdict.reported, ntfe.policy.published, ntfe.policy.rejected |
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.
| Constant | Value |
|---|---|
KMES_ATTACH_QUERY_SLOTS | 0xFFFFFFFF |
Largest entry count a single SYS_KMES_EMIT_BATCH call accepts.
| Constant | Value |
|---|---|
KMES_BATCH_MAX_ENTRIES | 256 |
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.
| Constant | Value |
|---|---|
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_DWORD | 4 |
KMES_CONFIG_TYPE_REG_QWORD | 11 |
KMES_CONFIG_BUFFER_CAPACITY_TYPE | 11 |
KMES_CONFIG_BUFFER_CAPACITY_DEFAULT | 4194304 |
KMES_CONFIG_BUFFER_CAPACITY_MIN | 65536 |
KMES_CONFIG_BUFFER_CAPACITY_MAX | 268435456 |
KMES_CONFIG_MAX_EVENT_SIZE_TYPE | 4 |
KMES_CONFIG_MAX_EVENT_SIZE_DEFAULT | 65536 |
KMES_CONFIG_MAX_EVENT_SIZE_MIN | 1024 |
KMES_CONFIG_MAX_EVENT_SIZE_MAX | 4194304 |
KMES_CONFIG_MAX_NESTING_DEPTH_TYPE | 4 |
KMES_CONFIG_MAX_NESTING_DEPTH_DEFAULT | 32 |
KMES_CONFIG_MAX_NESTING_DEPTH_MIN | 4 |
KMES_CONFIG_MAX_NESTING_DEPTH_MAX | 256 |
KMES_CONFIG_MAX_EMIT_RATE_PER_PROCESS_TYPE | 4 |
KMES_CONFIG_MAX_EMIT_RATE_PER_PROCESS_DEFAULT | 10000 |
KMES_CONFIG_MAX_EMIT_RATE_PER_PROCESS_MIN | 100 |
KMES_CONFIG_MAX_EMIT_RATE_PER_PROCESS_MAX | 1000000 |
Values mirror the corresponding KACS privilege bits while keeping this header standalone.
| Constant | Value |
|---|---|
KMES_EMIT_REQUIRED_PRIVILEGE | 0x0000000000200000 (1ULL << 21) |
KMES_ATTACH_REQUIRED_PRIVILEGE | 0x0000000000000100 (1ULL << 8) |
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.
| Constant | Value |
|---|---|
KMES_EVENT_SIZE_OFFSET | 0 |
KMES_EVENT_HEADER_SIZE_OFFSET | 4 |
KMES_EVENT_TIMESTAMP_NS_OFFSET | 8 |
KMES_EVENT_SEQUENCE_OFFSET | 16 |
KMES_EVENT_CPU_ID_OFFSET | 24 |
KMES_EVENT_ORIGIN_CLASS_OFFSET | 26 |
KMES_EVENT_EFFECTIVE_TOKEN_GUID_OFFSET | 27 |
KMES_EVENT_TRUE_TOKEN_GUID_OFFSET | 43 |
KMES_EVENT_PROCESS_GUID_OFFSET | 59 |
KMES_EVENT_TYPE_LEN_OFFSET | 75 |
Byte width of each identity GUID in the event header.
| Constant | Value |
|---|---|
KMES_EVENT_GUID_SIZE | 16 |
Byte size of the fixed event header — the offset at which the type string begins.
| Constant | Value |
|---|---|
KMES_EVENT_HEADER_BASE_SIZE | 77 |
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.
| Constant | Value |
|---|---|
KMES_RING_MAGIC | "KMESRING" |
KMES_RING_VERSION | 1 |
KMES_METADATA_PAGE_SIZE | 4096 |
KMES_METADATA_TOTAL_SIZE | 8192 |
KMES_MAPPING_PRODUCER_OFFSET | 0 |
KMES_MAPPING_CONSUMER_OFFSET | 4096 |
KMES_MAPPING_DATA_OFFSET | 8192 |
Field offsets within the producer metadata page.
| Constant | Value |
|---|---|
KMES_PRODUCER_MAGIC_OFFSET | 0 |
KMES_PRODUCER_VERSION_OFFSET | 8 |
KMES_PRODUCER_CPU_ID_OFFSET | 12 |
KMES_PRODUCER_CAPACITY_OFFSET | 16 |
KMES_PRODUCER_DATA_OFFSET_OFFSET | 24 |
KMES_PRODUCER_GENERATION_OFFSET | 32 |
KMES_PRODUCER_WRITE_POS_OFFSET | 64 |
KMES_PRODUCER_TAIL_POS_OFFSET | 72 |
KMES_PRODUCER_FUTEX_COUNTER_OFFSET | 128 |
Field offset within the consumer metadata page.
| Constant | Value |
|---|---|
KMES_CONSUMER_NEED_WAKE_OFFSET | 0 |
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.
| Constant | Value | Notes |
|---|---|---|
KMES_DROP_RING_FULL | 0 | capacity overwrite; oldest event dropped |
KMES_DROP_TAIL_RESYNC | 1 | corrupt ring header; pending events silently discarded |
KMES_DROP_VALIDATE | 2 | single kernel-emit size/type reject |
KMES_DROP_BATCH_STRUCT_INVALID | 3 | kernel-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.
| Constant | Value | Notes |
|---|---|---|
KMES_SWAP_BEGIN | 0 | capacity change accepted, rings allocating |
KMES_SWAP_COMPLETE | 1 | swap committed across all CPUs |
KMES_SWAP_MIGRATE_SKIP | 2 | shrink: old event too large, skipped (ret=bytes) |
KMES_SWAP_FAILED | 3 | swap 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.
| Constant | Value | Notes |
|---|---|---|
KMES_RATE_THROTTLE | 0 | emit denied -EAGAIN; tokens < requested |
KMES_RATE_RECONFIGURE | 1 | max 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.
| Constant | Value | Notes |
|---|---|---|
KMES_WAKE_NOTE | 0 | wake armed; futex counter incremented |
KMES_WAKE_FUTEX | 1 | blocked consumers woken |
kmes_ring_lifecycle reason — a generation-stable ring object transition.
ret is the outcome. Emitted by kmes:kmes_ring_lifecycle.
| Constant | Value | Notes |
|---|---|---|
KMES_RING_ALLOC | 0 | ring backing allocated (or -ENOMEM) |
KMES_RING_FREE | 1 | ring backing released |
KMES_RING_PRODUCER_PAGE | 2 | producer shmem/meta page attached |
KMES_RING_CONSUMER_FD | 3 | consumer 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.
| Constant | Value | Notes |
|---|---|---|
KMES_INGRESS_OVER_MAX | 0 | event_size exceeds configured max_event_size |
KMES_INGRESS_OVER_CAP_HALF | 1 | event_size exceeds ring_capacity/2 |
KMES_INGRESS_SIZE_OVERFLOW | 2 | declared header/event size overflow |
KMES_INGRESS_EMIT_OVERSIZE | 3 | staged event exceeds live capacity/2 at emit |
KMES_INGRESS_BATCH_PARTIAL | 4 | batch 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.
| Constant | Value | Notes |
|---|---|---|
KMES_VAL_EINVAL | 0 | Rust 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 #
| Syscall | Parameter | Type | Meaning |
|---|---|---|---|
kmes_emit | event_type | const char * | Event type string. |
event_type_len | u16 | Its length in bytes. | |
payload | const void * | MessagePack payload. | |
payload_len | u32 | Its length in bytes. | |
kmes_emit_batch | entries | struct kmes_emit_entry * | Array of event descriptors. |
count | u32 | Number of entries, 1 to KMES_BATCH_MAX_ENTRIES. | |
emitted_out | u32 * | Receives the number of events emitted. | |
kmes_attach | cpu_id | unsigned int | Ring slot index, or KMES_ATTACH_QUERY_SLOTS to query the slot count. |
capacity | u64 * | 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.
| Operation | Required privilege | Bit |
|---|---|---|
kmes_emit, kmes_emit_batch | SeAuditPrivilege | 21 |
| Rate-limit exemption on both | SeTcbPrivilege | 7 |
kmes_attach | SeSecurityPrivilege | 8 |
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.
| Quantity | Value | Where |
|---|---|---|
| Maximum event size, structural | 50% of ring capacity | kmes/kmes.c |
| MessagePack validator nesting stack | 256 | kmes/kmes_validate.rs |
| Self-configuration payload buffer | 768 bytes | kmes/kmes.c |
| Self-configuration audit intents per read | 4 | kmes/kmes.h |
| Self-configuration parameter name | 64 bytes | kmes/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.
| Key | Effect |
|---|---|
BufferCapacity | Per-CPU ring size in bytes. Must be a power of two. Changing it swaps every ring; see §2.6. |
MaxEventSize | Largest event a syscall emitter may produce. |
MaxNestingDepth | Deepest MessagePack container nesting the validator will accept. |
MaxEmitRatePerProcess | Token-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 #
| Errno | Condition |
|---|---|
EPERM | SeAuditPrivilege not held or not enabled, or recording its used state failed. |
EAGAIN | Per-process rate limit exceeded. |
EINVAL | Zero event type length, event type not valid UTF-8, declared size arithmetic overflowed, payload not valid msgpack, or nesting depth over MaxNestingDepth. |
EFAULT | Event type or payload pointer inaccessible. |
ENOSPC | Event exceeds MaxEventSize or 50% of ring capacity. |
ENOMEM | Staging buffer allocation failed, or KMES not initialised. |
2.B.5.2 kmes_emit_batch #
| Errno | Condition |
|---|---|
EPERM | As kmes_emit. |
EAGAIN | Fewer than count tokens available. |
EINVAL | count is 0 or over KMES_BATCH_MAX_ENTRIES, or the failing entry hit one of kmes_emit's EINVAL conditions. |
EFAULT | emitted_out, the entry array, or the failing entry's type or payload pointer inaccessible. |
ENOSPC | The failing entry exceeds MaxEventSize or 50% of ring capacity. |
ENOMEM | Kernel allocation failed, or KMES not initialised. |
2.B.5.3 kmes_attach #
| Errno | Condition |
|---|---|
EPERM | SeSecurityPrivilege not held or not enabled, or recording its used state failed. |
EINVAL | cpu_id at or beyond the ring array size, or its slot holds no live ring. |
EFAULT | capacity pointer inaccessible. |
ENOMEM | Kernel 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) #
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) #
| Field | Type | Description |
|---|---|---|
token_type | enum | Primary or Impersonation. |
impersonation_level | enum | Anonymous, 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) #
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) #
| Field | Type | Description |
|---|---|---|
elevation_type | enum | Default (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) #
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) #
3.2.2.8 Mutation tracking (adjustable) #
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) #
| Field | Description |
|---|---|
interactivity_scope | 0 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) #
| Field | Type | Description |
|---|---|---|
user_claims | CLAIM_ATTRIBUTES[] | Name-value pairs from the user's directory object, fed into conditional ACE evaluation. |
device_claims | CLAIM_ATTRIBUTES[] | Name-value pairs from the machine's directory object. |
3.2.2.11 LCS registry credentials (fixed) #
| Field | Type | Description |
|---|---|---|
lcs_scope_guids | GUID[] | 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_layers | string[] | 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) #
| Field | Type | Description |
|---|---|---|
device_groups | SID_AND_ATTRIBUTES[]? | The machine's group memberships, for compound identity. |
restricted_device_groups | SID_AND_ATTRIBUTES[]? | Filtered device groups for restricted tokens. |
3.2.2.13 Confinement (fixed) #
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) #
| Field | Type | Description |
|---|---|---|
audit_policy | u32 | Per-token audit overrides as a bitmask, fixed at creation — no adjustment operation exists. |
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) #
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) #
| Field | Description |
|---|---|
security_descriptor | The token's own SD, controlling who may query, adjust, duplicate, or impersonate it. |
3.2.2.17 Internal #
| Field | Description |
|---|---|
refcount | Reference 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.
- 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.
- If the file's level is lower than the token's, a new token is
created following DuplicateToken semantics — new
token_id, newtoken_guid,modified_idinitialised to the newtoken_id,elevation_typereset to Default — withintegrity_levelset to the file's label. Every other field — the impersonation level included — is copied from the source, and the original token is dropped. - 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.
| LUID | Value | Description |
|---|---|---|
SYSTEM_LUID | 999 (0x3E7) | The SYSTEM LogonSession, created at kernel init. |
ANONYMOUS_LOGON_LUID | 998 (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 toowner_sid_index; - that the primary group SID is the user SID or a group on the token,
resolved to
primary_group_index; - that
auth_idreferences an existing LogonSession; - that
user_deny_onlyis true wheneverwrite_restrictedis; - that
confinement_sidis present wheneverisolation_boundaryis; - that the wire format's
elevation_typefield 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):
- 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.
- authd enumerates processes whose primary token carries the
target
auth_id, by walking/proc, opening each process withpidfd_open, opening its primary token withkacs_open_process_tokenon that pidfd, and readingTokenStatistics, which includesauth_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. - authd sends each
SIGTERMwithpidfd_send_signal, waits up to a grace period (5 seconds) for them to exit, and sendsSIGKILLto the survivors. - It walks again, since a process may have forked meanwhile, for at most three rounds.
- The processes terminate, dropping their token references.
- 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 #
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 right | Maps to |
|---|---|
GENERIC_READ | TOKEN_QUERY | READ_CONTROL |
GENERIC_WRITE | TOKEN_ADJUST_PRIVILEGES | TOKEN_ADJUST_GROUPS | TOKEN_ADJUST_DEFAULT | WRITE_DAC |
GENERIC_EXECUTE | TOKEN_IMPERSONATE |
GENERIC_ALL | TOKEN_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) #
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) #
| Field | Type | Description |
|---|---|---|
pip_type | u32 | Process Integrity Protection type. Determined by the binary's cryptographic signature at exec. |
pip_trust | uint | Trust 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) #
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) #
| Field | Description |
|---|---|
ui_access | Permits 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) #
| Field | Description |
|---|---|
no_child_process | Once 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) #
| Field | Description |
|---|---|
virtualization | Per-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 #
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 | # | Default | Notes |
|---|---|---|---|
SIGHUP | 1 | Terminate | Session hangup |
SIGINT | 2 | Terminate | Ctrl-C |
SIGQUIT | 3 | Terminate + core | Quit request |
SIGILL | 4 | Terminate + core | Illegal instruction |
SIGTRAP | 5 | Terminate + core | Debug trap |
SIGABRT | 6 | Terminate + core | Abort |
SIGBUS | 7 | Terminate + core | Bus error |
SIGFPE | 8 | Terminate + core | Floating point exception |
SIGKILL | 9 | Terminate | Forced kill, cannot be caught |
SIGUSR1 | 10 | Terminate | User-defined |
SIGSEGV | 11 | Terminate + core | Segfault |
SIGUSR2 | 12 | Terminate | User-defined |
SIGPIPE | 13 | Terminate | Broken pipe |
SIGALRM | 14 | Terminate | Alarm timer |
SIGTERM | 15 | Terminate | Graceful termination request |
SIGSTKFLT | 16 | Terminate | Stack fault |
SIGXCPU | 24 | Terminate + core | CPU time exceeded |
SIGXFSZ | 25 | Terminate + core | File size exceeded |
SIGVTALRM | 26 | Terminate | Virtual timer |
SIGPROF | 27 | Terminate | Profiling timer |
SIGIO | 29 | Terminate | I/O possible |
SIGPWR | 30 | Terminate | Power failure |
SIGSYS | 31 | Terminate + core | Bad syscall |
PROCESS_SUSPEND_RESUME — default action stop or continue:
| Signal | # | Default | Notes |
|---|---|---|---|
SIGSTOP | 19 | Stop | Forced stop, cannot be caught |
SIGTSTP | 20 | Stop | Terminal stop, Ctrl-Z |
SIGTTIN | 21 | Stop | Background read from terminal |
SIGTTOU | 22 | Stop | Background write to terminal |
SIGCONT | 18 | Continue | Resume a stopped process |
PROCESS_SIGNAL — default action ignore:
| Signal | # | Default | Notes |
|---|---|---|---|
SIGCHLD | 17 | Ignore | Child status change |
SIGURG | 23 | Ignore | Urgent socket data |
SIGWINCH | 28 | Ignore | Window 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 right | Maps to |
|---|---|
GENERIC_READ | PROCESS_QUERY_INFORMATION | PROCESS_VM_READ | READ_CONTROL |
GENERIC_WRITE | PROCESS_SET_INFORMATION | PROCESS_VM_WRITE | WRITE_DAC |
GENERIC_EXECUTE | PROCESS_TERMINATE | PROCESS_SUSPEND_RESUME | PROCESS_QUERY_LIMITED |
GENERIC_ALL | every 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_BOOTmapping 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 #
| Privilege | Bit | Mask | Enforcement |
|---|---|---|---|
SeCreateTokenPrivilege | 2 | 0x4 | Kernel standalone |
SeAssignPrimaryTokenPrivilege | 3 | 0x8 | Kernel standalone |
SeImpersonatePrivilege | 29 | 0x20000000 | Kernel 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 #
| Privilege | Bit | Mask | Enforcement |
|---|---|---|---|
SeSecurityPrivilege | 8 | 0x100 | AccessCheck + standalone |
SeTakeOwnershipPrivilege | 9 | 0x200 | AccessCheck |
SeBackupPrivilege | 17 | 0x20000 | AccessCheck + standalone |
SeRestorePrivilege | 18 | 0x40000 | AccessCheck + standalone |
SeRelabelPrivilege | 32 | 0x1_0000_0000 | AccessCheck + standalone |
SeChangeNotifyPrivilege | 23 | 0x800000 | Kernel standalone |
SeCreateSymbolicLinkPrivilege | 35 | 0x8_0000_0000 | Kernel 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 #
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).
SeSystemtimePrivilegechanges the clock,SeIncreaseBasePriorityPrivilegeraises scheduling priority and sets CPU affinity for other processes,SeIncreaseQuotaPrivilegeoverrides resource limits,SeLockMemoryPrivilegelocks pages in physical memory,- and
SeAuditPrivilegewrites 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 #
| Privilege | Bit | Enforcement |
|---|---|---|
SeSyncAgentPrivilege | 26 | Application-level |
SeEnableDelegationPrivilege | 27 | Application-level |
SeMachineAccountPrivilege | 6 | Application-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.
| Privilege | Bit | Reservation rationale |
|---|---|---|
SeCreatePagefilePrivilege | 15 | Absorbed into SeTcbPrivilege. |
SeCreatePermanentPrivilege | 16 | No Linux equivalent. |
SeSystemEnvironmentPrivilege | 22 | Gated by descriptors on efivar files under FACS. |
SeUndockPrivilege | 25 | Server operating system. |
SeCreateGlobalPrivilege | 30 | Peios has no per-LogonSession object namespaces. |
SeTrustedCredManAccessPrivilege | 31 | Reserved for future secrets infrastructure. |
SeIncreaseWorkingSetPrivilege | 33 | Linux does not gate memory residency hints. |
SeTimeZonePrivilege | 34 | Linux 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_TOKENattach 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_TOKENset 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_TOKENcmsg. The kernel gates the attach exactly as it would gate impersonating that token: the fd must carryTOKEN_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 withEPERM— 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_sigreturnsENOENTon 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()needsPROCESS_SET_INFORMATION;getpgid()andgetsid()needPROCESS_QUERY_LIMITED;- the scheduler, affinity and I/O priority queries need
PROCESS_QUERY_INFORMATION; - and the memory-placement mutations Linux routes through
task_movememoryneedPROCESS_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.
| SID | Level | Name |
|---|---|---|
S-1-16-0 | 0 | Untrusted |
S-1-16-4096 | 4096 | Low |
S-1-16-8192 | 8192 | Medium |
S-1-16-12288 | 12288 | High |
S-1-16-16384 | 16384 | System |
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 #
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_restrictedtoken withoutuser_deny_onlyis rejected as invalid. - In the orchestrator, a null descriptor is rejected and
AccessCheckResultListis 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:
- Impersonation level gate. An impersonation token at Identification level is denied immediately. Anonymous tokens proceed through the full pipeline.
- Input validation. Reject a descriptor with no owner. A null group SID is valid and has no direct effect on the decision.
- Generic mapping. Map generic bits in the desired mask to
object-specific bits; strip
MAXIMUM_ALLOWED. - Effective privileges. Clear the backup and restore bits when the corresponding intent flag is absent.
- Privilege grants. Resolve
ACCESS_SYSTEM_SECURITY, backup and restore. Seeddecided,grantedandprivilege_granted. - 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.
- Virtual group resolution.
S-1-3-4andS-1-5-10become matchable where the caller is the owner or the object's principal. - Tree initialisation. Seed each node from the scalar state.
- Normal DACL evaluation. Owner implicit rights, then the walk.
- Post-DACL WRITE_OWNER override.
SeTakeOwnershipPrivilegegrantsWRITE_OWNERif the DACL did not and no mandatory mechanism blocked it. - Restricted token pass, with intersection and privilege restoration.
- Confinement pass, with absolute intersection.
- CAAP. Evaluate each applicable rule's DACL through the full per-descriptor pipeline and intersect; collect SACLs.
- Privilege-use auditing.
- Audit emission, over the object's SACL and any CAAP SACLs.
- 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 #
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_PATHdescriptors, 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/dup3andforkproduce the same open file description and therefore the same rights;- descriptors without
FD_CLOEXECsurvive exec unchanged; SCM_RIGHTStransfers the descriptor as a capability token, with possession as authorization and no re-check of the receiver's identity; andpidfd_getfd()is gated by an AccessCheck forPROCESS_DUP_HANDLEagainst 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
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 #
| Value | Name | If it exists | If it does not |
|---|---|---|---|
| 0 | FILE_SUPERSEDE | Delete and recreate | Create |
| 1 | FILE_OPEN | Open | Fail |
| 2 | FILE_CREATE | Fail | Create |
| 3 | FILE_OPEN_IF | Open | Create |
| 4 | FILE_OVERWRITE | Truncate to zero | Fail |
| 5 | FILE_OVERWRITE_IF | Truncate to zero | Create |
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 #
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
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:
| Flags | Core rights |
|---|---|
O_RDONLY | FILE_READ_DATA | FILE_READ_ATTRIBUTES |
O_WRONLY | FILE_WRITE_DATA | FILE_READ_ATTRIBUTES |
O_RDWR | FILE_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 #
A fallocate mode outside the supported set fails closed, and
PUNCH_HOLE additionally requires KEEP_SIZE.
3.9.4.2 Metadata operations #
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:
| Command | Required right |
|---|---|
F_GETLK / F_GETLK64 / F_OFD_GETLK | Any data right |
F_GETLEASE / F_GETDELEG | FILE_READ_ATTRIBUTES |
F_GETPIPE_SZ | FILE_READ_ATTRIBUTES |
F_SETPIPE_SZ | FILE_WRITE_ATTRIBUTES |
F_GET_SEALS | FILE_READ_ATTRIBUTES |
F_ADD_SEALS | FILE_WRITE_ATTRIBUTES |
F_GET_RW_HINT / F_GET_FILE_RW_HINT | FILE_READ_ATTRIBUTES |
F_SET_RW_HINT / F_SET_FILE_RW_HINT | FILE_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:
| ioctl | Required right |
|---|---|
FIBMAP | FILE_READ_DATA |
FIGETBSZ | FILE_READ_ATTRIBUTES |
FIFREEZE / FITHAW | FILE_WRITE_ATTRIBUTES |
FITRIM | FILE_WRITE_ATTRIBUTES |
FS_IOC_GETFSUUID | FILE_READ_ATTRIBUTES |
FS_IOC_GETFSSYSFSPATH | FILE_READ_ATTRIBUTES |
FS_IOC_GETLBMD_CAP | FILE_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:
| ioctl | Required right |
|---|---|
FS_IOC_FIEMAP | FILE_READ_DATA |
FIONREAD | FILE_READ_DATA on a regular file; any data right otherwise |
FS_IOC_GETFLAGS | FILE_READ_ATTRIBUTES |
FS_IOC_SETFLAGS | FILE_WRITE_ATTRIBUTES |
FS_IOC_GETVERSION | FILE_READ_ATTRIBUTES |
FS_IOC_SETVERSION | FILE_WRITE_ATTRIBUTES |
FS_IOC_RESVSP / FS_IOC_RESVSP64 | FILE_APPEND_DATA or FILE_WRITE_DATA |
FS_IOC_UNRESVSP / FS_IOC_UNRESVSP64 | FILE_WRITE_DATA |
FS_IOC_ZERO_RANGE | FILE_WRITE_DATA |
FICLONE / FICLONERANGE | FILE_WRITE_DATA |
FIDEDUPERANGE | FILE_WRITE_DATA |
FIOQSIZE | FILE_READ_ATTRIBUTES |
FS_IOC_FSGETXATTR | FILE_READ_ATTRIBUTES |
FS_IOC_FSSETXATTR | FILE_WRITE_ATTRIBUTES |
FS_IOC_GETFSLABEL | FILE_READ_ATTRIBUTES |
FS_IOC_SETFSLABEL | FILE_WRITE_ATTRIBUTES |
FS_IOC_GET_ENCRYPTION_PWSALT | FILE_READ_ATTRIBUTES |
FS_IOC_GET_ENCRYPTION_POLICY | FILE_READ_ATTRIBUTES |
FS_IOC_GET_ENCRYPTION_POLICY_EX | FILE_READ_ATTRIBUTES |
FS_IOC_SET_ENCRYPTION_POLICY | FILE_WRITE_ATTRIBUTES |
FS_IOC_ADD_ENCRYPTION_KEY | FILE_WRITE_ATTRIBUTES |
FS_IOC_REMOVE_ENCRYPTION_KEY | FILE_WRITE_ATTRIBUTES |
FS_IOC_REMOVE_ENCRYPTION_KEY_ALL_USERS | FILE_WRITE_ATTRIBUTES |
FS_IOC_GET_ENCRYPTION_KEY_STATUS | FILE_READ_ATTRIBUTES |
BLKGETSIZE64 | FILE_READ_ATTRIBUTES |
BLKFLSBUF | FILE_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.
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 component | Replaced control bits |
|---|---|
| Owner | OWNER_DEFAULTED |
| Group | GROUP_DEFAULTED |
| DACL | DACL_DEFAULTED, DACL_TRUSTED, DACL_AUTO_INHERIT_REQ, DACL_AUTO_INHERITED, DACL_PROTECTED |
| Full SACL | SACL_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.
| Phase | Objects admitted |
|---|---|
| Source read | The pinned provider only. |
| Create | One 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. |
| Populate | The pinned provider and the one staged object bound to the context. |
| Publish by link | The bound staged object, one destination parent, one absent destination dentry. |
| Publish by rename | The bound staged object and its parent, one destination parent, one absent destination dentry. |
| Cleanup | One bound staging or materialisation dentry and its exact parent. |
| Orphan cleanup | One positive provider dentry carrying an authenticated stale staging marker, and its exact parent. |
| Orphan-marker cleanup | The 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 action | Enforcement points |
|---|---|
| Open and read provider data or a symlink target | security_inode_permission, security_file_open, security_file_permission, security_inode_readlink |
| Read provider attributes and extended attributes | the inode and file getattr, setattr-preflight, getxattr and listxattr paths as applicable |
| Create a staged file, directory or symlink, and materialise parents | security_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 xattrs | security_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. |
| Publish | security_inode_permission on the exact staged object or destination parent, then security_inode_link or security_inode_rename |
| Remove staging or roll back materialised entries | security_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.
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.
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:
| Field | Meaning |
|---|---|
policy | The new policy class (one of KACS_MOUNT_POLICY_DENY_MISSING, KACS_MOUNT_POLICY_SYNTHESIZE_EPHEMERAL, KACS_MOUNT_POLICY_SYNTHESIZE_PERSISTENT). |
flags | Reserved; must be zero. |
generation | (Output) The new generation counter value after the change. |
template_sd_ptr, template_sd_len | Optional mount-level SD template. Max 64 KiB. |
The kernel:
- Validates the fd and identifies the superblock.
- Validates the policy value. Attempts to set
KACS_MOUNT_POLICY_UNMANAGED(which is not settable via this ABI) are rejected with-EINVAL. - Validates the template SD if provided — must parse, must be within size limits.
- Checks the caller's privileges. Enabled
SeManageVolumePrivilegeorSeTcbPrivilegeis required, and marking the privilege as used must succeed. - Atomically updates the superblock's policy class, the template SD, and increments the generation counter.
- Writes the new generation to
args.generation. - 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 #
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:
- Validates the fd, identifies the superblock.
- Checks the caller's privileges. Enabled
SeManageVolumePrivilegeorSeTcbPrivilegeis required, with successful privilege-use marking. - Writes the policy class, generation, and template to
args(template only if a buffer was provided). - 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:
| Error | Cause |
|---|---|
-EBADF | The fd is invalid. |
-EPERM | Neither accepted privilege is enabled, or privilege-use marking failed. |
-EINVAL | Setting 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:
| Error | Cause |
|---|---|
-EBADF | The fd is invalid. |
-EPERM | Neither accepted privilege is enabled, or privilege-use marking failed. |
-ERANGE | A 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:
| CAP | Name | Rationale |
|---|---|---|
| 0 | CAP_CHOWN | KACS file hooks enforce. |
| 1 | CAP_DAC_OVERRIDE | KACS file hooks enforce. |
| 2 | CAP_DAC_READ_SEARCH | KACS file hooks enforce. |
| 3 | CAP_FOWNER | KACS file hooks enforce. |
| 4 | CAP_FSETID | KACS file hooks enforce. |
| 5 | CAP_KILL | The task_kill hook enforces. |
| 6 | CAP_SETGID | Cosmetic under KACS. |
| 7 | CAP_SETUID | Cosmetic under KACS. |
| 11 | CAP_NET_BROADCAST | Unused in modern kernels. |
| 15 | CAP_IPC_OWNER | KACS IPC hooks enforce. |
| 28 | CAP_LEASE | KACS file hooks enforce. |
| 10 | CAP_NET_BIND_SERVICE | The 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:
| CAP | Name | Privilege |
|---|---|---|
| 9 | CAP_LINUX_IMMUTABLE | SeTcbPrivilege |
| 12 | CAP_NET_ADMIN | SeTcbPrivilege |
| 13 | CAP_NET_RAW | SeTcbPrivilege |
| 14 | CAP_IPC_LOCK | SeLockMemoryPrivilege |
| 16 | CAP_SYS_MODULE | SeLoadDriverPrivilege |
| 17 | CAP_SYS_RAWIO | SeTcbPrivilege |
| 18 | CAP_SYS_CHROOT | SeTcbPrivilege |
| 19 | CAP_SYS_PTRACE | SeDebugPrivilege |
| 20 | CAP_SYS_PACCT | SeTcbPrivilege |
| 21 | CAP_SYS_ADMIN | SeTcbPrivilege (but see the mount note below) |
| 22 | CAP_SYS_BOOT | SeShutdownPrivilege |
| 23 | CAP_SYS_NICE | SeIncreaseBasePriorityPrivilege |
| 24 | CAP_SYS_RESOURCE | SeIncreaseQuotaPrivilege |
| 25 | CAP_SYS_TIME | SeSystemtimePrivilege |
| 26 | CAP_SYS_TTY_CONFIG | SeTcbPrivilege |
| 27 | CAP_MKNOD | SeTcbPrivilege |
| 29 | CAP_AUDIT_WRITE | SeAuditPrivilege |
| 30 | CAP_AUDIT_CONTROL | SeSecurityPrivilege |
| 33 | CAP_MAC_ADMIN | SeSecurityPrivilege |
| 34 | CAP_SYSLOG | SeTcbPrivilege |
| 35 | CAP_WAKE_ALARM | SeTcbPrivilege |
| 36 | CAP_BLOCK_SUSPEND | SeTcbPrivilege |
| 37 | CAP_AUDIT_READ | SeSecurityPrivilege |
| 38 | CAP_PERFMON | SeSystemProfilePrivilege or SeProfileSingleProcessPrivilege or SeLoadDriverPrivilege |
| 39 | CAP_BPF | SeTcbPrivilege |
| 40 | CAP_CHECKPOINT_RESTORE | SeTcbPrivilege |
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.
| Mechanism | With privilege | Without privilege |
|---|---|---|
setuid() syscall | Designed: all UIDs and token change. Actual: fails with EOPNOTSUPP. | Silent no-op |
| Setuid bit on exec | Designed: 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>:
| Right | Grants |
|---|---|
KACS_IPC_READ | msgrcv; shmat read-only; semop without alter; GETVAL, GETPID, GETNCNT, GETZCNT, GETALL |
KACS_IPC_WRITE | msgsnd; shmat read-write; semop with alter; SETVAL, SETALL |
KACS_IPC_QUERY_INFORMATION | IPC_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_INFORMATION | SHM_LOCK, SHM_UNLOCK — SHM_LOCK additionally needs SeLockMemoryPrivilege, Linux's CAP_IPC_LOCK gate |
DELETE | IPC_RMID |
WRITE_DAC and WRITE_OWNER | IPC_SET — one command carries mode, uid and gid, so it needs both |
READ_CONTROL | reading 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>:
| Right | Grants |
|---|---|
KACS_PORT_BIND (0x00000001) | bind(2) to any port the selector contains |
READ_CONTROL | reading 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:
- If the requested port is 0 — ephemeral allocation — nothing is checked. Protocols no reservation covers (SCTP, raw) pass untouched.
- 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. - 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, withobject.kindsocket. - 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:
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>:
| Right | Grants |
|---|---|
KACS_MNTNS_MOUNT | changing the table: a bind mount, an unmount, pivot_root |
KACS_MNTNS_ENTER | joining 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_OWNER | the 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:
- Privilege. A token holding
SeManageVolumePrivilegeorSeTcbPrivilege, 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. - 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.
- Descriptor. Otherwise, and only for an operation the descriptor
can admit, AccessCheck for
KACS_MNTNS_MOUNTruns 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) shape | Gate operation | Admissible by descriptor |
|---|---|---|
MS_BIND, with or without MS_REC | bind | yes |
umount2 without MNT_FORCE | unmount | yes |
pivot_root | pivot_root | yes |
a new tmpfs, proc or stratafs (do_new_mount) | new filesystem | yes |
| any other filesystem type | new filesystem | no |
MS_REMOUNT, including MS_REMOUNT|MS_BIND | other | no |
MS_MOVE; MS_SHARED, MS_PRIVATE, MS_SLAVE, MS_UNBINDABLE | other | no |
open_tree(OPEN_TREE_CLONE), fsopen, fspick, fsmount, move_mount, mount_setattr | other | no |
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.
| Number | PKM constant | glibc alias | Signature |
|---|---|---|---|
| 1000 | SYS_KACS_OPEN_SELF_TOKEN | SYS_kacs_open_self_token | kacs_open_self_token(unsigned int flags, u32 access_mask) |
| 1001 | SYS_KACS_OPEN_PROCESS_TOKEN | SYS_kacs_open_process_token | kacs_open_process_token(int pidfd, u32 access_mask) |
| 1002 | SYS_KACS_OPEN_THREAD_TOKEN | SYS_kacs_open_thread_token | kacs_open_thread_token(int pidfd, int tid, u32 access_mask) |
| 1003 | SYS_KACS_CREATE_TOKEN | SYS_kacs_create_token | kacs_create_token(const void __user *spec, size_t spec_len) |
| 1004 | SYS_KACS_CREATE_LOGON_SESSION | SYS_kacs_create_logon_session | kacs_create_logon_session(const void __user *spec, size_t spec_len) |
| 1005 | SYS_KACS_SET_PSB | SYS_kacs_set_psb | kacs_set_psb(int pidfd, u32 mitigations) |
| 1006 | SYS_KACS_DESTROY_EMPTY_LOGON_SESSION | SYS_kacs_destroy_empty_logon_session | kacs_destroy_empty_logon_session(u64 auth_id) |
| 1012 | SYS_KACS_REVERT | SYS_kacs_revert | kacs_revert(void) |
| 1020 | SYS_KACS_OPEN | SYS_kacs_open | kacs_open(int dirfd, const char __user *path, struct kacs_open_how __user *uhow, size_t howsize, u32 __user *status_out) |
| 1021 | SYS_KACS_GET_SD | SYS_kacs_get_sd | kacs_get_sd(int dirfd, const char __user *path, u32 security_info, void __user *buf, u32 buf_len, u32 flags) |
| 1022 | SYS_KACS_SET_SD | SYS_kacs_set_sd | kacs_set_sd(int dirfd, const char __user *path, u32 security_info, const void __user *sd_buf, u32 sd_len, u32 flags) |
| 1023 | SYS_KACS_ACCESS_CHECK | SYS_kacs_access_check | kacs_access_check(const void __user *uargs) |
| 1024 | SYS_KACS_ACCESS_CHECK_LIST | SYS_kacs_access_check_list | kacs_access_check_list(const void __user *uargs, struct kacs_node_result __user *results, u32 results_count) |
| 1025 | SYS_KACS_SET_CAAP | SYS_kacs_set_caap | kacs_set_caap(const void __user *policy_sid, u32 policy_sid_len, const void __user *spec, u32 spec_len) |
| 1026 | SYS_KACS_GET_MOUNT_POLICY | SYS_kacs_get_mount_policy | kacs_get_mount_policy(int fd, struct kacs_mount_policy_args __user *uargs, size_t argsize) |
| 1027 | SYS_KACS_SET_MOUNT_POLICY | SYS_kacs_set_mount_policy | kacs_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.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | token_class |
| 4 | 4 | __u32 | buf_len |
| 8 | 8 | __u64 | buf_ptr |
3.A.2.2 struct kacs_adjust_privs_args #
Total size 24 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | count |
| 4 | 4 | __u32 | _pad |
| 8 | 8 | __u64 | data_ptr |
| 16 | 8 | __u64 | previous_enabled |
3.A.2.3 struct kacs_priv_entry #
Total size 8 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | luid |
| 4 | 4 | __u32 | attributes |
3.A.2.4 struct kacs_adjust_groups_args #
Total size 144 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | count |
| 4 | 4 | __u32 | _pad |
| 8 | 8 | __u64 | data_ptr |
| 16 | 128 | __u64``[16] | previous_state |
3.A.2.5 struct kacs_duplicate_args #
Total size 16 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | access_mask |
| 4 | 4 | __u32 | token_type |
| 8 | 4 | __u32 | impersonation_level |
| 12 | 4 | __s32 | result_fd |
3.A.2.6 struct kacs_group_entry #
Total size 8 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | index |
| 4 | 4 | __u32 | enable |
3.A.2.7 struct kacs_adjust_default_args #
Total size 16 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 8 | __u64 | dacl_ptr |
| 8 | 4 | __u32 | dacl_len |
| 12 | 2 | __u16 | owner_index |
| 14 | 2 | __u16 | group_index |
3.A.2.8 struct kacs_restrict_args #
Total size 40 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 8 | __u64 | privs_to_delete |
| 8 | 4 | __u32 | num_deny_indices |
| 12 | 4 | __u32 | num_restrict_sids |
| 16 | 4 | __u32 | data_len |
| 20 | 4 | __u32 | flags |
| 24 | 8 | __u64 | data_ptr |
| 32 | 4 | __s32 | result_fd |
| 36 | 4 | __u32 | _pad |
3.A.2.9 struct kacs_link_tokens_args #
Total size 16 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __s32 | elevated_fd |
| 4 | 4 | __s32 | filtered_fd |
| 8 | 8 | __u64 | logon_session_id |
3.A.2.10 struct kacs_get_linked_token_args #
Total size 4 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __s32 | result_fd |
3.A.2.11 struct kacs_access_check_args #
Total size 136 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | caller_size |
| 4 | 4 | __s32 | token_fd |
| 8 | 8 | __u64 | sd_ptr |
| 16 | 4 | __u32 | sd_len |
| 20 | 4 | __u32 | desired_access |
| 24 | 4 | __u32 | mapping_read |
| 28 | 4 | __u32 | mapping_write |
| 32 | 4 | __u32 | mapping_execute |
| 36 | 4 | __u32 | mapping_all |
| 40 | 8 | __u64 | self_sid_ptr |
| 48 | 4 | __u32 | self_sid_len |
| 52 | 4 | __u32 | privilege_intent |
| 56 | 8 | __u64 | object_tree_ptr |
| 64 | 4 | __u32 | object_tree_count |
| 68 | 4 | __u32 | _pad0 |
| 72 | 8 | __u64 | local_claims_ptr |
| 80 | 4 | __u32 | local_claims_len |
| 84 | 4 | __u32 | _pad1 |
| 88 | 8 | __u64 | granted_out_ptr |
| 96 | 4 | __u32 | pip_type |
| 100 | 4 | __u32 | pip_trust |
| 104 | 8 | __u64 | audit_context_ptr |
| 112 | 4 | __u32 | audit_context_len |
| 116 | 4 | __u32 | _pad2 |
| 120 | 8 | __u64 | continuous_audit_out_ptr |
| 128 | 8 | __u64 | staging_mismatch_out_ptr |
3.A.2.12 struct kacs_object_type_entry #
Total size 20 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 2 | __u16 | level |
| 2 | 2 | __u16 | _reserved |
| 4 | 16 | __u8``[16] | guid |
3.A.2.13 struct kacs_node_result #
Total size 8 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | granted |
| 4 | 4 | __s32 | status |
3.A.2.14 struct kacs_open_how #
Total size 32 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | desired_access |
| 4 | 4 | __u32 | create_disposition |
| 8 | 4 | __u32 | create_options |
| 12 | 4 | __u32 | flags |
| 16 | 8 | __u64 | sd_ptr |
| 24 | 4 | __u32 | sd_len |
| 28 | 4 | __u32 | __pad |
3.A.2.15 struct kacs_mount_policy_args #
Total size 32 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | policy |
| 4 | 4 | __u32 | flags |
| 8 | 4 | __u32 | generation |
| 12 | 4 | __u32 | __pad0 |
| 16 | 8 | __u64 | template_sd_ptr |
| 24 | 4 | __u32 | template_sd_len |
| 28 | 4 | __u32 | __pad1 |
3.A.2.16 struct kacs_generic_mapping #
Total size 16 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | read |
| 4 | 4 | __u32 | write |
| 8 | 4 | __u32 | execute |
| 12 | 4 | __u32 | all |
3.A.3 Token constants #
From uapi/pkm/token.h.
kacs_open_self_token (SYS_KACS_OPEN_SELF_TOKEN) flags.
| Constant | Value |
|---|---|
KACS_TOKEN_OPEN_REAL | 0x01 (1) |
Per-handle token rights (the low 16 bits of a token access mask).
| Constant | Value |
|---|---|
KACS_TOKEN_ASSIGN_PRIMARY | 0x0001 (1) |
KACS_TOKEN_DUPLICATE | 0x0002 (2) |
KACS_TOKEN_IMPERSONATE | 0x0004 (4) |
KACS_TOKEN_QUERY | 0x0008 (8) |
KACS_TOKEN_QUERY_SOURCE | 0x0010 (16) |
KACS_TOKEN_ADJUST_PRIVS | 0x0020 (32) |
KACS_TOKEN_ADJUST_GROUPS | 0x0040 (64) |
KACS_TOKEN_ADJUST_DEFAULT | 0x0080 (128) |
KACS_TOKEN_ADJUST_INTERACTIVITY_SCOPE | 0x0100 (256) |
KACS_TOKEN_ALL_ACCESS | 0x000F01FF |
Token ioctl interface identifier.
| Constant | Value |
|---|---|
KACS_IOC_MAGIC | 0x4B (75) |
kacs_priv_entry.attributes bits.
| Constant | Value |
|---|---|
KACS_PRIVILEGE_ATTR_ENABLED | 0x00000002 (2) |
KACS_PRIVILEGE_ATTR_REMOVED | 0x00000004 (4) |
kacs_adjust_privs bulk-reset flag (not a per-entry attribute).
| Constant | Value |
|---|---|
KACS_PRIVILEGE_RESET_ALL_DEFAULTS | 0x80000000 |
kacs_restrict_args.flags bits.
| Constant | Value |
|---|---|
KACS_TOKEN_RESTRICT_WRITE_RESTRICTED | 0x00000001 (1) |
Token type (KACS_TOKEN_CLASS_TYPE).
| Constant | Value |
|---|---|
KACS_TOKEN_TYPE_PRIMARY | 0x01 (1) |
KACS_TOKEN_TYPE_IMPERSONATION | 0x02 (2) |
Impersonation level (KACS_TOKEN_CLASS_IMPERSONATION_LEVEL).
| Constant | Value |
|---|---|
KACS_IMLEVEL_ANONYMOUS | 0x00 (0) |
KACS_IMLEVEL_IDENTIFICATION | 0x01 (1) |
KACS_IMLEVEL_IMPERSONATION | 0x02 (2) |
KACS_IMLEVEL_DELEGATION | 0x03 (3) |
Elevation type (KACS_TOKEN_CLASS_ELEVATION_TYPE).
| Constant | Value |
|---|---|
KACS_ELEVATION_DEFAULT | 0x01 (1) |
KACS_ELEVATION_FULL | 0x02 (2) |
KACS_ELEVATION_LIMITED | 0x03 (3) |
Mandatory-policy bits (KACS_TOKEN_CLASS_MANDATORY_POLICY).
| Constant | Value |
|---|---|
KACS_TOKEN_MANDATORY_POLICY_NO_WRITE_UP | 0x00000001 (1) |
KACS_TOKEN_MANDATORY_POLICY_NEW_PROCESS_MIN | 0x00000002 (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.
| Constant | Value |
|---|---|
KACS_AUDIT_POLICY_OBJECT_ACCESS_SUCCESS | 0x00000001 (1) |
KACS_AUDIT_POLICY_OBJECT_ACCESS_FAILURE | 0x00000002 (2) |
KACS_AUDIT_POLICY_PRIVILEGE_USE_SUCCESS | 0x00000004 (4) |
KACS_AUDIT_POLICY_PRIVILEGE_USE_FAILURE | 0x00000008 (8) |
Logon type (KACS_TOKEN_CLASS_LOGON_TYPE).
| Constant | Value |
|---|---|
KACS_LOGON_TYPE_INTERACTIVE | 2 |
KACS_LOGON_TYPE_NETWORK | 3 |
KACS_LOGON_TYPE_BATCH | 4 |
KACS_LOGON_TYPE_SERVICE | 5 |
KACS_LOGON_TYPE_NETWORK_CLEARTEXT | 8 |
KACS_LOGON_TYPE_NEW_CREDENTIALS | 9 |
KACS_LOGON_TYPE_REMOTE_INTERACTIVE | 10 |
Maximum number of groups a token may carry.
| Constant | Value |
|---|---|
KACS_TOKEN_MAX_GROUPS | 1024 |
Number of 64-bit words in a group enabled-state bitmask (KACS_TOKEN_MAX_GROUPS / 64).
| Constant | Value |
|---|---|
KACS_TOKEN_GROUP_MASK_WORDS | 16 |
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.
| Constant | Value |
|---|---|
KACS_TOKEN_SPEC_VERSION | 2 |
KACS_TOKEN_SPEC_HEADER_BYTES | 192 |
KACS_TOKEN_SPEC_MIN_BYTES | 192 |
KACS_TOKEN_SPEC_MAX_BYTES | 65536 |
Byte offsets of the fixed token-spec header fields.
| Constant | Value |
|---|---|
KACS_TOKEN_SPEC_OFF_VERSION | 0 |
KACS_TOKEN_SPEC_OFF_TOKEN_TYPE | 4 |
KACS_TOKEN_SPEC_OFF_IMPERSONATION_LEVEL | 5 |
KACS_TOKEN_SPEC_OFF_RESERVED0 | 6 |
KACS_TOKEN_SPEC_OFF_INTEGRITY_RID | 8 |
KACS_TOKEN_SPEC_OFF_MANDATORY_POLICY | 12 |
KACS_TOKEN_SPEC_OFF_PRIVS_PRESENT | 16 |
KACS_TOKEN_SPEC_OFF_PRIVS_ENABLED | 24 |
KACS_TOKEN_SPEC_OFF_RESERVED1 | 32 |
KACS_TOKEN_SPEC_OFF_PROJECTED_UID | 36 |
KACS_TOKEN_SPEC_OFF_PROJECTED_GID | 40 |
KACS_TOKEN_SPEC_OFF_AUDIT_POLICY | 44 |
KACS_TOKEN_SPEC_OFF_EXPIRATION | 48 |
KACS_TOKEN_SPEC_OFF_LOGON_SESSION_ID | 56 |
KACS_TOKEN_SPEC_OFF_OWNER_SID_INDEX | 64 |
KACS_TOKEN_SPEC_OFF_PRIMARY_GROUP_INDEX | 68 |
KACS_TOKEN_SPEC_OFF_SOURCE_NAME | 72 |
KACS_TOKEN_SPEC_OFF_SOURCE_ID | 80 |
KACS_TOKEN_SPEC_OFF_USER_SID_OFFSET | 88 |
KACS_TOKEN_SPEC_OFF_GROUPS_OFFSET | 92 |
KACS_TOKEN_SPEC_OFF_GROUPS_COUNT | 96 |
KACS_TOKEN_SPEC_OFF_DEFAULT_DACL_OFFSET | 100 |
KACS_TOKEN_SPEC_OFF_DEFAULT_DACL_LEN | 104 |
KACS_TOKEN_SPEC_OFF_USER_CLAIMS_OFFSET | 108 |
KACS_TOKEN_SPEC_OFF_USER_CLAIMS_LEN | 112 |
KACS_TOKEN_SPEC_OFF_DEVICE_CLAIMS_OFFSET | 116 |
KACS_TOKEN_SPEC_OFF_DEVICE_CLAIMS_LEN | 120 |
KACS_TOKEN_SPEC_OFF_DEVICE_GROUPS_OFFSET | 124 |
KACS_TOKEN_SPEC_OFF_DEVICE_GROUPS_COUNT | 128 |
KACS_TOKEN_SPEC_OFF_RESTRICTED_SIDS_OFFSET | 132 |
KACS_TOKEN_SPEC_OFF_RESTRICTED_SIDS_COUNT | 136 |
KACS_TOKEN_SPEC_OFF_CONFINEMENT_SID_OFFSET | 140 |
KACS_TOKEN_SPEC_OFF_CONFINEMENT_SID_LEN | 144 |
KACS_TOKEN_SPEC_OFF_CONFINEMENT_CAPS_OFFSET | 148 |
KACS_TOKEN_SPEC_OFF_CONFINEMENT_CAPS_COUNT | 152 |
KACS_TOKEN_SPEC_OFF_CONFINEMENT_EXEMPT | 156 |
KACS_TOKEN_SPEC_OFF_WRITE_RESTRICTED | 157 |
KACS_TOKEN_SPEC_OFF_USER_DENY_ONLY | 158 |
KACS_TOKEN_SPEC_OFF_ISOLATION_BOUNDARY | 159 |
KACS_TOKEN_SPEC_OFF_SUPP_GIDS_OFFSET | 160 |
KACS_TOKEN_SPEC_OFF_SUPP_GIDS_COUNT | 164 |
KACS_TOKEN_SPEC_OFF_RESTRICTED_DEVICE_GROUPS_OFFSET | 168 |
KACS_TOKEN_SPEC_OFF_RESTRICTED_DEVICE_GROUPS_COUNT | 172 |
KACS_TOKEN_SPEC_OFF_ORIGIN | 176 |
KACS_TOKEN_SPEC_OFF_INTERACTIVITY_SCOPE | 184 |
KACS_TOKEN_SPEC_OFF_LCS_CREDENTIALS_OFFSET | 188 |
Byte length of the fixed token-source-name field.
| Constant | Value |
|---|---|
KACS_TOKEN_SPEC_SOURCE_NAME_BYTES | 8 |
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.
| Constant | Value |
|---|---|
KACS_TOKEN_LCS_EXT_VERSION | 1 |
KACS_TOKEN_LCS_EXT_HEADER_BYTES | 16 |
KACS_TOKEN_LCS_SCOPE_GUID_BYTES | 16 |
KACS_TOKEN_LCS_MAX_SCOPE_GUIDS | 256 |
KACS_TOKEN_LCS_MAX_PRIVATE_LAYERS | 256 |
KACS_TOKEN_LCS_MAX_LAYER_NAME_BYTES | 255 |
Byte offsets of the fixed LCS-extension header fields.
| Constant | Value |
|---|---|
KACS_TOKEN_LCS_EXT_OFF_VERSION | 0 |
KACS_TOKEN_LCS_EXT_OFF_RESERVED | 4 |
KACS_TOKEN_LCS_EXT_OFF_SCOPE_COUNT | 8 |
KACS_TOKEN_LCS_EXT_OFF_PRIVATE_LAYER_COUNT | 12 |
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.
| Constant | Value |
|---|---|
KACS_LOGON_SESSION_SPEC_MIN_BYTES | 15 |
KACS_LOGON_SESSION_SPEC_MAX_BYTES | 4096 |
Byte offsets of the fixed-position session-spec fields.
| Constant | Value |
|---|---|
KACS_LOGON_SESSION_SPEC_OFF_LOGON_TYPE | 0 |
KACS_LOGON_SESSION_SPEC_OFF_AUTH_PKG_LEN | 1 |
KACS_LOGON_SESSION_SPEC_OFF_AUTH_PKG | 3 |
| Constant | Value |
|---|---|
KACS_IOC_QUERY | 0xC0104B00 |
KACS_IOC_ADJUST_PRIVS | 0x40184B01 |
KACS_IOC_DUPLICATE | 0xC0104B02 |
KACS_IOC_INSTALL | 0x00004B03 |
KACS_IOC_RESTRICT | 0xC0284B04 |
KACS_IOC_LINK_TOKENS | 0x40104B05 |
KACS_IOC_GET_LINKED_TOKEN | 0xC0044B06 |
KACS_IOC_ADJUST_GROUPS | 0x40904B07 |
KACS_IOC_IMPERSONATE | 0x00004B08 |
KACS_IOC_ADJUST_DEFAULT | 0x40104B09 |
KACS_IOC_ADJUST_INTERACTIVITY_SCOPE | 0x40044B0A |
Token information classes (kacs_query_args.token_class).
GUID returns 16 immutable raw bytes in the same order as KMES headers.
| Constant | Value |
|---|---|
KACS_TOKEN_CLASS_USER | 0x01 (1) |
KACS_TOKEN_CLASS_GROUPS | 0x02 (2) |
KACS_TOKEN_CLASS_PRIVILEGES | 0x03 (3) |
KACS_TOKEN_CLASS_TYPE | 0x04 (4) |
KACS_TOKEN_CLASS_INTEGRITY_LEVEL | 0x05 (5) |
KACS_TOKEN_CLASS_OWNER | 0x06 (6) |
KACS_TOKEN_CLASS_PRIMARY_GROUP | 0x07 (7) |
KACS_TOKEN_CLASS_INTERACTIVITY_SCOPE | 0x08 (8) |
KACS_TOKEN_CLASS_RESTRICTED_SIDS | 0x09 (9) |
KACS_TOKEN_CLASS_SOURCE | 0x0A (10) |
KACS_TOKEN_CLASS_STATISTICS | 0x0B (11) |
KACS_TOKEN_CLASS_ORIGIN | 0x0C (12) |
KACS_TOKEN_CLASS_ELEVATION_TYPE | 0x0D (13) |
KACS_TOKEN_CLASS_DEVICE_GROUPS | 0x0E (14) |
KACS_TOKEN_CLASS_APPCONTAINER_SID | 0x0F (15) |
KACS_TOKEN_CLASS_CAPABILITIES | 0x10 (16) |
KACS_TOKEN_CLASS_MANDATORY_POLICY | 0x11 (17) |
KACS_TOKEN_CLASS_LOGON_TYPE | 0x12 (18) |
KACS_TOKEN_CLASS_LOGON_SID | 0x13 (19) |
KACS_TOKEN_CLASS_DEFAULT_DACL | 0x14 (20) |
KACS_TOKEN_CLASS_IMPERSONATION_LEVEL | 0x15 (21) |
KACS_TOKEN_CLASS_USER_CLAIMS | 0x16 (22) |
KACS_TOKEN_CLASS_DEVICE_CLAIMS | 0x17 (23) |
KACS_TOKEN_CLASS_PROJECTED_SUPPLEMENTARY_GIDS | 0x18 (24) |
KACS_TOKEN_CLASS_GUID | 0x19 (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, …).
| Constant | Value |
|---|---|
KACS_SE_CREATE_TOKEN_PRIVILEGE | 4 |
KACS_SE_ASSIGN_PRIMARY_TOKEN_PRIVILEGE | 8 |
KACS_SE_LOCK_MEMORY_PRIVILEGE | 16 |
KACS_SE_INCREASE_QUOTA_PRIVILEGE | 32 |
KACS_SE_TCB_PRIVILEGE | 128 |
KACS_SE_SECURITY_PRIVILEGE | 256 |
KACS_SE_TAKE_OWNERSHIP_PRIVILEGE | 512 |
KACS_SE_LOAD_DRIVER_PRIVILEGE | 1024 |
KACS_SE_SYSTEM_PROFILE_PRIVILEGE | 2048 |
KACS_SE_SYSTEMTIME_PRIVILEGE | 4096 |
KACS_SE_PROFILE_SINGLE_PROCESS_PRIVILEGE | 8192 |
KACS_SE_INCREASE_BASE_PRIORITY_PRIVILEGE | 16384 |
KACS_SE_BACKUP_PRIVILEGE | 131072 |
KACS_SE_RESTORE_PRIVILEGE | 262144 |
KACS_SE_SHUTDOWN_PRIVILEGE | 524288 |
KACS_SE_DEBUG_PRIVILEGE | 0x100000 (1048576) |
KACS_SE_AUDIT_PRIVILEGE | 0x200000 (2097152) |
KACS_SE_CHANGE_NOTIFY_PRIVILEGE | 0x800000 (8388608) |
KACS_SE_REMOTE_SHUTDOWN_PRIVILEGE | 0x1000000 (16777216) |
KACS_SE_MANAGE_VOLUME_PRIVILEGE | 0x10000000 (268435456) |
KACS_SE_IMPERSONATE_PRIVILEGE | 0x20000000 (536870912) |
KACS_SE_RELABEL_PRIVILEGE | 0x100000000 (4294967296) |
KACS_SE_CREATE_SYMBOLIC_LINK_PRIVILEGE | 0x800000000 (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.
| Constant | Value |
|---|---|
SOL_KACS | 4096 |
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.
| Constant | Value |
|---|---|
KACS_SO_PEER_TOKEN | 1 |
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.
| Constant | Value |
|---|---|
KACS_SO_IMPERSONATION_LEVEL | 2 |
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.
| Constant | Value |
|---|---|
KACS_SO_PASS_TOKEN | 3 |
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.
| Constant | Value |
|---|---|
KACS_SO_RESTAMP | 4 |
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.
| Constant | Value |
|---|---|
KACS_SCM_TOKEN | 1 |
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.
| Constant | Value | Notes |
|---|---|---|
KACS_IPC_READ | 0x00000001 (1) | msgrcv, shmat read-only, semop without alter, GETVAL/GETALL |
KACS_IPC_WRITE | 0x00000002 (2) | msgsnd, shmat read-write, semop with alter, SETVAL/SETALL |
KACS_IPC_QUERY_INFORMATION | 0x00000004 (4) | IPC_STAT and the *_STAT variants |
KACS_IPC_SET_INFORMATION | 0x00000008 (8) | SHM_LOCK / SHM_UNLOCK |
KACS_IPC_ALL_ACCESS | 983055 |
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.
| Constant | Value |
|---|---|
KACS_SD_AT_SYSV_SHM | 0x01000000 |
KACS_SD_AT_SYSV_MSG | 0x02000000 |
KACS_SD_AT_SYSV_SEM | 0x04000000 |
KACS_SD_AT_SYSV_MASK | 117440512 |
3.A.6 mntns.h #
From uapi/pkm/mntns.h.
KACS access rights for mount namespaces.
| Constant | Value | Notes |
|---|---|---|
KACS_MNTNS_MOUNT | 0x00000001 (1) | change the mount table |
KACS_MNTNS_ENTER | 0x00000002 (2) | join the namespace with setns(2) |
KACS_MNTNS_ALL_ACCESS | 917507 |
3.A.7 AccessCheck constants #
From uapi/pkm/access.h.
Full size of kacs_access_check_args the current kernel copies.
| Constant | Value |
|---|---|
KACS_ACCESS_CHECK_ARGS_SIZE | 136 |
Minimum caller_size the kernel accepts (the v1 / v0.20 layout).
| Constant | Value |
|---|---|
KACS_ACCESS_CHECK_ARGS_V1_SIZE | 40 |
Byte size of one kacs_object_type_entry in the object-type tree array.
| Constant | Value |
|---|---|
KACS_OBJECT_TYPE_ENTRY_SIZE | 20 |
Largest object-audit-context buffer the kernel accepts.
| Constant | Value |
|---|---|
KACS_ACCESS_CHECK_MAX_AUDIT_CONTEXT_LEN | 4096 |
Largest @Local claims blob the kernel accepts (local_claims_ptr).
| Constant | Value |
|---|---|
KACS_ACCESS_CHECK_MAX_LOCAL_CLAIMS_LEN | 65536 |
Largest object-type tree entry count the kernel accepts.
| Constant | Value |
|---|---|
KACS_ACCESS_CHECK_MAX_OBJECT_TYPE_COUNT | 1024 |
Claim value types — the discriminant of one attribute in the @Local claims array (local_claims_ptr).
| Constant | Value |
|---|---|
KACS_CLAIM_TYPE_INT64 | 0x0001 (1) |
KACS_CLAIM_TYPE_UINT64 | 0x0002 (2) |
KACS_CLAIM_TYPE_STRING | 0x0003 (3) |
KACS_CLAIM_TYPE_SID | 0x0005 (5) |
KACS_CLAIM_TYPE_BOOLEAN | 0x0006 (6) |
KACS_CLAIM_TYPE_OCTET | 0x0010 (16) |
| Constant | Value |
|---|---|
KACS_CLAIM_ATTR_CASE_SENSITIVE | 0x0002 (2) |
KACS_CLAIM_ATTR_USE_FOR_DENY_ONLY | 0x0004 (4) |
KACS_CLAIM_ATTR_DISABLED | 0x0010 (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.
| Constant | Value |
|---|---|
KACS_CAAP_SPEC_VERSION | 0x01 (1) |
KACS_CAAP_MAX_SPEC_BYTES | 262144 |
KACS_CAAP_MAX_RULE_COUNT | 256 |
KACS_CAAP_MAX_FIELD_BYTES | 65536 |
KACS_CAAP_MAX_ACL_BYTES | 65535 |
Byte offsets of the fixed CAAP-spec prefix fields.
| Constant | Value |
|---|---|
KACS_CAAP_SPEC_OFF_VERSION | 0 |
KACS_CAAP_SPEC_OFF_RULE_COUNT | 1 |
Byte length of the fixed CAAP-spec prefix (version + rule_count).
| Constant | Value |
|---|---|
KACS_CAAP_SPEC_PREFIX_BYTES | 5 |
3.A.8 File and open constants #
From uapi/pkm/file.h.
Minimum caller-supplied size accepted for each argument block.
| Constant | Value |
|---|---|
KACS_OPEN_HOW_MIN_SIZE | 16 |
KACS_MOUNT_POLICY_ARGS_MIN_SIZE | 16 |
Create dispositions (kacs_open_how.create_disposition).
| Constant | Value |
|---|---|
KACS_DISPOSITION_SUPERSEDE | 0 |
KACS_DISPOSITION_OPEN | 1 |
KACS_DISPOSITION_CREATE | 2 |
KACS_DISPOSITION_OPEN_IF | 3 |
KACS_DISPOSITION_OVERWRITE | 4 |
KACS_DISPOSITION_OVERWRITE_IF | 5 |
Create options (kacs_open_how.create_options).
| Constant | Value |
|---|---|
KACS_CREATE_OPT_DIRECTORY | 0x0001 (1) |
KACS_CREATE_OPT_DELETE_ON_CLOSE | 0x0002 (2) |
| Constant | Value |
|---|---|
KACS_BACKUP_INTENT | 0x00000001 (1) |
KACS_RESTORE_INTENT | 0x00000002 (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.
| Constant | Value |
|---|---|
KACS_FILE_READ_DATA | 0x00000001 (1) |
KACS_FILE_WRITE_DATA | 0x00000002 (2) |
KACS_FILE_APPEND_DATA | 0x00000004 (4) |
KACS_FILE_READ_EA | 0x00000008 (8) |
KACS_FILE_WRITE_EA | 0x00000010 (16) |
KACS_FILE_EXECUTE | 0x00000020 (32) |
KACS_FILE_DELETE_CHILD | 0x00000040 (64) |
KACS_FILE_READ_ATTRIBUTES | 0x00000080 (128) |
KACS_FILE_WRITE_ATTRIBUTES | 0x00000100 (256) |
KACS_FILE_LIST_DIRECTORY | 1 |
KACS_FILE_TRAVERSE | 32 |
KACS_FILE_ADD_FILE | 2 |
KACS_FILE_ADD_SUBDIRECTORY | 4 |
Mount-policy values (kacs_mount_policy_args.policy).
| Constant | Value |
|---|---|
KACS_MOUNT_POLICY_UNMANAGED | 1 |
KACS_MOUNT_POLICY_DENY_MISSING | 2 |
KACS_MOUNT_POLICY_SYNTHESIZE_EPHEMERAL | 3 |
KACS_MOUNT_POLICY_SYNTHESIZE_PERSISTENT | 4 |
Status word kacs_open writes back, describing what happened to the file.
| Constant | Value |
|---|---|
KACS_STATUS_OPENED | 1 |
KACS_STATUS_CREATED | 2 |
KACS_STATUS_OVERWRITTEN | 3 |
KACS_STATUS_SUPERSEDED | 4 |
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.
| Constant | Value |
|---|---|
KACS_PROCESS_TERMINATE | 0x00000001 (1) |
KACS_PROCESS_SIGNAL | 0x00000002 (2) |
KACS_PROCESS_VM_READ | 0x00000010 (16) |
KACS_PROCESS_VM_WRITE | 0x00000020 (32) |
KACS_PROCESS_DUP_HANDLE | 0x00000040 (64) |
KACS_PROCESS_SET_INFORMATION | 0x00000200 (512) |
KACS_PROCESS_QUERY_INFORMATION | 0x00000400 (1024) |
KACS_PROCESS_SUSPEND_RESUME | 0x00000800 (2048) |
KACS_PROCESS_QUERY_LIMITED | 0x00001000 (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.
| Constant | Value | Notes |
|---|---|---|
KACS_MIT_WXP | 0x001 (1) | Write-XOR-Execute protection |
KACS_MIT_TLP | 0x002 (2) | Trusted Library Paths |
KACS_MIT_LSV | 0x004 (4) | Library Signature Verification |
KACS_MIT_CFI | 0x008 (8) | legacy alias: CFIF | CFIB |
KACS_MIT_UI_ACCESS | 0x010 (16) | UI interaction (reserved) |
KACS_MIT_NO_CHILD | 0x020 (32) | cannot fork (one-way) |
KACS_MIT_CFIF | 0x040 (64) | forward-edge CFI (Intel IBT) |
KACS_MIT_CFIB | 0x080 (128) | backward-edge CFI (shadow stack) |
KACS_MIT_PIE | 0x100 (256) | reject non-PIE binaries at exec |
KACS_MIT_SML | 0x200 (512) | speculation mitigation lock |
All valid mitigation bits OR'd together — the accepted-request mask.
| Constant | Value |
|---|---|
KACS_MIT_ALL | 0x3FF (1023) |
3.A.11 Security descriptor constants #
From uapi/pkm/sd.h.
Byte length of the self-relative security-descriptor header.
| Constant | Value |
|---|---|
KACS_SD_HEADER_BYTES | 20 |
SECURITY_INFORMATION selector bits — which components of a security descriptor a kacs_get_sd / kacs_set_sd call reads or writes.
| Constant | Value |
|---|---|
KACS_SECINFO_OWNER | 0x00000001 (1) |
KACS_SECINFO_GROUP | 0x00000002 (2) |
KACS_SECINFO_DACL | 0x00000004 (4) |
KACS_SECINFO_SACL | 0x00000008 (8) |
KACS_SECINFO_LABEL | 0x00000010 (16) |
SECURITY_DESCRIPTOR_CONTROL bits — the SD header control field.
| Constant | Value |
|---|---|
KACS_SD_OWNER_DEFAULTED | 0x0001 (1) |
KACS_SD_GROUP_DEFAULTED | 0x0002 (2) |
KACS_SD_DACL_PRESENT | 0x0004 (4) |
KACS_SD_DACL_DEFAULTED | 0x0008 (8) |
KACS_SD_SACL_PRESENT | 0x0010 (16) |
KACS_SD_SACL_DEFAULTED | 0x0020 (32) |
KACS_SD_DACL_TRUSTED | 0x0040 (64) |
KACS_SD_SERVER_SECURITY | 0x0080 (128) |
KACS_SD_DACL_AUTO_INHERIT_REQ | 0x0100 (256) |
KACS_SD_SACL_AUTO_INHERIT_REQ | 0x0200 (512) |
KACS_SD_DACL_AUTO_INHERITED | 0x0400 (1024) |
KACS_SD_SACL_AUTO_INHERITED | 0x0800 (2048) |
KACS_SD_DACL_PROTECTED | 0x1000 (4096) |
KACS_SD_SACL_PROTECTED | 0x2000 (8192) |
KACS_SD_RM_CONTROL_VALID | 0x4000 (16384) |
KACS_SD_SELF_RELATIVE | 0x8000 (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.
| Constant | Value |
|---|---|
KACS_ACCESS_DELETE | 0x00010000 (65536) |
KACS_ACCESS_READ_CONTROL | 0x00020000 |
KACS_ACCESS_WRITE_DAC | 0x00040000 |
KACS_ACCESS_WRITE_OWNER | 0x00080000 |
KACS_ACCESS_SYNCHRONIZE | 0x00100000 |
KACS_ACCESS_ACCESS_SYSTEM_SECURITY | 0x01000000 |
KACS_ACCESS_MAXIMUM_ALLOWED | 0x02000000 |
KACS_ACCESS_GENERIC_ALL | 0x10000000 |
KACS_ACCESS_GENERIC_EXECUTE | 0x20000000 |
KACS_ACCESS_GENERIC_WRITE | 0x40000000 |
KACS_ACCESS_GENERIC_READ | 0x80000000 |
ACE types — the ace_type byte of an ACE header (MS-DTYP 2.4.4.1).
| Constant | Value |
|---|---|
KACS_ACE_TYPE_ACCESS_ALLOWED | 0x00 (0) |
KACS_ACE_TYPE_ACCESS_DENIED | 0x01 (1) |
KACS_ACE_TYPE_SYSTEM_AUDIT | 0x02 (2) |
KACS_ACE_TYPE_SYSTEM_ALARM | 0x03 (3) |
KACS_ACE_TYPE_ACCESS_ALLOWED_COMPOUND | 0x04 (4) |
KACS_ACE_TYPE_ACCESS_ALLOWED_OBJECT | 0x05 (5) |
KACS_ACE_TYPE_ACCESS_DENIED_OBJECT | 0x06 (6) |
KACS_ACE_TYPE_SYSTEM_AUDIT_OBJECT | 0x07 (7) |
KACS_ACE_TYPE_SYSTEM_ALARM_OBJECT | 0x08 (8) |
KACS_ACE_TYPE_ACCESS_ALLOWED_CALLBACK | 0x09 (9) |
KACS_ACE_TYPE_ACCESS_DENIED_CALLBACK | 0x0A (10) |
KACS_ACE_TYPE_ACCESS_ALLOWED_CALLBACK_OBJECT | 0x0B (11) |
KACS_ACE_TYPE_ACCESS_DENIED_CALLBACK_OBJECT | 0x0C (12) |
KACS_ACE_TYPE_SYSTEM_AUDIT_CALLBACK | 0x0D (13) |
KACS_ACE_TYPE_SYSTEM_ALARM_CALLBACK | 0x0E (14) |
KACS_ACE_TYPE_SYSTEM_AUDIT_CALLBACK_OBJECT | 0x0F (15) |
KACS_ACE_TYPE_SYSTEM_ALARM_CALLBACK_OBJECT | 0x10 (16) |
KACS_ACE_TYPE_SYSTEM_MANDATORY_LABEL | 0x11 (17) |
KACS_ACE_TYPE_SYSTEM_RESOURCE_ATTRIBUTE | 0x12 (18) |
KACS_ACE_TYPE_SYSTEM_SCOPED_POLICY_ID | 0x13 (19) |
KACS_ACE_TYPE_SYSTEM_PROCESS_TRUST_LABEL | 0x14 (20) |
KACS_ACE_TYPE_SYSTEM_ACCESS_FILTER | 0x15 (21) |
ACE header ace_flags byte — inheritance and audit control.
| Constant | Value |
|---|---|
KACS_ACE_FLAG_OBJECT_INHERIT | 0x01 (1) |
KACS_ACE_FLAG_CONTAINER_INHERIT | 0x02 (2) |
KACS_ACE_FLAG_NO_PROPAGATE_INHERIT | 0x04 (4) |
KACS_ACE_FLAG_INHERIT_ONLY | 0x08 (8) |
KACS_ACE_FLAG_INHERITED | 0x10 (16) |
KACS_ACE_FLAG_SUCCESSFUL_ACCESS | 0x40 (64) |
KACS_ACE_FLAG_FAILED_ACCESS | 0x80 (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.
| Constant | Value |
|---|---|
KACS_SYSTEM_MANDATORY_LABEL_NO_READ_UP | 0x00000001 (1) |
KACS_SYSTEM_MANDATORY_LABEL_NO_WRITE_UP | 0x00000002 (2) |
KACS_SYSTEM_MANDATORY_LABEL_NO_EXECUTE_UP | 0x00000004 (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.
| Constant | Value |
|---|---|
KACS_ACE_OBJECT_TYPE_PRESENT | 0x00000001 (1) |
KACS_ACE_INHERITED_OBJECT_TYPE_PRESENT | 0x00000002 (2) |
3.A.12 SID constants #
From uapi/pkm/sid.h.
Largest sub_authority_count a valid SID may declare.
| Constant | Value |
|---|---|
KACS_SID_MAX_SUB_AUTHORITIES | 15 |
Encoded byte length of a SID with the given sub-authority count.
| Constant | Value |
|---|---|
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.
| Constant | Value |
|---|---|
KACS_SID_GROUP_MANDATORY | 0x00000001 (1) |
KACS_SID_GROUP_ENABLED_BY_DEFAULT | 0x00000002 (2) |
KACS_SID_GROUP_ENABLED | 0x00000004 (4) |
KACS_SID_GROUP_OWNER | 0x00000008 (8) |
KACS_SID_GROUP_USE_FOR_DENY_ONLY | 0x00000010 (16) |
KACS_SID_GROUP_INTEGRITY | 0x00000020 (32) |
KACS_SID_GROUP_INTEGRITY_ENABLED | 0x00000040 (64) |
KACS_SID_GROUP_RESOURCE | 0x20000000 |
KACS_SID_GROUP_LOGON_ID | 0xC0000000 |
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).
| Constant | Value | Notes |
|---|---|---|
KACS_TR_DECISION | 0 | resolved allow/deny |
KACS_TR_BAD_ARGS | 1 | NULL/zero argument guard |
KACS_TR_NO_ISEC | 2 | inode has no i_security blob |
KACS_TR_UNMANAGED | 3 | superblock mount policy UNMANAGED |
KACS_TR_PIP_CONTEXT | 4 | current PIP context unavailable |
KACS_TR_NO_TOKEN | 5 | no effective subject token |
KACS_TR_NO_DENTRY_ALIAS | 6 | inode has no dentry alias yet |
KACS_TR_DELETE_ON_CLOSE_PENDING | 7 | open of a delete-on-close file |
KACS_TR_NATIVE_STAMP | 8 | native-open granted-access stamp |
KACS_TR_NATIVE_ARM | 9 | native-open delete-on-close arm |
KACS_TR_STAMP | 10 | legacy-open granted-access stamp |
KACS_TR_LAZY_DENTRY_RELOOKUP | 11 | native create lazy re-lookup |
KACS_TR_NEGATIVE_AFTER_CREATE | 12 | negative dentry after create |
KACS_TR_CHANGE_NOTIFY_PRIV | 13 | traverse via CHANGE_NOTIFY priv |
KACS_TR_CHANGE_NOTIFY_PRIV_EXHAUSTED | 14 | CHANGE_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.
| Constant | Value | Notes |
|---|---|---|
KACS_SDC_HIT | 0 | current, valid cache present |
KACS_SDC_MISS_NONE | 1 | no cache attached |
KACS_SDC_MISS_STALE_GEN | 2 | cache present but stale generation |
KACS_SDC_MISS_NEEDS_SYNTH | 3 | missing SD requires synthesis |
KACS_SDC_CORRUPT_EMPTY_OR_OVERSIZE | 4 | stored SD zero-length or oversize |
KACS_SDC_CORRUPT_VALIDATE_FAIL | 5 | stored 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.
| Constant | Value | Notes |
|---|---|---|
KACS_PA_ALLOW | 0 | access granted |
KACS_PA_BAD_ARGS | 1 | NULL subject/target guard |
KACS_PA_NO_TARGET | 2 | target has no process state/SD |
KACS_PA_NO_SD | 3 | target process SD unavailable |
KACS_PA_SD_ERROR | 4 | SD check failed (non-EACCES) |
KACS_PA_PIP_DENIED | 5 | denied by process-integrity policy |
KACS_PA_DEBUG_RESCUE | 6 | SD denial rescued by SeDebugPrivilege |
KACS_PA_DEBUG_DENIED | 7 | denied; no usable SeDebugPrivilege |
KACS_PA_PIP_DOMINANCE | 8 | caller 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.
| Constant | Value | Notes |
|---|---|---|
KACS_EXEC_CREDS_ALLOW | 0 | exec cred transition allowed |
KACS_EXEC_BAD_ARGS | 1 | NULL cred/token guard |
KACS_EXEC_ID_CHANGE_NO_TOKEN | 2 | uid/gid change, no subject token |
KACS_EXEC_ID_CHANGE_PRIV_UNSUPPORTED | 3 | id change + ASSIGN_PRIMARY_TOKEN priv |
KACS_EXEC_TOKEN_NPM_DERIVED | 4 | exec token derived via new-process-min |
KACS_EXEC_TOKEN_CLONE | 5 | exec token via primary clone fallback |
KACS_EXEC_NPM_NO_FILE | 6 | new-process-min needs file, none supplied |
KACS_EXEC_NPM_DERIVE_FAIL | 7 | new_process_min_exec derivation failed |
KACS_EXEC_TOKEN_INSTALL_FAIL | 8 | install token ref on new cred failed |
KACS_EXEC_TOKEN_CLONE_FAIL | 9 | primary token clone returned NULL |
KACS_EXEC_INTEGRITY_NO_ISEC | 10 | exec file inode has no i_security |
KACS_EXEC_INTEGRITY_NO_CACHE | 11 | exec file SD cache absent |
KACS_EXEC_INTEGRITY_INVALID_SD | 12 | exec file cached SD invalid/empty |
KACS_EXEC_IMPERSONATION_REVERT_FAIL | 13 | bprm impersonation revert failed |
KACS_EXEC_PIP_COMMITTED | 14 | pending exec PIP committed at commit |
KACS_EXEC_UMH_NOT_TCB | 15 | usermodehelper exec below PeiosTcb trust |
KACS_EXEC_SIGNATURE_UNVERIFIABLE | 16 | exec refused: signature could not be verified |
KACS_EXEC_PIP_CAPPED_UNSAFE | 17 | exec 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.
| Constant | Value | Notes |
|---|---|---|
KACS_SIG_UNSIGNED | 0 | material source NONE (unsigned) |
KACS_SIG_BAD_KEY_TABLE | 1 | key table malformed / bad args |
KACS_SIG_NO_KEY_MATCH | 2 | no key verified the signature |
KACS_SIG_VERIFIED | 3 | a key verified; trust assigned |
KACS_SIG_CRYPTO_UNAVAILABLE | 4 | mldsa65 tfm allocation failed |
KACS_SIG_CRYPTO_MISMATCH | 5 | set-pubkey/verify returned nonzero |
KACS_SIG_PROBE_FOUND | 6 | valid signature material committed |
KACS_SIG_ELF_MAGIC_READ | 7 | failed reading ELF magic |
KACS_SIG_ELF_SHORT_EHDR | 8 | file too short for Elf64_Ehdr |
KACS_SIG_ELF_EHDR_READ | 9 | failed reading ELF header |
KACS_SIG_ELF_BAD_IDENT | 10 | unsupported e_ident class/data/version |
KACS_SIG_ELF_BAD_SHTABLE | 11 | bad shentsize/shstrndx |
KACS_SIG_ELF_SHDRS_RANGE | 12 | section-header table offset/len out of range |
KACS_SIG_ELF_SHSTR_READ | 13 | failed reading shstrtab section header |
KACS_SIG_ELF_STRTAB_RANGE | 14 | shstrtab offset/len out of range |
KACS_SIG_ELF_SHDR_READ | 15 | failed reading a section header |
KACS_SIG_ELF_NAME_READ | 16 | failed reading a section name |
KACS_SIG_ELF_BAD_SIG_SECTION | 17 | sig section wrong type/size/range |
KACS_SIG_ELF_BAD_BLOB | 18 | sig blob read failed or invalid |
KACS_SIG_ELF_HASH_FAIL | 19 | hashing failed for ELF sig |
KACS_SIG_XATTR_BAD_BLOB | 20 | xattr sig blob invalid |
KACS_SIG_XATTR_HASH_FAIL | 21 | hashing failed for xattr sig |
KACS_SIG_SIZE_CHANGED | 22 | file 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.
| Constant | Value | Notes |
|---|---|---|
KACS_FW_ALLOWED | 0 | verified at PeiosTcb |
KACS_FW_UNSIGNED | 1 | no signature material on the file |
KACS_FW_NO_KEY_MATCH | 2 | signed, but no built-in key verified it |
KACS_FW_BELOW_TCB | 3 | verified, but the key's tier is below PeiosTcb |
KACS_FW_UNVERIFIABLE | 4 | verification could not run (crypto unavailable) |
KACS_FW_PROBE_FAILED | 5 | reading 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.
| Constant | Value | Notes |
|---|---|---|
KACS_SOCK_BAD_ARGS | 0 | NULL/guard argument rejected |
KACS_SOCK_NOT_UNIX | 1 | not AF_UNIX / unsupported type |
KACS_SOCK_NO_SECURITY | 2 | sock has no sk_security blob |
KACS_SOCK_NO_TOKEN | 3 | no effective subject/client token |
KACS_SOCK_BAD_LEVEL | 4 | invalid impersonation level |
KACS_SOCK_WRONG_STATE | 5 | socket state forbids the op |
KACS_SOCK_NO_PEER_TOKEN | 6 | no captured peer token present |
KACS_SOCK_PIP_CONTEXT | 7 | caller PIP context unavailable |
KACS_SOCK_SD_DECISION | 8 | socket-SD check produced verdict |
KACS_SOCK_NO_SD | 9 | no socket SD; allowed without check |
KACS_SOCK_HAVE_SD | 10 | socket SD present; check performed |
KACS_SOCK_ALREADY_BOUND | 11 | socket SD already installed |
KACS_SOCK_BIND | 12 | abstract-socket SD bind result |
KACS_SOCK_CONNECT | 13 | unix_stream_connect result |
KACS_SOCK_LEVEL_SET | 14 | impersonation level updated |
KACS_SOCK_OPEN_TOKEN | 15 | open peer-token fd result |
16 was KACS_SOCK_IMPERSONATE (kacs_impersonate_peer); retired, not reused.
| Constant | Value | Notes |
|---|---|---|
KACS_SOCK_ATTACH | 17 | identity attached to a send |
KACS_SOCK_GATE | 18 | explicit KACS_SCM_TOKEN send-gate verdict |
KACS_SOCK_REGISTER | 19 | conveyed-identity register advanced |
KACS_SOCK_DELIVER | 20 | KACS_SCM_TOKEN delivered to a receiver |
KACS_SOCK_LISTEN | 21 | listener identity captured at listen() |
KACS_SOCK_RESTAMP | 22 | listener identity replaced by KACS_SO_RESTAMP |
KACS_SOCK_PORT_BIND | 23 | inet bind: port reservation SD verdict |
KACS_SOCK_PORT_TABLE | 24 | port reservation table load result |
KACS_SOCK_OWNER | 25 | governing identity stamped on an inet socket |
kacs_ipc reason — a System V IPC object SD decision (ipc.c).
| Constant | Value | Notes |
|---|---|---|
KACS_IPC_ALLOC | 0 | default SD stamped at *get creation |
KACS_IPC_PERMISSION | 1 | ipc_permission: read/write access to the object |
KACS_IPC_CTL | 2 | *ctl command gate (RMID/SET/STAT/...) |
KACS_IPC_SD_QUERY | 3 | kacs_get_sd on the object |
KACS_IPC_SD_SET | 4 | kacs_set_sd on the object |
KACS_IPC_NO_SD | 5 | object 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.
| Constant | Value | Notes |
|---|---|---|
KACS_NS_PRIMARY | 0 | the op's principal decision |
KACS_NS_PARENT_FALLBACK | 1 | delete: parent DELETE_CHILD fallback |
KACS_NS_SOURCE | 2 | link/rename source-side decision |
KACS_NS_DEST | 3 | link/rename destination-parent add |
KACS_NS_DELETE_EXISTING | 4 | rename: 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_*.
| Constant | Value | Notes |
|---|---|---|
KACS_PSB_APPLY_OK | 0 | mitigations applied; result_bits set |
KACS_PSB_APPLY_NORMALIZE | 1 | requested mask bad or unsupported (EINVAL/ENODEV) |
KACS_PSB_APPLY_MM_ACQUIRE | 2 | could not acquire target mm (EACCES) |
KACS_PSB_APPLY_CFIF | 3 | forward-CFI (IBT) activation failed |
KACS_PSB_APPLY_SML | 4 | speculative-mitigation-lock activation failed |
KACS_PSB_APPLY_CFIB | 5 | backward-CFI (shadow stack) activation failed |
KACS_PSB_WXP_MMAP | 6 | W^X blocked a W+X mmap |
KACS_PSB_WXP_MPROTECT | 7 | W^X blocked an mprotect transition |
KACS_PSB_WXP_EXISTING_VMA | 8 | W^X activation blocked by an existing W+X vma |
KACS_PSB_LSV_PROBE | 9 | LSV signing probe of the image failed |
KACS_PSB_LSV_VERIFY | 10 | LSV signature not verified/trusted |
KACS_PSB_LSV_PIP_DOMINANCE | 11 | LSV image PIP does not dominate process PIP |
KACS_PSB_PIE_ET_EXEC | 12 | PIE blocked a non-PIE ET_EXEC image |
KACS_PSB_PRCTL_SML | 13 | prctl blocked by SML lock |
KACS_PSB_PRCTL_CFIB | 14 | prctl blocked by shadow-stack (CFIB) lock |
KACS_PSB_PRCTL_PIP | 15 | prctl 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.
| Constant | Value | Notes |
|---|---|---|
KACS_TOK_QUERY | 0 | KACS_IOC_QUERY |
KACS_TOK_ADJUST_PRIVS | 1 | KACS_IOC_ADJUST_PRIVS |
KACS_TOK_ADJUST_GROUPS | 2 | KACS_IOC_ADJUST_GROUPS |
KACS_TOK_DUPLICATE | 3 | KACS_IOC_DUPLICATE |
KACS_TOK_INSTALL | 4 | KACS_IOC_INSTALL |
KACS_TOK_RESTRICT | 5 | KACS_IOC_RESTRICT |
KACS_TOK_LINK | 6 | KACS_IOC_LINK_TOKENS |
KACS_TOK_GET_LINKED | 7 | KACS_IOC_GET_LINKED_TOKEN |
KACS_TOK_IMPERSONATE | 8 | KACS_IOC_IMPERSONATE |
KACS_TOK_ADJUST_DEFAULT | 9 | KACS_IOC_ADJUST_DEFAULT |
KACS_TOK_ADJUST_INTERACTIVITY_SCOPE | 10 | KACS_IOC_ADJUST_INTERACTIVITY_SCOPE |
KACS_TOK_UNKNOWN | 11 | unrecognised 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.
| Constant | Value | Notes |
|---|---|---|
KACS_TREF_TO_FD | 0 | token installed into a new fd (ret == fd) |
KACS_TREF_RELEASE | 1 | token-fd released; ref dropped |
KACS_TREF_BIND | 2 | token cloned + bound onto an existing file |
KACS_TREF_OPEN | 3 | token 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.
| Constant | Value | Notes |
|---|---|---|
KACS_SES_CREATE | 0 | create_logon_session published a session |
KACS_SES_CREATE_PRIV_DENIED | 1 | create_logon_session: TCB privilege gate denied |
KACS_SES_DESTROY | 2 | destroy_empty_logon_session outcome |
KACS_SES_DESTROY_PRIV_DENIED | 3 | destroy: TCB privilege gate denied |
KACS_SES_CREATE_TOKEN | 4 | create_token issued a token fd |
KACS_SES_CREATE_TOKEN_PRIV_DENIED | 5 | create_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.
| Constant | Value | Notes |
|---|---|---|
KACS_CRED_PREPARE | 0 | cred_prepare token clone |
KACS_CRED_TRANSFER | 1 | cred_transfer token clone |
KACS_CRED_ALLOC_BLANK | 2 | cred_alloc_blank cleared sec |
KACS_CRED_FREE | 3 | cred_free released token/state |
KACS_CRED_INSTALL_TOKEN_REF | 4 | install token ref on a cred |
KACS_CRED_CLONE_THREAD_SHARE | 5 | CLONE_THREAD shares parent primary cred |
KACS_CRED_CLONE_FORK_COPY | 6 | fork deep-copies parent primary token |
KACS_CRED_PROJECT_UID0_BLOCKED | 7 | uid0 projection not allowed by token |
KACS_CRED_PROJECT_GROUPS_ALLOC_FAIL | 8 | groups_alloc failed (ENOMEM) |
KACS_CRED_PROJECT_E2BIG | 9 | supplementary 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.
| Constant | Value | Notes |
|---|---|---|
KACS_SETID_SETUID_NO_TOKEN | 0 | setuid gate: no subject token |
KACS_SETID_SETUID_PRIV_GATE | 1 | setuid gate: ASSIGN_PRIMARY priv |
KACS_SETID_SETGID_NO_TOKEN | 2 | setgid gate: no subject token |
KACS_SETID_SETGID_PRIV_GATE | 3 | setgid gate: ASSIGN_PRIMARY priv |
KACS_SETID_SETGROUPS_NO_TOKEN | 4 | setgroups gate: no subject token |
KACS_SETID_SETGROUPS_PRIV_GATE | 5 | setgroups 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.
| Constant | Value | Notes |
|---|---|---|
KACS_TASK_ALLOC_NO_CHILD_BLOCKED | 0 | clone blocked by NO_CHILD mitigation |
KACS_TASK_ALLOC_INHERIT_ENOMEM | 1 | process-state inherit failed (ENOMEM) |
KACS_TASK_ALLOC | 2 | task_alloc completed |
KACS_TASK_FREE | 3 | task_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.
| Constant | Value | Notes |
|---|---|---|
KACS_PRIM_INSTALL_OK | 0 | primary token install committed |
KACS_PRIM_SD_REALLOC | 1 | user-SID changed; process SD reallocated |
KACS_PRIM_SD_ALLOC_FAIL | 2 | process SD realloc failed (ENOMEM) |
KACS_PRIM_APPLY_COMMIT | 3 | new real creds committed (commit_creds) |
KACS_PRIM_IMPERSONATE_INSTALL | 4 | impersonation token installed (override_creds) |
KACS_PRIM_IMPERSONATE_REVERT | 5 | impersonation reverted (revert_creds) |
KACS_PRIM_SIBLING_REQUEUE | 6 | queued sibling install re-queued on ENOMEM |
KACS_PRIM_SIBLING_FAILED | 7 | queued 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.
| Constant | Value | Notes |
|---|---|---|
KACS_PTO_OPEN_OK | 0 | token fd opened |
KACS_PTO_BAD_ARGS | 1 | NULL subject/state/task guard |
KACS_PTO_NO_TARGET | 2 | target has no token |
KACS_PTO_BAD_ACCESS | 3 | invalid access mask rejected |
KACS_PTO_ACCESS_DENIED | 4 | process access check denied |
KACS_PTO_SELF | 5 | self-target inspection allowed |
KACS_PTO_CROSS | 6 | cross-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.
| Constant | Value | Notes |
|---|---|---|
KACS_PST_ALLOC | 0 | process state allocated |
KACS_PST_ALLOC_FAIL | 1 | process state alloc failed (ENOMEM) |
KACS_PST_FREE | 2 | process state freed (refcount hit 0) |
KACS_PST_INHERIT_SHARE | 3 | CLONE_THREAD: parent state shared |
KACS_PST_INHERIT_FORK | 4 | fork: new state allocated from parent |
KACS_PST_EXEC_PIP_STAGE | 5 | pending exec PIP staged |
KACS_PST_EXEC_PIP_COMMIT | 6 | pending exec PIP committed to state |
KACS_PST_DUMPABLE | 7 | exec dumpable hardened by PIP |
KACS_PST_CLONE_BLOCKED_NOCHILD | 8 | clone blocked by NO_CHILD mitigation |
KACS_PST_SD_ALLOC | 9 | default process SD allocated |
KACS_PST_SD_ALLOC_FAIL | 10 | process/socket SD alloc failed |
KACS_PST_SD_WRAP_FAIL | 11 | process SD wrapper alloc failed (ENOMEM) |
KACS_PST_SD_REPLACE | 12 | process SD replaced on state |
KACS_PST_SOCKET_SD_ALLOC | 13 | default 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.
| Constant | Value | Notes |
|---|---|---|
KACS_MP_SET_OK | 0 | policy committed; generation bumped |
KACS_MP_BAD_ARGS | 1 | NULL subject/sb/args guard (EINVAL) |
KACS_MP_NO_SECURITY | 2 | superblock has no s_security (EOPNOTSUPP) |
KACS_MP_UNMANAGED | 3 | magic-derived UNMANAGED; not settable (EOPNOTSUPP) |
KACS_MP_VALIDATE | 4 | mount-policy args validation failed (EINVAL) |
KACS_MP_TEMPLATE_INVALID | 5 | template SD bytes failed validation (EINVAL) |
KACS_MP_TCB_DENIED | 6 | SeTcbPrivilege gate denied (EPERM) |
KACS_MP_GET_OK | 7 | policy snapshot returned |
KACS_MP_GET_NO_SECURITY | 8 | get: no s_security; magic-derived policy returned |
KACS_MP_FIXED_POLICY | 9 | filesystem 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.
| Constant | Value | Notes |
|---|---|---|
KACS_SDS_KIND_TOKEN | 0 | token-fd target |
KACS_SDS_KIND_FILE | 1 | file/inode target |
KACS_SDS_KIND_PROCESS | 2 | pidfd process target |
KACS_SDS_KIND_PATH | 3 | path-resolved file target |
KACS_SDS_KIND_ACCESS_CHECK | 4 | AccessCheck 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.
| Constant | Value | Notes |
|---|---|---|
KACS_SDS_QUERY_OK | 0 | SD subset extracted and returned |
KACS_SDS_SET_OK | 1 | SD merged/replaced |
KACS_SDS_BAD_ARGS | 2 | NULL/zero argument guard (EINVAL) |
KACS_SDS_UNMANAGED | 3 | superblock UNMANAGED (EOPNOTSUPP) |
KACS_SDS_ACCESS_DENIED | 4 | SD access check denied (EACCES) |
KACS_SDS_NO_SD | 5 | target has no usable SD (EACCES) |
KACS_SDS_RESTORE_BYPASS | 6 | set via SeRestorePrivilege bypass |
KACS_SDS_QUERY_FAIL | 7 | subset 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.
| Constant | Value | Notes |
|---|---|---|
KACS_ACK_OK | 0 | ingress dispatched to the ABI bridge |
KACS_ACK_EVAL_CONTEXT | 1 | token-eval-context gate denied (EACCES) |
KACS_ACK_TOKEN_RESOLVE | 2 | token/args resolution failed |
KACS_ACK_CAAP_LOCK_FAIL | 3 | caap-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).
| Constant | Value | Notes |
|---|---|---|
KACS_FSOP_ACCESS | 0 | generic snapshot-grant access check |
KACS_FSOP_PERMISSION | 1 | file_permission hook |
KACS_FSOP_IOCTL | 2 | file ioctl snapshot |
KACS_FSOP_LOCK | 3 | file lock snapshot |
KACS_FSOP_FCNTL | 4 | file fcntl snapshot |
KACS_FSOP_TRUNCATE | 5 | file truncate snapshot |
KACS_FSOP_FALLOCATE | 6 | file fallocate snapshot |
KACS_FSOP_MMAP | 7 | file mmap snapshot |
KACS_FSOP_MPROTECT | 8 | file mprotect snapshot |
KACS_FSOP_WRITE_INTENT | 9 | write-intent snapshot |
KACS_FSOP_SYSFS_WRITE_GATE | 10 | unmanaged 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.
| Constant | Value | Notes |
|---|---|---|
KACS_FSR_DECISION | 0 | resolved allow/deny (grant compare) |
KACS_FSR_SIGNED_EXEC | 1 | signed-exec content mutation denied |
KACS_FSR_GRANT_DENY | 2 | granted access lacked required right |
KACS_FSR_APPEND_DENY | 3 | append/write intent lacked write grant |
KACS_FSR_UNMANAGED_SYSFS | 4 | unmanaged fd: sysfs write gate applied |
KACS_FSR_AUDIT_EMIT_FAIL | 5 | kacs.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).
| Constant | Value | Notes |
|---|---|---|
KACS_META_DECISION | 0 | generic metadata decision |
KACS_META_CONSUME_HIT | 1 | consumed a pre-staged decision |
KACS_META_BEGIN_BUSY | 2 | begin failed: a decision already active |
KACS_META_CANONICAL_SD | 3 | canonical SD xattr access denied |
KACS_META_CAPS_XATTR | 4 | capability xattr mutation denied (EPERM) |
KACS_META_ACL | 5 | POSIX ACL xattr denied |
KACS_META_SIGNED_EXEC | 6 | signed-exec xattr/size mutation denied |
KACS_META_BAD_ARGS | 7 | NULL name / dentry guard |
KACS_META_INTERNAL_SD | 8 | internal SD read/write re-entry allowed |
KACS_META_GETSECURITY | 9 | inode_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).
| Constant | Value | Notes |
|---|---|---|
KACS_NOX_PREPARE_OK | 0 | prepare accepted the request |
KACS_NOX_PREPARE_BAD_FLAGS | 1 | flags/create_options/__pad rejected |
KACS_NOX_PREPARE_BAD_SD_ARGS | 2 | sd_ptr/sd_len/disposition-sd combo bad |
KACS_NOX_PREPARE_BAD_DISPOSITION | 3 | create_disposition out of range |
KACS_NOX_PREPARE_BAD_ACCESS | 4 | desired-access mask invalid/empty |
KACS_NOX_PREPARE_UNSUPPORTED | 5 | valid but unsupported combination |
KACS_NOX_RESOLVE | 6 | resolve-existing-path outcome |
KACS_NOX_BUILD_CREATED_SD | 7 | build-created-file-SD outcome |
KACS_NOX_DELETE_ON_CLOSE_ARM | 8 | delete-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.
| Constant | Value | Notes |
|---|---|---|
KACS_OBJ_DELETE_ON_CLOSE_UNLINK | 0 | file_release delete-on-close unlink attempt |
KACS_OBJ_SIGNED_EXEC_PIN | 1 | inode pinned as signed-exec (immutable) |
KACS_OBJ_SIGNED_EXEC_MUTATION_BLOCKED | 2 | content 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.
| Constant | Value | Notes |
|---|---|---|
KACS_SFS_LOGON_SESSIONS_NO_TOKEN | 0 | sessions read: no effective subject token |
KACS_SFS_LOGON_SESSIONS_PIP_CONTEXT | 1 | sessions read: caller PIP context unavailable |
KACS_SFS_LOGON_SESSIONS_ACCESS_CHECK | 2 | sessions read: rust access check denied |
KACS_SFS_OPEN_SELF | 3 | open of kacs/self self-token file outcome |
KACS_SFS_INIT | 4 | securityfs 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.
| Constant | Value | Notes |
|---|---|---|
KACS_CAAP_TCB_GATE | 0 | SeTcbPrivilege gate denied the caller |
KACS_CAAP_SET | 1 | cache set (insert/evict); cache_len is post-set count |
KACS_CAAP_INIT | 2 | CAAP cache created |
KACS_CAAP_DESTROY | 3 | CAAP 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.
| Constant | Value | Notes |
|---|---|---|
KACS_CAP_ALLOW_GRANT | 0 | allow-cap auto-granted (no privilege needed) |
KACS_CAP_HARD_DENY | 1 | SETPCAP/SETFCAP/MAC_OVERRIDE hard-denied |
KACS_CAP_PRIV_NOT_ENABLED | 2 | mapped privilege not enabled on token |
KACS_CAP_USE_MARK_FAIL | 3 | privilege use-mark failed |
KACS_CAP_CAPSET | 4 | capset core outcome |
KACS_CAP_PRCTL_GUARD | 5 | prctl capability-guard outcome |
KACS_CAP_CAPABLE | 6 | capable() hook guard-deny outcome |
KACS_CAP_CAPGET | 7 | capget 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.
| Constant | Value | Notes |
|---|---|---|
KACS_PRIV_NULL_OR_ZERO | 0 | null token or zero privilege mask |
KACS_PRIV_NOT_ENABLED | 1 | privilege not enabled on token |
KACS_PRIV_USE_MARK_FAIL | 2 | privilege use-mark failed |
KACS_PRIV_CHANGE_NOTIFY | 3 | open_by_handle_at CHANGE_NOTIFY gate outcome |
KACS_PRIV_RCU_ENOMEM_FALLBACK | 4 | deferred-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.
| Constant | Value | Notes |
|---|---|---|
KACS_TLP_CHECK_PATH | 0 | executable transition denied: no prefix match |
KACS_TLP_REPLACE | 1 | TLP 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.
| Constant | Value | Notes |
|---|---|---|
KACS_MNTNS_SD_ALLOC | 0 | namespace minted with a creator descriptor |
KACS_MNTNS_SD_ALLOC_FAIL | 1 | descriptor could not be built |
KACS_MNTNS_GATE_PRIVILEGE | 2 | admitted by SeManageVolume / SeTcb |
KACS_MNTNS_GATE_NO_SD | 3 | namespace has no descriptor; refused |
KACS_MNTNS_GATE_OP_NOT_ADMITTED | 4 | op needs the privilege whatever the SD says |
KACS_MNTNS_GATE_SD_DECISION | 5 | access check against the namespace SD |
KACS_MNTNS_GATE_PIP_CONTEXT | 6 | caller PIP context unavailable |
KACS_MNTNS_GATE_FS_NOT_ADMITTED | 7 | filesystem type not on the unprivileged allowlist |
KACS_MNTNS_SB_STAMP | 8 | unprivileged tmpfs stamped synth-ephemeral, creator template |
KACS_MNTNS_SB_STAMP_FAIL | 9 | creator 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.
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 #
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.destroyedwhere 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:
| Field | Meaning |
|---|---|
token_class | The numeric class identifying what to return (1–24 in v0.20). |
buf_len | Input: the size of the output buffer in bytes. Output: the actual number of bytes the query needed. |
buf_ptr | Userspace pointer to the output buffer. |
The kernel:
- Validates the class against the catalog. Unknown classes return
-EINVAL. - Checks that the fd grants
TOKEN_QUERY. If not, returns-EACCES. - Computes the size the response needs.
- If
buf_ptris zero orbuf_lenis zero — this is a size query — writes the required size tobuf_lenand returns 0. - If
buf_ptris non-zero butbuf_lenis smaller than required, returns-ERANGEwith the required size still written tobuf_len. - 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:
- Call once with
buf_ptr = NULL(orbuf_len = 0). The kernel writes the required size intobuf_lenand returns 0. - Allocate a buffer of the indicated size.
- Call again with
buf_ptrset to the buffer andbuf_lenset 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.
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-DTYP | uapi 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_TOKEN | KACS_TOKEN_OPEN_REAL |
KACS_LEVEL_* | KACS_IMLEVEL_* |
KACS_FILE_SUPERSEDE, _OPEN, ... | KACS_DISPOSITION_* |
OWNER_SECURITY_INFORMATION, ... | KACS_SECINFO_* |
SE_PRIVILEGE_ENABLED / _REMOVED | KACS_PRIVILEGE_ATTR_ENABLED / _REMOVED |
KACS_PRIV_RESET_ALL_DEFAULTS | KACS_PRIVILEGE_RESET_ALL_DEFAULTS |
KACS_RESTRICT_WRITE_RESTRICTED | KACS_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.
-
Maximum allowed. O:116–127 says
MAXIMUM_ALLOWEDreturns 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 rejectMAXIMUM_ALLOWEDalone. The general DACL walk describes a separate AccessCheck layer and cannot settle open validity by analogy. -
Legacy masks. O:135–145 maps
O_RDONLYtoFILE_READ_DATA | FILE_READ_ATTRIBUTES | FILE_READ_EA | READ_CONTROL | SYNCHRONIZE,O_WRONLYtoFILE_WRITE_DATA | FILE_WRITE_ATTRIBUTES | FILE_WRITE_EA | READ_CONTROL | SYNCHRONIZE, andO_RDWRto their union. O:154–158 makes onlyFILE_READ_DATAcore for read-only opens and allowsFILE_READ_ATTRIBUTESto be dropped. L:16–37 instead makesFILE_READ_ATTRIBUTEScore with read data, write data, or both according to the flag; L:39–49 lists broader compat rights. O:138 saysO_APPENDaddsFILE_APPEND_DATA; L:30–33 replaces coreFILE_WRITE_DATAwith it, then re-adds write data forO_TRUNC. L:44–45 also requests write data as optional compat access on append opens. These are different mappings, not interchangeable summaries. -
Creator descriptors. O:96–97 groups open-existing branches under
EINVALand listsSUPERSEDEonly for an absent target. N:133–144 distinguishesFILE_OPEN(EOPNOTSUPP) from existingFILE_OPEN_IF,FILE_OVERWRITEandFILE_OVERWRITE_IF(EINVAL); N:82–89 also permits a caller-supplied SD on replacement byFILE_SUPERSEDE. D:36–46 and G:19–36 showOPEN_IFwith 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 separateSUPERSEDEcomparison. -
Replacement and status. O:71 says
SUPERSEDEremoves 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-targetFILE_SUPERSEDEreportsCREATED, withSUPERSEDEDonly for actual replacement. No atomicity guarantee follows from this comparison. -
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,forkandSCM_RIGHTS; later opens fail closed, and only regular files are supported. Generic inode last-reference semantics and this no-share lineage boundary differ. -
Unverified raw details. O:88 claims native
AT_EMPTY_PATHopens an empty path against the directory referenced bydirfd; N:66–69 discusses that flag for get/set-security, not native open. O:140–143 mapsO_CREATtoOPEN_IFwith parentFILE_ADD_FILE,O_CREAT | O_EXCLtoCREATE, andO_NOFOLLOWtoAT_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-componentENOTDIRand invalid-dispositionEINVAL. 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. -
SDK destination caveats. D:3 mentions share mode; D:28 describes no-follow, write-through and the rest of the
NtCreateFileoption 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
OPENplus creator SD withEOPNOTSUPPandOVERWRITEplus creator SD withEINVAL. Existing-path handling rejectsOPEN_IFandOVERWRITE_IFplus creator SD withEINVALwhen 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 withNULLSD; anEEXISTretry is a separate operation, not an atomic transaction. - Create options (part of group 7). The accepted-bit mask
contains only
DIRECTORYandDELETE_ON_CLOSE; unsupported bits returnEINVAL. The same SDK marshalling copiesoptionstocreate_optionsandflagstoflags, soAT_SYMLINK_NOFOLLOWbelongs 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:
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 entry | KACS structure | State carried |
|---|---|---|
lbs_cred | pkm_kacs_cred_security | Token reference, shared process_state, projected UID/GID, and the pending descriptor used while overlayfs creates or copies up an inode. |
lbs_task | pkm_kacs_task_security | Shared 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_sock | pkm_kacs_socket_security | Socket 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_ipc | pkm_kacs_ipc_security | Refcounted descriptor and lock for a System V message queue, shared-memory segment or semaphore array. |
lbs_file | pkm_kacs_file_security | Cached 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_file | pkm_kacs_backing_file_security | Cached granted_access and continuous_audit_mask, plus managed and inherited, for the explicit outer-to-backing-file snapshot transfer. |
lbs_inode | pkm_kacs_inode_security | Lock 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_superblock | pkm_kacs_superblock_security | Mount-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 hook | KACS callback | Implementation |
|---|---|---|
cred_prepare | pkm_kacs_cred_prepare | kacs/cred_lifecycle.c |
cred_transfer | pkm_kacs_cred_transfer | kacs/cred_lifecycle.c |
cred_alloc_blank | pkm_kacs_cred_alloc_blank | kacs/cred_lifecycle.c |
cred_free | pkm_kacs_cred_free | kacs/cred_lifecycle.c |
sb_alloc_security | pkm_kacs_sb_alloc_security | kacs/object_lifecycle.c |
sb_free_security | pkm_kacs_sb_free_security | kacs/object_lifecycle.c |
sb_kern_mount | pkm_kacs_sb_kern_mount | kacs/mnt_namespace.c |
inode_alloc_security | pkm_kacs_inode_alloc_security | kacs/object_lifecycle.c |
inode_free_security_rcu | pkm_kacs_inode_free_security_rcu | kacs/object_lifecycle.c |
inode_getattr | pkm_kacs_inode_getattr | kacs/file_metadata.c |
path_notify | pkm_kacs_path_notify | kacs/namespace.c |
inode_setattr | pkm_kacs_inode_setattr | kacs/file_metadata.c |
inode_file_getattr | pkm_kacs_inode_file_getattr | kacs/file_metadata.c |
inode_file_setattr | pkm_kacs_inode_file_setattr | kacs/file_metadata.c |
inode_xattr_skipcap | pkm_kacs_inode_xattr_skipcap | kacs/file_metadata.c |
inode_getxattr | pkm_kacs_inode_getxattr | kacs/file_metadata.c |
inode_setxattr | pkm_kacs_inode_setxattr | kacs/file_metadata.c |
inode_post_setxattr | pkm_kacs_inode_post_setxattr | kacs/file_metadata.c |
inode_removexattr | pkm_kacs_inode_removexattr | kacs/file_metadata.c |
inode_listxattr | pkm_kacs_inode_listxattr | kacs/file_metadata.c |
inode_copy_up | pkm_kacs_inode_copy_up | kacs/file_metadata.c |
inode_copy_up_xattr | pkm_kacs_inode_copy_up_xattr | kacs/file_metadata.c |
inode_follow_link | pkm_kacs_inode_follow_link | kacs/file_metadata.c |
inode_set_acl | pkm_kacs_inode_set_acl | kacs/file_metadata.c |
inode_remove_acl | pkm_kacs_inode_remove_acl | kacs/file_metadata.c |
inode_getsecurity | pkm_kacs_inode_getsecurity | kacs/file_metadata.c |
inode_permission | pkm_kacs_inode_permission | kacs/namespace.c |
inode_create | pkm_kacs_inode_create | kacs/namespace.c |
inode_link | pkm_kacs_inode_link | kacs/namespace.c |
inode_unlink | pkm_kacs_inode_unlink | kacs/namespace.c |
inode_symlink | pkm_kacs_inode_symlink | kacs/namespace.c |
inode_mkdir | pkm_kacs_inode_mkdir | kacs/namespace.c |
inode_rmdir | pkm_kacs_inode_rmdir | kacs/namespace.c |
inode_mknod | pkm_kacs_inode_mknod | kacs/namespace.c |
inode_rename | pkm_kacs_inode_rename | kacs/namespace.c |
inode_readlink | pkm_kacs_inode_readlink | kacs/namespace.c |
inode_init_security | pkm_kacs_inode_init_security | kacs/namespace.c |
inode_post_create_tmpfile | pkm_kacs_copy_up_post_create_tmpfile | kacs/copy_up.c |
dentry_create_files_as | pkm_kacs_dentry_create_files_as | kacs/namespace.c |
file_alloc_security | pkm_kacs_file_alloc_security | kacs/object_lifecycle.c |
backing_file_alloc | pkm_kacs_backing_file_alloc | kacs/object_lifecycle.c |
file_release | pkm_kacs_file_release | kacs/object_lifecycle.c |
file_open | pkm_kacs_file_open | kacs/file_access.c |
file_receive | pkm_kacs_file_receive | kacs/object_lifecycle.c |
file_permission | pkm_kacs_file_permission | kacs/file_access.c |
file_ioctl | pkm_kacs_file_ioctl | kacs/file_access.c |
file_ioctl_compat | pkm_kacs_file_ioctl_compat | kacs/file_access.c |
file_lock | pkm_kacs_file_lock | kacs/file_access.c |
file_fcntl | pkm_kacs_file_fcntl | kacs/file_access.c |
file_truncate | pkm_kacs_file_truncate | kacs/file_access.c |
kernel_read_file | pkm_kacs_kernel_read_file | kacs/firmware.c |
kernel_post_read_file | pkm_kacs_kernel_post_read_file | kacs/firmware.c |
task_alloc | pkm_kacs_task_alloc | kacs/task_lifecycle.c |
task_free | pkm_kacs_task_free | kacs/task_lifecycle.c |
sk_alloc_security | pkm_kacs_sk_alloc_security | kacs/socket.c |
sk_free_security | pkm_kacs_sk_free_security | kacs/socket.c |
sk_clone_security | pkm_kacs_sk_clone_security | kacs/socket.c |
socket_post_create | pkm_kacs_socket_post_create | kacs/socket.c |
socket_bind | pkm_kacs_socket_bind | kacs/socket.c |
socket_connect | pkm_kacs_socket_connect | kacs/socket.c |
unix_stream_connect | pkm_kacs_unix_stream_connect | kacs/socket.c |
unix_may_send | pkm_kacs_unix_may_send | kacs/socket.c |
socket_listen | pkm_kacs_socket_listen | kacs/socket.c |
ipc_permission | pkm_kacs_ipc_permission | kacs/ipc.c |
msg_queue_alloc_security | pkm_kacs_ipc_alloc_security | kacs/ipc.c |
msg_queue_free_security | pkm_kacs_ipc_free_security | kacs/ipc.c |
msg_queue_msgctl | pkm_kacs_msg_queue_msgctl | kacs/ipc.c |
shm_alloc_security | pkm_kacs_ipc_alloc_security | kacs/ipc.c |
shm_free_security | pkm_kacs_ipc_free_security | kacs/ipc.c |
shm_shmctl | pkm_kacs_shm_shmctl | kacs/ipc.c |
sem_alloc_security | pkm_kacs_ipc_alloc_security | kacs/ipc.c |
sem_free_security | pkm_kacs_ipc_free_security | kacs/ipc.c |
sem_semctl | pkm_kacs_sem_semctl | kacs/ipc.c |
task_kill | pkm_kacs_task_kill | kacs/process_access.c |
ptrace_access_check | pkm_kacs_ptrace_access_check | kacs/process_access.c |
ptrace_traceme | pkm_kacs_ptrace_traceme | kacs/process_access.c |
task_setnice | pkm_kacs_task_setnice | kacs/process_access.c |
task_setscheduler | pkm_kacs_task_setscheduler | kacs/process_access.c |
task_setioprio | pkm_kacs_task_setioprio | kacs/process_access.c |
task_setpgid | pkm_kacs_task_setpgid | kacs/process_access.c |
task_getpgid | pkm_kacs_task_getpgid | kacs/process_access.c |
task_getsid | pkm_kacs_task_getsid | kacs/process_access.c |
task_getscheduler | pkm_kacs_task_getscheduler | kacs/process_access.c |
task_getioprio | pkm_kacs_task_getioprio | kacs/process_access.c |
task_movememory | pkm_kacs_task_movememory | kacs/process_access.c |
task_fix_setuid | pkm_kacs_task_fix_setuid | kacs/cred_projection.c |
task_fix_setgid | pkm_kacs_task_fix_setgid | kacs/cred_projection.c |
task_fix_setgroups | pkm_kacs_task_fix_setgroups | kacs/cred_projection.c |
task_prlimit | pkm_kacs_task_prlimit | kacs/process_access.c |
capable | pkm_kacs_capable | kacs/capability.c |
capset | pkm_kacs_capset | kacs/capability.c |
task_prctl | pkm_kacs_task_prctl | kacs/psb.c |
mmap_file | pkm_kacs_mmap_file | kacs/psb.c |
mmap_backing_file | pkm_kacs_mmap_backing_file | kacs/object_lifecycle.c |
file_mprotect | pkm_kacs_file_mprotect | kacs/psb.c |
bprm_check_security | pkm_kacs_bprm_check_security | kacs/exec.c |
bprm_creds_from_file | pkm_kacs_bprm_creds_from_file | kacs/exec.c |
bprm_committing_creds | pkm_kacs_bprm_committing_creds | kacs/exec.c |
bprm_committed_creds | pkm_kacs_bprm_committed_creds | kacs/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 operation | Direct KACS entry point | Patch in kernel/patches/fs/ |
|---|---|---|
| Change mode | pkm_kacs_file_chmod | open-fchmod-fchown-gate.patch |
| Change owner/group | pkm_kacs_file_chown | open-fchmod-fchown-gate.patch |
| Change timestamps through a file descriptor | pkm_kacs_file_utimens | utimes-gate.patch |
| Read file attributes through a file descriptor | pkm_kacs_file_getattr | stat-getattr-gate.patch |
| Read an xattr through a file descriptor | pkm_kacs_file_sd_xattr_get | xattr-raw-sd-guard.patch |
| Set an xattr through a file descriptor | pkm_kacs_file_sd_xattr_set | xattr-raw-sd-guard.patch |
| Remove an xattr through a file descriptor | pkm_kacs_file_sd_xattr_remove | xattr-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.
| Flag | Bit | Meaning |
|---|---|---|
create | STRATAFS_F_CREATE | This stratum receives newly created objects and is the destination of copy-up. |
ro | STRATAFS_F_RO | stratafs does not modify this stratum. |
am | STRATAFS_F_AM | This 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.
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 #
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 detect | Why it is unnecessary here |
|---|---|
| A stratum gaining or losing a name | The next walk resolves the name afresh |
| A stratum's directory appearing | The next walk finds it |
| A stratum's directory removed, renamed away, or renamed over | The 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.
- If the mount is read-only, the route is read-only. This term short-circuits everything else.
- If the provider accepts modification, the operation is performed against the provider's object.
- 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.
- 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.
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 #
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.
- If there is no create stratum, or it is absent, the operation fails
with
EROFS. - 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. - 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:
-
Supersede applies to regular files only, and anything else is refused with
EOPNOTSUPP. -
The source must be in the create stratum, and source and destination must share a parent.
-
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
EROFSafter 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:
Pmust accept modification. OtherwiseEROFS— the source cannot be removed, so the rename would leave it still visible and amount to a copy.- The destination must not be provided by a stratum of higher
precedence than
P. OtherwiseEROFS— the renamed object would be shadowed at its destination and unreadable through the path it was renamed to. Pmust hold the directory containing the destination. OtherwiseEXDEV. That directory is not created to satisfy the condition, whether or notPis the create stratum: parent materialisation exists to receive a copy-up, and a rename is not one.- Where the source is a directory, the merged directory must contain
no entries provided by a stratum other than
P. OtherwiseEXDEV, 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 withEACCESwithout disclosing whether other strata contributed. - 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 withENOTDIR. Only the provider's type is compared, since a lower stratum's entry of a different type is masked and contributes no inode. - Where the destination's provider is a directory, that merged
directory must contain no entries at all — the same merged-emptiness
test
rmdiruses, with the same access requirement. OtherwiseENOTEMPTY. - The rename is performed within
P: both parents are resolved at the provider's index, and a singlevfs_renameis 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.
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:
Pmust accept modification. OtherwiseEXDEV.- The destination must not be held by any participating stratum.
Otherwise
EEXIST. Pmust hold the directory containing the destination. OtherwiseEXDEV. That directory is not created to satisfy the condition, whether or notPis the create stratum.- 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
flockunlock 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:
- The per-open-file mutation lock, taken by every operation that may copy up.
- The per-outer-inode rebind lock, taken inside it whenever an inode's provider is swapped.
- 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.
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-relative | The relative path within the mount, /-prefixed |
source.stratum.index | The stratum the object was copied from |
source.stratum.path | That stratum's path |
destination.stratum.index | The stratum it was copied into |
destination.stratum.path | That stratum's path |
outcome.success | Whether the copy-up succeeded |
outcome.errno | The 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 #
| Condition | Error |
|---|---|
| Caller lacks the access to resolve a stratum path or read its attributes | EACCES |
Empty stratum stack, or strata= absent | EINVAL |
| Stack longer than 16 strata | EINVAL |
More than one stratum carries create | EINVAL |
A stratum carries both create and ro | EINVAL |
| The same directory appears twice in the stack | EINVAL |
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 parsing | ENOMEM |
A create-bearing stack established outside the initial user namespace, or without CAP_SYS_ADMIN there | EPERM |
| A stratum path names a non-directory | ENOTDIR |
A stratum is absent without am | ENOENT |
| A stratum lies within the mount point, or within another stratafs mount whose strata include this mount point | ELOOP |
| The composed stack reaches the kernel's maximum stacking depth | ELOOP |
| Sixteen consecutive collisions allocating a mount cookie | EAGAIN |
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 #
| Condition | Error |
|---|---|
| No participating stratum holds the name | ENOENT |
| An ancestor of the path is masked by a non-directory provider | ENOTDIR |
| Operation requires a non-directory; provider is a directory | EISDIR |
| Traverse or list refused on any participating directory | EACCES |
| A stratum walk fails for a reason other than absence | that error |
A joined stratum path or child relative path exceeds PATH_MAX | ENAMETOOLONG |
| A task re-enters the same superblock while resolving | ELOOP |
4.8.3 Mutation #
| Condition | Error |
|---|---|
| Provider will not accept modification, and no create stratum has higher precedence | EROFS |
| Any mutation on a read-only-mounted stratafs | EROFS |
| Creation, unnamed-file creation, or unnamed-file link with no create stratum, or it absent | EROFS |
| Create stratum holds a path component as a non-directory | ENOTDIR |
| Exclusive creation where any stratum holds the name | EEXIST |
| Copy-up cannot preserve an extended attribute | EIO |
| Copy-up cannot preserve the descriptor | EACCES, 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 publication | ESTALE |
| Copy-up of a device node, FIFO or socket | EROFS |
| Unlink where the provider will not accept modification | EROFS, or EPERM where the provider inode is immutable |
| Rmdir where another stratum holds entries within | ENOTEMPTY |
| Rmdir or directory rename where list is refused on a participant | EACCES |
| Modifying a special file's mode, descriptor or extended attributes where its provider will not accept modification | EROFS |
| Establishing a shared write-capable mapping where routing yields read-only | EROFS |
| Replacing disposition where §4.5.4 would refuse the removal, or where a stratum above the create stratum would still hold the name | EROFS |
| Replacing disposition on anything but a regular file | EOPNOTSUPP |
| Rename where the source's provider will not accept modification | EROFS |
| Rename where a stratum above the source's provider provides the destination | EROFS |
| Rename where the source's provider does not hold the destination's directory | EXDEV |
| Rename of a directory containing entries from other strata | EXDEV |
| Rename of a non-directory onto a directory provider | EISDIR |
| Rename of a directory onto a non-directory provider | ENOTDIR |
| Rename onto a destination directory not empty across every stratum | ENOTEMPTY |
RENAME_NOREPLACE where any stratum holds the destination | EEXIST |
RENAME_EXCHANGE where the two names are not provided by one stratum that accepts modification | EROFS |
RENAME_EXCHANGE where another stratum holds entries under either directory name | EXDEV |
RENAME_WHITEOUT | EINVAL |
| Hard link whose source's provider will not accept modification, or does not hold the destination's directory | EXDEV |
| Hard link where any stratum holds the destination | EEXIST |
| Linking an unnamed file into a different mount | EXDEV |
| A dentry whose private state is missing, or whose provider identity no longer matches | ESTALE |
ioctl on a regular file | ENOTTY, or ENOIOCTLCMD for the compat form |
remap_file_range with flags outside dedupe and advisory | EINVAL |
4.8.4 Interface #
| Condition | Error |
|---|---|
| Set or remove of any reserved attribute | EPERM |
Read of a reserved attribute other than origin | ENODATA |
| Origin read where read-EA is refused on any participant | EACCES |
| Origin read into an undersized buffer | ERANGE |
| Freezing the filesystem | EOPNOTSUPP |
| Reading a mount's policy without TCB privilege | refused 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
EROFSarrives 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
ESTALEon 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). fstatandstatmay 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
readdiroffset 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 #
| Constant | Value | Meaning |
|---|---|---|
STRATAFS_MAGIC | 0x53545241 | Superblock magic, reported by statfs (§4.2.2) |
STRATAFS_NAME | "stratafs" | The name the filesystem registers under |
STRATAFS_MAX_STRATA | 16 | Longest 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 #
| Constant | Value | Meaning |
|---|---|---|
STRATAFS_F_CREATE | 0x1 | The create flag (§4.2.1) |
STRATAFS_F_RO | 0x2 | The ro flag |
STRATAFS_F_AM | 0x4 | The am flag |
4.A.3 Extended attributes #
| Constant | Value | Meaning |
|---|---|---|
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 #
| Constant | Value | Meaning |
|---|---|---|
STRATAFS_STAGE_MARKER_MAGIC | 0x53544731 | Marker magic |
STRATAFS_STAGE_MARKER_VERSION | 1 | Marker version |
STRATAFS_COPY_BUFFER_SIZE | 65536 | Copy-up read/write chunk, in bytes (§4.5.2) |
STRATAFS_STAGE_PREFIX | ".stratafs-stage-" | Staged-name prefix |
STRATAFS_RECOVERY_BATCH | 128 | Names 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.
| Offset | Size | Field | Type |
|---|---|---|---|
| 0 | 4 | magic | __le32 |
| 4 | 2 | version | __le16 |
| 6 | 2 | size | __le16 |
| 8 | 8 | boot_cookie | __le64 |
| 16 | 8 | mount_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 enumerator | Value | Rust |
|---|---|---|
STRATAFS_ROUTE_IN_PLACE | 0 | InPlace |
STRATAFS_ROUTE_COPY_UP | 1 | CopyUp |
STRATAFS_ROUTE_READ_ONLY | 2 | ReadOnly |
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.
| Constant | Value |
|---|---|
MAX_STRATA | 16 |
FLAG_CREATE | 0x1 |
FLAG_READ_ONLY | 0x2 |
FLAG_ABSENT_MAY | 0x4 |
FLAG_MASK | 0x7 |
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).
| Error | Discriminant |
|---|---|
Empty | 1 |
TooMany | 2 |
UnknownFlag | 3 |
RepeatedCreate | 4 |
CreateReadOnly | 5 |
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.olookup.oinode.ofile.odir.odir_cache.oxattr.ocopy_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_HOOKScompiles them in. Without it every hook site is an inline constant zero.stratafs.test_hooks=1on 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:
| Write | Effect |
|---|---|
hold | Tasks reaching the point block until the hook is cleared |
fail N | The next task to reach the point fails with errno N, once |
clear | Disarm; 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 #
| File | Moment |
|---|---|
copy-up-begin | Before 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-publish | After 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-provider | In rename, after the walk-time provider is captured and before the locked re-lookup that verifies it (§4.5.5). |
link-install | In link, between the lower link succeeding and the outer inode installing. A failure here exercises the rollback of §4.5.6. |
supersede-publish | After the staged source and destination are checked, immediately before the backing rename publishes a supersede replacement. Failure leaves the original provider untouched. |
supersede-remove | For 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-recover | After 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.
- 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.
- Values are layered. Every write is tagged with a layer; the effective value is the highest-precedence entry. Tombstones can actively mask lower layers.
- 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.
- 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.
- 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.
- 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 #
| Property | Layered | Resolved by | Survives layer deletion |
|---|---|---|---|
| Path existence | Yes | Highest precedence, then highest sequence | No |
| Values | Yes | Highest precedence, then highest sequence | No |
| Value tombstones | Yes | Masking lower precedence | No |
| Blanket tombstones | Yes | Masking all lower-precedence values on the key | No |
| Key hiding | Yes | Masking lower precedence | No |
| Key GUID | No | Direct on the key object | Yes |
| Security Descriptor | No | Direct on the key object | Yes |
| Volatile flag | No | Direct on the key object | Yes |
| Symlink flag | No | Direct on the key object | Yes |
| Last write time | No | Direct on the key object | Yes |
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.
| Windows | LCS | |
|---|---|---|
| Backing store | Kernel-internal hive files | Userspace sources over the RSI, so a storage backend is not a kernel change |
| Hive routing | A fixed set of predefined hives | Any source may register any name at runtime |
| Layers | None; registry.pol is applied by flattening values | Precedence-ordered layers with tombstones, resolved at query time, so removal reverts rather than tattoos |
| Change observation | RegNotifyChangeKeyValue, single-shot | Persistent watches, closing the re-registration race |
| Key identity | Hive cell offsets | GUIDs, stable across storage reorganisation |
| Forward slash | Not accepted | Accepted on input, normalised to backslash |
| Case comparison | RtlCompareUnicodeString | Unicode 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.
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_PRIVATEflag 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_registryat all requires an enabledSeTcbPrivilege, checked in the device'sopen()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).
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_KEYon the parent, as for any key;KEY_CREATE_LINKon the parent;- either an enabled
SeTcbPrivilegeor membership of Administrators.
The last is a genuine disjunction — either satisfies it — and the privilege branch marks the privilege used.
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.
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.
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_SZandREG_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.
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:
| Value | Type | Default if missing |
|---|---|---|
Precedence | REG_DWORD | 0 |
Enabled | REG_DWORD, 0 or 1 | true |
Owner | REG_BINARY, a SID | the creating token's SID for a new layer |
Three things are malformed metadata, and are rejected rather than coerced:
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.
5.3.4.2 Layer lifecycle #
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:
- 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.
- 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).
- 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).
- If there are no candidates, the answer is not-found.
- Take the maximum. A winning HIDDEN path entry, a winning
REG_TOMBSTONEvalue 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.
-
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.
-
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.
-
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_ALLOWEDis the exception, and the only way to ask for whatever is available. AccessCheck computes the full allowed set and that becomes the granted mask. -
Per-ioctl checks are bitmask tests. Each ioctl has a required right; LCS tests the fd's granted mask against it and returns
EACCESwithout 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_ALLOWEDmay appear alone or combined with anything else.SYNCHRONIZE(0x00100000) is not a registry right, so it is an unknown bit and failsEINVAL.
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 #
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_ACEand no propagation flags. - An inherit-only copy retaining the original SID and generic mask, with
INHERITED_ACEand 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.
| Principal | Rights | Inheritance |
|---|---|---|
| SYSTEM | KEY_ALL_ACCESS | Container-inherit |
| Administrators | KEY_ALL_ACCESS | Container-inherit |
| Authenticated Users | KEY_READ | Container-inherit |
| Principal | Rights | Inheritance |
|---|---|---|
| the user's SID | KEY_ALL_ACCESS | Container-inherit |
| SYSTEM | KEY_ALL_ACCESS | Container-inherit |
| Administrators | KEY_ALL_ACCESS | Container-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.
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.
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:
| Event | Operation | Right |
|---|---|---|
lcs.audit.value.set | REG_IOC_SET_VALUE | KEY_SET_VALUE |
lcs.audit.value.deleted | REG_IOC_DELETE_VALUE | KEY_SET_VALUE |
lcs.audit.key.tombstoned | REG_IOC_BLANKET_TOMBSTONE | KEY_SET_VALUE |
lcs.audit.key.deleted | REG_IOC_DELETE_KEY | DELETE |
lcs.audit.key.hidden | REG_IOC_HIDE_KEY | DELETE |
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 withEIOand 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 returnsEIOand 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 withEIO. 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_emitorlcs:lcs_audit_emit_failedtracepoint; 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:
| Field | Description |
|---|---|
| Source and key GUID | The identity of the opened key. |
| Granted access mask | Computed once by AccessCheck at open. Immutable. |
| Resolved path | After symlink resolution and CurrentUser\ rewriting. |
| Ancestor chain | The GUID at each path component from the hive root down. Captured during the open walk, used for subtree watch dispatch (§5.6.3). |
| Watch state | Armed 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 ;
Opens an existing key. Fails if it does not exist after layer resolution.
- Parse and canonicalise the path: normalise separators, reject empty components and a trailing separator, check the total length and each component length.
- Rewrite a leading
CurrentUser\toUsers\<caller SID>\— only for an absolute path, and only the first component (§5.2.1). - 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.
- Walk the path component by component through
RSI_LOOKUP, resolving each through the layer stack and following symlinks — except a final component whenREG_OPEN_LINKis set. Collect the ancestor chain as you go. - Run AccessCheck against the final key's descriptor.
- 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.
5.5.2.2 reg_create_key (1101) #
int ;
Opens the key if it exists after layer resolution, creates it with an inherited descriptor if not, and reports which happened.
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:
- Resolve the parent, which must exist. Check that the parent's depth
plus one is within
MaxKeyDepth. - AccessCheck the parent for
KEY_CREATE_SUB_KEY. - Perform layer write authorization against the target layer's metadata key (§5.3.4).
- Mint a fresh UUIDv4 GUID.
- Compute the new key's descriptor from the parent's, through KACS.
- Create the path entry with a new sequence number, then the key record (§5.2.5).
- AccessCheck the new key's inherited descriptor against
desired_access— an inherited descriptor may not grant everything the creator asked for. - 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).
5.5.2.3 reg_begin_transaction (1102) #
int ;
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 #
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:
EINVALfor an unknown type or a tombstone with data.EAGAINfor a failed conditional write.ENOSPCfor the layer cap or oversized data.ENAMETOOLONGfor the value name.EPERMfor aPrecedenceabove 0 withoutSeTcbPrivilege(§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:
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.
| State | terminal_errno |
|---|---|
REG_TXN_ACTIVE_UNBOUND | 0 |
REG_TXN_ACTIVE_BOUND | 0 |
REG_TXN_COMMITTED | 0 |
REG_TXN_ABORTED | EINVAL |
REG_TXN_TIMED_OUT | ETIMEDOUT |
REG_TXN_SOURCE_DOWN | EIO |
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.
| Errno | What it means here |
|---|---|
ENOENT | A 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. |
EACCES | AccessCheck denied a right, the fd's granted mask lacks it, or layer write authorization failed. |
EINVAL | An 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. |
EFAULT | An invalid userspace pointer. A zero-length output probe ignores its pointer; a non-zero length with a null or unwritable pointer does not. |
ENAMETOOLONG | A key or value name exceeds MaxPathComponentLength, or a path exceeds MaxTotalPathLength. |
ELOOP | The symlink resolution depth limit was exceeded. |
ETIMEDOUT | The source did not answer within RequestTimeoutMs, or a timed-out transaction fd was used. |
EIO | The source failed, is unavailable, returned malformed data, or a transaction's bound source went Down. |
ENOMEM | Kernel allocation failure. |
ENOSPC | A layer cap was exceeded, or value data exceeds MaxValueSize. |
ENOTEMPTY | A key with visible children cannot be deleted. |
EXDEV | An operation targeted a different hive from the one its transaction is bound to. |
EPERM | A privilege the caller does not hold: symlink creation, creating or raising a layer above precedence 0, backup, restore. |
EAGAIN | A conditional write failed — the layer entry's sequence did not match. Re-read and retry. |
EBUSY | A commit could not take the source's write lock, or MaxBoundTransactionsPerSource or MaxReadOnlyTransactionsPerSource was exceeded. |
ERANGE | An output buffer is too small. Every determinable required size is written; buffers are not partially filled. Retry with larger ones. |
EEXIST | A path entry or key already exists at the source. |
ENOTSUP | The source does not support the transaction mode being requested. |
EBADF | An 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. |
EOVERFLOW | A 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. |
ESTALE | A 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 #
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 bit | Value | Admits |
|---|---|---|
REG_NOTIFY_VALUE | 0x01 | VALUE_SET, VALUE_DELETED |
REG_NOTIFY_SUBKEY | 0x02 | SUBKEY_CREATED, SUBKEY_DELETED |
REG_NOTIFY_SD | 0x04 | SD_CHANGED |
REG_NOTIFY_ALL | 0x07 | all 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.
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | total_len |
| 4 | 2 | event_type |
| 6 | 2 | name_len |
| 8 | name_len | name, 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:
| Size | Field |
|---|---|
| 2 | path_depth |
2 + n each | path_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:
- Look up B's GUID in the watch map and queue the event, with
path_depth0, to every watcher whose filter admits it. - 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:
- The oldest queued event is dropped.
- An
OVERFLOWrecord is queued in its place. - 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.
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 #
| State | Value | Meaning |
|---|---|---|
REG_TXN_ACTIVE_UNBOUND | 0 | Active, no source chosen. |
REG_TXN_ACTIVE_BOUND | 1 | Active, bound to a source. |
REG_TXN_COMMITTED | 2 | Commit completed. |
REG_TXN_ABORTED | 3 | Explicitly or implicitly aborted. |
REG_TXN_TIMED_OUT | 4 | The lifetime timer fired. |
REG_TXN_SOURCE_DOWN | 5 | The 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_COMMITon the transaction fd. The source applies everything atomically, watch events fire, and the object becomesCOMMITTED. Later use of the fd returnsEINVAL. The fd should be closed. - Explicit abort.
close()without committing. The source is told to discard, and the object becomesABORTEDduring 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_COMMITandREG_IOC_TXN_STATUS; every other ioctl isENOTTY. - 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_PRIVATEcarries no scope GUID. - No unknown flag bits are set.
- The hive count is within
MaxHivesPerSource(64), and the source count is withinMaxRegisteredSources(32). Either limit isENOSPC; a zero hive count isEINVAL. - The reported maximum sequence can be advanced past without
overflowing 64 bits, or registration fails
EOVERFLOWand 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
EIOto its caller. - An
lcs.source.response.rejectedaudit 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_LAYERresponse. - 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:
- The slot is marked Down and its hives become unavailable.
- Every pending request fails
EIO, including ones queued but not yet delivered. - 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
EIOuntil the source comes back. - Bound transactions enter
REG_TXN_SOURCE_DOWN, their poll waiters are woken withPOLLERR | POLLHUP, their mutation logs are released, and further use of those fds returnsEIO. - Watches stay armed. Watch state is kernel-side and does not
depend on the source at all. Nothing is delivered during the window,
and
OVERFLOWarrives 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:
NotificationQueueSizeper queue, timesRLIMIT_NOFILEqueues 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 byMaxLayersPerValue. - 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 #
- Validate the whole stream, including the trailer's record count and checksum, and retain every replayable record.
- Read the layer manifest and apply the precedence gate (below).
- Verify the root record and its immutable flags against the live target.
- Tear down the target's contents and descendants — path entries, values, blanket tombstones, and descendant key records — inside the transaction.
- Write the root's mutable fields, then replay its section's values and blanket tombstones.
- For each non-root key in stream order: create it, write its last write time, then replay its path entries, values and blanket tombstones.
- 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).
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:
- LCS is always operational with compiled-in defaults. It accepts syscalls from the moment PKM initialises.
- The base layer requires no persisted state.
- Hot-swap is the only configuration transition. LCS never blocks, restarts, or re-initialises.
- 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.
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.rejectedaudit 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 #
- A source registers. LCS reads
Machine\System\Registry\*; the keys do not exist; compiled-in defaults are retained. - Seed restore populates them. The subtree watch fires, LCS validates and hot-swaps to the seed values.
- 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.
| Number | PKM constant | glibc alias | Signature |
|---|---|---|---|
| 1100 | SYS_REG_OPEN_KEY | SYS_reg_open_key | reg_open_key(int parent_fd, const char __user *path, u32 desired_access, u32 flags) |
| 1101 | SYS_REG_CREATE_KEY | SYS_reg_create_key | reg_create_key(const struct reg_create_key_args __user *args) |
| 1102 | SYS_REG_BEGIN_TRANSACTION | SYS_reg_begin_transaction | reg_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.
| Ioctl | Number | Direction | Argument | Arg size | Encoded |
|---|---|---|---|---|---|
REG_SRC_REGISTER | 0 | _IOW | struct reg_src_register_args | 24 | 0x40185200 |
REG_SRC_NEGOTIATE | 1 | _IOWR | struct reg_src_negotiate_args | 16 | 0xC0105201 |
REG_SRC_REQUEST_CONTEXT | 2 | _IOWR | struct reg_src_request_context_args | 40 | 0xC0285202 |
REG_SRC_RENEW | 3 | _IOWR | struct reg_src_renew_args | 32 | 0xC0205203 |
Key fd.
| Ioctl | Number | Direction | Argument | Arg size | Encoded |
|---|---|---|---|---|---|
REG_IOC_QUERY_VALUE | 0 | _IOWR | struct reg_query_value_args | 64 | 0xC0405200 |
REG_IOC_SET_VALUE | 1 | _IOW | struct reg_set_value_args | 64 | 0x40405201 |
REG_IOC_DELETE_VALUE | 2 | _IOW | struct reg_delete_value_args | 40 | 0x40285202 |
REG_IOC_BLANKET_TOMBSTONE | 3 | _IOW | struct reg_blanket_tombstone_args | 24 | 0x40185203 |
REG_IOC_QUERY_VALUES_BATCH | 4 | _IOWR | struct reg_query_values_batch_args | 24 | 0xC0185204 |
REG_IOC_ENUM_VALUES | 5 | _IOWR | struct reg_enum_value_args | 40 | 0xC0285205 |
REG_IOC_ENUM_SUBKEYS | 6 | _IOWR | struct reg_enum_subkey_args | 40 | 0xC0285206 |
REG_IOC_QUERY_KEY_INFO | 7 | _IOWR | struct reg_query_key_info_args | 64 | 0xC0405207 |
REG_IOC_DELETE_KEY | 8 | _IOW | struct reg_delete_key_args | 24 | 0x40185208 |
REG_IOC_HIDE_KEY | 9 | _IOW | struct reg_hide_key_args | 24 | 0x40185209 |
REG_IOC_GET_SECURITY | 10 | _IOWR | struct reg_get_security_args | 16 | 0xC010520A |
REG_IOC_SET_SECURITY | 11 | _IOW | struct reg_set_security_args | 24 | 0x4018520B |
REG_IOC_NOTIFY | 12 | _IOW | struct reg_notify_args | 8 | 0x4008520C |
REG_IOC_FLUSH | 13 | _IO | none | 0 | 0x0000520D |
REG_IOC_BACKUP | 14 | _IOW | struct reg_backup_args | 4 | 0x4004520E |
REG_IOC_RESTORE | 15 | _IOW | struct reg_restore_args | 4 | 0x4004520F |
Transaction fd.
| Ioctl | Number | Direction | Argument | Arg size | Encoded |
|---|---|---|---|---|---|
REG_IOC_COMMIT | 16 | _IO | none | 0 | 0x00005210 |
REG_IOC_TXN_STATUS | 17 | _IOR | struct reg_txn_status_args | 8 | 0x80085211 |
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.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __s32 | parent_fd |
| 4 | 4 | __u32 | _pad0 |
| 8 | 8 | __u64 | path_ptr |
| 16 | 4 | __u32 | desired_access |
| 20 | 4 | __u32 | flags |
| 24 | 8 | __u64 | layer_ptr |
| 32 | 4 | __s32 | txn_fd |
| 36 | 4 | __u32 | _pad1 |
| 40 | 8 | __u64 | disposition_ptr |
5.A.3.2 struct reg_query_value_args #
Total size 64 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | name_len |
| 4 | 4 | __u32 | _pad0 |
| 8 | 8 | __u64 | name_ptr |
| 16 | 4 | __u32 | type |
| 20 | 4 | __u32 | data_len |
| 24 | 4 | __s32 | txn_fd |
| 28 | 4 | __u32 | layer_buf_len |
| 32 | 8 | __u64 | data_ptr |
| 40 | 8 | __u64 | sequence |
| 48 | 4 | __u32 | layer_len |
| 52 | 4 | __u32 | _pad1 |
| 56 | 8 | __u64 | layer_ptr |
5.A.3.3 struct reg_set_value_args #
Total size 64 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | name_len |
| 4 | 4 | __u32 | _pad0 |
| 8 | 8 | __u64 | name_ptr |
| 16 | 4 | __u32 | type |
| 20 | 4 | __u32 | data_len |
| 24 | 8 | __u64 | data_ptr |
| 32 | 4 | __u32 | layer_len |
| 36 | 4 | __u32 | _pad1 |
| 40 | 8 | __u64 | layer_ptr |
| 48 | 4 | __s32 | txn_fd |
| 52 | 4 | __u32 | _pad2 |
| 56 | 8 | __u64 | expected_seq |
5.A.3.4 struct reg_delete_value_args #
Total size 40 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | name_len |
| 4 | 4 | __u32 | _pad0 |
| 8 | 8 | __u64 | name_ptr |
| 16 | 4 | __u32 | layer_len |
| 20 | 4 | __u32 | _pad1 |
| 24 | 8 | __u64 | layer_ptr |
| 32 | 4 | __s32 | txn_fd |
| 36 | 4 | __u32 | _pad2 |
5.A.3.5 struct reg_blanket_tombstone_args #
Total size 24 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | layer_len |
| 4 | 4 | __u32 | _pad0 |
| 8 | 8 | __u64 | layer_ptr |
| 16 | 1 | __u8 | set |
| 17 | 3 | __u8``[3] | _pad1 |
| 20 | 4 | __s32 | txn_fd |
5.A.3.6 struct reg_query_values_batch_args #
Total size 24 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | buf_len |
| 4 | 4 | __u32 | count |
| 8 | 8 | __u64 | buf_ptr |
| 16 | 4 | __s32 | txn_fd |
| 20 | 4 | __u32 | _pad |
5.A.3.7 struct reg_enum_value_args #
Total size 40 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | index |
| 4 | 4 | __u32 | name_len |
| 8 | 8 | __u64 | name_ptr |
| 16 | 4 | __u32 | type |
| 20 | 4 | __u32 | data_len |
| 24 | 8 | __u64 | data_ptr |
| 32 | 4 | __s32 | txn_fd |
| 36 | 4 | __u32 | _pad |
5.A.3.8 struct reg_enum_subkey_args #
Total size 40 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | index |
| 4 | 4 | __u32 | name_len |
| 8 | 8 | __u64 | name_ptr |
| 16 | 8 | __u64 | last_write_time |
| 24 | 4 | __u32 | subkey_count |
| 28 | 4 | __u32 | value_count |
| 32 | 4 | __s32 | txn_fd |
| 36 | 4 | __u32 | _pad |
5.A.3.9 struct reg_query_key_info_args #
Total size 64 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | name_len |
| 4 | 4 | __u32 | _pad0 |
| 8 | 8 | __u64 | name_ptr |
| 16 | 8 | __u64 | last_write_time |
| 24 | 4 | __u32 | subkey_count |
| 28 | 4 | __u32 | value_count |
| 32 | 4 | __u32 | max_subkey_name_len |
| 36 | 4 | __u32 | max_value_name_len |
| 40 | 4 | __u32 | max_value_data_size |
| 44 | 4 | __u32 | sd_size |
| 48 | 1 | __u8 | volatile_key |
| 49 | 1 | __u8 | symlink |
| 50 | 6 | __u8``[6] | _pad1 |
| 56 | 8 | __u64 | hive_generation |
5.A.3.10 struct reg_delete_key_args #
Total size 24 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | layer_len |
| 4 | 4 | __u32 | _pad0 |
| 8 | 8 | __u64 | layer_ptr |
| 16 | 4 | __s32 | txn_fd |
| 20 | 4 | __u32 | _pad1 |
5.A.3.11 struct reg_hide_key_args #
Total size 24 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | layer_len |
| 4 | 4 | __u32 | _pad0 |
| 8 | 8 | __u64 | layer_ptr |
| 16 | 4 | __s32 | txn_fd |
| 20 | 4 | __u32 | _pad1 |
5.A.3.12 struct reg_get_security_args #
Total size 16 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | security_info |
| 4 | 4 | __u32 | sd_len |
| 8 | 8 | __u64 | sd_ptr |
5.A.3.13 struct reg_set_security_args #
Total size 24 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | security_info |
| 4 | 4 | __u32 | sd_len |
| 8 | 8 | __u64 | sd_ptr |
| 16 | 4 | __s32 | txn_fd |
| 20 | 4 | __u32 | _pad |
5.A.3.14 struct reg_notify_args #
Total size 8 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | filter |
| 4 | 1 | __u8 | subtree |
| 5 | 3 | __u8``[3] | _pad |
5.A.3.15 struct reg_backup_args #
Total size 4 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __s32 | output_fd |
5.A.3.16 struct reg_restore_args #
Total size 4 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __s32 | input_fd |
5.A.3.17 struct reg_txn_status_args #
Total size 8 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | state |
| 4 | 4 | __s32 | terminal_errno |
5.A.3.18 struct reg_src_register_args #
Total size 24 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | hive_count |
| 4 | 4 | __u32 | _pad |
| 8 | 8 | __u64 | max_sequence |
| 16 | 8 | __u64 | hives_ptr |
5.A.3.19 struct reg_src_negotiate_args #
Total size 16 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | version |
| 4 | 4 | __u32 | flags |
| 8 | 8 | __u64 | session |
5.A.3.20 struct reg_src_request_context_args #
Total size 40 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 8 | __u64 | session |
| 8 | 8 | __u64 | request_id |
| 16 | 8 | __u64 | request_deadline_ns |
| 24 | 8 | __u64 | lifetime_deadline_ns |
| 32 | 8 | __u64 | lease_deadline_ns |
5.A.3.21 struct reg_src_renew_args #
Total size 32 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 8 | __u64 | session |
| 8 | 8 | __u64 | transaction_id |
| 16 | 8 | __u64 | lifetime_deadline_ns |
| 24 | 8 | __u64 | lease_deadline_ns |
5.A.3.22 struct reg_src_hive_entry #
Total size 56 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | name_len |
| 4 | 4 | __u32 | _pad0 |
| 8 | 8 | __u64 | name_ptr |
| 16 | 16 | __u8``[16] | root_guid |
| 32 | 4 | __u32 | flags |
| 36 | 4 | __u32 | _pad1 |
| 40 | 16 | __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.
| Constant | Value |
|---|---|
REG_SRC_LIFETIME_VERSION | 1 |
Syscall and ioctl argument sizes (Kernel TRM section 5.A, LCS ABI).
| Constant | Value |
|---|---|
REG_CREATE_KEY_ARGS_SIZE | 48 |
REG_QUERY_VALUE_ARGS_SIZE | 64 |
REG_SET_VALUE_ARGS_SIZE | 64 |
REG_DELETE_VALUE_ARGS_SIZE | 40 |
REG_BLANKET_TOMBSTONE_ARGS_SIZE | 24 |
REG_QUERY_VALUES_BATCH_ARGS_SIZE | 24 |
REG_ENUM_VALUE_ARGS_SIZE | 40 |
REG_ENUM_SUBKEY_ARGS_SIZE | 40 |
REG_QUERY_KEY_INFO_ARGS_SIZE | 64 |
REG_DELETE_KEY_ARGS_SIZE | 24 |
REG_HIDE_KEY_ARGS_SIZE | 24 |
REG_GET_SECURITY_ARGS_SIZE | 16 |
REG_SET_SECURITY_ARGS_SIZE | 24 |
REG_NOTIFY_ARGS_SIZE | 8 |
REG_BACKUP_ARGS_SIZE | 4 |
REG_RESTORE_ARGS_SIZE | 4 |
REG_TXN_STATUS_ARGS_SIZE | 8 |
REG_SRC_NEGOTIATE_ARGS_SIZE | 16 |
REG_SRC_REQUEST_CONTEXT_ARGS_SIZE | 40 |
REG_SRC_RENEW_ARGS_SIZE | 32 |
REG_SRC_REGISTER_ARGS_SIZE | 24 |
REG_SRC_HIVE_ENTRY_SIZE | 56 |
| Constant | Value |
|---|---|
REG_TXN_ACTIVE_UNBOUND | 0 |
REG_TXN_ACTIVE_BOUND | 1 |
REG_TXN_COMMITTED | 2 |
REG_TXN_ABORTED | 3 |
REG_TXN_TIMED_OUT | 4 |
REG_TXN_SOURCE_DOWN | 5 |
Syscall flags and dispositions.
| Constant | Value |
|---|---|
REG_OPEN_LINK | 0x01 |
REG_OPTION_VOLATILE | 0x01 |
REG_OPTION_CREATE_LINK | 0x02 |
REG_CREATED_NEW | 1 |
REG_OPENED_EXISTING | 2 |
| Constant | Value |
|---|---|
KEY_QUERY_VALUE | 0x00000001 |
KEY_SET_VALUE | 0x00000002 |
KEY_CREATE_SUB_KEY | 0x00000004 |
KEY_ENUMERATE_SUB_KEYS | 0x00000008 |
KEY_NOTIFY | 0x00000010 |
KEY_CREATE_LINK | 0x00000020 |
DELETE | 0x00010000 |
READ_CONTROL | 0x00020000 |
WRITE_DAC | 0x00040000 |
WRITE_OWNER | 0x00080000 |
ACCESS_SYSTEM_SECURITY | 0x01000000 |
MAXIMUM_ALLOWED | 0x02000000 |
GENERIC_ALL | 0x10000000 |
GENERIC_EXECUTE | 0x20000000 |
GENERIC_WRITE | 0x40000000 |
GENERIC_READ | 0x80000000 |
KEY_READ | 0x00020019 |
KEY_WRITE | 0x00020006 |
KEY_ALL_ACCESS | 0x000F003F |
REG_VALID_DESIRED_ACCESS_MASK | 0xF30F003F |
REG_VALID_MAPPED_ACCESS_MASK | 0x010F003F |
REG_VALID_ACE_ACCESS_MASK | 0xF10F003F |
Security information flags for REG_IOC_GET_SECURITY / SET_SECURITY.
| Constant | Value |
|---|---|
OWNER_SECURITY_INFORMATION | 0x00000001 |
GROUP_SECURITY_INFORMATION | 0x00000002 |
DACL_SECURITY_INFORMATION | 0x00000004 |
SACL_SECURITY_INFORMATION | 0x00000008 |
LABEL_SECURITY_INFORMATION | 0x00000010 |
REG_VALID_SECURITY_INFORMATION | 0x0000001F |
| Constant | Value |
|---|---|
REG_NONE | 0 |
REG_SZ | 1 |
REG_EXPAND_SZ | 2 |
REG_BINARY | 3 |
REG_DWORD | 4 |
REG_DWORD_BIG_ENDIAN | 5 |
REG_LINK | 6 |
REG_MULTI_SZ | 7 |
REG_RESOURCE_LIST | 8 |
REG_FULL_RESOURCE_DESCRIPTOR | 9 |
REG_RESOURCE_REQUIREMENTS_LIST | 10 |
REG_QWORD | 11 |
REG_TOMBSTONE | 0xFFFF |
Watch event types and filters.
| Constant | Value |
|---|---|
REG_WATCH_VALUE_SET | 1 |
REG_WATCH_VALUE_DELETED | 2 |
REG_WATCH_SUBKEY_CREATED | 3 |
REG_WATCH_SUBKEY_DELETED | 4 |
REG_WATCH_SD_CHANGED | 5 |
REG_WATCH_KEY_DELETED | 6 |
REG_WATCH_OVERFLOW | 7 |
| Constant | Value |
|---|---|
REG_WATCH_EVENT_TOTAL_LEN_OFFSET | 0 |
REG_WATCH_EVENT_TYPE_OFFSET | 4 |
REG_WATCH_EVENT_NAME_LEN_OFFSET | 6 |
REG_WATCH_EVENT_NAME_OFFSET | 8 |
REG_WATCH_EVENT_MIN_SIZE | 8 |
REG_WATCH_SUBTREE_PATH_DEPTH_REL_OFFSET | 0 |
REG_WATCH_SUBTREE_PATH_DEPTH_SIZE | 2 |
REG_WATCH_SUBTREE_PATH_COMPONENTS_REL_OFFSET | 2 |
REG_WATCH_PATH_COMPONENT_LEN_SIZE | 2 |
REG_NOTIFY_VALUE | 0x01 |
REG_NOTIFY_SUBKEY | 0x02 |
REG_NOTIFY_SD | 0x04 |
REG_NOTIFY_ALL | 0x07 |
| Constant | Value |
|---|---|
RSI_REQUEST_TOTAL_LEN_OFFSET | 0 |
RSI_REQUEST_ID_OFFSET | 4 |
RSI_REQUEST_OP_CODE_OFFSET | 12 |
RSI_REQUEST_TXN_ID_OFFSET | 14 |
RSI_REQUEST_HEADER_SIZE | 22 |
RSI_RESPONSE_TOTAL_LEN_OFFSET | 0 |
RSI_RESPONSE_ID_OFFSET | 4 |
RSI_RESPONSE_OP_CODE_OFFSET | 12 |
RSI_RESPONSE_HEADER_SIZE | 14 |
RSI_RESPONSE_STATUS_OFFSET | 14 |
RSI_STATUS_SIZE | 4 |
RSI_MIN_RESPONSE_SIZE | 18 |
RSI_LENGTH_PREFIX_SIZE | 4 |
RSI_GUID_SIZE | 16 |
RSI_RESPONSE_BIT | 0x8000 |
RSI op codes and response op codes.
| Constant | Value |
|---|---|
RSI_LOOKUP | 0x0001 |
RSI_CREATE_ENTRY | 0x0002 |
RSI_HIDE_ENTRY | 0x0003 |
RSI_DELETE_ENTRY | 0x0004 |
RSI_ENUM_CHILDREN | 0x0005 |
RSI_CREATE_KEY | 0x0010 |
RSI_READ_KEY | 0x0011 |
RSI_WRITE_KEY | 0x0012 |
RSI_DROP_KEY | 0x0013 |
RSI_QUERY_VALUES | 0x0020 |
RSI_SET_VALUE | 0x0021 |
RSI_DELETE_VALUE_ENTRY | 0x0022 |
RSI_SET_BLANKET_TOMBSTONE | 0x0023 |
RSI_BEGIN_TRANSACTION | 0x0030 |
RSI_COMMIT_TRANSACTION | 0x0031 |
RSI_ABORT_TRANSACTION | 0x0032 |
RSI_FLUSH | 0x0040 |
RSI_DELETE_LAYER | 0x0050 |
RSI_LOOKUP_RESPONSE | 0x8001 |
RSI_CREATE_ENTRY_RESPONSE | 0x8002 |
RSI_HIDE_ENTRY_RESPONSE | 0x8003 |
RSI_DELETE_ENTRY_RESPONSE | 0x8004 |
RSI_ENUM_CHILDREN_RESPONSE | 0x8005 |
RSI_CREATE_KEY_RESPONSE | 0x8010 |
RSI_READ_KEY_RESPONSE | 0x8011 |
RSI_WRITE_KEY_RESPONSE | 0x8012 |
RSI_DROP_KEY_RESPONSE | 0x8013 |
RSI_QUERY_VALUES_RESPONSE | 0x8020 |
RSI_SET_VALUE_RESPONSE | 0x8021 |
RSI_DELETE_VALUE_ENTRY_RESPONSE | 0x8022 |
RSI_SET_BLANKET_TOMBSTONE_RESPONSE | 0x8023 |
RSI_BEGIN_TRANSACTION_RESPONSE | 0x8030 |
RSI_COMMIT_TRANSACTION_RESPONSE | 0x8031 |
RSI_ABORT_TRANSACTION_RESPONSE | 0x8032 |
RSI_FLUSH_RESPONSE | 0x8040 |
RSI_DELETE_LAYER_RESPONSE | 0x8050 |
| Constant | Value |
|---|---|
RSI_OK | 0 |
RSI_NOT_FOUND | 1 |
RSI_ALREADY_EXISTS | 2 |
RSI_STORAGE_ERROR | 3 |
RSI_NOT_EMPTY | 4 |
RSI_TOO_LARGE | 5 |
RSI_TXN_BUSY | 6 |
RSI_INVALID | 7 |
RSI_CAS_FAILED | 8 |
RSI_TXN_NOT_SUPPORTED | 9 |
| Constant | Value |
|---|---|
RSI_PATH_TARGET_GUID | 0 |
RSI_PATH_TARGET_HIDDEN | 1 |
RSI_WRITE_KEY field mask bits.
| Constant | Value |
|---|---|
RSI_WRITE_KEY_FIELD_SD | 0x01 |
RSI_WRITE_KEY_FIELD_LAST_WRITE_TIME | 0x02 |
RSI_WRITE_KEY_FIELD_KNOWN_MASK | 0x00000003 |
RSI transaction modes and source-registration flags.
| Constant | Value |
|---|---|
RSI_TXN_READ_WRITE | 0 |
RSI_TXN_READ_ONLY | 1 |
RSI_HIVE_PRIVATE | 0x01 |
Backup record types and magic.
| Constant | Value |
|---|---|
REG_BACKUP_HEADER | 0x01 |
REG_BACKUP_LAYER | 0x02 |
REG_BACKUP_KEY | 0x03 |
REG_BACKUP_PATH_ENTRY | 0x04 |
REG_BACKUP_VALUE | 0x05 |
REG_BACKUP_BLANKET_TOMBSTONE | 0x06 |
REG_BACKUP_TRAILER | 0xFF |
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.
| Constant | Value | Notes |
|---|---|---|
LCS_OP_LOOKUP | 0 | RSI_LOOKUP |
LCS_OP_READ_KEY | 1 | RSI_READ_KEY |
LCS_OP_ENUM_CHILDREN | 2 | RSI_ENUM_CHILDREN |
LCS_OP_QUERY_VALUES | 3 | RSI_QUERY_VALUES |
LCS_OP_SET_VALUE | 4 | RSI_SET_VALUE |
LCS_OP_DELETE_VALUE | 5 | RSI_DELETE_VALUE_ENTRY |
LCS_OP_BLANKET_TOMBSTONE | 6 | RSI_SET_BLANKET_TOMBSTONE |
LCS_OP_DROP_KEY | 7 | RSI_DROP_KEY |
LCS_OP_CREATE_ENTRY | 8 | RSI_CREATE_ENTRY |
LCS_OP_HIDE_ENTRY | 9 | RSI_HIDE_ENTRY |
LCS_OP_DELETE_ENTRY | 10 | RSI_DELETE_ENTRY |
LCS_OP_CREATE_KEY | 11 | RSI_CREATE_KEY |
LCS_OP_WRITE_KEY | 12 | RSI_WRITE_KEY |
LCS_OP_TXN_BEGIN | 13 | RSI_BEGIN_TRANSACTION |
LCS_OP_TXN_COMMIT | 14 | RSI_COMMIT_TRANSACTION |
LCS_OP_TXN_ABORT | 15 | RSI_ABORT_TRANSACTION |
LCS_OP_FLUSH | 16 | RSI_FLUSH |
LCS_OP_DELETE_LAYER | 17 | RSI_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.
| Constant | Value | Notes |
|---|---|---|
LCS_RESP_ACCEPTED | 0 | response matched an in-flight request |
LCS_RESP_DESYNC | 1 | no matching delivered/unaccepted record |
LCS_RESP_OP_MISMATCH | 2 | response op != request op | RESPONSE_BIT |
LCS_RESP_UNKNOWN_STATUS | 3 | rsi_status not a known status code |
LCS_RESP_MALFORMED_PAYLOAD | 4 | per-op response body failed validation |
LCS_RESP_LATE_COMMIT_FAIL | 5 | commit late-effect failed; source DOWN |
LCS_RESP_LATE_MUTATION_FAIL | 6 | mutation late-effect failed; source DOWN |
LCS_RESP_LATE_BEGIN_FAIL | 7 | begin-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.
| Constant | Value | Notes |
|---|---|---|
LCS_SRC_OPEN | 0 | new source fd issued (post-TCB check) |
LCS_SRC_RELEASE | 1 | fd .release() teardown |
LCS_SRC_MALFORMED | 2 | malformed protocol frame -> mark down |
LCS_SRC_EXPLICIT | 3 | explicit mark-down of this fd |
LCS_SRC_MARK_BY_ID | 4 | mark-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.
| Constant | Value | Notes |
|---|---|---|
LCS_IF_INSERT | 0 | request inserted into in-flight table |
LCS_IF_DELIVERED | 1 | request delivered to source read() |
LCS_IF_RELEASE | 2 | request released from in-flight table |
lcs_route op — which resolution the lcs:lcs_route event describes.
| Constant | Value | Notes |
|---|---|---|
LCS_ROUTE_HIVE_NAME | 0 | hive-name -> source/root resolution |
LCS_ROUTE_ABSOLUTE_PATH | 1 | absolute-path -> source/root resolution |
LCS_ROUTE_SYMLINK_TARGET | 2 | symlink-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.
| Constant | Value | Notes |
|---|---|---|
LCS_REG_NEW | 0 | new source slot admitted |
LCS_REG_RESUME_DOWN | 1 | down source slot resumed |
LCS_REG_COPY | 2 | registration input copied from user |
LCS_REG_REPLAY_FAIL | 3 | resume pending-delete replay failed (EIO) |
LCS_REG_OVERFLOW_FAIL | 4 | resume 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.
| Constant | Value | Notes |
|---|---|---|
LCS_BOOT_REGISTRY | 0 | registry root discover phase |
LCS_BOOT_KMES | 1 | kmes config root discover phase |
LCS_BOOT_LAYERS | 2 | layer metadata root discover phase |
LCS_BOOT_SELF_WATCH | 3 | self-watch arm phase |
LCS_BOOT_COMPLETE | 4 | bootstrap refresh completed |
LCS_BOOT_SELF_CONFIG_REFRESH | 5 | self-config refresh-from-key outcome |
LCS_BOOT_SELF_CONFIG_PARAM_INVALID | 6 | self-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.
| Constant | Value | Notes |
|---|---|---|
LCS_LIM_REQUEST_TIMEOUT_MS | 0 | |
LCS_LIM_TRANSACTION_TIMEOUT_MS | 1 | |
LCS_LIM_NOTIFICATION_QUEUE_SIZE | 2 | |
LCS_LIM_SYMLINK_DEPTH_LIMIT | 3 | |
LCS_LIM_MAX_VALUE_SIZE | 4 | |
LCS_LIM_MAX_KEY_DEPTH | 5 | |
LCS_LIM_MAX_PATH_COMPONENT_LENGTH | 6 | |
LCS_LIM_MAX_TOTAL_PATH_LENGTH | 7 | |
LCS_LIM_MAX_LAYERS_PER_VALUE | 8 | |
LCS_LIM_MAX_BOUND_TRANSACTIONS_PER_SOURCE | 9 | |
LCS_LIM_MAX_READ_ONLY_TRANSACTIONS_PER_SOURCE | 10 | |
LCS_LIM_MAX_TOTAL_LAYERS | 11 | |
LCS_LIM_MAX_REGISTERED_SOURCES | 12 | |
LCS_LIM_MAX_HIVES_PER_SOURCE | 13 | |
LCS_LIM_MAX_CONCURRENT_RSI_REQUESTS | 14 | |
LCS_LIM_MAX_SCOPE_GUIDS_PER_TOKEN | 15 | |
LCS_LIM_MAX_PRIVATE_LAYERS_PER_TOKEN | 16 | |
LCS_LIM_MAX_SUBTREE_WATCH_DEPTH | 17 | |
LCS_LIM_MAX_TRANSACTION_WATCH_EVENT_BURST | 18 | |
LCS_LIM_ALL | 19 | whole-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.
| Constant | Value | Notes |
|---|---|---|
LCS_AUDIT_KEY_OPEN | 0 | key-open SACL audit |
LCS_AUDIT_BACKUP_START | 1 | |
LCS_AUDIT_BACKUP_COMPLETE | 2 | |
LCS_AUDIT_RESTORE_START | 3 | |
LCS_AUDIT_RESTORE_COMPLETE | 4 | |
LCS_AUDIT_VALIDATION_FAILURE | 5 | source validation-failure audit |
LCS_AUDIT_SELF_CONFIG_INVALID | 6 | self-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.
| Constant | Value | Notes |
|---|---|---|
LCS_TXN_ST_ACTIVE_UNBOUND | 0 | allocated, not yet source-bound |
LCS_TXN_ST_ACTIVE_BOUND | 1 | bound to a source + root guid |
LCS_TXN_ST_COMMITTED | 2 | commit round-trip succeeded |
LCS_TXN_ST_ABORTED | 3 | aborted (close / layer writer abort) |
LCS_TXN_ST_TIMED_OUT | 4 | deadline timer or commit timeout |
LCS_TXN_ST_SOURCE_DOWN | 5 | bound 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.
| Constant | Value | Notes |
|---|---|---|
LCS_KCMD_NONE | 0 | no ioctl verb (publish/release/read) |
LCS_KCMD_SET_VALUE | 1 | |
LCS_KCMD_DELETE_VALUE | 2 | |
LCS_KCMD_BLANKET_TOMBSTONE | 3 | |
LCS_KCMD_DELETE_KEY | 4 | |
LCS_KCMD_HIDE_KEY | 5 | |
LCS_KCMD_QUERY_VALUE | 6 | |
LCS_KCMD_QUERY_VALUES_BATCH | 7 | |
LCS_KCMD_ENUM_VALUES | 8 | |
LCS_KCMD_ENUM_SUBKEYS | 9 | |
LCS_KCMD_QUERY_KEY_INFO | 10 | |
LCS_KCMD_GET_SECURITY | 11 | |
LCS_KCMD_SET_SECURITY | 12 | |
LCS_KCMD_FLUSH | 13 | |
LCS_KCMD_BACKUP | 14 | |
LCS_KCMD_RESTORE | 15 | |
LCS_KCMD_NOTIFY | 16 |
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,PacketandFlow, each a registry key underMachine\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,sharedornone(§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 everyNETDEV_REGISTERininit_netand unregisters them onNETDEV_UNREGISTER; becauseregister_netdevice_notifier()replaysNETDEV_REGISTERfor 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 priorityNF_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 untilPOST_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:
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:
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, thendev_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 — aREJECTalways 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), thenip_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:ECONNREFUSEDfor a reset or a port unreachable,EHOSTUNREACHfor IPv4's admin-filtered,EACCESfor 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 insideconnect(), 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_MFset or a non-zero fragment offset), the protocol, and the L4 facts fromihl * 4on. 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 readsProtocol = 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:
- 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 (PacketandFlowlayers;RawPacketreads none),peios_ntfe_counter_read()for every view — and gives aFlowforest its own facts,RelatedandStart.*; - evaluates with
EvalContext { reporting_level }; - 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) andexpires_at(0 = never) intostruct peios_ntfe_outcome; - 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:
- reads the Rules key's own values for
CurrentReportingLevel(REG_DWORD,REG_DWORD_BIG_ENDIANorREG_QWORD; 1..6; absent = 1; anything else refuses); - enumerates its children for the layer keys
Packet,RawPacketandFlow(Interfaceis netd's — the interface layer is built and judged in userspace with the samepnp-core, and the kernel never reads it; any other name is ignored); - for each present layer, opens a builder and walks the layer key's
subkeys as rule roots. Each rule is one
RSI_QUERY_VALUESround trip (its effective, layering-resolved values, delivered in the batch record formatu32 name_len | name | u32 type | u32 data_len | data) followed by oneRSI_ENUM_CHILDRENround 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:
- 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 oneRSI_QUERY_VALUESround trip reads itsNameandTrust(REG_SZ; absent or unreadable is empty, and the id is still a fact), up to 256 records; - every child of
Interfaces\is an interface; itsStatussubkey is found by oneRSI_ENUM_CHILDRENand read by oneRSI_QUERY_VALUESforName(the kernel interface name) andNetwork(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, anIGNOREd orDOWNinterface — 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-deepandtoo-many-rules, with-E2BIG;bad-value-type,bad-value-lengthandnot-utf8, in the rule that holds the value;bad-reporting-level,counter-store-refusedandout-of-memory;registry-read-failedfor 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.
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
REJECTit 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_untrackedortag_refused; - a count it could not land →
count_key_absentorcount_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 injudgedandflow_judged), the outcome is written as the new sentence, an event is emitted (withREJUDGEDwhen a stale sentence was replaced,flow_rejudgedorflow_expiredsaying 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; theCOUNTnoise 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
sharedbefore 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_REUSEPORTgroup 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 thesharedtest 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 samenoneresult 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; aTIME_WAITminisock is nobody's:kernel). - Nothing found:
kernelif 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 — elsenone, 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 aPacketrule is linted as never present, andPresenton 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.
sharedand a later gate onIP_ADD_MEMBERSHIP(C11) instead. - Copying the group list into the snapshot. Up to 1024 SIDs per
token, in softirq: the
Principalview answers membership instead. Program.Trustand 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).
| Constant | Value | Definition |
|---|---|---|
PEIOS_NTFE_IOC_STATUS | 0x81704E01 | _IOR(PEIOS_NTFE_IOC_TYPE, PEIOS_NTFE_IOC_STATUS_NR, struct peios_ntfe_status) |
PEIOS_NTFE_IOC_COUNTERS | 0xC0184E02 | _IOWR(PEIOS_NTFE_IOC_TYPE, PEIOS_NTFE_IOC_COUNTERS_NR, struct peios_ntfe_counters_query) |
PEIOS_NTFE_IOC_FLOWS | 0xC0184E03 | _IOWR(PEIOS_NTFE_IOC_TYPE, PEIOS_NTFE_IOC_FLOWS_NR, struct peios_ntfe_flows_query) |
PEIOS_NTFE_IOC_LISTENERS | 0xC0184E04 | _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.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 8 | __u64 | seq |
| 8 | 8 | __u64 | t_ns |
| 16 | 1 | __u8 | seat |
| 17 | 1 | __u8 | layer |
| 18 | 1 | __u8 | verdict |
| 19 | 1 | __u8 | flags |
| 20 | 1 | __u8 | direction |
| 21 | 1 | __u8 | addr_family |
| 22 | 1 | __u8 | protocol |
| 23 | 1 | __u8 | flow_state |
| 24 | 4 | __u32 | ifindex |
| 28 | 2 | __u16 | src_port |
| 30 | 2 | __u16 | dst_port |
| 32 | 2 | __u16 | ether_type |
| 34 | 1 | __u8 | reject_kind |
| 35 | 1 | __u8 | _pad0 |
| 36 | 16 | __u8[16] | src_addr |
| 52 | 16 | __u8[16] | dst_addr |
| 68 | 4 | __u32 | length |
| 72 | 4 | __u32 | effects |
| 76 | 96 | __u8[PEIOS_NTFE_EV_ATTR_LEN] | attributed |
| 172 | 4 | __u32 | _pad1 |
| 176 | 1 | __u8 | local_kind |
| 177 | 1 | __u8 | remote_kind |
| 178 | 1 | __u8 | local_unresolved |
| 179 | 1 | __u8 | remote_unresolved |
| 180 | 4 | __s32 | local_pid |
| 184 | 4 | __s32 | remote_pid |
| 188 | 16 | __u8[PEIOS_NTFE_GUID_LEN] | local_guid |
| 204 | 16 | __u8[PEIOS_NTFE_GUID_LEN] | remote_guid |
| 220 | 16 | __u8[PEIOS_NTFE_COMM_LEN] | local_comm |
| 236 | 16 | __u8[PEIOS_NTFE_COMM_LEN] | remote_comm |
| 252 | 68 | __u8[PEIOS_NTFE_SID_LEN] | local_user |
| 320 | 68 | __u8[PEIOS_NTFE_SID_LEN] | remote_user |
| 388 | 32 | __u8[PEIOS_NTFE_SERVICE_SID_LEN] | local_service |
| 420 | 32 | __u8[PEIOS_NTFE_SERVICE_SID_LEN] | remote_service |
| 452 | 4 | __u32 | _pad2 |
6.A.2.2 struct peios_ntfe_status #
Total size 368 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 8 | __u64 | abi |
| 8 | 8 | __u64 | generation |
| 16 | 8 | __u64 | enforcing |
| 24 | 8 | __u64 | events_dropped |
| 32 | 8 | __u64 | seen_ingress |
| 40 | 8 | __u64 | seen_egress |
| 48 | 8 | __u64 | seen_local_in |
| 56 | 8 | __u64 | deferred |
| 64 | 8 | __u64 | fallback_judged |
| 72 | 8 | __u64 | parse_errors |
| 80 | 8 | __u64 | judged |
| 88 | 8 | __u64 | permissive |
| 96 | 8 | __u64 | fail_closed |
| 104 | 8 | __u64 | verdict_pass |
| 112 | 8 | __u64 | verdict_drop |
| 120 | 8 | __u64 | verdict_reject |
| 128 | 8 | __u64 | reject_degraded |
| 136 | 8 | __u64 | fx_tags |
| 144 | 8 | __u64 | fx_counts |
| 152 | 8 | __u64 | fx_reports |
| 160 | 8 | __u64 | fx_prompts |
| 168 | 8 | __u64 | last_ingest_error |
| 176 | 8 | __u64 | last_ingest_t_ns |
| 184 | 8 | __u64 | tag_writes |
| 192 | 8 | __u64 | tag_untracked |
| 200 | 8 | __u64 | tag_refused |
| 208 | 8 | __u64 | count_writes |
| 216 | 8 | __u64 | count_key_absent |
| 224 | 8 | __u64 | count_refused |
| 232 | 8 | __u64 | reports_emitted |
| 240 | 8 | __u64 | counter_cells |
| 248 | 8 | __u64 | reporting_level |
| 256 | 8 | __u64 | seen_local_out |
| 264 | 8 | __u64 | flow_judged |
| 272 | 8 | __u64 | flow_cached |
| 280 | 8 | __u64 | flow_rejudged |
| 288 | 8 | __u64 | flow_expired |
| 296 | 8 | __u64 | flow_uncached |
| 304 | 8 | __u64 | refusals_emitted |
| 312 | 8 | __u64 | refusals_bypassed |
| 320 | 8 | __u64 | teardowns_emitted |
| 328 | 8 | __u64 | identity_unresolved |
| 336 | 8 | __u64 | changes_noted |
| 344 | 8 | __u64 | changes_walked |
| 352 | 8 | __u64 | contexts |
| 360 | 8 | __u64[1] | _reserved |
6.A.2.3 struct peios_ntfe_counter_rec #
Total size 232 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 64 | __u8[PEIOS_NTFE_COUNTER_NAME_LEN] | name |
| 64 | 8 | __u64 | hash |
| 72 | 1 | __u8 | keyspec |
| 73 | 1 | __u8 | family |
| 74 | 2 | __u8[2] | _pad0 |
| 76 | 4 | __s32 | ifindex |
| 80 | 16 | __u8[16] | src_addr |
| 96 | 16 | __u8[16] | dst_addr |
| 112 | 8 | __u64 | total |
| 120 | 8 | __u64 | last_secs |
| 128 | 4 | __u32 | n_windows |
| 132 | 4 | __u32 | _pad1 |
| 136 | 32 | __u32[PEIOS_NTFE_COUNTER_MAX_WINDOWS] | window_secs |
| 168 | 64 | __u64[PEIOS_NTFE_COUNTER_MAX_WINDOWS] | window_value |
6.A.2.4 struct peios_ntfe_counters_query #
Total size 24 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 8 | __u64 | buf |
| 8 | 4 | __u32 | buf_len |
| 12 | 4 | __u32 | count |
| 16 | 4 | __u32 | total |
| 20 | 4 | __u32 | _pad0 |
6.A.2.5 struct peios_ntfe_flow_rec #
Total size 568 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 4 | __u32 | id |
| 4 | 1 | __u8 | family |
| 5 | 1 | __u8 | protocol |
| 6 | 1 | __u8 | direction |
| 7 | 1 | __u8 | loopback |
| 8 | 1 | __u8 | seen_reply |
| 9 | 1 | __u8 | assured |
| 10 | 1 | __u8 | related |
| 11 | 1 | __u8 | judged |
| 12 | 4 | __s32 | ifindex |
| 16 | 4 | __u32 | timeout_secs |
| 20 | 16 | __u8[16] | src_addr |
| 36 | 16 | __u8[16] | dst_addr |
| 52 | 2 | __u16 | src_port |
| 54 | 2 | __u16 | dst_port |
| 56 | 1 | __u8 | icmp_type |
| 57 | 1 | __u8 | icmp_code |
| 58 | 1 | __u8 | n_tags |
| 59 | 5 | __u8[5] | _pad0 |
| 64 | 8 | __u64 | start_secs |
| 72 | 16 | __u64[2] | packets |
| 88 | 16 | __u64[2] | bytes |
| 104 | 16 | __u64[PEIOS_NTFE_FLOW_SENTENCES] | sentence_generation |
| 120 | 16 | __s64[PEIOS_NTFE_FLOW_SENTENCES] | sentence_expires_at |
| 136 | 16 | __u64[PEIOS_NTFE_FLOW_SENTENCES] | sentence_rule_hash |
| 152 | 2 | __u8[PEIOS_NTFE_FLOW_SENTENCES] | sentence_verdict |
| 154 | 2 | __u8[PEIOS_NTFE_FLOW_SENTENCES] | sentence_reject_kind |
| 156 | 4 | __u8[4] | _pad1 |
| 160 | 64 | __u64[PEIOS_NTFE_FLOW_MAX_TAGS] | tag_hash |
| 224 | 64 | __u64[PEIOS_NTFE_FLOW_MAX_TAGS] | tag_value |
| 288 | 2 | __u8[PEIOS_NTFE_FLOW_SENTENCES] | owner_kind |
| 290 | 2 | __u8[PEIOS_NTFE_FLOW_SENTENCES] | owner_unresolved |
| 292 | 4 | __u8[4] | _pad2 |
| 296 | 8 | __s32[PEIOS_NTFE_FLOW_SENTENCES] | owner_pid |
| 304 | 32 | __u8[32] | owner_guid |
| 336 | 32 | __u8[32] | owner_comm |
| 368 | 136 | __u8[136] | owner_user |
| 504 | 64 | __u8[64] | owner_service |
6.A.2.6 struct peios_ntfe_flows_query #
Total size 24 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 8 | __u64 | buf |
| 8 | 4 | __u32 | buf_len |
| 12 | 4 | __u32 | count |
| 16 | 4 | __u32 | total |
| 20 | 4 | __u32 | _pad0 |
6.A.2.7 struct peios_ntfe_listener_rec #
Total size 168 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 1 | __u8 | family |
| 1 | 1 | __u8 | protocol |
| 2 | 1 | __u8 | reuseport |
| 3 | 1 | __u8 | connected |
| 4 | 1 | __u8 | v6only |
| 5 | 1 | __u8 | owner_kind |
| 6 | 1 | __u8 | owner_unresolved |
| 7 | 1 | __u8 | _pad0 |
| 8 | 2 | __u16 | port |
| 10 | 2 | __u16 | _pad1 |
| 12 | 4 | __s32 | ifindex |
| 16 | 16 | __u8[16] | addr |
| 32 | 4 | __s32 | owner_pid |
| 36 | 16 | __u8[PEIOS_NTFE_GUID_LEN] | owner_guid |
| 52 | 16 | __u8[PEIOS_NTFE_COMM_LEN] | owner_comm |
| 68 | 68 | __u8[PEIOS_NTFE_SID_LEN] | owner_user |
| 136 | 32 | __u8[PEIOS_NTFE_SERVICE_SID_LEN] | owner_service |
6.A.2.8 struct peios_ntfe_listeners_query #
Total size 24 bytes.
| Offset | Size | Type | Field |
|---|---|---|---|
| 0 | 8 | __u64 | buf |
| 8 | 4 | __u32 | buf_len |
| 12 | 4 | __u32 | count |
| 16 | 4 | __u32 | total |
| 20 | 4 | __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.
| Constant | Value |
|---|---|
PEIOS_NTFE_ABI_VERSION | 5 |
Which standing seat judged the traversal.
| Constant | Value |
|---|---|
PEIOS_NTFE_EV_SEAT_INGRESS | 1 |
PEIOS_NTFE_EV_SEAT_EGRESS | 2 |
PEIOS_NTFE_EV_SEAT_LOCAL_IN | 3 |
PEIOS_NTFE_EV_SEAT_LOCAL_OUT | 4 |
| Constant | Value |
|---|---|
PEIOS_NTFE_EV_LAYER_PACKET | 0 |
PEIOS_NTFE_EV_LAYER_RAWPACKET | 1 |
PEIOS_NTFE_EV_LAYER_FLOW | 2 |
The verdict, in strictness order.
| Constant | Value |
|---|---|
PEIOS_NTFE_EV_VERDICT_PASS | 0 |
PEIOS_NTFE_EV_VERDICT_REJECT | 1 |
PEIOS_NTFE_EV_VERDICT_DROP | 2 |
The story a REJECT told (meaningful iff verdict == REJECT).
| Constant | Value | Notes |
|---|---|---|
PEIOS_NTFE_EV_REJECT_REFUSED | 0 | RST / port-unreachable |
PEIOS_NTFE_EV_REJECT_PROHIBITED | 1 | admin-prohibited |
| Constant | Value |
|---|---|
PEIOS_NTFE_EV_DIR_IN | 0 |
PEIOS_NTFE_EV_DIR_OUT | 1 |
Flow state as the snapshot carried it (0 = the fact was absent).
| Constant | Value |
|---|---|
PEIOS_NTFE_EV_FLOW_ABSENT | 0 |
PEIOS_NTFE_EV_FLOW_NEW | 1 |
PEIOS_NTFE_EV_FLOW_ESTABLISHED | 2 |
PEIOS_NTFE_EV_FLOW_RELATED | 3 |
PEIOS_NTFE_EV_FLOW_INVALID | 4 |
PEIOS_NTFE_EV_FLOW_UNTRACKED | 5 |
| Constant | Value | Notes |
|---|---|---|
PEIOS_NTFE_EV_F_BACKSTOP | 0x01 | nothing yielded; DROP |
PEIOS_NTFE_EV_F_FAIL_CLOSED | 0x02 | evaluation failed; DROP |
PEIOS_NTFE_EV_F_REJECT_DEGRADED | 0x04 | REJECT emitted as DROP |
PEIOS_NTFE_EV_F_REJUDGED | 0x08 | Flow: a stale sentence re-judged |
PEIOS_NTFE_EV_F_IDENTITY_UNRESOLVED | 0x10 | Flow: an endpoint could not be attributed |
What stood at an endpoint: the Flow layer's Local / Remote facts (ABI 4).
| Constant | Value | Notes |
|---|---|---|
PEIOS_NTFE_EV_LOCAL_ABSENT | 0 | not a Flow event / not local |
PEIOS_NTFE_EV_LOCAL_PROGRAM | 1 | a process's socket |
PEIOS_NTFE_EV_LOCAL_KERNEL | 2 | the stack itself |
PEIOS_NTFE_EV_LOCAL_SHARED | 3 | inbound multicast / broadcast |
PEIOS_NTFE_EV_LOCAL_NONE | 4 | nothing receives it |
PEIOS_NTFE_EV_ATTR_LEN | 96 |
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.
| Constant | Value |
|---|---|
PEIOS_NTFE_SID_LEN | 68 |
A per-service SID (S-1-5-80 + five sub-authorities) is exactly this.
| Constant | Value |
|---|---|
PEIOS_NTFE_SERVICE_SID_LEN | 32 |
PEIOS_NTFE_COMM_LEN | 16 |
PEIOS_NTFE_GUID_LEN | 16 |
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.
| Constant | Value |
|---|---|
PEIOS_NTFE_COUNTER_NAME_LEN | 64 |
PEIOS_NTFE_COUNTER_MAX_WINDOWS | 8 |
| Constant | Value |
|---|---|
PEIOS_NTFE_KEY_SRC_ADDR | 0x01 |
PEIOS_NTFE_KEY_DST_ADDR | 0x02 |
PEIOS_NTFE_KEY_INTERFACE | 0x04 |
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.
| Constant | Value |
|---|---|
PEIOS_NTFE_FLOW_MAX_TAGS | 8 |
PEIOS_NTFE_FLOW_SENTENCES | 2 |
The listeners dump: fills buf with as many records as fit; count is
how many were written, total how many sockets the walk saw.
| Constant | Value |
|---|---|
PEIOS_NTFE_IOC_TYPE | 0x0000004E |
PEIOS_NTFE_IOC_STATUS_NR | 0x00000001 |
PEIOS_NTFE_IOC_COUNTERS_NR | 0x00000002 |
PEIOS_NTFE_IOC_FLOWS_NR | 0x00000003 |
PEIOS_NTFE_IOC_LISTENERS_NR | 0x00000004 |
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.
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 #
attributedis the winning rule's path relative to its layer key, UTF-8, NUL-terminated, truncated toPEIOS_NTFE_EV_ATTR_LEN− 1 bytes (95), which may split a multi-byte character. Two reserved values:backstop(nothing yielded) andfail-closed(evaluation failed).effectspacks 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_kindis meaningful only whenverdictisPEIOS_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_REJUDGEDmarks aFlow-layer evaluation that replaced a stale sentence (policy change or time edge). A packet answered by a current sentence produces no event at all.layer2 isFlow;seat4 isLOCAL_OUT. AFlowevent'sdirectionis 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_familyis 4, all 16 when 6, undefined when 0. Ports are 0 when the fact was absent — checkprotocol. flow_state0 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
Flowevents only and zero elsewhere.local_kind/remote_kindarePEIOS_NTFE_EV_LOCAL_*:ABSENT(0) for a non-Flow event, and forremotewhenever the other end is not local;remoteis filled only on a loopback flow. For aPROGRAMend,*_guid,*_pidand*_commare the process facts at the socket's stamp,*_userthe token's user SID and*_serviceits 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).*_unresolvedsays the end could not be attributed and was reported as the kernel's or as absent.
6.B.4 Counter records #
nameis the stream name (a rule'sCOUNT(Name)), NUL-terminated;hashis its FNV-1a identity as the kernel keys tables.keyspecsays which ofsrc_addr,dst_addr,ifindexare meaningful for this cell's key; the others are zero.familyis 4, 6, or 0 when no address fact is keyed.window_secs[i]/window_value[i]fori < n_windowsare the table's windows and this cell's current value in each;totalis cumulative since the cell was created (or migrated — see §6.6);last_secsisCLOCK_REALTIMEseconds 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 #
idis 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_portcarries the echo id andicmp_type/icmp_codethe type and code;dst_portis 0.direction,ifindexandloopbackare meaningful only whenjudgedis 1: they were recorded at the Flow layer's first judgment. A flow withjudged == 0has 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 == 0is empty.sentence_expires_at0 means never.sentence_rule_hashis FNV-1a-64 (offset0xcbf29ce484222325, prime0x100000001b3) of the whole attributing path relative to the layer key, however long — not of the event's truncatedattributed.backstophashes like any other path;fail-closedis never a sentence's, since a failed evaluation is not cached. packets/bytesare conntrack's accounting, original then reply; NTFE turns conntrack accounting on at init (net.netfilter.nf_conntrack_acct).timeout_secsis the entry's remaining lifetime as conntrack sees it;start_secsisCLOCK_REALTIMEseconds when conntrack created it.tag_hash/tag_valuehold up toPEIOS_NTFE_FLOW_MAX_TAGS(8) present tags by name hash and value;n_tagsis 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]isPEIOS_NTFE_EV_LOCAL_*(ABSENT= not resolved;ABSENTwithowner_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_guid16 bytes per slot,owner_comm16,owner_user68,owner_service32 — 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 (connectedwhen it also has a peer and so receives from one address only).addrall zero is the wildcard;ifindexisSO_BINDTODEVICE, 0 for any;reuseportmarks a member of aSO_REUSEPORTgroup, of which each member is listed.v6onlysays anAF_INET6socket 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_kindisPROGRAMorKERNEL(a socket nobody stamped readsKERNELwithowner_unresolvedset). - 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 #
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
svctlcommand surface includingsvctl 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 #
| Concern | Component |
|---|---|
| Authentication and token minting | authd |
| The local identity database | lpsd |
| Log storage, indexing and queries | eventd |
| Registry storage | registryd, over LCS |
| Packaging and installation | peipkg and the role system |
| Device management | eudev |
| Network configuration | a dedicated service |
| File access control enforcement | FACS |
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.
| Object | What it is | Lifetime |
|---|---|---|
| Service | A 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. |
| Job | A 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. |
| Operation | A 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 on | For |
|---|---|
| KACS | Service identity (installing tokens on children), control-interface authentication (peer token + AccessCheck), and per-service access control. See Access decisions. |
| LCS / the registry | Service definitions, boot configuration, and its own parameters. See The registry. |
| registryd | The 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.) |
| authd | Minting tokens for non-platform services. peinit requests; authd mints. |
| eventd | Service logs (forwarded over a socket) and job/operation/audit events (emitted through KMES). |
| KACS, again | Conveying 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 value | Token source |
|---|---|
SYSTEM | Minted by peinit from its own SYSTEM identity. |
| Any other principal name or SID | Requested from authd. |
| Absent or empty | authd, 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).
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.
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,/sysand/devare mounted and have been moved into the real root;- the environment holds
TERMand 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.
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:
| Service | Why |
|---|---|
| registryd | Starts before authd exists at all. |
| authd | Needs 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.
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 point | Filesystem | Flags | Provided by |
|---|---|---|---|
/proc | proc | nosuid, nodev, noexec | initramfs |
/sys | sysfs | nosuid, nodev, noexec | initramfs |
/dev | devtmpfs | nosuid | initramfs |
/dev/pts | devpts | nosuid, noexec | peinit |
/dev/shm | tmpfs | nosuid, nodev | peinit |
/run | tmpfs | nosuid, nodev | peinit |
/sys/fs/cgroup | cgroup2 | nosuid, nodev, noexec | peinit |
/sys/kernel/security | securityfs | nosuid, nodev, noexec | peinit |
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:
| Node | DACL |
|---|---|
/dev/null, /dev/zero, /dev/full, /dev/random, /dev/urandom, /dev/tty, /dev/ptmx | D:(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.
2.3.6 Step 6: Start registryd #
peinit holds a compiled-in definition for registryd — the only compiled-in service definition there is :
| Field | Value |
|---|---|
| ImagePath | /sbin/registryd |
| Arguments | Machine=/var/state/loregd/Machine.hive, Users=/var/state/loregd/Users.hive |
| Identity | SYSTEM |
| Readiness | Notify |
| ErrorControl | Critical |
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:
- 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). - 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). - Loopback: peinit brings up
loover netlink, because services that bind127.0.0.1need 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 #
| Failure | Response |
|---|---|
| Root writability probe fails | Recovery |
| A mount point cannot be created | Recovery |
| A peinit-owned filesystem fails to mount | Recovery |
| A mounted filesystem cannot be stamped with its descriptor | Recovery |
/proc/self/mountinfo unreadable or unparseable | Recovery |
/proc, /sys or /dev already mounted, or EBUSY | Tolerated as success |
| A device node in the policy list cannot be stamped | Warning; node keeps the inherited default |
| A device node in the policy list does not exist | Noted; boot continues |
| Random seed absent, oversized, empty, or unrestorable | Warning; boot continues |
| Machine ID generation fails (CSPRNG) | Recovery |
| Machine ID unreadable, or unwritable at its path | Warning; boot continues with an identifier for this boot only |
| Machine ID absent, empty, or malformed | Regenerated; boot continues |
| Any RTC or clock failure | Recovery |
| registryd fails to start, or setup times out | Recovery |
| registryd readiness times out | Recovery |
| Base registry provisioning fails | Recovery |
| Schema-version probe returns a wrong type or length | Recovery |
| An autorun script is missing, unspawnable, or exits non-zero | Warning; boot continues |
| A provisioning entry is malformed | Warning; entry skipped |
| An optional provisioned path fails | Warning; boot continues |
| A required provisioned path fails | Recovery |
| Control socket creation or stamping fails | Recovery |
| Jobs socket creation or stamping fails | Recovery |
| Loopback bring-up fails | Warning; 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.
| Value | Type | Required | Default | Meaning |
|---|---|---|---|---|
Kind | string | yes | — | directory or file. |
Path | string | yes | — | Absolute path to create or verify. |
Security | binary | no | built-in | The Peios file Security Descriptor to apply. |
Required | dword | no | 0 | If 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
Requirestarget fails the dependent. - Warnings — a
Readiness=Aliveservice with dependents that require it — are logged and do not prevent boot: a[ WARN ]peinit warning:line on the console and apeinit.graph.validation.warnedevent under phaseboot, 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:
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.
- eudev — device management. SYSTEM, with
RequiredPrivilegesstripped to the minimum it needs. Starts before authd exists. - lpsd — the local identity database. Depends only on registryd.
- authd — the identity authority. Depends on registryd and lpsd. Once it is ready, token minting is available.
- eventd — logging and audit. Services started before it log into peinit's pre-eventd buffer.
- Networking — the first service to receive a token from authd.
- Application services, in dependency order.
- Login services, last, so the system is operational before it accepts a session.
2.5.3.2 The bootstrap matrix #
| Service | Phase | Identity | Token from | Readiness | ErrorControl |
|---|---|---|---|---|---|
| registryd | 1 | SYSTEM | minted by peinit | Notify | Critical |
| eudev | 2 | SYSTEM | minted by peinit, privileges stripped | Alive | Normal |
| lpsd | 2 | SYSTEM | minted by peinit | Notify | Critical |
| authd | 2 | SYSTEM | minted by peinit | Notify | Critical |
| eventd | 2 | SYSTEM | minted by peinit | Notify | Critical |
| networking | 2 | service token | authd | Notify | Normal |
| sshd | 2 | service token | authd | Alive | Normal |
| application services | 2 | service token | authd | per-service | Normal |
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:
| Key | Default | Meaning |
|---|---|---|
Machine\System\Boot\SettleTimeout | 5 | Seconds 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.
2.5.5 Boot success #
A boot is successful once every Critical service has held a dependent-satisfying state continuously for a grace period:
| Key | Default | Meaning |
|---|---|---|
Machine\System\Boot\BootSuccessGrace | 30 | Seconds 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 #
| Failure | Response |
|---|---|
| A service definition fails to decode | That service Failed with cause ValidationError; the rest of the boot proceeds |
| A registry read times out | Recovery |
Invalid MaxParallelStarts or SettleTimeout | Recovery |
| A dependency cycle | All services in it Failed; Safe mode if any is Critical (see below) |
| An unresolvable conflict | Both Failed; Safe mode if either is Critical (see below) |
A missing Requires target | The dependent Failed |
| A Critical service fails during boot | Restart budget, then reboot |
| A non-Critical service fails | Failed; its Requires dependents fail; the rest continue |
| authd unavailable when a service needs a token | That 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=1services 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.
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=1on 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:
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 #
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.
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=1on 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:
- Completes Phase 1 steps 1–5 if they have not been reached yet.
- Ensures the base registry structure exists, so the shell sees a normal layout even on a system that has never been provisioned.
- Attempts to start registryd. A failure here is ignored — recovery delivers a shell whatever registryd's state.
- Skips all Phase 2 services.
- Starts a shell on
/dev/consolefrom a compiled-in definition, with no registry dependency:/bin/recshif it is present and executable, otherwise/bin/sh, running as SYSTEM with a fixed environment ofPATH=/sbin:/bin,TERM=linuxandHOME=/. peinit does not care where either binary comes from. - 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.
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.
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=1it stays there; without, it passes through Completed to release dependents and then goes Inactive. - A non-zero exit goes to Failed.
ExecStartPostruns after the successful exit rather than after a readiness signal, and does not run at all if the Oneshot failed.StartTimeoutcovers 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.
| Field | Type | Default | Meaning |
|---|---|---|---|
| ImagePath | string | required | Absolute path to the service binary. |
| Arguments | multi_string | — | Arguments passed to the binary. |
| Type | dword | 0 (Simple) | 0 Simple, 1 Oneshot. §3.1 |
| Triggers | multi_string | — | When the service starts automatically. §3.4 |
| Disabled | dword | 0 | If 1, no trigger activates the service. |
| SafeMode | dword | 0 | If 1, attempt this service in Safe mode. Implied by ErrorControl=Critical. §2.6 |
| Identity | string | LocalService | SYSTEM, shared LocalService/NetworkService, Service for the service’s own virtual account, or a designated principal. §4.1 |
| RequiredPrivileges | multi_string | — | Privileges to keep; all others are removed. §4.5 |
| Requires | multi_string | — | Hard dependencies. §7.1 |
| Wants | multi_string | — | Soft dependencies. §7.1 |
| BindsTo | multi_string | — | Runtime coupling. §7.1 |
| Conflicts | multi_string | — | Mutual exclusion. §7.1 |
| Provides | multi_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 |
| OnFailure | string | — | Service to start when this one fails. §6.3 |
| ErrorControl | dword | 0 (Normal) | 0 Normal, 1 Critical. |
| RemainAfterExit | dword | 0 | Oneshot only: stay Completed after a successful exit. |
| SuccessExitCodes | multi_string | — | Non-zero exit codes treated as success. |
| ExecStartPre | multi_string | — | Commands run before the main binary, sequentially. §5.3 |
| ExecStartPost | multi_string | — | Commands run after readiness or successful exit. §5.3 |
| HookIdentity | string | — | Principal for the hook processes. Falls back to Identity. §4.1 |
| ExecReload | string | — | Reload command, or signal:<NAME>. Absent means SIGHUP. §6.5 |
| PreStartCheckTimeout | dword | 5 | Seconds before a filesystem check helper is killed. §3.5 |
| StartTimeout | dword | 30 | Seconds for the entire start sequence. §5.3 |
| StopTimeout | dword | 10 | Seconds after SIGTERM before SIGKILL. |
| WatchdogTimeout | dword | 0 | Seconds between expected WATCHDOG=1 pings; 0 disables. §6.6 |
| HealthCheck | string | — | Command run periodically. Exit 0 is healthy. §5.6 |
| HealthCheckInterval | dword | 30 | Seconds between health checks. |
| HealthCheckTimeout | dword | 5 | Seconds before a health check is killed and counted failed. |
| HealthCheckRetries | dword | 3 | Consecutive failures before the service is unhealthy. |
| RestartPolicy | dword | 1 (OnFailure) | 0 Never, 1 OnFailure, 2 Always. §6.4 |
| RestartMaxRetries | dword | 5 | Consecutive restarts before Failed. §6.4 |
| RestartWindow | dword | 120 | Seconds of sustained health that reset the restart counter. |
| RestartDelay | dword | 1 | Seconds before a restart; doubles each consecutive failure, capped at 60. |
| Readiness | dword | 0 (Notify) | 0 Notify, 1 Alive. Ignored for Oneshot. |
| NotifyAccess | dword | 0 (Main) | Who may send notifications. Main is the only mode. §10.5 |
| FdStoreMax | dword | 0 | Maximum descriptors held for the service; 0 disables the store. §10.6 |
| TimerPersistent | dword | 1 | Catch up a missed timer run after a reboot. §9.3 |
| TimerJitter | dword | 0 | Maximum random delay added to each firing. §9.4 |
| Environment | multi_string | — | KEY=VALUE pairs added to the environment. §5.5 |
| WorkingDirectory | string | / | Working directory for the process. |
| TTYPath | string | — | Terminal to attach as the standard streams and controlling terminal. §5.4 |
| TTYPrecedence | dword | 0 | Who wins that terminal when several services want it at once. Higher wins. Refused without a TTYPath. §11.6 |
| RuntimeDirectories | multi_string | — | Private directories under /run, created before the main process. |
| LimitNOFILE | dword | — | RLIMIT_NOFILE. |
| LimitCORE | dword | — | RLIMIT_CORE, in bytes. |
| Conditions | multi_string | — | Start-time conditions; failure skips the service. §3.5 |
| Asserts | multi_string | — | Start-time assertions; failure fails the service. §3.5 |
| DisplayName | string | — | Human-readable name for status display. |
| Description | string | — | What the service does. |
| ServiceSecurity | binary | inherit | Descriptor controlling runtime operations on the service. §4.6 |
3.2.1 Registry types #
| Schema type | Registry type |
|---|---|
| string | REG_SZ, UTF-8 |
| multi_string | REG_MULTI_SZ, an ordered list |
| dword | REG_DWORD, 32-bit unsigned |
| binary | REG_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 toLocalService.HookIdentity— empty is the same as absent, and falls back to the service'sIdentity.DisplayNameandDescription— 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.
| Trigger | Form | Meaning |
|---|---|---|
| Boot | boot | Start during the Phase 2 boot sequence. |
| Deferred boot | boot:settled | Start once the boot set has settled, or the deadline expires. §2.5 |
| Terminal handover | tty:released | Start when this service's TTYPath is let go of. §11.6 |
| Timer | timer:<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.
| Check | Form | Passes when |
|---|---|---|
| path | path:<path> | The path exists, of any type. |
| file | file:<path> | A regular file exists there. |
| directory | directory:<path> | A directory exists there. |
| registry | registry:<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.
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:
stopis accepted, so an administrator can drain it cleanly, using the cachedStopTimeout;start,restartandreloadare rejected withUNKNOWN_SERVICE— there is no definition to work from;statusreports the current runtime state withdefinition_removedset, 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:
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.
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.
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.
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:
| Field | Value |
|---|---|
identity | The definition's Identity value, verbatim |
service | The 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 #
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:
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:
- Takes the caller's token, captured when the connection was accepted.
- 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. - 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}}. - 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 akacs.audit.access.checkedrecord withobject.kindservice,object.service.name, the caller's token, and the access requested and granted (§8.4). - 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 #
The generic mapping:
| Generic right | Maps to |
|---|---|
GENERIC_READ | SYSTEM_QUERY_STATUS |
GENERIC_WRITE | SYSTEM_RELOAD_CONFIG |
GENERIC_EXECUTE | SYSTEM_SHUTDOWN |
GENERIC_ALL | all 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.
/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.
- If the service names a
TTYPathand another service is holding that terminal, it goes to Skipped with causeTtyUnavailableand 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. - Conditions are evaluated. Any failure transitions the service to Skipped and abandons the start. Skipped satisfies dependents.
- If every condition passed and the service has asserts, they are
evaluated. Any failure transitions the service to Failed with cause
AssertionErrorand 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):
| Bytes | Content |
|---|---|
| 0–3 | little-endian u32, the child setup step identifier |
| 4–7 | little-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.
| Id | Step |
|---|---|
| 1 | Close the read end of the error pipe |
| 2 | Set the standard streams |
| 3 | Reset the signal environment |
| 4 | Install the service token |
| 5 | Set the resource limits |
| 6 | Set oom_score_adj |
| 7 | Set the working directory |
| 8 | reserved |
| 9 | Confirm NOTIFY_SOCKET |
| 10 | Inject stored descriptors |
| 11 | Exec the binary |
| 12 | Create a session |
| 13 | Acquire 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 #
- Close the write end of the error pipe.
- 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.
- 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 forREADY=1, then Active. - Simple with
Readiness=Alive: Active as soon as exec succeeds. - Oneshot: wait for the exit. Success is Completed; without
RemainAfterExitit 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:
| # | Step | Id |
|---|---|---|
| 1 | Close the read end of the error pipe | 1 |
| 2 | setsid() — only with a TTYPath | 12 |
| 3 | Set the standard streams | 2 |
| 4 | ioctl(TIOCSCTTY) — only with a TTYPath | 13 |
| 5 | Reset the signal environment | 3 |
| 6 | Set RLIMIT_NOFILE and RLIMIT_CORE | 5 |
| 7 | Set oom_score_adj | 6 |
| 8 | Install the KACS token, then close its descriptor | 4 |
| 9 | Change the working directory | 7 |
| 10 | Confirm NOTIFY_SOCKET is present in the environment | 9 |
| 11 | Inject stored descriptors from fd 3 upward | 10 |
| 12 | execve | 11 |
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:
| Variable | Value |
|---|---|
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.
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.
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:
- Marks it leaked, recording the path, the kind and the time.
- Increments the service's cgroup generation, so the next start builds a fresh tree (§5.1).
- 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.leakedevent on the event stream, carrying the service, the sub-cgroup path (object.cgroup.path) and its kind (object.cgroup.type:service-tree,health,hooksorhelper). When it was found is the event's own time.
The pull side survives the moment of detection, for anyone who was not watching:
-
A status query includes a
warningsarray, one entry per leak, each an object with the sub-cgroup path, its kind, and the time of detection: -
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.
| State | Process? | Satisfies dependents? | Meaning |
|---|---|---|---|
| Inactive | No | No | Not started, or stopped cleanly and not set to restart. |
| Starting | Maybe | No | Activation in progress: checks, hooks, fork, or the readiness wait. The process may not exist yet. |
| Active | Yes | Yes | Running and ready. |
| Reloading | Yes | Yes | Re-reading configuration. Still satisfies dependents. |
| Stopping | Briefly | No | SIGTERM sent, awaiting exit or SIGKILL escalation. |
| Completed | No | Yes | Oneshot only. Exited successfully. |
| Backoff | No | No | A restart is pending; waiting out the backoff delay. |
| Failed | No | No | Exited abnormally with the restart policy exhausted or absent. |
| Abandoned | Yes, unkillably | No | SIGKILL sent and processes survived. Supervision has stopped, the cgroup is leaked. |
| Skipped | No | Yes | Conditions 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 #
- 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.
- 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.
- Only peinit transitions a service. The control socket produces operations; nothing outside peinit writes state.
- 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.
- Readiness is per start generation. The generation increments on every
transition into Starting,
and a
READY=1carrying 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.
| From | To | Trigger |
|---|---|---|
| Inactive | Starting | Start command, dependency resolution, or a timer trigger. |
| Inactive | Skipped | A condition check failed. |
| Inactive | Failed | Validation, assertion, cycle or dependency failure — before any process existed. |
| Starting | Active | Readiness. Simple only: READY=1 received, or the process exists under Readiness=Alive. |
| Starting | Completed | Oneshot exited successfully. Without RemainAfterExit it passes through, releasing dependents, then goes Inactive. |
| Starting | Skipped | A condition failed after the activation had already entered Starting. |
| Starting | Failed | An assert failed after entering Starting; or a timeout, hook failure, setup failure, or an exit before readiness. |
| Starting | Backoff | A startup failure with the restart policy allowing a retry and budget remaining. |
| Starting | Stopping | An explicit stop cancelling an in-progress start. A restart on a Starting service is queued rather than cancelling. |
| Starting | Failed | The shutdown wave SIGKILLed a starting service. |
| Active | Reloading | ExecReload issued, or SIGHUP delivered. |
| Active | Stopping | Explicit stop, conflict eviction, a bound dependency stopping, or shutdown. |
| Active | Backoff | A crash, a watchdog timeout, or health check failure, with a restart allowed and budget remaining. |
| Active | Failed | The same three, with RestartPolicy=Never or the budget exhausted. |
| Active | Inactive | A Simple service exited successfully and RestartPolicy is not Always. Cause CleanExit. |
| Active | Backoff | A Simple service exited successfully and RestartPolicy=Always. Cause CleanExitRestart. |
| Reloading | Active | Reload resolved: READY=1, the detection window expiring, the extended wait expiring, or the reload command exiting — success or failure. |
| Reloading | Stopping | The same triggers as Active to Stopping. The reload is cancelled. |
| Reloading | Backoff | The main process exited during the reload, with a restart allowed. |
| Reloading | Failed | The same, with no restart available. |
| Stopping | Inactive | The process exited after an explicit stop or a shutdown. |
| Stopping | Failed | The process exited after a conflict eviction or a bound dependency stopping. |
| Stopping | Abandoned | SIGKILL sent and main/ still populated after the post-kill deadline. |
| Stopping | Starting | An explicit start queued against a service that is still stopping. Cause ExplicitStart. |
| Backoff | Starting | The backoff delay elapsed. Cause RestartPolicy. |
| Backoff | Inactive | An explicit stop cancelled the pending restart. |
| Backoff | Skipped | The 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. |
| Backoff | Failed | The backoff delay elapsed and peinit could not execute the relaunch. Cause InternalError. |
| Starting, Active, Reloading, Stopping | Failed | peinit could not carry out a step on the service and contained the error to it (§8.2). Cause InternalError. |
| Completed | Inactive | RemainAfterExit=0 and dependents released; or an explicit stop; or shutdown clearing it. |
| Completed | Starting | A start command or timer trigger re-running the Oneshot. |
| Failed | Starting | An explicit start, a bound dependency recovering, or a timer trigger. |
| Failed | Inactive | A reset command clearing the state. |
| Failed | Abandoned | A service SIGKILLed by the shutdown wave whose cgroup stayed populated past the post-kill deadline. |
| Abandoned | Inactive | A reset command. |
| Skipped | Inactive | A 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 #
| Cause | Leads to | Meaning |
|---|---|---|
ExplicitStart | Starting | An administrator, an OnFailure handler, or a boot plan started it. |
ExplicitStart | Inactive | An explicit start on a Skipped service, clearing it so the conditions are re-evaluated. |
DependencyStart | Starting | Started to satisfy another service's dependency. |
RestartPolicy | Starting | An automatic restart after a backoff delay. |
BindsToRecovery | Starting | A bound dependency returned to Active. |
Timer | Starting | A timer trigger fired. |
ExplicitStop | Stopping | An administrator requested a stop. |
ExplicitReload | Reloading, Active | A reload was issued and resolved. |
ExplicitReset | Inactive | An administrator cleared Failed, Abandoned or Skipped. |
ConflictEviction | Stopping | A conflicting service started; this one lost. |
BindsToPropagation | Stopping | A bound dependency stopped. |
ShutdownWave | Stopping, Failed | The system is shutting down. Active and Reloading services go to Stopping; Starting services go straight to Failed. |
ProcessCrash | Failed, Backoff | The main process exited unexpectedly. |
CleanExit | Inactive | A Simple process exited successfully with a policy other than Always. |
CleanExitRestart | Backoff | A Simple process exited successfully under RestartPolicy=Always. |
ReadinessTimeout | Failed, Backoff | StartTimeout expired before readiness. |
WatchdogTimeout | Failed, Backoff | A keepalive did not arrive in time. |
HealthCheckFailure | Failed, Backoff | HealthCheckRetries consecutive failures. |
PreHookFailure | Failed, Backoff | An ExecStartPre hook exited non-zero, or its token failed, or the start timed out during hooks. |
ParentSetupFailure | Failed, Backoff | A parent-side failure before the fork. No child was created. |
PreExecFailure | Failed, Backoff | Post-fork setup failed before exec, reported through the error pipe. |
DependencyFailure | Failed | A Requires dependency entered Failed. |
RestartBudgetExhausted | Failed | RestartMaxRetries reached. |
CycleDetected | Failed | The service is part of a dependency cycle. |
ValidationError | Failed | The definition failed graph validation. |
AssertionError | Failed | A start-time assert failed. |
ConditionSkipped | Skipped | A start-time condition failed. |
TtyUnavailable | Skipped | Another service was holding the terminal this one names in TTYPath. §11.6 |
ProcessUnkillable | Abandoned | Processes survived SIGKILL. |
InternalError | Failed | peinit 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.
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.
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 #
| Policy | Value | Behaviour |
|---|---|---|
| Never | 0 | Never restart. The service stays Failed. |
| OnFailure | 1 | Restart on a non-zero exit or a runtime failure. An exit matching SuccessExitCodes is not restarted. |
| Always | 2 | Restart 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.
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:
| Phase | Base | Ceiling |
|---|---|---|
| Starting | StartTimeout | StartTimeout × 4 |
| Stopping | StopTimeout | StopTimeout × 4 |
| Reloading | StartTimeout | StartTimeout × 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.
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 causeDependencyFailurewithout 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
BindsToPropagationfrom 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.
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 #
| Relationship | A missing target means |
|---|---|
Requires | The dependent is failed with DependencyFailure. |
BindsTo | The same — treated as a missing Requires. |
Wants | Silently dropped. |
Conflicts | Silently 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=Alivethat something else depends on hard.Alivereadiness 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 whoseIdentityis notSYSTEM. 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:
CycleDetectedValidationErrorDependencyFailure
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:
- Everything that
RequiresorBindsToit transitions to Failed with causeDependencyFailure. - Everything that
Wantsit is unaffected and starts normally. - 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:
- Collect the transitive
RequiresandBindsToclosure. - Collect the transitive
Wantsclosure, best-effort. - Validate the sub-graph — cycles, missing targets.
- Resolve conflicts, stopping whatever has to stop.
- Start the sub-graph with the same parallel algorithm. Dependencies
started this way carry cause
DependencyStartand operation sourceDependencyPropagation.
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.
| Boot | On-demand | |
|---|---|---|
| Members | Every boot-triggered root plus its closure | One requested service plus its closure |
| A missing hard-dependency target | Blocks the dependent | Blocks the dependent |
| A disabled hard-dependency target | Blocks the dependent | Blocks the dependent |
| A validation finding | The service is failed, others continue | The start fails |
| Multiple findings on one service | Only the highest-precedence one is reported | — |
| Conflicts | Two boot-triggered conflicting services fail both | The conflicting service is evicted |
| Context | One boot context | One context per explicit start |
| Failure of the whole build | Recovery mode | An 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 withValidationErrorand 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 #
| Publisher | Levels |
|---|---|
netd, as the role network | link → addressed → routed, defined by Peios Network Policy |
| timed | unsynchronised, 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
RequiresorBindsTo, 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.BindsTorequires 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
| State | Meaning |
|---|---|
| Created | The 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. |
| Running | Exec succeeded and the process is alive. |
| Completed | The process exited successfully — code 0, or one in SuccessExitCodes. |
| Failed | The process failed, or peinit classified the job failed before the fork because parent setup failed. |
| Abandoned | The 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:
idis assigned before the fork, so a job that never forks still has an identity.pidandpidfdland 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
SIGCHLDis 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_nsrecords the classification time,failure_causerecords what went wrong, andpid,pidfd,started_at_ns,exit_codeandexit_signalall stay null. There was no process to have a PID or an exit status. exit_codeandexit_signalare populated only when peinit observed an exit — never both, since a process either exits or is killed.- For an Abandoned job,
ended_at_nsrecords 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 #
| Concern | Owner |
|---|---|
Restart policy, dependencies, health check schedule, ErrorControl | Service |
| Current state — Active, Failed, … | Service |
| PID, pidfd, exit code, exit signal | Job |
| Execution timestamps | Job |
| Identity and token | Job |
| cgroup assignment | Job |
| Log correlation | Job |
| Submitter, job descriptor, progress, retention | Submitted 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
| State | Meaning |
|---|---|
| Pending | Validated and queued, waiting on a precondition. |
| Running | Executing. |
| Completed | The goal was reached. Start: Active for Simple, Completed or Inactive for Oneshot. Stop: the service is no longer running. Reload: the reload resolved. |
| Failed | The 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). |
| Merged | Merged into an existing identical operation, whose identifier is recorded. |
| Cancelled | Terminated while Pending. It never executed. |
| Aborted | Terminated 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:
| Source | Meaning |
|---|---|
Admin | A control client asked for it. |
Boot | The Phase 2 boot plan. |
Shutdown | The shutdown lifecycle. |
DependencyPropagation | A start operation created one for an unsatisfied dependency. |
RestartPolicy | A restart policy generated a start. |
Timer | A timer trigger fired. |
BindsToRecovery | A bound target returned to Active. |
BindsToPropagation | A bound target stopped. |
ConflictResolution | A conflict evicted the running service. |
OnFailure | A failed service's fallback handler. |
TtyRelease | A 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.
| Existing | New | Resolution |
|---|---|---|
| Start | Start | Merge |
| Stop | Stop | Merge |
| Reload | Reload | Merge |
| Restart | Start | Merge — 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 #
| Existing | New | Resolution |
|---|---|---|
| Start (Pending) | Stop | Cancel the start, create the stop |
| Start (Running) | Stop | Abort the start, create the stop |
| Start (Pending) | Restart | Cancel the start, queue the restart |
| Start (Running) | Restart | Queue the restart |
| Stop (either) | Start | Queue the start |
| Stop (either) | Restart | Queue the restart |
| Restart (Pending) | Stop | Cancel the restart, create the stop |
| Restart (Running) | Stop | Abort the restart, create the stop |
| Restart (either) | Restart | Queue |
| Reload (Pending) | Stop | Cancel the reload, create the stop |
| Reload (Running) | Stop | Abort the reload, create the stop |
| Reload (Pending) | Restart | Cancel the reload, create the restart |
| Reload (Running) | Restart | Abort the reload, create the restart |
| Anything (either) | Reset | Reject |
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 #
- Stop wins over start. An explicit stop always takes priority. The administrator said stop, so stop; a queued start can follow.
- Later supersedes earlier. Start then immediately stop means the stop wins and the start is cancelled, recorded as superseded.
- 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— sourceDependencyPropagation. If one fails, the parent operation fails withDependencyFailure.Wants— sourceDependencyPropagation. If one fails, the parent continues.BindsTorecovery — sourceBindsToRecovery, created when a bound target returns to Active, for dependents sitting in Failed with causeBindsToPropagation.
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:
| Type | State | Action |
|---|---|---|
| Oneshot | Inactive, Completed, Failed | Create a start, source Timer. |
| Oneshot | Active, Starting | Set the pending flag. One catch-up run, no operation. |
| Simple | Inactive, Failed | Create a start, source Timer. |
| Simple | Active, Starting | No-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.
| Event | Adds |
|---|---|
peinit.operation.requested | — |
peinit.operation.started | — |
peinit.operation.ended | outcome.success, outcome.detail, object.operation.duration |
peinit.operation.merged | object.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.suppressedwhen the fallback chain guard trips;peinit.graph.validation.failedandpeinit.graph.validation.warnedfor validation findings, one per finding, andpeinit.boot.downgradedfor each finding that forced Safe mode;peinit.config.reload.appliedfor an explicitreload-configand for the reload that ends the boot window, andpeinit.config.reload.deferredfor a reload during it (§3.7);peinit.notify.rejectedfor a refused notification datagram, andpeinit.fd-store.rejectedfor a refused descriptor;peinit.notify.status.reported,.errno.reported,.exit-status.reported,.stopping.reportedand.progress.reportedfor the event-emitting notification fields, the last at most once a second for each activation (§10.5);peinit.cgroup.leakedthe first time a sub-cgroup is found still populated after its post-kill deadline (§5.7);peinit.graph.operation.endedfor a graph member's terminal outcome;peinit.recovery.enteredfor the reason peinit dropped to a recovery shell (§2.8);peinit.critical-service.failedandpeinit.service.abandonedfrom the shutdown path (§12);peinit.service.reload.timed-outfor 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:
| Checked | Context | The 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 binary | object.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_PEERPIDFDat accept; the token comes frompeios_token_open_processon that pidfd withQUERY | 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
submitmessage throughKACS_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, atQUERY | 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:
- Parse and validate the definition against the attachment count
(below). A malformed definition is
INVALID_ARGUMENTSbefore any identity work is done. - Refuse during shutdown with
INVALID_STATE. The other four commands keep working while the system goes down. - Establish the job identity, as above.
- 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 isQUOTA_EXCEEDED. A job stops counting the moment it is terminal, not when its retained record is dropped. - Build the job's descriptor: the one the submission supplied in SDDL, used as given, or the default (below).
- Create the record, the entry and the launch queue entry, in one
SupervisorWorktransaction.
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 #
| Field | Default | Validation |
|---|---|---|
image_path | required | Absolute, 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 | "" | |
timeout | 0, no limit | Non-negative integer, seconds. |
stop_timeout | 10 | Positive integer, seconds. |
readiness | none | none or notify. |
readiness_timeout | 30 | Non-negative integer, seconds. |
success_exit_codes | [] | Integers 0–255. |
descriptors | [] | One name per injected descriptor; non-empty, no :. |
output | false | Requires at least one attached descriptor. |
security_descriptor | the default | SDDL 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.
| Right | Bit |
|---|---|
JOB_QUERY | 0x0001 |
JOB_STOP | 0x0002 |
JOB_SIGNAL | 0x0004 |
JOB_ALL_ACCESS | 0x0007 |
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:
| Field | Effect |
|---|---|
READY=1 | For 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=1 | Recorded, so a later stop sends no termination signal. |
| Everything else | Ignored: 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:
| Deadline | Due | Action |
|---|---|---|
Timeout | started_at + timeout | Begin a stop with cause timeout. |
ReadinessTimeout | started_at + readiness_timeout, for a notify job not yet ready | Begin a stop with cause readiness_timeout. |
StopKill | stop requested + stop_timeout | SIGKILL the job's cgroup. |
PostKill | kill + PostKillTimeout | Abandon (below), or re-arm if the cgroup is empty but the exit not yet reaped. |
CgroupCleanup | terminal + PostKillTimeout | Retry 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
backupdruns 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:
| Form | Meaning | Shown as |
|---|---|---|
PROGRESS=N | Counting, 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/T | N 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.
| Field | Form | Default if omitted |
|---|---|---|
| DayOfWeek | weekday names | any day |
| Date | Year-Month-Day | *-*-* |
| Time | Hour:Minute:Second | 00:00:00 |
| Second | the :Second of Time | :00 |
| Timezone | an IANA name | system-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/stepmatches the value and every multiple of the step above it, so0/15in the minute field is 0, 15, 30 and 45.start..end/stepwalks 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 #
| Shortcut | Equivalent |
|---|---|
minutely | *-*-* *:*:00 |
hourly | *-*-* *:00:00 |
daily | *-*-* 00:00:00 |
weekly | Mon *-*-* 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/Londonskips 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 #
| Expression | Meaning |
|---|---|
*-*-* 02:00:00 | Every day at 2am, system-local. |
Mon *-*-* 00:00:00 | Every Monday at midnight. |
*-*-1,15 12:00:00 | The 1st and 15th, at noon. |
*-*~01 00:00:00 | The last day of each month, at midnight. |
Mon..Fri *-*-* 09:00:00 | Weekdays at 9am. |
*-*-* *:00/15:00 | Every fifteen minutes. |
*-*-* 02:00:00 Europe/London | Every day at 2am London time. |
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:
- Read the last-run timestamp.
- Compute the next scheduled firing after it.
- If that time has already passed, at least one run was missed: fire once, immediately.
- 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.
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.
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:
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.
- The shutdown gate. If peinit is shutting down, everything except
status,list,operation-status,boot,job-status,job-listandjob-stopis rejected.bootstays 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. - Resolve the target. A command naming no definition, and no
addressable definition-removed entry, returns
UNKNOWN_SERVICE; one naming no submitted job peinit holds returnsUNKNOWN_JOB. peinit does not synthesise a descriptor for something that does not exist. - 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).
- On denial, return
ACCESS_DENIED. The decision is recorded by KACS, askacs.audit.access.checkednaming 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). - On grant, classify the command against the service's state (§10.3) and act.
10.2.1 Rights #
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.
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.
startcreates 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.stopcancels 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.restartreplaces the automatic restart with the administrator's. A Restart operation with sourceadminis queued Pending and honours the remaining delay exactly as a deferredstartdoes; 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 deferredstartalready pending is cancelled with reasonsuperseded_by_restart, and a secondrestartmerges 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.reloadandresetare 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
startimmediately. - 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:
- 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=Mainthe 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. - The job is Running. A job still in pending setup has not exec'd.
- The job has a pidfd.
- 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.
- 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=1from 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:
- If
FdStoreMaxis 0, peinit logs the rejection and closes the descriptor. - If the store already holds
FdStoreMaxentries, peinit logs the rejection and closes the descriptor. The existing store is not modified — a full store does not evict. FDNAME=<name>names it; an absent or empty name meansstored.FDPOLL=0marks 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):
- 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. LISTEN_FDSis set to the count.LISTEN_FDNAMESis set to a colon-separated list of names in the same order as the descriptor numbers.- 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.
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 :
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:
| Wait | Set by | Answered when |
|---|---|---|
| Submit | submit | The job leaves created: exec confirmed, or the launch failed. |
| Wait | wait | The condition holds — terminal, or ready-or-terminal. |
| Stop | stop with wait | The 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 | When |
|---|---|
normal | A Full boot. |
requested | A Safe boot asked for with peios.safemode=1. |
safe_mode_downgrade | A 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.
confirmed | waiting_on | confirms_at | confirm_error | Meaning |
|---|---|---|---|---|
| false | the Critical services not holding | null | null | The boot is waiting for them. |
| false | empty | a time | null | Every Critical service holds; if they keep holding, the boot counts then. |
| true | empty | null | null | The boot has counted, and the counter is 0. |
| false | empty | null | why | The 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:
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/resetreturns anoperation_id— a GUID you can poll. Conflict resolution between concurrent commands happens at the operation layer, which is why two simultaneousstarts merge instead of colliding.
10.9.2 Reading status #
status returns the full picture for one service:
display_nameanddescriptionare the definition's, ornull.stateandcauseare the lifecycle pair — read them together.status_textis the latestSTATUS=string the service sent via sd_notify (nullif never sent; cleared on each restart).progressis how far the service last said it had got, fromPROGRESS=andPROGRESS_UNIT=:{"current": 3, "total": 10, "bounded": true, "unit": "items"}, withtotalandunitnullwhen it didn't say them.nullif it never sent one; cleared on each restart.current_jobandcurrent_operationare the job and operation GUIDs, ornull.healthishealthy,unhealthy,unknown, ornull(no health check).definition_removedistruewhen the definition was deleted but an instance is still draining.warningslists leaked sub-cgroups and other operator-relevant notices.timershas one entry for each timer trigger, described below.grantedis what you may do to this service, as peinit checks it: the rights the service's permissions give you, out ofquery_status,start,stopandinterrogate(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:
scheduled_atis the schedule's next occurrence.fires_atis when the timer will actually fire. It is later thanscheduled_atby the random delayTimerJitterdrew for this firing.last_fired_atis when it last fired, ornullif it has not fired since peinit started and has no recorded run.not_armedis set only for a schedule peinit refused, such as one that never comes round. It gives the reason, and the times are thennull.
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:
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":
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_REALTIMEtimestamp, - 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.
| Key | Default | Minimum | Meaning |
|---|---|---|---|
Machine\System\Init\PreEventdBuffer | 1048576 | 4096 | Bytes 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.
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.
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.
- Start sending to eventd's log datagram socket, at the path from
Machine\System\eventd\LogSocketPath. - 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.
- Switch to real-time forwarding — new output sent as it arrives.
- 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 :
| Key | Type | Content |
|---|---|---|
origin | string | The service name, or the hook identifier. |
is_error | bool | True for stderr. |
message | string | The line. |
timestamp | uint | Nanoseconds, wall clock. |
job_id | bin | The 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.
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.
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/consoleand/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.
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:
| Holding | Released |
|---|---|
| Starting, Active, Reloading, Stopping, Backoff | Inactive, 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\) | Default | Limits |
|---|---|---|
MaxLogLineLength | 8192 | Bytes per line; longer lines are truncated with a [truncated] marker. |
MaxLogBufferPerService | 65536 | Bytes buffered per service pipe before back-pressure. |
Two mechanisms back these up:
- A per-readable-event budget.
LogReadBytesPerEventbounds 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.
11.7.4 The eventd handoff #
When eventd reaches Active, peinit switches from buffering to forwarding:
- It begins sending to eventd's log datagram socket (path from
Machine\System\eventd\LogSocketPath). - 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.
- It switches to real-time forwarding — new output goes out as it arrives.
- 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):
| Type | Effect |
|---|---|
poweroff | Stop everything, unmount, power off. |
reboot | Stop everything, unmount, reboot. |
halt | Stop everything, unmount, halt — the CPU stops, the system stays powered. |
12.1.2 Signals #
| Signal | Meaning |
|---|---|
| SIGINT | Reboot. The kernel sends it on Ctrl+Alt+Del. |
| SIGTERM | Poweroff. PID 1 cannot be killed by it but may choose to act on it. |
| SIGPWR | Poweroff. 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
ShutdownWavewhile post-kill verification is pending. A cgroup still populated after the post-kill timeout takes the service to Abandoned with causeProcessUnkillableand 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:
- Each eligible service receives SIGTERM. Already-stopping ones do not.
- Each has
StopTimeoutto exit. - On expiry, SIGKILL to the service's entire cgroup.
- 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,ConflictEvictionorBindsToPropagation.
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 #
| Key | Default | Meaning |
|---|---|---|
Machine\System\Boot\ShutdownTimeout | 90 | Seconds for the whole sequence. |
Machine\System\Boot\PostKillTimeout | 5 | Seconds 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 #
- Snapshot the mount table first, from
/proc/self/mountinfo. - 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.
- Process in descending path depth, so children go before parents.
- A mount point already gone is a successful no-op.
- On failure, attempt a read-only remount. If that fails too, record it and continue.
- 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.
sync(). Called on all three paths — graceful, forced and Critical reboot.reboot(2)withRB_POWER_OFF,RB_AUTOBOOTorRB_HALT_SYSTEM.- 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 #
| Signal | Behaviour |
|---|---|
| SIGCHLD | Reap children with waitpid. Match them to tracked jobs; also reap orphans belonging to nothing. |
| SIGINT | Reboot. Three within five seconds forces one. |
| SIGTERM | Poweroff. |
| SIGPWR | Poweroff. |
| SIGHUP | Ignored. PID 1 has no controlling terminal. |
| SIGPIPE | Ignored. 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.
| Path | Stop waves | Seed save | Unmount | Sync | Final action |
|---|---|---|---|---|---|
| Graceful | Yes | Yes | Yes | Yes | Yes |
| Forced — three SIGINTs | No, SIGKILL everything | No | No | Yes | Yes |
| Critical service failure | No | No | No | Yes | Yes |
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 capability | Used for |
|---|---|
SeCreateTokenPrivilege | Minting SYSTEM tokens for platform services during bootstrap, before authd exists (§4.2). |
SeTcbPrivilege | Requesting 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. |
SeAuditPrivilege | Writing 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 duplication | Turning 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 creation | Fork and exec, inherent to PID 1. |
| cgroup management | Creating and destroying trees under /sys/fs/cgroup/peinit/. |
| Signal delivery | SIGTERM and SIGKILL to managed processes. |
| Mount operations | The 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.
13.3 The Attack Surface
Peios / Peios technical reference / peinit / Security
| Surface | Reachable by | Controls | Protected by |
|---|---|---|---|
| Control socket | Every authenticated principal, by default | Service lifecycle, shutdown, observing and stopping jobs | The peer token and AccessCheck against the target's descriptor for every command; a per-user connection limit |
| Jobs socket | Every authenticated principal, by default | Running a program under supervision as an identity the submitter holds; managing the jobs it submitted | The 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 socket | Anything that can connect | Service readiness, watchdog, stored descriptors | The socket inode's descriptor, then PID matching verified through a pidfd, plus the start generation |
| Registry keys | Anything with registry access | Definitions, triggers, configuration | Registry key descriptors, enforced by LCS |
Machine\System\Init\EnvVars\ | Anything with registry access | The environment of every service | That key's descriptor, and nothing else — variable names are not filtered |
| Service cgroups | peinit | Process tracking and clean kill | Ownership of the hierarchy |
| Phase 1 mounts | peinit | Virtual filesystem availability | Hardcoded, no external input |
/lcl/policy/autorun.d | Whatever can write it | Arbitrary code as SYSTEM in early boot | The directory's descriptor |
| Boot attempt counter | Whatever can write /.peinit/ | Recovery mode entry | That 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:
| Cgroup | Consequence |
|---|---|
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:
| What | Where | How |
|---|---|---|
| Boot attempt counter | /.peinit/boot-attempts | A plain integer, rewritten each boot. |
| Local machine ID | /lcl/etc/machine-id | Temporary file, flush, rename. |
| Random seed | /var/state/peinit/random-seed | Temporary 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 #
| Key | Purpose | Defined in |
|---|---|---|
Machine\System\Services\ | Parent key. Each child key is one service. | §3.2 |
Machine\System\Services\SchemaVersion | Schema version guard. REG_DWORD, currently 1. Created by peinit if absent. | §2.3, §3.2 |
Machine\System\Services\ServiceSecurity | The descriptor inherited by definitions that carry none. REG_BINARY. | §4.6 |
Machine\System\Services\<name> | One service definition. | §3.2 |
Machine\System\Services\<name>\Provides | Roles 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>\LastTimerRun | Last-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 #
| Key | Type | Default | Purpose | Defined in |
|---|---|---|---|---|
Machine\System\Boot\MaxParallelStarts | dword, > 0 | 10 | Services starting concurrently during boot. Zero, a type mismatch, or a malformed payload is invalid and enters recovery. | §2.5 |
Machine\System\Boot\BootSuccessGrace | dword | 30 | Seconds every Critical service has to hold a dependent-satisfying state before the boot counts as successful. | §2.5 |
Machine\System\Boot\SettleTimeout | dword | 5 | Seconds to wait for the boot set to settle before starting boot:settled services regardless. Zero is legal. | §2.5 |
Machine\System\Boot\ShutdownTimeout | dword | 90 | Seconds for the entire graceful shutdown. | §12.2 |
Machine\System\Boot\PostKillTimeout | dword | 5 | Seconds 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 #
| Key | Type | Default | Purpose | Defined in |
|---|---|---|---|---|
Machine\System\Init\ControlSecurity | binary | SYSTEM and Administrators, every right; Authenticated Users, SYSTEM_QUERY_STATUS | The descriptor for system-level control operations and the boot query. | §4.7 |
Machine\System\Init\MaxControlConnectionsPerUser | dword | 16 | Concurrent control socket connections one caller's user SID may hold. SYSTEM is exempt. | §10.1 |
Machine\System\Init\MaxControlConnections | dword | 256 | Concurrent control socket connections in all. | §10.1 |
Machine\System\Init\MaxRequestSize | dword | 65536 | Maximum control request size, in bytes. | §10.1 |
Machine\System\Init\ConnectionTimeout | dword | 30 | Seconds before an idle control connection is closed. | §10.1 |
Machine\System\Init\MaxJobsConnections | dword | 64 | Concurrent jobs socket connections. | §10.7 |
Machine\System\Init\MaxJobMessageSize | dword | 32768 | Maximum jobs socket message content, in bytes. | §10.7 |
Machine\System\Init\JobsConnectionTimeout | dword | 30 | Seconds before an idle jobs connection is closed. | §10.7 |
Machine\System\Init\MaxJobsPerSubmitter | dword | 64 | Live submitted jobs one submitter SID may hold. SYSTEM is exempt. | §8.5 |
Machine\System\Init\MaxLogLineLength | dword | 8192 | Bytes per output line before truncation. Minimum 256; below that the default is used and a warning logged. | §11.3 |
Machine\System\Init\MaxLogBufferPerService | dword | 65536 | Pipe capacity per service, applied with F_SETPIPE_SZ. Minimum 4096 (one page, the kernel's own floor). | §11.3 |
Machine\System\Init\LogReadBytesPerEvent | dword | 16384 | Bytes drained from one output pipe per readable event. Minimum 512. | §11.3 |
Machine\System\Init\PreEventdBuffer | dword | 1048576 | Bytes of output retained before eventd is available. Applied at boot and on reload. Minimum 4096. | §11.2 |
Machine\System\Init\EnvVars\ | parent key | empty | Variables 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 key | empty | Boot-time path provisioning entries. | §2.4 |
Machine\System\Init\ProvisionedPaths\<name>\Kind | string | — | directory or file. Required. | §2.4 |
Machine\System\Init\ProvisionedPaths\<name>\Path | string | — | The absolute path. Required. | §2.4 |
Machine\System\Init\ProvisionedPaths\<name>\Security | binary | built-in | The descriptor to apply. | §2.4 |
Machine\System\Init\ProvisionedPaths\<name>\Required | dword | 0 | If 1, a failure enters recovery before Phase 2. | §2.4 |
A.4 Other subsystems #
| Key | Type | Purpose | Defined in |
|---|---|---|---|
Machine\System\eventd\LogSocketPath | string | Where 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 #
| Path | Purpose |
|---|---|
/usr/bin/peinit2 | Where peinit is installed in package storage. |
/bin/peinit2 | The runtime path the kernel init= names. |
/sbin/registryd | The compiled-in registryd image path. |
/var/state/loregd/Machine.hive | The machine hive, passed to registryd. |
/var/state/loregd/Users.hive | The users hive, passed to registryd. |
/.peinit/ | peinit's own state directory on the root filesystem. |
/.peinit/boot-attempts | The boot attempt counter. |
/lcl/etc/machine-id | The local machine identifier. |
/var/state/peinit/random-seed | The persisted entropy seed. |
/lcl/policy/autorun.d | Phase 1 autorun scripts. |
/run/services/peinit/control.sock | The control socket. |
/run/services/peinit/notify.sock | The notification socket, by default. |
/run/services/peinit/jobs.sock | The 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/rtc0 | The hardware clock, in that order of preference. |
/dev/console | Where peinit writes its own messages. |
/bin/recsh, /bin/sh | The recovery shell, in that order of preference. |
B.2 Timeouts and limits #
| Constant | Value | Meaning |
|---|---|---|
| registryd setup timeout | 30 s | Process setup, driven synchronously in Phase 1. |
| registryd readiness timeout | 30 s | Waiting for READY=1 in Phase 1. |
| Reload detection window | 2 s | Waiting for RELOADING=1 after a reload signal. Bounded above by the operation deadline. |
| Restart delay cap | 60 s | The ceiling on exponential backoff. |
| Timeout extension cap | ×4 | The multiple of a phase's base timeout an extension may reach. |
| Operation retention | 60 s | How long a terminal operation is kept before being dropped. |
| Submitted job retention | 60 s | How long a terminal submitted job's entry answers status before being dropped. |
peinit.job.status.reported interval | 1 s | The least spacing between two peinit.job.status.reported events for one job. |
Submitted job stop_timeout | 10 s | The default, per submission. |
Submitted job readiness_timeout | 30 s | The default, per submission. |
| Submitted job argv and environment | 2 MiB | The bound on a definition's arguments and environment together. |
| Boot attempt threshold | 3 | The default, overridden by peios.bootattempts=. |
OnFailure chain depth | 16 | The maximum handler chain from one originating failure. |
| Pre-eventd buffer | 1 MiB | The compiled-in buffer capacity. |
| Notification datagram | 64 KiB | The receive buffer size. |
| Descriptors per datagram | 64 | The control message buffer capacity. |
| Random seed | 512 bytes | Written at shutdown. Restoring accepts 1–4096 bytes. |
| Control listen backlog | 32 | |
| Jobs listen backlog | 32 | |
| Descriptors per jobs message | 64 | Including an output sink. |
| Tokens per jobs message | 1 | |
| First injected descriptor | 3 | Where stored descriptors are placed. |
| Final action retry | 1 s | The minimum interval between reboot(2) retries. |
B.3 Environment #
| Variable | Value |
|---|---|
PATH | /sbin:/bin — the compiled-in base for every service. |
The recovery shell additionally receives TERM=linux and HOME=/.
B.4 Kernel command line #
| Parameter | Effect |
|---|---|
peios.safemode=1 | Force Safe mode. |
peios.recovery=1 | Force recovery mode. |
peios.bootattempts=N | The recovery threshold; 0 disables the check. |
peios.quiet=N | Console verbosity: 0, 1 or 2. |
peios.notifysocket=PATH | Override the notification socket path. |
B.5 Descriptors peinit applies #
| Where | SDDL |
|---|---|
/dev/shm, /run, /sys/fs/cgroup after mounting | O: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 Security | O:SYG:SYD:(A;;GA;;;SY)(A;;GA;;;BA)(A;;FR;;;BU) |
A service's /run/<name> runtime directory | O:SYG:SYD:(A;;GA;;;SY)(A;;GA;;;BA)(A;;GA;;;<service SID>) |
| The random seed file | O:SYG:SYD:(A;;GA;;;SY) |
| The default ServiceSecurity | O:SYG:BAD:(A;;0x000F;;;SY)(A;;0x000F;;;BA)(A;;0x0001;;;AU) |
| The default ControlSecurity | O:SYG:BAD:(A;;0x0007;;;SY)(A;;0x0007;;;BA)(A;;0x0004;;;AU) |
| The control socket inode | O:SYG:SYD:(A;;GA;;;SY)(A;;GA;;;BA)(A;;FW;;;AU) |
| The jobs socket inode | O:SYG:SYD:(A;;GA;;;SY)(A;;GA;;;BA)(A;;FW;;;AU) |
A submitted job with no security_descriptor | Owner and group the submitter; (A;;0x0007;;;<submitter>)(A;;0x0007;;;SY)(A;;0x0007;;;BA) |
B.6 Access rights #
| Right | Bit |
|---|---|
SERVICE_QUERY_STATUS | 0x0001 |
SERVICE_START | 0x0002 |
SERVICE_STOP | 0x0004 |
SERVICE_INTERROGATE | 0x0008 |
SERVICE_ALL_ACCESS | 0x000F |
SYSTEM_SHUTDOWN | 0x0001 |
SYSTEM_RELOAD_CONFIG | 0x0002 |
SYSTEM_QUERY_STATUS | 0x0004 |
JOB_QUERY | 0x0001 |
JOB_STOP | 0x0002 |
JOB_SIGNAL | 0x0004 |
JOB_ALL_ACCESS | 0x0007 |
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:
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 reportwal, 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_registryreturns 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.
SIGTERMandSIGINTare 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.
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 :
(
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:
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 #
(
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
);
3.2.2 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)
);
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.
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 #
[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)
);
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 #
(
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.
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.
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=walon 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_KEYstores 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_KEYevicts immediately outside a transaction, or through a commit hook inside one.RSI_DELETE_LAYERevicts 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:
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_VALUESresponse comes from its ownUNION ALL, separate from the value entries it travels beside, and takes the same order they do. - The orphan-GUID array in an
RSI_DELETE_LAYERresponse 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:
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:
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 #
| Concern | Where it lives |
|---|---|
| Event emission, buffering and delivery | KMES, in the Peios Kernel TRM; the consumer protocol in PSPK |
| Event type vocabulary and payload schemas | the emitting subsystem's own documentation |
| Access control primitives | KACS, in the Peios Kernel TRM |
| Configuration storage | LCS and loregd |
| Daemon lifecycle, and forwarding service output | peinit |
| The three interfaces eventd exposes | PSPU §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.
- Drain. One thread per CPU reads events from that CPU's ring buffer, following the lock-free read protocol PSPK specifies (§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).
- 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).
- 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_posstarts attail_poson first attachment, so eventd begins at the oldest surviving event rather than at the newest.- The drain loop loads
write_poswith acquire ordering, checkstail_posfor lapping, validates the event's structural integrity, and advancesread_posbyevent_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_wakeand 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:
- Records the sequence number of the last event it has successfully handed off.
- Finishes draining the old buffer to its now-frozen
write_pos, updating that sequence number as it goes. - Calls
kmes_attachagain for the same logical CPU, obtains the replacement descriptor, and maps it while the old mapping remains valid. - Scans the new buffer from
tail_posfor the first event whose sequence is greater than the final sequence drained from the old buffer, and sets the newread_posthere. - Closes and unmaps the old buffer only after that scan succeeds.
- 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:
| Relation | Result |
|---|---|
| shards == CPUs | one shard per CPU, the 1:1 case |
| shards < CPUs | several CPUs share a shard |
| shards > CPUs | a 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.
- When the first event is available, the writer opens a transaction and records the start time.
- It reads available events from its drain threads and inserts them.
- After each group of inserts it commits if any of these holds:
- Otherwise it keeps reading and inserting.
- 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 #
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.
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:
| Field | Column |
|---|---|
event.time | timestamp |
event.sequence | sequence |
event.cpu | cpu_id |
event.type | event_type |
event.boot.guid | boot_id |
emitter.class | origin_class |
emitter.token.guid | effective_token_guid |
emitter.true-token.guid | true_token_guid |
emitter.process.guid | process_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:
| Column | Type | Contents |
|---|---|---|
event_type | TEXT PRIMARY KEY | A 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:
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:
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:
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 #
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 #
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 #
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 #
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 #
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:
- WAL mode,
PRAGMA journal_mode=WAL PRAGMA synchronous=FULL- the
events,event_types,receipt_ranges, andmetadatatables (§3.1) - the
idx_events_timestampindex - the
schema_versionandcreated_atentries
3.3.4 Opening an active shard #
- Open in WAL mode.
- Set synchronous to FULL.
- Read and verify
schema_version. Missing or unrecognised is a startup failure. No migration is attempted. - Verify structural integrity — the required tables and write-time indexes exist. Failing this, with SQLite reporting no corruption, is a startup failure.
- If SQLite reports corruption while opening or verifying, quarantine
and replace (below).
A database holding schema objects but no
metadatatable 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).
desired_indexes — the computed desired index set.
sequence_checkpoints — diagnostic only.
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.
| Column | Type | Contents |
|---|---|---|
key | TEXT PRIMARY KEY | Metadata key. |
value | BLOB NOT NULL | Strings 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 #
- Open
eventd-meta.db, creating it if absent. - Verify the schema version and required
metaentries; recreate from defaults on failure. - Load
index_countersinto memory. - Load
desired_indexesinto memory. - Load
sequence_checkpoints, for diagnostics only. - 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:
- Identify every non-current boot ID present in the shards.
- Order them by their newest event timestamp, oldest boot first.
- 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.
- 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 #
| Store | Column |
|---|---|
| Event | events.boot_id, every row |
| Log | logs.boot_id, every row |
| Metric | samples.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.
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
LogMaxBatchSizerecords LogMaxBatchLatencyMshas 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.
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_timestamponlogs(timestamp)— time-range filtering, as everywhere.idx_logs_originonlogs(origin)— "show me logs from X", which is the dominant log query.idx_logs_job_idonlogs(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:
- WAL mode,
PRAGMA journal_mode=WAL PRAGMA synchronous=NORMAL- the
logs,log_originsandmetadatatables (§4.2) - the
idx_logs_timestamp,idx_logs_originandidx_logs_job_idindexes - the
schema_versionandcreated_atentries
4.3.3 Opening #
- Open in WAL mode.
- Set synchronous to NORMAL.
- Verify the schema version. Missing or unrecognised is a startup failure; no migration is attempted.
- Verify structural integrity — required tables and indexes present. Failing this, with SQLite reporting no corruption, is a startup failure.
- On SQLite reporting corruption, quarantine and replace.
A database holding schema objects but no
metadatatable 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:
- Authorize the name. The datagram's KACS token must hold
EVENTD_PUBLISHon the resolved metric-name descriptor (§7.6). - 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
serieswith the record's type, and the cache is updated. - 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.
- Insert the sample into
sampleswith the resolvedseries_id, the timestamp and the value. SQLite assignssamples.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 inhistogram_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
MetricMaxBatchSizesamples MetricMaxBatchLatencyMshas 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 #
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:
boundary_count,u32little-endian- that many IEEE-754
f64values, 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 #
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_timestamponsamples(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_nameonseries(name)— name lookups.idx_series_label_hashonseries(label_hash)— series resolution on the ingestion path.idx_series_boundaries_hashonseries(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 #
- 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). - Hash it.
- 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.
- Look up
seriesbyname,label_hash, and for histogramsboundaries_hash. - On a match, verify the full
labelsstring, 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 existingseries_id. - On no match, insert a new
seriesrow 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:
- WAL mode.
PRAGMA synchronous=NORMAL— the log store's reasoning, for the same reason: metric loss on power failure is acceptable (§4.1).- The
series,samples,rollupsandmetadatatables (§5.2, §5.6). - Every write-time index.
- The
schema_versionandcreated_atentries.
5.4.3 Opening #
- Open in WAL mode with synchronous NORMAL.
- 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.
- Verify structural integrity — required tables and indexes present. Failing this, with SQLite reporting no corruption, is a startup failure.
- On SQLite reporting corruption, quarantine and replace, exactly as
for a shard (§3.3): matching
-waland-shmfiles renamed with the same.corrupt.<timestamp_ns>suffix,.Nappended if the name is taken, and a fresh empty store created at the configured path. A database holding schema objects but nometadatatable 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 #
- Delete rows from
samplesolder thanMetricRetentionDays(§A) until none remain. - If
MetricRetentionMaxBytesis 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. - Track every
series_idwhose samples were deleted by either step. - Before deleting a sample batch, delete all adaptive rollups belonging to any affected series (§5.6).
- Delete every
seriesrow 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
SINCEand an explicitAVG_OVER,MIN_OVER,MAX_OVERorSUM_OVERfunction; - the selected output is per series — the selector is bracketed, or it resolves to exactly one series;
- it has no
WHEREpredicate or cross-type filter; and AdaptiveRollupMaxRowsis 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:
(
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 #
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:
- Identifies the mode from the first token —
EVENTS,LOGSorMETRIC. - Extracts the primary selector: a type pattern, a
FROMlist, or a metric name with an optional label selector. - Collects every clause, in whatever order they appear.
- Validates that the clauses suit the mode —
CONTAININGonly in log mode,RATEonly in metric mode,SELECTonly 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:
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:
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.
| Store | Approximate floor | From |
|---|---|---|
| Events | MaxBatchLatencyMs, default 100 ms | §2.4 |
| Logs | LogMaxBatchLatencyMs, 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 #
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).
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:
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:
- Look for an exact match on the full identifier,
kacs.audit.access.checked. - Remove the last dot-separated component and look again,
kacs.audit.access. - Repeat:
kacs.audit, thenkacs. - 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:
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:
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 order | subject.token.sid | subject.process.pid |
|---|---|---|
deny subject, allow subject.token.sid | denied | denied |
deny subject.token.sid, allow subject | denied | read |
allow subject.token.sid, deny subject | read | denied |
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.
- Obtain the caller's token from the connection (§7.1). Failure denies the query.
- Parse the query to establish its data sources and filters (§6.1).
- 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.*,LOGSwithoutFROMandMETRIC cpu.*are each resolved identifier by identifier. - 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). - 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.
- 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.
- 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.
- Execute, with root filtering already part of the logical row set.
- 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_listwith 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. - 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
WHEREpredicates - metric label filters in a primary selector
GROUP,COUNT BY,TOP N BY,SORTandDISTINCTfields- event and log aggregation arguments —
SUM,AVG,MIN,MAX - metric transforms and terminal aggregations, all of which read the
fixed
valuefield: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 readstype
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.
| Check | Audit 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.
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.
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 #
- Read every configuration key under
Machine\System\eventd\. The six required keys areEventStorePath,LogStorePath,MetricStorePath,QuerySocketPath,LogSocketPathandMetricSocketPath; a missing or invalid one fails startup. - 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).
- Read the optional keys and apply compiled-in defaults for those absent (§A).
- Arm a persistent watch on the subtree, for runtime changes (§8.3).
8.2.2 Phase 2 — KMES attachment and shard sizing #
- Query the KMES logical slot count with
kmes_attach(KMES_ATTACH_QUERY_SLOTS, …), walk every slot, skipEINVALholes, 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. - Map each per-CPU ring buffer.
- Resolve the active shard count from
StorageShards— the attached KMES-buffer count when it is 0, the configured value otherwise. - Compute the shard-to-CPU assignment (§2.3).
8.2.3 Phase 3 — Storage #
- 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. - Open or create
logs.dbin the log store directory — schema verified, WAL,synchronous=NORMAL, quarantine on corruption (§4.3). - Open or create
metrics.dbin the metric store directory, likewise (§5.4). The series cache starts empty and fills on demand. - 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 #
- 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. - Read and merge every committed
receipt_rangesrow for that boot from every readable active and historical shard, grouped by logical CPU ID (§2.2, §3.1). - 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).
- 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 #
- Create the query socket at
QuerySocketPath. A stale pathname left by a crash is unlinked first if it is anAF_UNIXsocket; 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). - 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). - Create the metric socket at
MetricSocketPath, then replace its inherited descriptor with the protected one that lets every authenticated caller send (§7.6). - 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 #
- One writer thread per active shard.
- One drain thread per attached logical CPU, each beginning to reconcile and read its ring buffer.
- The log ingestion thread.
- The metric ingestion thread.
- The retention coordinator thread.
- The adaptive indexing policy thread.
8.2.7 Phase 7 — Ready #
- Write and commit an
eventd.daemon.startedevent 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. - 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 #
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 #
- 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.
- Drain ingestion. Shut the log and metric sockets for reading, so
that a later send fails with
EPIPEand 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 mostnet.unix.max_dgram_qlendatagrams plus one (§4.1), so it completes quickly. - Final event drain. Each drain thread performs one last drain cycle from its ring buffer.
- Final commit. Every writer commits its current batch immediately, whatever its size. The log and metric writers do the same.
- Record sequence state. Derive each logical CPU's highest
contiguously covered sequence from committed receipt ranges and write
it to
sequence_checkpointsfor diagnostics (§3.5). - Emit the shutdown event. Write
eventd.daemon.stoppedwith 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. - Close databases. Close every connection, writer and reader. SQLite checkpoints the write-ahead log automatically on close.
- Unmap. Unmap every ring buffer and close the per-CPU descriptors.
- 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 #
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.lostrecord 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:
| Mechanism | Effect |
|---|---|
| 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 capacity | The 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_checkandkacs_access_check_listfail, 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_PUBLISHcheck (§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:
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.
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.
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:
|
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.
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 onceendhas arrived, or the error and no records.Tail::start(socket, text, capacity)follows aSTREAMquery. It reports the initial result once, complete, whenwatcharrives; then each live batch; then why the tail ended. If the query fails beforewatch, 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).
stringwrites a quoted string: the quote and backslash escaped, and every control character as\n,\r,\tor\uXXXX.binarywritesx"…";durationwrites90s,2hand 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_guidderives a field's GUID (§7.3). eventd-core computes the same derivation for itself, and a test holds the two equal.resolvefinds the descriptor eventd would check an identifier against: the identifier, each shorter dotted prefix, then*, with an origin's producer left off (§7.2).accesschecks 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 withEVENTD_READon the root or on any field, as eventd shows them (§7.4).field_treebuilds that list for a set of field names, andfield_readsmakes the check over it, answering per field; eventd uses both, so the tree a client asks about is the one eventd builds.field_grantslists 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.readablesays 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).
A.2 SQLite storage #
| Key | Type | Default | Range | Description |
|---|---|---|---|---|
WalCheckpointPages | REG_DWORD | 1000 | 100–100000 | WAL page threshold triggering a passive checkpoint, on shard, log, metric and metadata databases alike. |
A.3 Event ingestion #
A.4 Log ingestion #
A.5 Metric ingestion #
A.6 Adaptive indexing #
A.7 Index shedding #
A.8 Retention #
A.9 Querying #
A.10 Adaptive metric rollups #
A.11 Cross-type filtering #
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 #
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 #
B.2 Generic mapping #
Passed to AccessCheck in the generic_read, generic_write,
generic_execute and generic_all fields.
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 type | GUID |
|---|---|
| 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 #
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 #
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 #
Stored in series.type.
The query language exposes the names, not the numbers (PSPU §3.22).
B.9 Log severity #
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.
| Parameter | Value |
|---|---|
| Offset basis | 0xcbf29ce484222325 |
| Prime | 0x100000001b3 |
| Stored as | hash & 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 #
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.tomlfile 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 #
| Verb | Effect |
|---|---|
install | Add packages, resolving and installing their dependency closure |
upgrade | Move installed packages to newer versions; with no name, every installed package |
downgrade | Move a package to an older version, requiring explicit authorisation |
uninstall | Remove packages, cascading to or refusing on dependents |
undo | Revert the effect of a previous transaction |
claim | Inspect, grant, or revoke the holder of a role |
repo | Add, list, remove, and refresh repositories |
query, list, info, owns | Read the package database |
verify | Re-hash installed files against what was recorded at install |
recover | Resolve an interrupted transaction |
clean | Garbage-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 #
| Flag | Effect |
|---|---|
--yes | Confirm the routine "apply this plan?" prompt |
--cascade | On uninstall, remove dependents rather than refusing |
--allow-stale | Proceed despite a repository's trust state exceeding its maximum age |
--claim, --claim-all, --no-claim | Change which roles an install claims (§9.4) |
--dangerously-bypass-path-restrictions | Permit 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 #
| Verb | Effect |
|---|---|
init | Establish a new repository tree, with a descriptor and empty indexes |
publish | Ingest one or more package files, derive both indexes, and sign everything |
verify | Check 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 #
| Content | Purpose |
|---|---|
| Installed packages | Name, version, architecture, originating repository, install time, and the stored manifest |
| Owned files | One row per path a package owns, with its type and the hash recorded at install |
| Repositories | Base URL, trust keys with their statuses, priority, signature policy, and the recorded freshness floor |
| Role holders | Which package holds each role |
| Claim links | Which links have been materialised, and for which role and slot |
| Transactions | The journal: pending and completed transactions, their operations, and the backup map |
| Machine metadata | The 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.
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.
| Event | Members | Meaning |
|---|---|---|
plan | operations, authorisations, notes, other_roots | The resolved plan, before anything is asked |
question | id, kind, and members by kind | Something to answer on standard input |
progress | phase, step, steps, package | The transaction has reached a step |
warning | text | What a terminal run prints as peipkg: warning: |
message | text | Anything else a terminal run would print |
done | summary, transaction | Terminal: the command finished |
cancelled | reason | Terminal: an answer refused, so nothing was changed |
error | code, message | Terminal: 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,downgradeorremove;name;fromandto, the versions, where they apply;repository, andlocal(true for a package given as a file);size_downloadandsize_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.
| Kind | Members | Answers |
|---|---|---|
authorise | text, the elevated action | yes or no |
proceed | none | yes or no |
modified | package, path | remove, 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:
| Phase | Step |
|---|---|
fetch | One per package brought in: fetched and verified |
stage | One per package brought in: its content written beside its destinations |
apply | Every change moved into place |
commit | The new state recorded; past this, the transaction stands |
finish | Backups 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 #
| Code | Meaning | The way on |
|---|---|---|
stale | A 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 |
busy | Another package operation holds the lock | Try again once it finishes |
denied | The operator may not change what the transaction touches (§13.1) | None from this account |
unowned | A 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-upgrade | A package is upgraded by some other means (§6) | Run again with --bypass-alternate-upgrade |
unresolvable | No plan satisfies the request (§4) | Change the request |
untrusted | A 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 |
failed | Anything else | Read 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:
State | Means |
|---|---|
| 0 | Not installed |
| 1 | Installing (interrupted, if nothing is running) |
| 5 | Installed |
| 6 | Turning on |
| 10 | On |
| 9 | Turning off |
| 4 | Removing |
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:
| Member | Is |
|---|---|
name | The feature's name, its directory under /libexec/features/. |
title, description | What 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. |
state | As feat list writes it. |
defined | false for a feature whose definition is gone, known only by the state the registry still holds. |
phases | The scripts it has: install, enable, disable, uninstall. |
may_change | Whether you may change its state, asked of the registry (see below). |
metadata_problem | Present 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.
| Event | Members | Means |
|---|---|---|
progress | phase, step, steps | A script is starting: phase is install, enable, disable or uninstall, step of the steps this change will run. |
message | text | A line a script wrote, on its standard output or error, or what feat says it did. |
done | summary, state | The change is made, or needed nothing; state is the feature's now. |
error | code, message | The 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.
| Setting | Meaning |
|---|---|
| Base URL | Where the descriptor and everything it points at are served from |
| Trust anchors | Expected key fingerprints, supplied out of band |
| Signature policy | required or optional (PSPU §5.37) |
| Priority | A positive integer; lower is higher priority |
| Minimum index version | The out-of-band freshness floor applied at first add |
| Maximum trusted age | How long a repository's trust state stays usable without a refresh |
| Insecure transport | Whether a non-HTTPS base URL is permitted for this repository |
| Allow SD overrides | Whether 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:
= 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.
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.
| Artifact | Cap |
|---|---|
| Repository descriptor | 4 MiB |
| Detached signature | 4 KiB |
| Public key file | 64 KiB |
| Index | 64 MiB |
| Package file | the 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.
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.
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.
-
An exact architecture match beats
noarch. A candidate whose architecture equals the system's primary architecture is preferred over anoarchcandidate from a different provider. -
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.
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.
-
Higher repository priority is preferred — a lower numeric priority (§3.3).
-
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
providesentry's version when it matched throughprovides. A candidate matched through an unversionedprovidesstates no version and is preferred less than any candidate that states one. -
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.
-
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.
| Condition | Meaning |
|---|---|
| Unsatisfiable | A package in the candidate plan has a dependency no available package satisfies |
| Conflict | Two packages in the proposed resulting set trigger a conflict against each other |
| Architecture mismatch | A package in the plan is built for neither the system's primary architecture nor noarch |
| Version regression | An operation would move a package backwards without authorisation |
| Cycle | A dependency cycle the resolver cannot break by ordering |
| Too complex | The 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.
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:
- The package's hash matches what the repository index recorded.
- The package's signature, if present, verifies against the trust set scoped to its originating repository.
- The package is not already installed at the same version and architecture in this root.
- The package's architecture is the system's primary architecture or
noarch. - The package's dependencies are satisfied by the installed set, by the plan in flight, or by both together.
- 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.
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.
- Decompress and walk the archive, enforcing the layout and ordering rules of PSPU §5.12.
- Parse the manifest.
- Parse the files manifest.
- Check that every payload file has an entry in the files manifest and that every entry has a file — in both directions.
- Check that the manifest's name, version, and architecture match what the plan selected.
- Validate every payload path against PSPU §5.13 and every entry type against PSPU §5.12.
- Validate every symlink's target against PSPU §5.17.
- 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.
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.
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
unownederror 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.
| Category | Meaning | At commit |
|---|---|---|
| Added | In the new set, not the old | Staged and renamed in |
| Replaced | In both, with different content | Old renamed aside, staged renamed in |
| Untouched | In both, with identical content hash | Left alone |
| Removed | In the old set, not the new | Renamed 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 #
- Validate the new package exactly as an install does (§5.2).
- Diff the old file list against the new (§6.1).
- Stage every added and replaced file to a temporary sibling of its destination, verifying its content hash.
- Apply, at commit: rename added files in; rename replaced originals aside and the staged files in; rename removed files aside.
- Schedule side effects, including those implied by files being removed as well as those the new version declares.
- 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.
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 #
- Enumerate the package's owned paths from the database.
- 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.
- Prepare the removal.
- Remove: rename each path aside as a backup rather than deleting it, so that the uninstall can be rolled back.
- Schedule side effects implied by what was removed.
- Deregister: delete the package's record, withdraw any role it held, and reconcile any role whose claim paths it declared (§9.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).
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.
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 kind | Content |
|---|---|
| Transaction | Identifier, state, schema version, and for a cross-root operation the shared cross-root identifier |
| Operation | One per package operation: kind, package, root |
| File | One per file operation: final path, staged path, backup path, action |
| Directory | One per directory the transaction created, so rollback can remove it |
| Commit payload | For 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.
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.
8.1.1 When it happens #
- Any step of any operation fails — a hash does not verify, disk space runs out, a security descriptor is rejected.
- The operator cancels.
- The process terminates abnormally before commit, in which case rollback runs on the next invocation (§7.8).
8.1.2 The procedure #
- Discard the transaction's staged files.
- Leave the pending database changes uncommitted — the commit never ran — and clear the transaction from the journal.
- Restore every displaced original by renaming its backup back into place.
- Remove the directories the transaction created.
- 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:
- Every write operation — install, upgrade, uninstall — is refused until recovery completes.
- Read operations proceed but carry the indeterminate-state warning in their output.
- 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.
- 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.
- 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).
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 #
- Query the archive index for the available versions of the package.
- Select the desired one.
- 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.
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.
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.
| Flag | Effect |
|---|---|
--no-claim | Claim nothing, including unheld roles |
--claim <roles> | Claim each named role, even if another package holds it |
--claim-all | Claim 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.
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.
| Form | Effect |
|---|---|
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> revoke | Revoke 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.
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.
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.
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.
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.
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.
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:
| Key | Meaning |
|---|---|
arch | The primary architecture, which becomes the composed database's recorded value |
source_date | The composition timestamp recorded in the package database and generated metadata |
local_packages | Globs 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.
| Option | Effect |
|---|---|
-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.
| Option | Effect |
|---|---|
--out <dir> | The output root directory. Required. Must not already exist. |
--locked | Require 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. |
--update | Re-resolve from scratch, overwrite the lock, then build. |
--dangerously-bypass-path-restrictions | Permit 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-dependencies | Install 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.
= 1
= "x86_64"
= "2026-07-01T00:00:00Z"
= ["./bootstrap/*.peipkg"]
[[]]
= "core"
= "https://repo.example.org/core"
= 10
= "required"
= ["a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"]
[[]]
= "initramfs"
= "boot/initramfs"
[[]]
= "base-system"
[[]]
= "nginx"
= ">=1.27, <1.28"
= "core"
[[]]
= "live-boot"
= "initramfs"
10.5.4.1 Top-level fields #
| Field | Type | Required | Meaning |
|---|---|---|---|
schema | int | yes | Manifest schema version. Must be 1. |
arch | string | yes | Target architecture; becomes the image's primary architecture. |
source_date | string | yes | An RFC 3339 timestamp. Fixes every build-stamped time in the output, for reproducibility — the manifest's SOURCE_DATE_EPOCH. |
local_packages | array of strings | no | Paths or globs of local .peipkg files. A bootstrap path for packages that live in no repository. |
[[repository]] | array of tables | no | Package sources. Also written into the image as .repo files. |
[[root]] | array of tables | no | Named roots, for a multi-root image. |
[[package]] | array of tables | yes | The 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.
| Field | Type | Required | Meaning |
|---|---|---|---|
name | string | yes | Repository name; also the .repo filename stem. |
base_url | string | yes | Where the repository's index and packages are served from. |
priority | int | no | Selection priority. Default 50. |
signature_policy | string | no | required (default) or optional. |
trust_anchors | array of strings | no | Hex key fingerprints trusted to sign this repository's index. |
allow_insecure_transport | bool | no | Permit non-TLS transport. Default false. |
min_index_version | int | no | Reject 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.
| Field | Type | Required | Meaning |
|---|---|---|---|
name | string | yes | The package to install. |
version | string | no | A 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. |
repository | string | no | Pin the source repository. Must name a declared [[repository]]. |
root | string | no | Target 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.
| Field | Type | Required | Meaning |
|---|---|---|---|
name | string | yes | A single named-root segment matching [a-z0-9][a-z0-9_-]* — no dots. |
path | string | yes | Location 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 --lockedrequires the lock to already exist and does no resolution at all — the reproducible, air-gapped rebuild.build --updatere-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
.peipkgfiles and it does not serve repositories — building and signing is pekit's job, and publishing ispeipkg-repo's. Compose consumes ordinary peipkg packages and repositories unchanged, exactly as PSPU §5 defines them. - No side effects.
depmodandman-dbare 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-xattrsto receive the implied attributes as JSONL and write them into the image directly instead; the composed directory then carries none of them. Unlikepeipkg 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 #
| Code | Meaning |
|---|---|
0 | Success — or the help output (-h, --help, help). |
1 | A compose operation failed — resolution, trust verification, a fetch, a hash mismatch, a stale lock a --locked build could not use, or a file operation. |
2 | A 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.
| Identifier | Rebuilds |
|---|---|
depmod | The kernel module dependency cache — modules.dep and its companions under /usr/lib/modules/<release>/ |
man-db | The 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.
| Identifier | Host 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 -q | Not 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.
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.
| File | Role |
|---|---|
pekit.toml | The recipe: source acquisition, and the build, test, install, clean, and gen targets |
workspace.pekit.toml | Workspace marker: member globs, distro-wide environment, derivation policy |
package.pekit.toml | Shared package metadata — the base layer |
<selector>.package.pekit.toml | One emitted package per file |
packages.pekit/ | An alternative directory holding the two above |
env.pekit.toml, <name>.env.pekit.toml | A build entry's environment, wrapper, and dependency provider |
<name>.keyring.pekit.toml | A secrets tree exported into the build environment |
pekit.lock | Machine-written source pin |
<patches>/series | The 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.
| Key | Type | Meaning |
|---|---|---|
out_dir | string | Stage and output base, relative to the recipe root. Defaults to out |
env | table | Environment for target commands |
wrap | table | A command wrapper applied to every target command |
source | table | Where the source comes from |
delegate | bool or table | Borrow build targets, environment, wrapper, or package definitions from the fetched source tree |
build, test, install, clean | tables | Target namespaces |
gen | table | Generation targets, with their own verification |
source_package | table | Whether 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.
| Key | Meaning |
|---|---|
command | Required. A shell string, or a non-empty argv array |
needs | Other targets that run first |
clear_out | Whether to clear the target's stage directory first. Defaults to true |
dependencies | Build-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.
| Key | Meaning |
|---|---|
command | Required. Regenerates the artifact |
verify_command | Checks that the committed artifact is up to date |
verify_on_build, verify_on_test | Which targets in that namespace the verification gates |
dependencies, verify_dependencies | As 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 #
| Key | Meaning |
|---|---|
url | Required |
ref | Templated, defaulting to the version placeholder |
versions | A constraint capping which upstream tags are eligible |
tag_regex | A pattern filtering tags during version enumeration |
12.3.2 A fetched artifact #
| Key | Meaning |
|---|---|
url | Required, templated |
extract | Whether to unpack the artifact |
root | The subdirectory of the unpacked tree that is the source root |
versions | A constraint cap |
file_regex | A pattern applied to a directory listing to enumerate versions; named version-component captures can map upstream filenames to canonical package versions |
checksum | A 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.
| Key | Meaning |
|---|---|
url | Templated; defaults to the source URL with a signature suffix |
of | artifact or decompressed — which bytes the signature covers |
key_files | Required and non-empty: the pinned public keys |
fingerprints | An allowlist of acceptable signing fingerprints |
ignore_expiry | Boolean, 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.
| Key | Meaning |
|---|---|
format | tar (the default) or peipkg |
clear_out | Whether to clear the package's stage directory first |
builds | Which build targets to stage; inferred from the file references when omitted |
package | The metadata table |
files | Source reference to destination |
symlinks | Destination to target |
excludes | Patterns removed from the matched set |
multipack | Fan this one definition into several packages |
publish | Where to publish the result |
dependencies, optional_dependencies, conflicts, provides, replaces | The manifest relationships |
side_effects | The declared maintenance operations |
sd_overrides | Path to security descriptor |
claims | Role 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:
= ">= 1.2"
= { = ">= 1.2", = "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:
| Reference | Means |
|---|---|
@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:
= "{{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.
| Payload | Declaration | Result |
|---|---|---|
A .ko or .ko.* under usr/lib/modules/ | no depmod | Error |
| No kernel module | depmod declared | Error |
A file under usr/share/man/ | no man-db | Warning |
| No man page | man-db declared | Warning |
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:
[]
= "tcb.priv"
= "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).
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
depmodside 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).
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 #
| Field | Where it lives |
|---|---|
| Operation | The event's type |
| Time | The kernel-stamped header |
| The caller's token, true token and process | The kernel-stamped header, as GUIDs |
| The acting principal | The payload: subject.token.sid |
| The package | The payload: name, version, previous version, architecture |
| Source repository | The payload: source.repository.name |
| Transaction identifier | The payload: transaction.id |
| Outcome, with an error code and the error text | The 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.
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 action | Gate |
|---|---|
| Downgrade | A per-action prompt, audited |
| A low-trust provider filling a high-trust role | A per-action prompt, audited |
A foreign replaces against a higher-priority package | A per-action prompt, audited |
| Proceeding on stale trust state | The --allow-stale flag, audited, no prompt |
Installing unsigned content under an optional policy | Warned per package on every operation; not gated, not audited |
| Enabling insecure transport | Not gated, not audited |
| Resolving an interrupted transaction | Not 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.
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.
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 #
| Location | Content |
|---|---|
<root>/var/state/peipkg/db.sqlite | The package database: installed packages, owned files, repositories, claims, named roots, and the transaction journal (§2.5) |
<root>/lcl/conf/peipkg/<name>.repo | One 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 #
| Pattern | Meaning |
|---|---|
<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-new | A 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 #
| Location | Content |
|---|---|
<manifest-stem>.lock.toml | The pinned closure a composition resolves to |
<out>.peipkg-compose-tmp | The tree under construction, renamed into place on success |
<out>/usr/share/licenses.json | The license inventory the composer writes, owned by no package |
A.4 Side-effect tools #
| Identifier | Host 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 -q | Skipped 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 #
| File | Role |
|---|---|
pekit.toml | The recipe |
workspace.pekit.toml | The workspace marker |
package.pekit.toml, <selector>.package.pekit.toml | Package definitions, layered |
packages.pekit/ | An alternative directory for the above |
env.pekit.toml, <name>.env.pekit.toml | Build environments |
<name>.keyring.pekit.toml | Secrets, including the package signing key |
pekit.lock | The source pin |
<patches>/series | The 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 #
| Type | Tier | Written for |
|---|---|---|
peipkg.package.installed | essential | Each package a transaction installs, or a request to install one that failed |
peipkg.package.upgraded | essential | Each package a transaction moves to another version — upgrade, downgrade or undo — or a request to that failed |
peipkg.package.uninstalled | essential | Each package a transaction removes, or a request to remove one that failed |
peipkg.repository.added | essential | A repository add and its trust ceremony, successful or failed |
peipkg.repository.removed | essential | A repository removal, successful or failed |
peipkg.repository.reconfigured | essential | Each trust-relevant setting a repository add changed |
peipkg.repository.refreshed | standard | A refresh, successful or partly failed |
peipkg.claim.changed | standard | A claim grant or revoke, successful or failed |
peipkg.transaction.recovered | standard | Every run of peipkg recover, successful or failed |
peipkg.action.authorised | essential | An 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.
| Field | Carried by | Content |
|---|---|---|
transaction.id | package, claim | The transaction; in a cross-root transaction, the transaction of the package's own root. Absent when none opened |
object.package.name | package, authorised | The package |
object.package.version | package | The version installed, moved to, or removed |
object.package.version-previous | upgraded | The version moved from |
object.package.architecture | installed, upgraded | The package's architecture |
source.repository.name | installed, upgraded | The repository the package came from; absent for a local file |
object.repository.name | repository, authorised | The repository |
object.repository.url | added | Its base URL |
config.name | reconfigured | The setting, as the repository file names it |
config.text, config.text-previous | reconfigured | signature_policy, new and old |
config.value, config.value-previous | reconfigured | Any 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.holder | claim | The role, and its new holder; no holder on a revoke |
operation.succeeded-count, operation.failed-count | refreshed | Repositories refreshed and failed |
operation.succeeded-count | recovered | Interrupted transactions rolled back |
operation.name | authorised | low-trust-provides, foreign-replaces, downgrade, remove-modified-file, stale-trust-state or stale-index |
object.file.path | authorised | The modified file, for remove-modified-file |
outcome.success | all but reconfigured | Whether it succeeded; for authorised, whether the operator agreed |
outcome.reason | package, claim, added | On failure, peipkg's error code |
outcome.detail | all but reconfigured and refreshed | On 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-stalewas not given writes no authorisation record; only proceeding with one does. - Enabling insecure transport, and installing unsigned content under an
optionalpolicy, write no authorisation record. peipkg-composewrites 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 incom.google.googletest-develand static libraries incom.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 incom.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 theAM_PATH_CHECKAutoconf macro inio.github.libcheck.check-develand the static library inio.github.libcheck.check-static.checkmk, which generates Check test programs from compact descriptions, is the separateio.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 #
| Subject | Where |
|---|---|
| The interface layer's vocabulary — facts, verdicts, the collation laws — and every profile value, as an operator writes them | The network policy reference (~peios/networking/network-policy-reference) |
| The rule engine itself: ingestion, conditions, collation, refusal | Kernel 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 layers | PKM §6.5 |
| The netd → resolver channel's contract | PSPU §6.9 |
| What a resolver does with the snapshots | PSPU book 6 and the resolvd manual |
| The query-channel framing the control socket uses | PSPU book 3 |
| How peinit holds a service until a readiness level is published | The peinit manual |
| Every registry value netd reads or writes, one entry each | regman '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.
| Term | Meaning |
|---|---|
| interface | A kernel network link netd has seen, keyed by its kernel index while it exists and named in the registry by its interface id. |
| interface id | A UUID-shaped digest of an interface's bus path and MAC (§4.1). Stable across boots and kernel renames. |
| verdict | What 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. |
| profile | A resolved key under Profiles\, inheritance applied: how a joined interface stands on its network. |
| generation | One 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. |
| pass | One run of netd's converge sequence (§2.2): identify networks, judge links, start or stop clients, reconcile, publish. |
| desired state | What one interface should look like: up or down, its MTU, its addresses and its routes (§4.2). |
| reconcile | Diffing desired state against the kernel's and applying the difference (§4.3). |
| network | What is on the other side of a joined interface's carrier, identified from what it has offered (§7.1) and recorded under Networks\<id>. |
| offer | Everything a network has said: a DHCPv4 lease, router advertisements, a stateless DHCPv6 reply. |
| level | How far an interface has got towards being usable: absent, link, addressed or routed (§8.2). |
| scope | What 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:
| Value | Setting | Why |
|---|---|---|
ImagePath | /usr/sbin/netd | |
Triggers | boot | |
Identity | SYSTEM | rtnetlink changes, the packet socket and a bind to udp/68 each need a privilege only SYSTEM holds |
Provides | network | the role a dependent names: Requires = ["network:routed"] |
Readiness | 0 (Notify) | readiness is READY=1 on the notify socket |
RuntimeDirectories | netd | peinit creates /run/netd for the control socket |
RestartPolicy | 2 (Always) | a stopped network manager is a machine whose leases silently expire |
ErrorControl | 0 |
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:
- 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.
- Opens the control socket at
/run/netd/control.sock, removing a stale one first (§9.1). Failure is fatal. - 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.
- Switches the kernel's RA processing off on every interface that
exists, and in
allanddefault(§6.1). This happens before any link is brought up. - 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.
- Arms the registry watch on
Machine\System\Network. Failure is a warning, and the configuration is then read once, never again. - Dumps the kernel's state (links, addresses and routes, both families). Failure is fatal.
- Runs the first pass (§2.2): every interface is judged, every static address applied, every client that is due started.
- Sends
READY=1on 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 #
| Input | Descriptor | What it leads to |
|---|---|---|
| Kernel events | the rtnetlink multicast socket | a fresh dump, then a full pass |
| Registry changes | the watch on Machine\System\Network | a reload (§2.3), then a full pass |
| udp/68 absorber | the absorber socket | drained and discarded |
| DHCPv4 replies | one packet socket per running client | the client's actions; a reconcile if the network changed |
| Router advertisements | one ICMPv6 socket per interface doing router discovery | the engine's actions; a full pass if anything changed |
| DHCPv6 replies | one UDP socket per running DHCPv6 client | the client's actions; a reconcile if the DNS facts changed |
| Control requests | the listener and accepted client sockets | the request (§9.2); reconcile runs a full pass |
| Time | the earliest deadline of any client | every 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:
- identify networks on joined interfaces with carrier (§7.1);
- 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;
- start DHCPv4 clients where due (§5.1);
- start or stop router discovery and DHCPv6 (§6.1, §6.3);
- 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 key | Used 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 it | the interface layer (§3.2) |
Profiles\ and everything under it | the 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 type | Lowered to |
|---|---|
REG_DWORD (4 bytes) | an integer |
REG_QWORD (8 bytes) | an integer |
REG_SZ, REG_EXPAND_SZ | a string, cut at the first NUL; other if not UTF-8 |
REG_MULTI_SZ | a list of strings, empty items dropped, invalid UTF-8 items dropped |
| anything else, or a DWORD or QWORD of the wrong length | other |
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):
| Shape | Accepted |
|---|---|
| boolean | an integer (non-zero is true), or a string on, yes, true, 1, off, no, false, 0 in any case |
| number | a non-negative integer that fits 32 bits, or a string of one, surrounding space trimmed |
| list | a multi-string (empty items dropped), or a single string as a one-item list; an empty string is the empty list |
| string | a 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 #
| Value | Shape | What netd does with it |
|---|---|---|
Address.Offered | boolean | runs a DHCPv4 client while IPv4 is in the families, and router discovery while IPv6 is |
Address.Families | list | items 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.Static | list | each item address/prefix, either family, prefix at most 32 or 128. An item that does not parse refuses the generation. |
Address.LinkLocal | boolean | link-local fallback (§5.5) |
Address.Temporary | boolean | temporary IPv6 addresses (§6.2) |
Address.OnExpiry | string | Drop or Keep, in any case. Anything else refuses the generation. |
Route.Offered | boolean | takes the lease's gateway and classless routes and the routers' default route (§4.2) |
Route.Gateway | list | each 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.Metric | number | the metric of every route netd adds for the interface, and its place in DNS scope order |
Mtu.Offered | boolean | takes the lease's MTU, else the routers' |
Mtu.Value | number | the MTU to set. Below 68 refuses the generation. |
Hostname.Announce | boolean | sends the machine's Hostname in DHCP option 12 |
Hostname.Offered | boolean | adopts a lease's hostname when Hostname is unset (§8.4) |
Dns.Offered | boolean | adds the network's DNS servers and search domains after the profile's own (§8.3) |
Dns.Servers | list | each item an address of either family. An item that does not parse refuses the generation. |
Dns.Domains | list | taken as written; nothing validates a domain |
Dns.Default | boolean | present sets the scope's default-route flag; absent follows the interface's level (§8.3) |
Dns.Exclusive | boolean | sets 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 #
- Profiles are resolved (§3.1). Any error refuses the generation.
- If
Rules\Interfacedoes not exist, the generation has no forest. Every interface meets the backstop,IGNORE, and the profiles are kept for nothing to name. - Each subkey of
Rules\Interfaceis a root rule. Its tree is lowered topnp-core's input and built as theInterfacelayer withbuild_forest— the same ingestion, condition parsing and lints the kernel applies to the packet layers (PKM §6.5). Every build error refuses the generation. - Every profile a
JOINnames is looked up. AJOINnaming no profile refuses the generation withJOIN(<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:
| Cause | Message |
|---|---|
| An unknown fact in a condition | rule <path>: unknown fact <key> |
| A malformed operator | rule <path>: bad operator in <key> |
| A malformed pattern | rule <path>: bad pattern in <key> |
Actions not a list | rule <path>: Actions is not a list |
| A malformed action | rule <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 layer | rule <path>: <key> does not exist at the interface layer |
Present on a fact never present at this layer | rule <path>: <key> on a fact that never exists at this layer |
Priority not an integer | rule <path>: Priority is not an integer |
Enabled not 0 or 1 | rule <path>: Enabled is not 0 or 1 |
| A malformed rule name | rule <path>: bad name |
A JOIN naming no profile | JOIN(<path>) names no profile |
| A malformed profile | the 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:
| Fact | Value |
|---|---|
Interface | the kernel's name for the link |
Interface.Kind | wired, wireless, loopback or other (§4.1) |
Interface.Id | the interface id (§4.1) |
Interface.Mac | the hardware address, when the link has a 6-byte one |
Interface.Path | the bus path; absent when sysfs gives none |
Interface.Driver | the driver; absent when sysfs gives none |
Network.Id | the id of the network identified on the link (§7.1); absent until one is |
Network.Name | the record's Name; absent when unset or empty |
Network.Trust | the record's Trust; absent when unset or empty |
Network.Kind | the 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'sithJOINnames, 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 #
| Verdict | Clients | Desired state (§4.2) |
|---|---|---|
JOIN(profile) | started as the profile asks | up, with the profile's addresses and routes |
DOWN | none | down, carrying no addresses and no netd routes |
IGNORE | none | none: 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:
| Link | Interface.Kind |
|---|---|
| the loopback flag set | loopback |
link type Ethernet, with /sys/class/net/<name>/wireless or /sys/class/net/<name>/phy80211 present | wireless |
| link type Ethernet otherwise | wired |
| any other link type | other |
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 Networkis cleared and theNetwork.*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, withRoute.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 classless0.0.0.0/0route. 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 #
- every IPv6 entry of
Address.Static, at its prefix, preferred, with a prefix route; - 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:
- Link up, if it is desired up and is down.
- MTU, if one is desired, differs from the link's, and is at least 68.
- Address deletions: every address netd owns whose address and prefix length together match no desired address.
- Address additions: every desired address the kernel does not hold with the same flags, in ascending address order, IPv4 before IPv6.
- Route deletions: every protocol-200 route on the interface that is not desired.
- Route additions: every desired route the kernel does not hold.
- 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:
| Operation | Request |
|---|---|
| link up or down | RTM_SETLINK changing only IFF_UP |
| MTU | RTM_SETLINK with IFLA_MTU |
| address add | RTM_NEWADDR with NLM_F_CREATE and NLM_F_REPLACE |
| address delete | RTM_DELADDR |
| IPv4 route add | RTM_NEWROUTE with NLM_F_CREATE and NLM_F_APPEND, main table, protocol 200 |
| IPv6 route add | RTM_NEWROUTE with NLM_F_CREATE and NLM_F_EXCL, main table, protocol 200 |
| route delete | RTM_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_LOCALandIFA_ADDRESSset 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.Offeredand 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 socketand tries again on the next pass.
A new client starts with no lease. It is configured with:
| Setting | Value |
|---|---|
chaddr | the 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 |
| randomness | 8 bytes of /dev/urandom XORed with the interface index, seeding transaction ids and jitter |
| a previous address | the 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 #
| State | Entered | Leaves on |
|---|---|---|
| Selecting | start without a previous address; a NAK while requesting or rebooting; a request that went stale; a reboot nobody confirmed; a lost lease | an acceptable OFFER |
| Requesting | an OFFER in Selecting | an ACK (Bound), a NAK (Selecting), four unanswered transmissions (Selecting) |
| Rebooting | start with a previous address | an ACK (Bound), a NAK (Selecting), two unanswered transmissions (Selecting) |
| Bound | an ACK | T1 (Renewing) |
| Renewing | T1; or an operator renew while bound, renewing or rebinding | an ACK (Bound), a NAK (lease lost, Selecting), T2 (Rebinding) |
| Rebinding | T2 | an 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:
| Option | Content |
|---|---|
| 53 | the message type |
| 61 | the client identifier |
| 57 | maximum message size 1500 |
| 55 | the parameter request list: 1, 3, 6, 12, 15, 26, 28, 42, 51, 58, 59, 119, 121 |
| 12 | the 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 #
| Message | When | Broadcast flag | ciaddr | Option 50 | Option 54 | Sent |
|---|---|---|---|---|---|---|
| DISCOVER | Selecting | set | 0 | the address of a lease that expired, until a NAK or a reboot timeout forgets it | — | broadcast |
| REQUEST | Requesting | set | 0 | the offered address | the offer's | broadcast |
| REQUEST | Rebooting | set | 0 | the previous address | — | broadcast |
| REQUEST | Renewing | clear | the lease address | — | — | unicast to the server |
| REQUEST | Rebinding | clear | the lease address | — | — | broadcast |
| RELEASE | a stop while holding a lease | clear | the lease address | — | the server | unicast 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.
| State | Transmissions before it gives up | Then |
|---|---|---|
| Selecting | never gives up | — |
| Requesting | four REQUESTs (about 0, 4, 12 and 28 s) | at the next timer, about 60 s, Selecting with a new DISCOVER |
| Rebooting | two 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
chaddris 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 #
| State | Message | Result |
|---|---|---|
| Selecting | OFFER with a non-zero yiaddr and a server identifier | taken: Requesting. The first acceptable OFFER wins; there is no comparison between offers. |
| Selecting | OFFER without either | ignored |
| Requesting, Rebooting, Renewing, Rebinding | ACK that reads as a lease (below) | Bound, with the lease |
| Requesting, Rebooting, Renewing, Rebinding | ACK that does not read as a lease | ignored; retransmission continues |
| Requesting, Rebooting | NAK | Selecting; the previous address and the offer are forgotten |
| Renewing, Rebinding | NAK | the lease is lost (§5.4); Selecting |
| any other combination | ignored |
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 #
| Option | Kept as |
|---|---|
| 6 | DNS servers, every 4-byte entry |
| 15 | the domain name: the bytes up to the first NUL; dropped if not UTF-8 |
| 119 | the domain search list, RFC 1035 names with compression pointers followed, at most 16 jumps per name |
| 12 | the hostname offered: the bytes up to the first NUL; dropped if not UTF-8 |
| 26 | the MTU, when it is at least 68 |
| 28 | the broadcast address |
| 42 | NTP 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 logsinterface <name>: lease lostand forgets the lease. The next reconcile removes the address and the routes that came with it.Keep: netd logsinterface <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
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:
| Field | Value |
|---|---|
| source | 0.0.0.0 |
| destination | 255.255.255.255 |
| TOS | 0x10 |
| identification | 0 |
| flags | don't-fragment |
| TTL | 64 |
| UDP ports | 68 → 67 |
| UDP checksum | 0 (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:
- the registry's
Machine\System\Network Duid, as hex, when it is set and parses; - otherwise the file
/var/state/netd/duid, when it holds at least 4 bytes; - otherwise a new DUID-LL:
00 03 00 01followed 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):
- the
RequestedAddressof the network currently identified on the interface, when there is one; otherwise - the
RequestedAddressof the network the interface'sStatus LastNetworknames (§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.Offeredand 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:
| Option | Accepted when | Limits |
|---|---|---|
| Prefix information (3) | 32 bytes long; prefix length ≤ 128; the prefix neither multicast nor link-local | the first 16 per advertisement |
| MTU (5) | 8 bytes long | |
| RDNSS (25) | at least 24 bytes, and a whole number of addresses | 16 servers per option; unspecified, multicast and loopback addresses dropped |
| DNSSL (31) | at least 16 bytes | 16 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:
- the bytes
peios-ndp-stable-iid|; - the machine's 32-byte secret;
- the prefix's first 8 bytes;
- the interface id's text;
- 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 lifetime | Remaining valid lifetime | Result |
|---|---|---|
| above two hours, or above what remains | any finite | the advertised lifetime |
| two hours or less, not above what remains | two hours or less | unchanged |
| two hours or less, not above what remains | above two hours | two hours from now |
| above two hours | forever | the advertised lifetime |
| two hours or less | forever | forever |
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:
| Option | Content |
|---|---|
| 1, client identifier | the machine's DUID (§5.7) |
| 8, elapsed time | hundredths of a second since this exchange began, capped at 65535 |
| 6, option request | 23 (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:
| Signal | From |
|---|---|
| kind | the interface's Interface.Kind |
| server | the DHCPv4 lease's server identifier |
| subnet | the lease address masked to the lease prefix |
| gateway | the lease's gateway (§5.3) |
| router | the default router router discovery chose (§6.1) |
| prefixes | the subnet, then every non-deprecated autoconfigured address's prefix, each once |
| DNS servers | the 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:
| Value | Type | Content |
|---|---|---|
Kind | REG_SZ | the interface kind it was seen on |
Server | REG_SZ | the DHCPv4 server; removed when there is none |
Gateway | REG_SZ | the lease's gateway; removed when there is none |
Router | REG_SZ | the IPv6 default router; removed when there is none |
Prefixes | REG_MULTI_SZ | the subnet and the autoconfigured prefixes, CIDR form |
DnsServers | REG_MULTI_SZ | the DNS servers offered, either family |
LastSeen | REG_SZ | the wall-clock time of the identification, in Unix seconds |
LastInterface | REG_SZ | the 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:
| Value | Read as | Meaning |
|---|---|---|
Name | REG_SZ, empty is unset | the Network.Name fact |
Trust | REG_SZ, empty is unset | the Network.Trust fact |
RequestedAddress | REG_SZ, an IPv4 address | the 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 #
| Value | Content | Present |
|---|---|---|
Name | the kernel's name for the link | always |
Kind | wired, wireless or other | always |
Mac | the hardware address, lower-case, colon-separated | when the link has one |
Path | the bus path | when there is one |
Driver | the driver | when there is one |
Verdict | JOIN, IGNORE or DOWN | when a rule spoke; removed when the backstop answered or rules tied |
Rule | the 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 |
Profile | the profile path as written | JOIN only |
Readiness | the interface's level (§8.2) | JOIN only |
Network | the id of the network identified on the link now | while one is identified; removed when the carrier goes |
LastNetwork | the id of the network the link last stood on | from 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:
| Level | When |
|---|---|
absent | the interface is not up, or has no carrier |
link | up with carrier, and no usable address |
addressed | a usable address, and no default route |
routed | a 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:
- logs
machine readiness is <level>; - sends the complete readiness set below in one datagram on the notify
socket named by
NOTIFY_SOCKET; - writes
ReadinessonMachine\System\Network.
The notify set is determined by the highest machine level:
| Machine level | LEVEL values, in wire order |
|---|---|
absent | absent |
link | link |
addressed | link, addressed |
routed | link, 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:
| Field | Content |
|---|---|
ifid | the interface id |
name | the kernel name |
servers | DNS servers, as text, in the order to try them (below) |
domains | search domains, likewise |
ntp | the lease's NTP servers (option 42) |
addresses | every address the kernel holds on the interface, address/prefix, link-local ones included |
default_route | the profile's Dns.Default when set; otherwise whether the interface's level is routed |
exclusive | the profile's Dns.Exclusive |
metric | the interface's metric (§4.2) |
level | the interface's level (§8.2) |
8.3.4 Merging DNS facts #
Servers, in this order:
- the profile's
Dns.Servers, always; - with
Dns.Offered: the lease's servers (option 6); - with
Dns.Offered: the routers' RDNSS servers, including link-local IPv6; - with
Dns.Offered: the DHCPv6 reply's servers, including link-local IPv6.
Domains, in this order:
- the profile's
Dns.Domains, always; - with
Dns.Offered: the lease's domain search list (option 119) if it is not empty, otherwise its domain name (option 15); - with
Dns.Offered: the routers' DNSSL domains; - 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 #
Machine\System\Network Hostname, when it is set and not empty;- otherwise the hostname option (12) of a lease. netd takes the first
joined interface, in kernel index order, whose profile has
Hostname.Offeredand whose lease carries a hostname; - 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:
- creates
/run/netdif it is missing, sets its mode to0755, and gives it the descriptor below; - removes a socket file left by a previous run, logging
removed a stale /run/netd/control.sock; - 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:
| Trustee | Rights |
|---|---|
| SYSTEM | NETWORK_ALL_ACCESS |
| Administrators | NETWORK_ALL_ACCESS |
| Everyone | NETWORK_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 #
| Right | Value |
|---|---|
NETWORK_QUERY | 0x00000001 |
NETWORK_CONTROL | 0x00000002 |
NETWORK_ALL_ACCESS | 0x000F0003: both, plus the standard rights |
The generic mapping:
| Generic right | Maps to |
|---|---|
| read | NETWORK_QUERY, READ_CONTROL |
| write | NETWORK_CONTROL, READ_CONTROL |
| execute | NETWORK_QUERY |
| all | NETWORK_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 stringerror.
An oversized, incomplete-at-EOF, or undecodable request is answered with an error reply and the connection ends:
| Problem | error |
|---|---|
no query | missing field query |
an unknown query | unknown query "<query>" |
| a duplicate key | duplicate field <key> |
renew without interface | missing field interface |
| malformed MessagePack | malformed 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 #
query | Right | Does | Reply |
|---|---|---|---|
status | NETWORK_QUERY | nothing | the status (below) |
subscribe | NETWORK_QUERY | keeps the connection as a subscriber (§8.3) | a snapshot now, and another on each change |
reconcile | NETWORK_CONTROL | a full pass (§2.2) | {ok: true} |
renew, with interface | NETWORK_CONTROL | the 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 #
| Field | Content |
|---|---|
hostname | the name netd last set, empty if none (§8.4) |
level | the machine level (§8.2) |
refusal | why the newest generation was refused, or nil (§3.2) |
interfaces | one map per non-loopback interface netd has seen, joined or not, in kernel index order |
Each interface:
| Field | Content |
|---|---|
ifid, name, index | the interface id, kernel name and index |
mac, path, driver | identity (§4.1); empty strings when absent |
verdict | JOIN, IGNORE or DOWN; nil when the backstop answered or rules tied |
rule | the rule that spoke, backstop, or the tied rules <a> vs <b> |
profile | the profile path, JOIN only |
network, network_name, network_trust | the network identified now (§7) |
warning | discovery unanswered (§5.2), or a tie (§3.3); nil otherwise |
up, carrier | the link's flags |
level | the interface's level (§8.2), computed for every interface |
addresses | every address the kernel holds, address/prefix, link-local ones included |
gateway, gateway6 | the gateway of the interface's first IPv4 and IPv6 default route, any owner's |
dns, search | the merged DNS facts (§8.3); empty for an interface not joined |
lease | while 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 #
| Command | Does |
|---|---|
net status | prints the status reply (§9.2) |
net renew <interface> | sends renew |
net reconcile | sends reconcile |
net rules | prints 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 list | prints 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 policy | prints 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 --version | prints 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 #
| Code | Meaning |
|---|---|
| 0 | success |
| 1 | netd unreachable, an error reply, a timeout, a refused policy, or a registry key that cannot be opened |
| 2 | usage: 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/kmsgatKERN_INFO,KERN_WARNINGorKERN_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/kmsgdescriptor, 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 shows | Likely cause |
|---|---|
warning asked for an address; nobody answered, a 169.254 address or none | no 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 warning | the 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 address | no 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 shows | Meaning |
|---|---|
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 backstop | no rule speaks for the interface. It is left as the kernel left it. |
verdict (none) by <a> vs <b>, warning rules … tie | two 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 it | by 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.Staticinstead. - A route added by hand stays, unless it carries protocol 200.
- A pinned gateway never appears.
Route.Gatewayis not checked against the interface's subnets. When the kernel cannot reach it, every reconcile logsreconcile: AddRoute(…) failed, and the interface staysaddressed. - The hostname does not change back when
Hostnameis 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/netdlost its descriptor. The service restarts netd always;evctlshows 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), orMachine\System\Networkdid not exist then (§2.1). Restart netd.
11.1.8 Getting back to a known state #
- Undo the last registry write. A refused generation names what to undo, and the next good write is taken at once.
net reconcileruns a full pass without waiting for an event.net renew <interface>renews a lease without releasing it.- 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 #
| Path | Use |
|---|---|
Machine\System\Network | configuration root, readiness, DUID (§2.3, §8) |
/run/netd/control.sock | control socket (§9.1) |
/run/netd | runtime directory, created by peinit (§2.1) |
/var/state/netd/duid | the DUID, when the registry has none (§5.7) |
/var/state/netd/secret | the RFC 7217 secret (§6.2) |
/usr/sbin/netd | the daemon |
/proc/sys/net/ipv6/conf/<name>/accept_ra | set to 0 (§6.1) |
A.2 Numbers #
| Constant | Value | § |
|---|---|---|
| Routing protocol stamped on netd's routes | 200 | §4.3 |
| Default route metric | 100; 600 for wireless | §4.2 |
| Minimum MTU applied | 68 | §4.3 |
| Converge repetitions per pass | at most 4 | §2.2 |
| Registry tree depth read | 16 | §2.3 |
| Control message ceiling | 65536 bytes | §9.2 |
| Control read and write timeout | 2 s | §9.1 |
| DHCPv4 first backoff, ceiling | 4 s, 64 s, ±1 s jitter | §5.2 |
| DISCOVER retransmissions before "nobody answered" | 3 | §5.2 |
| REQUESTs in Requesting before rediscovery | 4 | §5.2 |
| REQUESTs in Rebooting before rediscovery | 2 | §5.2 |
| Renewing and rebinding retransmission floor | 60 s | §5.2 |
| Shortest lease accepted | 4 s | §5.3 |
| DHCPv4 maximum message size advertised | 1500 | §5.1 |
| DHCPv4 message padding | to 300 bytes | §5.1 |
| Domain-search compression jumps per name | 16 | §5.3 |
| Link-local range | 169.254.1.0–169.254.254.255 | §5.5 |
| Router solicitation interval, ceiling | 4 s, 3600 s, ±10% jitter | §6.1 |
| Solicitations before backoff | 3 | §6.1 |
| Prefixes, RDNSS servers, DNSSL domains per advertisement option | 16 | §6.1 |
| SLAAC prefix length | 64 | §6.2 |
| Two-hour rule | 7200 s | §6.2 |
| Temporary address preferred lifetime | 86400 s minus 0–599 s | §6.2 |
| Temporary address valid lifetime | 172800 s | §6.2 |
| Temporary addresses per prefix | 4 | §6.2 |
| Stable address counter attempts | 8 | §6.2 |
| DHCPv6 first retransmission, ceiling | 1 s, 3600 s, ±10% jitter | §6.3 |
| DHCPv6 refresh default, floor | 86400 s, 600 s | §6.3 |
| DHCPv6 servers and domains kept | 16 each | §6.3 |
A.3 Identifiers #
| Identifier | Value | § |
|---|---|---|
| Interface id digest prefix | peios-netd-ifid| | §4.1 |
| Network id digest prefix | peios-netd-network| | §7.1 |
| Stable address digest prefix | peios-ndp-stable-iid| | §6.2 |
| DUID-LL header | 00 03 00 01 | §5.7 |
Status descriptor | O:SYG:SYD:P(A;;KA;;;SY)(A;;KR;;;WD) | §8.1 |
NETWORK_QUERY, NETWORK_CONTROL, NETWORK_ALL_ACCESS | 0x1, 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):
| Door | Where | Who uses it |
|---|---|---|
| Native socket | /run/resolvd/resolv.sock | The resolv command, the NSS shim, Peios tools |
| Stub listener | 127.0.0.53:53, UDP and TCP | Programs that speak DNS themselves |
| NSS shim | libnss_peios_net.so.2, loaded into every glibc process | getaddrinfo, 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:
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:
| Crate | What it is |
|---|---|
dns | The DNS wire codec: names, messages, records. No I/O and no policy. |
libresolv | The native channel's types, MessagePack codec and framing. No I/O beyond framing. |
resolvd | The engine, the cache, the stub door's DNS rendering, and the daemon around them. |
resolv | The operator command. |
nss | The 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 #
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:
| Key | Value | Type |
|---|---|---|
Machine\System\Network\TcpIp\PortReservations | tcp,udp:53 | REG_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=1to the socket named by theNOTIFY_SOCKETenvironment 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_SOCKETis unset, and logs a failure to send asreadiness 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:
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 #
- 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). - 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 logsstub listener on 127.0.0.53:53: <error>and exits with status 1. - The registry.
Machine\System\Network\Dnsis read whole (§2.3). A missing or unreadable key is not an error; every value takes its default. - The registry watch. A subtree watch is armed on
Machine\System\Network(§2.3). If it cannot be armed, resolvd logsregistry watch unavailable (<error>); configuration is read onceand runs on the configuration it read in step 3 until it restarts. - 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. - 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. - The control object is built from
ControlSecurity(§5.2). - netd. The first attempt to subscribe is made immediately (§3.1). Its failure is not fatal and is retried with backoff (§3.2).
- The listening line. resolvd logs
listening on /run/resolvd/resolv.sock and 127.0.0.53:53at info level. - Readiness.
READY=1is sent (§2.1). - 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.
| Value | Type read | Default | Used for |
|---|---|---|---|
FallbackServers | REG_MULTI_SZ, or one REG_SZ / REG_EXPAND_SZ | none | The fallback scope's servers (§4.4) |
ExtraSearchDomains | REG_MULTI_SZ, or one REG_SZ / REG_EXPAND_SZ | none | Search domains after every interface's (§4.3) |
ControlSecurity | REG_BINARY | the compiled default | The control object (§5.2) |
Hosts\ | a subkey | empty | Static names (§4.2) |
2.3.2 Reading strings #
- A
REG_SZorREG_EXPAND_SZis 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_SZis read as it is stored; nothing in it is expanded. - A
REG_MULTI_SZis 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 loneREG_SZthat is empty or only whitespace, a whitespace-only string in a multi-string, and an address with a zone such asfe80::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_BINARYvalue 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 defaulteach 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\Hostsis one static name. The value's name is the host name and its data is one address (REG_SZorREG_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 asDns 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
Printerandprinterare 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:
- rebuilds the control object from
ControlSecurity; - 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;
- replaces the static names;
- logs
configuration changedat 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-armingand 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:
- creates
/run/resolvdif peinit has not, and sets its mode to0755; - replaces the directory's DACL (below);
- removes any existing
/run/resolvd/resolv.sock, loggingremoved a stale /run/resolvd/resolv.sockat warn level when there was one, and a removal that fails is fatal (§2.2); - binds the socket, sets its mode to
0666, and replaces its DACL in the same way; - 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:
| Trustee | Access |
|---|---|
| SYSTEM | GENERIC_ALL |
| resolvd's own account (its token's user) | GENERIC_ALL |
| Everyone | GENERIC_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.53port 53 and both nonblocking (PSPU §6.8). - Nothing is bound on
::1or on any other address.
Binding port 53 depends on the port reservation (§2.1).
2.4.3 Other descriptors #
| Descriptor | Count | Lifetime |
|---|---|---|
The registry watch on Machine\System\Network | 0 or 1 | From startup; replaced on a watch error (§2.3) |
| The netd channel | 0 or 1 | While subscribed (§3.1) |
| Native connections reading or awaiting resolution | up to 256 total | Reading: at most 5 s (§5.1); resolving: until completion or definite departure (§4.1) |
| Stub TCP connections reading or awaiting resolution | up to 256 total | Reading: at most 10 s (§6.1); resolving: until completion or definite departure (§4.1) |
| Queued native reply writers | up to 256 | Until written, failed, or the one-second write deadline (§2.5) |
| Queued stub TCP reply writers | up to 256 | Until written, failed, or the one-second write deadline (§2.5) |
| Upstream sockets | one per transaction, at most 4 096 including retries | One 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:
- the native listener, except during its accept-error backoff;
- the stub UDP socket;
- the stub TCP listener, except during its accept-error backoff;
- the netd channel, while connected;
- the registry watch, while armed;
- every native connection still sending its request;
- every stub TCP connection still sending its query;
- every upstream socket — for reading, or for writing while a TCP connection is still being established or its query is still being sent;
- native and stub TCP replies waiting to write, for writability;
- 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:
- waiting native and stub TCP streams with definite hangup/error, cancelling their questions and upstream transactions;
- upstream sockets — replies that have arrived are answers already owed;
- queued native replies, then queued stub TCP replies, whose sockets are writable;
- the stub UDP socket, reading at most 64 datagrams;
- the stub TCP listener, accepting at most 64 connections;
- stub TCP connections still sending queries;
- the native listener, accepting at most 64 connections;
- native connections still sending requests;
- the netd channel;
- 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:
- connects to
/run/netd/control.sock; - sets a two-second write timeout and writes the
subscriberequest in netd's framing — a four-byte little-endian length and a MessagePack map; - 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:
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:
| Attempt | Time after the first |
|---|---|
| 2 | 0.5 s |
| 3 | 1.5 s |
| 4 | 3.5 s |
| 5 | 7.5 s |
| 6 | 15.5 s |
| 7 and later | every 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 reachablewarning is not logged for the failures that follow, however many there are; thelost the netd channel; reconnectingline 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
hostnameis 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
statusreports an emptyhostname. - 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:
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 #
- 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
notfoundat once. - Synthetic names. The name as asked is checked against the synthetic names (§4.2). A match answers the task.
- 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. - Routing. Each candidate in turn is routed to one scope (§4.4).
When no scope can take it, the task is answered
unavailable. - The cache. Unless the request asked to bypass the cache, the cache is consulted under that scope (§4.5).
- The network. Otherwise the candidate is sent upstream (§4.6 to §4.8).
- The next candidate. A
notfoundfor a candidate — from the cache or from a server — moves to the next candidate when there is one. Any other result answers the task. - The last candidate. When the last candidate is
notfound, the task is answerednotfound.
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 #
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 #
localhost, or any name under it.127.0.0.1forA,::1forAAAA, both forANY. Sourcesynthetic.- A static name. An exact, case-insensitive match against the
Hosts\values (§2.3). The name's addresses of the asked family — IPv4 forA, IPv6 forAAAA, both forANY. Sourcehosts. - The machine's hostname. An exact, case-insensitive match against
the hostname (§3.3), answered with the machine's own addresses
(below). Source
synthetic. .local. The namelocalor any name under it isnotfound, whatever the type asked. Sourcesynthetic. No query is sent.- A reverse-mapping name, for
PTRorANYonly. 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
addressedor 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.1and::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.arpalabel parses as a decimal number from 0 to 255. Leading zeros and a leading+are accepted, so1.0.0.0127.in-addr.arpaand+1.0.0.127.in-addr.arpaboth name127.0.0.1. - Each
ip6.arpalabel 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:
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_cacheflag has no effect on them. - Each one adds one to the
syntheticcounter (§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 asprinteris. - A top-level domain such as
comis 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.
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.
ExtraSearchDomainsis 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.unavailableis the answer, and later candidates are not asked.- When the last candidate is
notfound, the question isnotfound(§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 #
- Exclusive. The routable scopes that are exclusive and at level
addressedor better. The one with the lowest metric takes the candidate; among equal metrics, the first in snapshot order. - 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.
- 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.
- Default route. The routable scopes with
default_routeset. The lowest metric wins, then the earliest. - 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.
- Fallback. When there are no routable scopes at all, the fallback
scope, provided
FallbackServersnames at least one server. Its scope key isfallback.
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
addressedor 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
unavailablewithout 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 matchingINanswer 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:
4.5.2.1 The negative lifetime #
- The negative lifetime comes from the first
INSOArecord 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'sMINIMUMfield 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
SOAin 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
notfoundhit with further candidates waiting moves to the next candidate, as anotfoundfrom a server would (§4.1).
4.5.4 Bypassing the cache #
- A
resolverequest withno_cacheset skips the lookup for each of its candidates. The reply it gets is still stored. - Stub queries,
lookupandreversealways 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_entriescount instatusincludes 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:
- every entry whose expiry is at or before the current time;
- 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 #
| Event | Discards |
|---|---|
| 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 restart | Everything |
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:
| Bound | Value |
|---|---|
| Transaction timeout | 2 s |
| Attempts per question (all candidates and transports) | 3 |
| Demotion period | 30 s |
| Upstream transactions in flight | 4 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
NOERRORnorNXDOMAIN—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
NOERRORorNXDOMAINthat resolvd uses — a UDP reply withoutTC, or a TCP reply — removes the server's mark at once. A UDP reply withTCset 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.
statuslists, 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
NXDOMAINthat 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
unavailableat once, and therefusedcounter goes up by one. A question whose scope has no servers left is answeredunavailablebefore 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 #
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
TCset 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 isunavailable. - A TCP reply with
TCset 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:
- it decodes as a DNS message — bytes after its last section are tolerated;
- its ID equals the transaction's;
- its
QRbit is set; - it has exactly one question;
- that question's type and class equal those sent;
- 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:
| Reply | Result |
|---|---|
UDP, TC set | TCP retry to the same server (§4.7) |
Response code NOERROR | found |
Response code NXDOMAIN | notfound |
| Any other response code | The 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
NXDOMAINnone 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
INCNAME 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
INtypes 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:
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' outcomes | outcome |
|---|---|
At least one found, with or without records | found |
None found, at least one unavailable | unavailable |
Every one notfound | notfound |
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.
- 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 theCNAME's target. - This repeats at most 16 times; a loop stops there.
- The addresses are the
A(for theAtask) orAAAA(for theAAAAtask) 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 #
addresseslists 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.canonicalandsourcecome from the same selectedfoundtask. Prefer a task that produced addresses; when both or neither did, preferAoverAAAA. Completion order does not affect this choice.canonicalis the name that task's CNAME chase ended at, after search expansion. Without afoundtask it is the name asked, as parsed, in presentation form without a trailing dot (the root is.).- A
foundwith no addresses still reports the expanded or chased name. - A name that does not parse is answered at once with
outcomenotfound,canonical., no addresses andsourcelocal; no task is started. sourceis the source of the selected task, andlocalwhen no task wasfound. It describes that task; the other family's records can have a different source.validationisunvalidated.- A
lookupadds one toquerieshowever 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 #
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.
- Read exactly the four-byte little-endian payload length. A length
above 65 536 gets the reply
request too largeand the connection closes, without reading any payload bytes. - 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.
- 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 (auint 64of 2^63 or more) anywhere inside the value, a map key included, fails the request withmalformed 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.
querynames the request; a missingqueryis an error, and an unknown one is an error.- Every key resolvd knows —
query,name,type,no_cache,familyandaddress— 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;typeis checked against 65 535 in every request, so{query: status, type: 70000}is refused too.familytakes any string (§5.3), andaddressis parsed as an address only in areverse. - 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:
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 #
- 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. - 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.
- A denied request is answered
access denied(§5.1).
5.2.1.1 The generic mapping #
The check uses this generic mapping:
| Generic right | Maps to |
|---|---|
GENERIC_READ | RESOLVER_QUERY, READ_CONTROL |
GENERIC_WRITE | RESOLVER_CONTROL, READ_CONTROL |
GENERIC_EXECUTE | RESOLVER_QUERY |
GENERIC_ALL | RESOLVER_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):
| Component | Value |
|---|---|
| Owner | SYSTEM |
| Group | SYSTEM |
| DACL | SYSTEM: 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:
statusandflushare 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 #
The reply's outcome, source, server, interface and rcode are
as §4.1 describes. Each record carries:
| Type | text |
|---|---|
A, AAAA | The address |
CNAME, PTR, NS | The target name, written as name is |
SOA | mname rname serial refresh retry expire minimum |
MX | preference exchange |
SRV | priority weight port target |
TXT | Each 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 #
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:
subnetsholds the interface's addresses asaddress/prefix— the address itself, not the network it is in;demotedholds those of the scope's servers that are demoted at this moment (§4.6);- a scope's
levelis not reported.
5.3.6 flush #
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:
- A datagram whose source address is not a loopback address — anything
outside
127.0.0.0/8— is dropped without a reply. - It is checked as a query (below). A refused query gets its error reply, or nothing.
- 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 bothqueriesandrefused. This admission check precedes synthetic and cache lookup. - 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 #
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,
RDandCDbits, and setsQRandRA; every other bit is clear; - the question section is the query's, whatever it holds;
- when the query carried an
OPTrecord, the reply carries one whose payload size is the size the query advertised, withDOclear and no options; without one, the reply has none.
6.1.4 What an accepted query contributes #
- Only
INquestions 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, theDObit 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,
RDandCDbits and its question section. QRandRAare set.AA,ADandTCare clear, except thatTCis 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
CNAMEfrom 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
foundwith no records at an expanded name isNOERRORwith only that leadingCNAME. Its TTL is the negative cache lifetime: the lesser of the enclosing upstreamSOArecord's TTL and minimum field, capped at 300 seconds and at the TTL of anyCNAMEpath the reply followed (§4.5). A cache hit uses the remaining lifetime, rounded down to whole seconds. With no enclosingSOA, the TTL is zero; no lifetime is invented. The authority section remains empty. An unexpanded emptyfoundhas noCNAME.
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
OPTadvertised, or 512 bytes when that is smaller or there is noOPT. - A reply over the limit is replaced by a truncated one, which keeps the
same header with
TCset, and the question. - The truncated reply keeps the
OPTrecord 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.2is installed bydev.peios.resolvd-nssat/usr/lib/x86_64-linux-peios/libnss_peios_net.so.2.- It is built from the
nsscrate, with the native channel codec compiled in. Its only shared-object dependencies arelibc.so.6and 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 loadlibgcc_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 point | Asks |
|---|---|
_nss_peios_net_gethostbyname4_r | lookup, family any |
_nss_peios_net_gethostbyname3_r | lookup, family from af |
_nss_peios_net_gethostbyname2_r | as gethostbyname3_r, with no TTL or canonical name out |
_nss_peios_net_gethostbyname_r | as gethostbyname3_r, with af AF_INET |
_nss_peios_net_gethostbyaddr2_r | reverse |
_nss_peios_net_gethostbyaddr_r | as 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 #
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
localhostor 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.1for IPv4,::1for IPv6, or both, IPv4 first, with canonical namelocalhostand 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 isNOTFOUND(§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:
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:
| Field | Content |
|---|---|
h_name | The reply's canonical name |
h_aliases | Empty |
h_addrtype | The af asked for |
h_length | 4 or 16 |
h_addr_list | Every 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). Afoundreply with no records isNOTFOUND(§7.1). - In source
b4f7085, a reply whose records include noPTRrecord — onlyCNAMEs, for instance — isNOTFOUNDwithHOST_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:
| Field | Content |
|---|---|
h_name | The first PTR record's name |
h_aliases | The remaining PTR records' names |
h_addrtype | The af asked for |
h_length | 4 or 16 |
h_addr_list | The 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 #
8.1.2.1 The command line #
--no-cacheis recognised anywhere on the command line and removed before the rest is matched; it affects onlyquery.- 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>ofqueryisAwhen omitted. It is one ofA,NS,CNAME,SOA,PTR,MX,TXT,AAAA,SRVorANY, in any case, orTYPE<n>for any type number from 0 to 65 535. - Any other
<type>printsresolv: unknown record type "<type>"and exits 1 without contacting resolvd. - The
<address>ofreverseis checked in the same way: anything that is not an IPv4 or IPv6 address printsresolv: "<address>" is not an addressand 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 #
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 #
| Signal | Tells you |
|---|---|
resolv status | Whether 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-cache | Where 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 resolv | 2 for notfound, 3 for unavailable, 1 when resolvd could not be asked (§8.1) |
| The door's own error | NXDOMAIN 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:
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 connectedandscopes (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_failedrising 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:
; ;
9.3 resolvd Cannot Be Reached
Peios / Peios technical reference / resolvd / Failure Modes
9.3.1 resolvd is not running #
| Door | What a client sees |
|---|---|
| NSS shim | localhost and loopback reverses still answer; everything else is UNAVAIL with NO_RECOVERY (§7.1) |
| Stub listener | Nothing listens on 127.0.0.53; UDP queries go unanswered or are refused by the kernel, TCP connections are refused |
resolv | Exit 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 fortcp,udp:53is not in the registry — its seed is inert until the image applies it (§2.1) — or something else already holds127.0.0.53:53. The error text says which.native socket: <error>:/run/resolvdcould 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 descriptorline. Ordinary programs cannot reach the socket at all. ControlSecurityholds a valid descriptor that does not grantRESOLVER_QUERYto them (§5.2). They reach the socket and are denied:resolv queryprintsresolv: 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 #
| Bound | Value | Described in |
|---|---|---|
| Native request size ceiling (payload) | 65 536 bytes | §5.1 |
| Maximum buffered native frame | 65 540 bytes | §5.1 |
| Native connection read chunk | 4 096 bytes | §5.1 |
| Native request delivery bound | 5 s | §5.1 |
| Native connections still sending a request | 256 | §5.1 |
| Queued native replies | 256 | §2.5 |
| Queued stub TCP replies | 256 | §2.5 |
| Native reply write deadline | 1 s total | §5.1 |
| Stub TCP connections still sending a query | 256 | §6.1 |
| Stub TCP query delivery bound | 10 s | §6.1 |
| Stub TCP message buffer | 65 537 bytes | §6.1 |
| Stub TCP read chunk | 4 096 bytes | §6.1 |
| Stub UDP read buffer | 4 096 bytes | §6.1 |
| Stub TCP reply write deadline | 1 s total | §6.2 |
Classic UDP reply limit, no OPT | 512 bytes | §6.2 |
| EDNS0 payload size advertised, upstream and stub | 1 232 bytes | §4.7, §6.2 |
| Transaction timeout | 2 s | §4.6 |
| Attempts per question (all candidates and transports) | 3 | §4.6 |
| Demotion period | 30 s | §4.6 |
| Service descriptor limit | 16 384 | §2.1 |
| Listener retry delay after accept error | 250 ms | §2.5, §9.1 |
| Upstream transactions in flight | 4 096 | §4.6 |
| Upstream UDP read buffer | 4 096 bytes | §4.7 |
| Cache entries | 8 192 | §4.5 |
| Positive lifetime cap | 86 400 s | §4.5 |
| Negative lifetime cap | 300 s | §4.5 |
CNAME chase depth in lookup | 16 | §4.9 |
| netd reconnect backoff | 0.5 s doubling to 10 s | §3.2 |
| netd channel write timeout | 2 s | §3.1 |
| netd frame ceiling | 65 536 bytes | §3.1 |
| netd channel read chunk | 8 192 bytes | §3.1 |
| Registry watch event buffer | 16 384 bytes | §2.3 |
| Kernel hostname read buffer | 256 bytes | §3.3 |
| Unknown-value nesting depth, native requests | 32 | §5.1 |
| NSS shim read and write timeout | 10 s | §7.1 |
| DNS label | 63 bytes | §4.1 |
| DNS name on the wire | 255 bytes | §4.1 |
A.2 Rights #
| Right | Value | Described in |
|---|---|---|
RESOLVER_QUERY | 0x00000001 | §5.2 |
RESOLVER_CONTROL | 0x00000002 | §5.2 |
RESOLVER_ALL_ACCESS | 0x000F0003 | §5.2 |
A.3 Paths, addresses and identities #
| Item | Value | Described 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 listener | 127.0.0.53:53, UDP and TCP | §2.4 |
| netd control socket | /run/netd/control.sock | §3.1 |
| Upstream port | 53 | §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 SID | S-1-5-80-3864064249-1823296737-2008945602-1354971773-2894779966 | §2.1 |
| Fallback scope key | fallback | §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 #
| Key | Value | Type | Default | Described in |
|---|---|---|---|---|
Machine\System\Network\Dns | FallbackServers | REG_MULTI_SZ (or one REG_SZ or REG_EXPAND_SZ) | none | §2.3 |
Machine\System\Network\Dns | ExtraSearchDomains | REG_MULTI_SZ (or one REG_SZ or REG_EXPAND_SZ) | none | §2.3 |
Machine\System\Network\Dns | ControlSecurity | REG_BINARY | the compiled default of §5.2 | §2.3, §5.2 |
Machine\System\Network\Dns\Hosts | any name: a static name | REG_SZ, REG_EXPAND_SZ or REG_MULTI_SZ | none | §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).
| Seed | Key | Values |
|---|---|---|
resolvd-service.reg | Machine\System\Services\resolvd | ImagePath, Triggers, Identity, Readiness, RuntimeDirectories, RestartPolicy, ErrorControl, DisplayName, Description |
resolvd-port.reg | Machine\System\Network\TcpIp\PortReservations | tcp,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 #
| Page | Holds |
|---|---|
| Access mask bits | Per-object-type rights for files, processes, tokens, registry keys and services; the GenericMapping tables; the *_ALL_ACCESS and STANDARD_RIGHTS_* aggregates. |
| Other constants | Impersonation 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.
| Catalogue | Canonical home |
|---|---|
| ACE types, ACE flags, and their numeric values | PCDS §5.4 — see ACE types and flags |
| Well-known SIDs | PCDS §4.4 — see Well-known SIDs |
| Every privilege, with its LUID bit | Peios Kernel TRM §3.4.2 — see Privilege catalog |
The three pages above are signposts. Following one gets you to the table.
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:
SeSecurityPrivilegeSeTakeOwnershipPrivilegeSeBackupPrivilegeSeRestorePrivilegeSeRelabelPrivilege
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 range | Region | Examples |
|---|---|---|
| 0–15 | Object-specific | FILE_READ_DATA, PROCESS_TERMINATE, TOKEN_QUERY |
| 16–20 | Standard rights | DELETE, READ_CONTROL, WRITE_DAC, WRITE_OWNER, SYNCHRONIZE |
| 21–23 | Reserved | Rejected at parse. PCDS §5.3. |
| 24–25 | Special | ACCESS_SYSTEM_SECURITY, MAXIMUM_ALLOWED |
| 26–27 | Reserved | Rejected at parse. |
| 28–31 | Generic | GENERIC_ALL, GENERIC_EXECUTE, GENERIC_WRITE, GENERIC_READ |
File access rights #
| Right | Value | For files | For directories (alias) |
|---|---|---|---|
FILE_READ_DATA | 0x0001 | Read file content | FILE_LIST_DIRECTORY — list entries |
FILE_WRITE_DATA | 0x0002 | Write file content | FILE_ADD_FILE — create files |
FILE_APPEND_DATA | 0x0004 | Append-only write | FILE_ADD_SUBDIRECTORY — create subdirectory |
FILE_READ_EA | 0x0008 | Read extended attributes | Same |
FILE_WRITE_EA | 0x0010 | Write extended attributes | Same |
FILE_EXECUTE | 0x0020 | Execute file | FILE_TRAVERSE — traverse through |
FILE_DELETE_CHILD | 0x0040 | (not applicable) | Delete children regardless of their permissions |
FILE_READ_ATTRIBUTES | 0x0080 | Read attributes | Same |
FILE_WRITE_ATTRIBUTES | 0x0100 | Write attributes | Same |
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 right | Maps to |
|---|---|
GENERIC_READ | FILE_READ_DATA | FILE_READ_ATTRIBUTES | FILE_READ_EA | READ_CONTROL | SYNCHRONIZE |
GENERIC_WRITE | FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_ATTRIBUTES | FILE_WRITE_EA | READ_CONTROL | SYNCHRONIZE |
GENERIC_EXECUTE | FILE_EXECUTE | FILE_READ_ATTRIBUTES | READ_CONTROL | SYNCHRONIZE |
GENERIC_ALL | Every file-specific bit, plus DELETE, READ_CONTROL, WRITE_DAC, WRITE_OWNER, SYNCHRONIZE |
FILE_ALL_ACCESS = STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE | 0x1FF = 0x001F01FF.
Process access rights #
| Right | Value | Meaning |
|---|---|---|
PROCESS_TERMINATE | 0x0001 | Send terminating signals. |
PROCESS_SIGNAL | 0x0002 | Send non-terminating informational signals — SIGCHLD, SIGURG, SIGWINCH. |
PROCESS_VM_READ | 0x0010 | Read process memory — ptrace(PTRACE_PEEK*), process_vm_readv, /proc/<pid>/mem reads. |
PROCESS_VM_WRITE | 0x0020 | Write process memory — ptrace(PTRACE_POKE*, ATTACH), process_vm_writev. |
PROCESS_DUP_HANDLE | 0x0040 | Duplicate file descriptors out via pidfd_getfd. |
PROCESS_SET_INFORMATION | 0x0200 | Change process attributes — priority, affinity, rlimits, /proc/<pid>/* writes. |
PROCESS_QUERY_INFORMATION | 0x0400 | Detailed process info — token, full /proc/<pid>/* reads. |
PROCESS_SUSPEND_RESUME | 0x0800 | Send stop and continue signals. |
PROCESS_QUERY_LIMITED | 0x1000 | Basic info — PID, image name, state. Required by pidfd_open. |
Bits 0x0004, 0x0008, 0x0080 and 0x0100 are unused.
Process GenericMapping #
| Generic right | Maps to |
|---|---|
GENERIC_READ | PROCESS_QUERY_INFORMATION | PROCESS_VM_READ | READ_CONTROL |
GENERIC_WRITE | PROCESS_SET_INFORMATION | PROCESS_VM_WRITE | WRITE_DAC |
GENERIC_EXECUTE | PROCESS_TERMINATE | PROCESS_SUSPEND_RESUME | PROCESS_QUERY_LIMITED |
GENERIC_ALL | All process-specific bits, plus STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE |
PROCESS_ALL_ACCESS = STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE | 0x1FFF = 0x001F1FFF.
Token access rights #
| Right | Value | Meaning |
|---|---|---|
TOKEN_ASSIGN_PRIMARY | 0x0001 | Install as a process's primary token. |
TOKEN_DUPLICATE | 0x0002 | Create a copy. |
TOKEN_IMPERSONATE | 0x0004 | Install as a thread's impersonation token. |
TOKEN_QUERY | 0x0008 | Read token information. |
TOKEN_QUERY_SOURCE | 0x0010 | Subsumed by TOKEN_QUERY. Reserved for format compatibility. |
TOKEN_ADJUST_PRIVILEGES | 0x0020 | Enable, disable or remove privileges. |
TOKEN_ADJUST_GROUPS | 0x0040 | Enable or disable groups. |
TOKEN_ADJUST_DEFAULT | 0x0080 | Change default DACL, owner index, primary group index. |
TOKEN_ADJUST_INTERACTIVITY_SCOPE | 0x0100 | Change 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 right | Maps to |
|---|---|
GENERIC_READ | TOKEN_QUERY | READ_CONTROL |
GENERIC_WRITE | TOKEN_ADJUST_PRIVILEGES | TOKEN_ADJUST_GROUPS | TOKEN_ADJUST_DEFAULT | WRITE_DAC |
GENERIC_EXECUTE | TOKEN_IMPERSONATE |
GENERIC_ALL | TOKEN_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 #
| Right | Value | Meaning |
|---|---|---|
KEY_QUERY_VALUE | 0x0001 | Read a value. |
KEY_SET_VALUE | 0x0002 | Write a value. |
KEY_CREATE_SUB_KEY | 0x0004 | Create a subkey. |
KEY_ENUMERATE_SUB_KEYS | 0x0008 | Enumerate subkeys. |
KEY_NOTIFY | 0x0010 | Watch for changes. |
KEY_CREATE_LINK | 0x0020 | Create a symbolic link to another key. |
Bits from 0x0040 upward are reserved.
Registry GenericMapping #
| Generic right | Maps to |
|---|---|
GENERIC_READ | KEY_QUERY_VALUE | KEY_ENUMERATE_SUB_KEYS | KEY_NOTIFY | READ_CONTROL |
GENERIC_WRITE | KEY_SET_VALUE | KEY_CREATE_SUB_KEY | READ_CONTROL |
GENERIC_EXECUTE | READ_CONTROL |
GENERIC_ALL | All key-specific bits, plus STANDARD_RIGHTS_REQUIRED | SYNCHRONIZE |
Service access rights #
| Right | Value | Meaning |
|---|---|---|
SERVICE_QUERY_CONFIG | 0x0001 | Read configuration. |
SERVICE_CHANGE_CONFIG | 0x0002 | Modify configuration. |
SERVICE_QUERY_STATUS | 0x0004 | Read runtime status. |
SERVICE_ENUMERATE_DEPENDENTS | 0x0008 | List dependent services. |
SERVICE_START | 0x0010 | Start the service. |
SERVICE_STOP | 0x0020 | Stop the service. |
SERVICE_PAUSE_CONTINUE | 0x0040 | Pause and resume. |
SERVICE_INTERROGATE | 0x0080 | Request a status update. |
SERVICE_USER_DEFINED_CONTROL | 0x0100 | Send service-specific control codes. |
Standard, special and generic rights #
| Right | Value |
|---|---|
DELETE | 0x00010000 |
READ_CONTROL | 0x00020000 |
WRITE_DAC | 0x00040000 |
WRITE_OWNER | 0x00080000 |
SYNCHRONIZE | 0x00100000 |
ACCESS_SYSTEM_SECURITY | 0x01000000 |
MAXIMUM_ALLOWED | 0x02000000 |
GENERIC_ALL | 0x10000000 |
GENERIC_EXECUTE | 0x20000000 |
GENERIC_WRITE | 0x40000000 |
GENERIC_READ | 0x80000000 |
The STANDARD_RIGHTS aggregates #
| Constant | Value | Composition |
|---|---|---|
STANDARD_RIGHTS_REQUIRED | 0x000F0000 | DELETE | READ_CONTROL | WRITE_DAC | WRITE_OWNER |
STANDARD_RIGHTS_READ | 0x00020000 | Alias of READ_CONTROL |
STANDARD_RIGHTS_WRITE | 0x00020000 | Alias of READ_CONTROL |
STANDARD_RIGHTS_EXECUTE | 0x00020000 | Alias of READ_CONTROL |
STANDARD_RIGHTS_ALL | 0x001F0000 | All 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.
Impersonation levels #
A token's impersonation level. The conceptual model is Impersonation levels.
| Constant | Value | Meaning |
|---|---|---|
KACS_IMLEVEL_ANONYMOUS | 0 | No identity. |
KACS_IMLEVEL_IDENTIFICATION | 1 | Inspect only. Cannot be used for an access check. |
KACS_IMLEVEL_IMPERSONATION | 2 | Act as the client locally. The default. |
KACS_IMLEVEL_DELEGATION | 3 | Act 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.
| Constant | RID | Name |
|---|---|---|
INTEGRITY_LEVEL_UNTRUSTED | 0 | Untrusted |
INTEGRITY_LEVEL_LOW | 4096 | Low |
INTEGRITY_LEVEL_MEDIUM | 8192 | Medium |
INTEGRITY_LEVEL_HIGH | 12288 | High |
INTEGRITY_LEVEL_SYSTEM | 16384 | System |
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.
| Flag | Value | Meaning |
|---|---|---|
NO_WRITE_UP | 0x01 | MIC blocks write-category access from lower integrity. |
NEW_PROCESS_MIN | 0x02 | At 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.
| Constant | Value | Meaning |
|---|---|---|
LOGON_TYPE_INTERACTIVE | 2 | Console, SSH, terminal services. |
LOGON_TYPE_NETWORK | 3 | Network resource access — SMB, RPC, federated. |
LOGON_TYPE_BATCH | 4 | Scheduled job. |
LOGON_TYPE_SERVICE | 5 | Service running under a specific principal. |
LOGON_TYPE_NETWORK_CLEARTEXT | 8 | Network logon with a cleartext credential. |
LOGON_TYPE_NEW_CREDENTIALS | 9 | Keep the local identity; use alternative credentials outbound. |
LOGON_TYPE_REMOTE_INTERACTIVE | 10 | A 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.
| Constant | Value | Meaning |
|---|---|---|
KACS_ELEVATION_DEFAULT | 1 | Not part of a linked pair. |
KACS_ELEVATION_FULL | 2 | The elevated half of a linked pair. |
KACS_ELEVATION_LIMITED | 3 | The non-elevated half. |
PIP tiers #
A process's PSB pip_type field.
| Value | Meaning |
|---|---|
| 0 | None. Unprotected, the default for unsigned binaries. |
| 512 | Protected. Standard PIP protection. |
| 1024 | Isolated. 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.
| Flag | Value | Meaning |
|---|---|---|
OBJECT_ACCESS_SUCCESS | 0x01 | Force a kacs.audit.access.checked event, with trigger.kind policy, on every successful access. |
OBJECT_ACCESS_FAILURE | 0x02 | Force a kacs.audit.access.checked event, with trigger.kind policy, on every failed access. |
PRIVILEGE_USE_SUCCESS | 0x04 | Emit a kacs.audit.privilege.used event when a privilege's bits survive. |
PRIVILEGE_USE_FAILURE | 0x08 | Emit a kacs.audit.privilege.used event when its bits are stripped. |
Create dispositions #
For kacs_open.
| Constant | Value | Behaviour |
|---|---|---|
KACS_DISPOSITION_SUPERSEDE | 0 | If it exists, delete and recreate; otherwise create. |
KACS_DISPOSITION_OPEN | 1 | If it exists, open; otherwise fail with ENOENT. |
KACS_DISPOSITION_CREATE | 2 | If it exists, fail with EEXIST; otherwise create. |
KACS_DISPOSITION_OPEN_IF | 3 | If it exists, open; otherwise create. |
KACS_DISPOSITION_OVERWRITE | 4 | If it exists, truncate and open; otherwise fail with ENOENT. |
KACS_DISPOSITION_OVERWRITE_IF | 5 | If 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.
| Flag | Value | Right to read | Right to write |
|---|---|---|---|
OWNER_SECURITY_INFORMATION | 0x01 | READ_CONTROL | WRITE_OWNER |
GROUP_SECURITY_INFORMATION | 0x02 | READ_CONTROL | WRITE_OWNER |
DACL_SECURITY_INFORMATION | 0x04 | READ_CONTROL | WRITE_DAC |
SACL_SECURITY_INFORMATION | 0x08 | ACCESS_SYSTEM_SECURITY | ACCESS_SYSTEM_SECURITY |
LABEL_SECURITY_INFORMATION | 0x10 | READ_CONTROL | WRITE_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 #
| Limit | Value | Context |
|---|---|---|
| Max SD size | 65,535 bytes | Any SD blob. Normative in PCDS §5.1. |
| Max ACL size | 64 KB | Any ACL within an SD |
| Max single ACE size | 64 KB | Bounded by the ACL size |
| Min SID size | 8 bytes | Revision, count and authority, no sub-authorities |
| Max SID size | 68 bytes | 15 sub-authorities |
| Max token wire spec | 64 KB | kacs_create_token input |
| Max session wire spec | 4096 bytes | kacs_create_logon_session input |
| Max CAAP wire spec | 256 KB | kacs_set_caap input |
| Max CAAP rules per policy | 256 | |
| Max applies-to expression | 64 KB | Per CAAP rule |
| Max conditional stack depth | 1024 | Evaluator limit |
| Max TLP cache entries | 64 | Trusted Library Path prefixes |
| Max TLP path length | 4096 bytes | Per prefix |
| Max mount template SD | 64 KB | kacs_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 #
| Structure | Where |
|---|---|
| Security descriptor header, self-relative | PCDS §5.1 |
| ACL header and ACE array | PCDS §5.2 |
| ACE header and per-type body layouts | PCDS §5.4 |
| Access mask layout | PCDS §5.3 |
Claim entry — CLAIM_SECURITY_ATTRIBUTE_RELATIVE_V1 | PCDS §5.9 |
| Conditional-ACE bytecode, with the full opcode table | PCDS §5.11 |
| Resource attribute ACEs | PCDS §5.10 |
See also Security descriptors.
Identifiers #
| Structure | Where |
|---|---|
| SID binary format | PCDS §4.1 |
| SID string format | PCDS §4.2 |
| GUID | PCDS §2 |
| LUID | PCDS §3 |
Kernel interfaces #
| Structure | Where |
|---|---|
| Token and session wire specs | Peios Kernel TRM §3.A — see Token and session specs |
| CAAP wire format | See CAAP format |
| Registry ABI, including watch record layout | Peios Kernel TRM §5.A |
| Token query class payloads | Peios Kernel TRM §3.A |
Event stream #
| Structure | Where |
|---|---|
| KMES event header, field by field | PSPK §2 |
| Event envelope, in summary | Events Index §1.2 |
| Per-event payload schemas | Events 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.
| Structure | Section |
|---|---|
Descriptor header, self-relative form — Revision, Sbz1, Control, and the four offsets | PCDS §5.1 |
| Control flags | PCDS §5.1 |
ACL header — AclRevision, AclSize, AceCount | PCDS §5.2 |
ACE header — AceType, AceFlags, AceSize | PCDS §5.4 |
| Per-type ACE body layouts | PCDS §5.4 |
| Access mask bit layout | PCDS §5.3 |
| SID binary format | PCDS §4.1 |
| Claim entry format | PCDS §5.9 |
| Conditional-ACE bytecode | PCDS §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/:
| Appendix | Covers |
|---|---|
| §2.A, KMES ABI Reference | Event syscalls, ring and event header layouts, configuration structures, and trace controls |
| §3.A, KACS ABI Reference | Syscall numbers, ioctls, structure layouts, token query classes, and the KACS constants |
| §5.A, LCS ABI Reference | The registry ABI — syscalls, ioctls, structure layouts, and the watch record header offsets |
| §6.A, NTFE ABI Reference | The 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
PKM_LEARN_ROOT=/path/to/docs-worktree
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.
| Field | Type | Meaning |
|---|---|---|
logon_session_id | decimal u64 | The session's LUID. Same as the auth_id recorded on every token belonging to this session. |
user_sid | lowercase hex | The SID of the principal who signed in. |
logon_type | decimal u32 | The logon type. See Logon types. |
auth_package | lowercase hex of UTF-8 | The auth-package name (e.g. "Kerberos", "NTLM", "local") encoded as lowercase hex of the UTF-8 bytes. |
created_at | decimal u64 | When 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 ID | Use |
|---|---|
| 999 | The SYSTEM session. Attached to init, inherited by every early-boot process. Stays present for the lifetime of the system. |
| 998 | The 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.
- Open the token. For a thread, use
/proc/<pid>/task/<tid>/token. For a process's primary, use/proc/<pid>/token. For yourself, usekacs_open_self_tokenor/sys/kernel/security/kacs/self. - Query
TokenStatisticsviaKACS_IOC_QUERY. The response includesauth_id(the session's LUID). - Look up
auth_idin/sys/kernel/security/kacs/sessionsto find the matchinglogon_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/sessionsand 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_idmatches. - Impersonation tokens currently installed on threads whose
auth_idmatches. - 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.
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:
| Field | Meaning |
|---|---|
pip_type | The process's PIP type: 0 for none, 512 for Protected. |
pip_trust | The PIP trust level within the type: 8192 for PeiosTcb. |
| Mitigation flags | The bitfield of enabled mitigations (WXP, LSV, TLP, CFIF, CFIB, PIE, SML, NO_CHILD, etc.). |
process_guid | The 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_LIMITEDon its process SD — the rightpsneeds 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_INFORMATIONfor its token and detailed/procentries,READ_CONTROLfor 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 withSeSecurityPrivilege). - 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:
| Flag | Bit | Meaning |
|---|---|---|
| WXP | 0x001 | Write-XOR-Execute enabled |
| TLP | 0x002 | Trusted Library Paths enabled |
| LSV | 0x004 | Library Signature Verification enabled |
| CFI (legacy) | 0x008 | CFIF + CFIB combined alias |
| UI_ACCESS | 0x010 | Reserved |
| NO_CHILD | 0x020 | Forbid fork/clone-new-process |
| CFIF | 0x040 | Forward CFI |
| CFIB | 0x080 | Backward CFI |
| PIE | 0x100 | PIE-only exec |
| SML | 0x200 | Speculation 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:
- Open the process's primary token via
/proc/<pid>/tokenorkacs_open_process_token. - Query
TokenUserfor the user SID. - Query
TokenStatisticsforauth_id. Cross-reference with/sys/kernel/security/kacs/sessionsfor session details. - Read
/proc/<pid>/psbfor PIP and mitigations. - 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_idcounter on the token tracks how many changes have happened. - The process SD can be modified by anyone with
WRITE_DACon 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_idcounter 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_READon 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
usedbit 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,
revstrmlets 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.
revstrmexercises 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.
| Option | Description |
|---|---|
-t, --type GLOB | Only 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 CLASS | Only 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, --pretty | Expand the msgpack payload across multiple indented lines instead of the compact one-line form. |
-s, --snapshot | Drain 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. |
--help | Print usage and exit. |
--version | Print 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
| Field | Meaning |
|---|---|
TIME | UTC 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. |
cpuN | The 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. |
#SEQUENCE | The event's per-ring sequence number. Gaps in the sequence on a given CPU indicate lost events. |
ORIGIN | The origin class: one of USR, KMES, KACS, LCS (or the catalogue's name for the class, e.g. NTFE, else cN). |
event.type | The event-type string (e.g. kacs.audit.access.checked, kacs.session.destroyed). |
payload | The 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 type | Rendered as |
|---|---|
bin.sid, bin.sid[] | Canonical string SIDs (S-1-5-18), one or an array of them. |
bin.guid | The canonical GUID string. |
uint.time | A UTC time with nanosecond precision (2026-10-06T12:34:56.123456789Z). |
uint.duration, uint.bytes | The 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.flags | The 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.integrity | Hexadecimal; the integrity level by name (high (0x3000)). |
int.errno | The 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),
revstrmruns until interrupted or until stdout closes. A downstreamhead(or any reader) closing the pipe shutsrevstrmdown cleanly — it treats the broken pipe as "nothing left to print to" and exits, rather than being killed bySIGPIPE. 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.
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:
| Request | Event |
|---|---|
add | lpsd.account.created |
remove | lpsd.account.deleted |
Any other change to an account: enable, disable, password, set, claim set, claim remove, rename, logon-types, key add, key remove, policy | lpsd.account.modified, with operation.name saying which |
group create / group delete | lpsd.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
lpscommand — the client this socket serves. - Authorities and principal sources — the authority/source split behind the design.